diff --git a/.bumpversion.toml b/.bumpversion.toml new file mode 100644 index 00000000..647d33f4 --- /dev/null +++ b/.bumpversion.toml @@ -0,0 +1,19 @@ +[tool.bumpversion] +current_version = "2.3.4" +commit = true +tag = true +parse = "(?P\\d+)\\.(?P\\d+)(.(?P\\d+))?" +serialize = [ + "{major}.{minor}.{patch}", + "{major}.{minor}" +] + +[[tool.bumpversion.files]] +filename = "VERSION" + +[[tool.bumpversion.files]] +filename = "data/metainfo/org.learningequality.Kolibri.metainfo.xml.in.in" +# This crude regex will break if the release tag contains any "/" or ">" characters. +regex = true +search = "]*\\s?version=\"{current_version}\\+next\"[^/>]*" +replace = "` section, there should +always be a release entry with `version` set to the previous version followed by +`+next`, like this: + +``` + + +
    +
  • The description of a new feature goes here.
  • +
+
+
+``` + +If there is not one, please create one as the first entry in ``. + +#### Creating releases + +To create a release, use [bump-my-version](): + +``` +bump-my-version bump minor +git push +git push --tags +``` + +This will create a new git tag, update the `VERSION` file in the project root, +and update the "+next" release entry in [org.learningequality.Kolibri.metainfo.xml.in.in](data/metainfo/org.learningequality.Kolibri.metainfo.xml.in.in). + +Note that it is possible to increment either the `major`, `minor`, or `patch` +component of the project's version number. + +### Debugging and advanced usage + +#### Web inspector + +For development builds, kolibri-gnome enables WebKit developer extras. You can +open the web inspector by pressing F12, or by right clicking and choosing +"Inspect Element" from the context menu. If this is not available, try running +the application with `env KOLIBRI_APP_DEVELOPER_EXTRAS=1` for a production +build, or with `env KOLIBRI_DEVEL_APP_DEVELOPER_EXTRAS=1` for a development +build. + +#### Automatic provisioning + +The kolibri-daemon service will automatically provision Kolibri when it starts +for the first time. This skips Kolibri's first-run setup wizard and sets up +Kolibri with no root user. To disable this feature, start kolibri-daemon with +the `KOLIBRI_APP_AUTOMATIC_PROVISION` environment variable set to `0` for a +production build, or with `KOLIBRI_DEVEL_APP_AUTOMATIC_PROVISION` for a +development build. For example, using the reference flatpak: + +``` +env KOLIBRI_DEVEL_APP_AUTOMATIC_PROVISION=0 flatpak run --command=/app/libexec/kolibri-app/kolibri-daemon org.learningequality.Kolibri.Devel +``` + +Alternatively, provide your own [automatic provisioning file](httpshttps://github.com/learningequality/kolibri/blob/release-v0.16.x/kolibri/core/device/utils.py#L335-L365) +and start kolibri-daemon with `env KOLIBRI_AUTOMATIC_PROVISION_FILE=/path/to/automatic_provision.json`. + +#### Automatic sign in + +The kolibri-gnome application will automatically sign in to Kolibri using a +private token assigned to the current desktop user. This is necessary to +support the automatic provisioning feature. To disable automatic sign in, so +Kolibri will instead require you to sign in with a password, start the +application with the `KOLIBRI_APP_AUTOMATIC_LOGIN` environment variable set +to `0` for a production build, or with `KOLIBRI_DEVEL_APP_AUTOMATIC_LOGIN` +for a development build. For example, using the reference flatpak: + +``` +env KOLIBRI_DEVEL_APP_AUTOMATIC_LOGIN=0 flatpak run org.learningequality.Kolibri.Devel +``` diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..3f684d2d --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2.3.4 diff --git a/build-aux/flatpak/modules/iproute2.json b/build-aux/flatpak/modules/iproute2.json index 20934b28..c47be75d 100644 --- a/build-aux/flatpak/modules/iproute2.json +++ b/build-aux/flatpak/modules/iproute2.json @@ -1,16 +1,21 @@ { - "name" : "iproute2", - "buildsystem" : "autotools", - "make-install-args" : [ + "name": "iproute2", + "buildsystem": "autotools", + "make-install-args": [ "PREFIX=${FLATPAK_DEST}", "SBINDIR=${FLATPAK_DEST}/bin", "CONFDIR=${FLATPAK_DEST}/etc/iproute2" ], - "sources" : [ + "sources": [ { - "type" : "archive", - "url" : "https://mirrors.edge.kernel.org/pub/linux/utils/net/iproute2/iproute2-5.7.0.tar.xz", - "sha256" : "725dc7ba94aae54c6f8d4223ca055d9fb4fe89d6994b1c03bfb4411c4dd10f21" + "type": "git", + "url": "https://git.kernel.org/pub/scm/network/iproute2/iproute2.git", + "tag": "v6.8.0", + "x-checker-data": { + "type": "git", + "tag-pattern": "^v([\\d.]+)$" + }, + "commit": "e5fd785830671180e934a84a44da93c51cce839d" } ] } diff --git a/build-aux/flatpak/modules/kolibri-home-template.json b/build-aux/flatpak/modules/kolibri-home-template.json deleted file mode 100644 index 5b45fae4..00000000 --- a/build-aux/flatpak/modules/kolibri-home-template.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name" : "kolibri-home-template", - "buildsystem" : "simple", - "build-options" : { - "env" : { - "KOLIBRI_HOME" : "/app/share/kolibri-home-template" - } - }, - "build-commands" : [ - "install -d ${KOLIBRI_HOME}", - "yes 'yes' | kolibri manage migrate", - "yes 'yes' | kolibri manage collectstatic", - "yes 'yes' | kolibri manage deprovision", - "rm -rf ${KOLIBRI_HOME}/logs", - "rm -rf ${KOLIBRI_HOME}/sessions", - "rm -rf ${KOLIBRI_HOME}/process_cache", - "touch ${KOLIBRI_HOME}/was_preseeded" - ] -} diff --git a/build-aux/flatpak/modules/python3-kolibri-app-desktop-xdg-plugin.json b/build-aux/flatpak/modules/python3-kolibri-app-desktop-xdg-plugin.json index 7e1094b5..fabfa53d 100644 --- a/build-aux/flatpak/modules/python3-kolibri-app-desktop-xdg-plugin.json +++ b/build-aux/flatpak/modules/python3-kolibri-app-desktop-xdg-plugin.json @@ -2,18 +2,51 @@ "name": "python3-kolibri-app-desktop-xdg-plugin", "buildsystem": "simple", "build-commands": [ - "pip3 install --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} kolibri-app-desktop-xdg-plugin" + "pip3 install --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} --use-pep517 kolibri-app-desktop-xdg-plugin" ], "sources": [ { "type": "file", - "url": "https://files.pythonhosted.org/packages/90/d4/a7c9b6c5d176654aa3dbccbfd0be4fd3a263355dc24122a5f1937bdc2689/Pillow-8.3.2.tar.gz", - "sha256": "dde3f3ed8d00c72631bc19cbfff8ad3b6215062a5eed402381ad365f82f0c18c" + "url": "https://files.pythonhosted.org/packages/f7/29/13965af254e3373bceae8fb9a0e6ea0d0e571171b80d6646932131d6439b/setuptools-69.5.1-py3-none-any.whl", + "sha256": "c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32", + "x-checker-data": { + "type": "pypi", + "name": "setuptools", + "packagetype": "bdist_wheel" + } }, { "type": "file", - "url": "https://files.pythonhosted.org/packages/3e/b8/46e65ec41b08c8f78a5e571357d6a0634187286f6ba558c447fb99f4512c/kolibri_app_desktop_xdg_plugin-1.1.4-py2.py3-none-any.whl", - "sha256": "16d16a21c55cae262dbf587e43815af1646b3b7dac70f07bf1d0c412833911aa" + "url": "https://files.pythonhosted.org/packages/7d/cd/d7460c9a869b16c3dd4e1e403cce337df165368c71d6af229a74699622ce/wheel-0.43.0-py3-none-any.whl", + "sha256": "55c570405f142630c6b9f72fe09d9b67cf1477fcf543ae5b8dcb1f5b7377da81", + "x-checker-data": { + "type": "pypi", + "name": "wheel", + "packagetype": "bdist_wheel" + } + }, + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/ef/43/c50c17c5f7d438e836c169e343695534c38c77f60e7c90389bd77981bc21/pillow-10.3.0.tar.gz", + "sha256": "9d2455fbf44c914840c793e89aa82d0e1763a14253a000743719ae5946814b2d", + "x-checker-data": { + "type": "pypi", + "name": "Pillow", + "versions": { + ">=": "10.1.0", + "<": "11.0.0" + } + } + }, + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/44/01/a40f3823fc367731e5cd7a90306b0cc3af080b1e13f59fd66efb3ff7bec4/kolibri_app_desktop_xdg_plugin-1.2.0-py3-none-any.whl", + "sha256": "d689e7cdcdad49a2897fa1ce9069ff9d57e375c7918a4e5b27bb6fbe4d3e5859", + "x-checker-data": { + "type": "pypi", + "name": "kolibri-app-desktop-xdg-plugin", + "packagetype": "bdist_wheel" + } } ] } diff --git a/build-aux/flatpak/modules/python3-kolibri-desktop-auth-plugin.json b/build-aux/flatpak/modules/python3-kolibri-desktop-auth-plugin.json deleted file mode 100644 index 2f9012c6..00000000 --- a/build-aux/flatpak/modules/python3-kolibri-desktop-auth-plugin.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "python3-kolibri-desktop-auth-plugin", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"kolibri-desktop-auth-plugin\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/bc/fe/f9a5b62da8daf554eb5e7a28283cfb01f401618902c18012df0011585c7a/kolibri_desktop_auth_plugin-0.0.7-py2.py3-none-any.whl", - "sha256": "f1920b49fd0805fe45aa3024b047690d107617efb1932c0100860a156ab39448" - } - ] -} diff --git a/build-aux/flatpak/modules/python3-kolibri-patches/0001-Allow-superuser-to-be-null-in-device-provision-API.patch b/build-aux/flatpak/modules/python3-kolibri-patches/0001-Allow-superuser-to-be-null-in-device-provision-API.patch deleted file mode 100644 index a411abcf..00000000 --- a/build-aux/flatpak/modules/python3-kolibri-patches/0001-Allow-superuser-to-be-null-in-device-provision-API.patch +++ /dev/null @@ -1,49 +0,0 @@ -From cdb3ae92eba9ee22ed5b264784e2c1bf72541666 Mon Sep 17 00:00:00 2001 -From: Dylan McCall -Date: Thu, 2 Dec 2021 15:31:30 -0800 -Subject: [PATCH] Allow superuser to be null in device provision API - ---- - kolibri/core/device/serializers.py | 20 ++++++++++++-------- - 1 file changed, 12 insertions(+), 8 deletions(-) - -diff --git a/kolibri/core/device/serializers.py b/kolibri/core/device/serializers.py -index 9103e49f93..c84de06979 100644 ---- a/kolibri/core/device/serializers.py -+++ b/kolibri/core/device/serializers.py -@@ -40,8 +40,8 @@ class DeviceSerializerMixin(object): - class DeviceProvisionSerializer(DeviceSerializerMixin, serializers.Serializer): - facility = FacilitySerializer() - preset = serializers.ChoiceField(choices=choices) -- superuser = NoFacilityFacilityUserSerializer() -- language_id = serializers.CharField(max_length=15) -+ superuser = NoFacilityFacilityUserSerializer(allow_null=True) -+ language_id = serializers.CharField(max_length=15, allow_null=True) - device_name = serializers.CharField(max_length=50, allow_null=True) - settings = serializers.JSONField() - allow_guest_access = serializers.BooleanField(allow_null=True) -@@ -78,12 +78,16 @@ class DeviceProvisionSerializer(DeviceSerializerMixin, serializers.Serializer): - facility.dataset.save() - - # Create superuser -- superuser = FacilityUser.objects.create_superuser( -- validated_data["superuser"]["username"], -- validated_data["superuser"]["password"], -- facility=facility, -- full_name=validated_data["superuser"].get("full_name"), -- ) -+ superuser_data = validated_data.pop("superuser") -+ if superuser_data: -+ superuser = FacilityUser.objects.create_superuser( -+ superuser_data["username"], -+ superuser_data["password"], -+ facility=facility, -+ full_name=superuser_data.get("full_name"), -+ ) -+ else: -+ superuser = None - - # Create device settings - language_id = validated_data.pop("language_id") --- -2.33.1 diff --git a/build-aux/flatpak/modules/python3-kolibri-patches/0001-Don-t-let-users-that-are-used-for-os-user-have-their.patch b/build-aux/flatpak/modules/python3-kolibri-patches/0001-Don-t-let-users-that-are-used-for-os-user-have-their.patch new file mode 100644 index 00000000..a5faa300 --- /dev/null +++ b/build-aux/flatpak/modules/python3-kolibri-patches/0001-Don-t-let-users-that-are-used-for-os-user-have-their.patch @@ -0,0 +1,215 @@ +From 6e0bcb540e56cd36917dd3edf4db218ee576e47a Mon Sep 17 00:00:00 2001 +From: Richard Tibbles +Date: Tue, 30 Apr 2024 16:51:48 -0700 +Subject: [PATCH] Don't let users that are used for os user have their + passwords set via the not specified password flow. + +--- + kolibri/core/auth/api.py | 8 +- + kolibri/core/auth/test/test_api.py | 150 +++++++++++++++++++++++++++++ + 2 files changed, 156 insertions(+), 2 deletions(-) + +diff --git a/kolibri/core/auth/api.py b/kolibri/core/auth/api.py +index 3ed56db2cd..689d399581 100644 +--- a/kolibri/core/auth/api.py ++++ b/kolibri/core/auth/api.py +@@ -854,7 +854,7 @@ class SetNonSpecifiedPasswordView(views.APIView): + except (ValueError, ObjectDoesNotExist): + raise Http404(error_message) + +- if user.password != NOT_SPECIFIED: ++ if user.password != NOT_SPECIFIED or hasattr(user, "os_user"): + raise Http404(error_message) + + user.set_password(password) +@@ -930,10 +930,14 @@ class SessionViewSet(viewsets.ViewSet): + unauthenticated_user = FacilityUser.objects.filter( + username__exact=username, facility=facility_id + ).first() +- if unauthenticated_user.password == NOT_SPECIFIED: ++ if unauthenticated_user.password == NOT_SPECIFIED and not hasattr( ++ unauthenticated_user, "os_user" ++ ): + # Here - we have a Learner whose password is "NOT_SPECIFIED" because they were created + # while the "Require learners to log in with password" setting was disabled - but now + # it is enabled again. ++ # Alternatively, they may have been created as an OSUser for automatic login with an ++ # authentication token. If this is the case, then we do not allow for the password to be set. + return Response( + [ + { +diff --git a/kolibri/core/auth/test/test_api.py b/kolibri/core/auth/test/test_api.py +index 5ea87b39b3..aab92784a6 100644 +--- a/kolibri/core/auth/test/test_api.py ++++ b/kolibri/core/auth/test/test_api.py +@@ -30,6 +30,7 @@ from .helpers import provision_device + from kolibri.core import error_constants + from kolibri.core.auth.backends import FACILITY_CREDENTIAL_KEY + from kolibri.core.auth.constants import demographics ++from kolibri.core.device.models import OSUser + from kolibri.core.device.utils import set_device_settings + + # A weird hack because of http://bugs.python.org/issue17866 +@@ -1309,6 +1310,40 @@ class LoginLogoutTestCase(APITestCase): + + self.assertEqual(response_user3.status_code, 401) + ++ def test_not_specified_password(self): ++ self.user.password = demographics.NOT_SPECIFIED ++ self.user.save() ++ ++ response = self.client.post( ++ reverse("kolibri:core:session-list"), ++ data={ ++ "username": self.user.username, ++ "facility": self.facility.id, ++ }, ++ format="json", ++ ) ++ ++ self.assertEqual(response.status_code, 400) ++ self.assertEqual(response.data[0]["id"], error_constants.PASSWORD_NOT_SPECIFIED) ++ ++ def test_not_specified_password_os_user(self): ++ self.user.password = demographics.NOT_SPECIFIED ++ self.user.save() ++ ++ OSUser.objects.create(user=self.user, os_username="os_user") ++ ++ response = self.client.post( ++ reverse("kolibri:core:session-list"), ++ data={ ++ "username": self.user.username, ++ "facility": self.facility.id, ++ }, ++ format="json", ++ ) ++ ++ self.assertEqual(response.status_code, 400) ++ self.assertEqual(response.data[0]["id"], error_constants.MISSING_PASSWORD) ++ + + class SignUpBase(object): + @classmethod +@@ -1982,3 +2017,118 @@ class DuplicateUsernameTestCase(APITestCase): + format="json", + ) + self.assertEqual(response.data, True) ++ ++ ++class SetNonSpecifiedPasswordViewTestCase(APITestCase): ++ def setUp(self): ++ self.url = reverse("kolibri:core:setnonspecifiedpassword") ++ self.facility = FacilityFactory.create() ++ self.user = models.FacilityUser.objects.create( ++ username="testuser", ++ facility=self.facility, ++ password=demographics.NOT_SPECIFIED, ++ ) ++ ++ def test_set_non_specified_password(self): ++ # Make a POST request to set the password ++ data = { ++ "username": "testuser", ++ "password": "newpassword", ++ "facility": self.facility.id, ++ } ++ response = self.client.post(self.url, data) ++ ++ # Check that the response has a 200 OK status code ++ self.assertEqual(response.status_code, status.HTTP_200_OK) ++ ++ # Refresh the user object from the database ++ self.user.refresh_from_db() ++ ++ # Check that the password has been updated ++ self.assertTrue(self.user.check_password("newpassword")) ++ ++ def test_set_non_specified_password_invalid_facility(self): ++ # Make a POST request to set the password ++ data = { ++ "username": "testuser", ++ "password": "newpassword", ++ "facility": uuid.uuid4().hex, ++ } ++ response = self.client.post(self.url, data) ++ ++ # Check that the response has a 404 Not Found status code ++ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) ++ ++ def test_set_non_specified_password_missing_facility(self): ++ # Make a POST request to set the password ++ data = { ++ "username": "testuser", ++ "password": "newpassword", ++ } ++ response = self.client.post(self.url, data) ++ ++ # Check that the response has a 400 Bad Request status code ++ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) ++ ++ def test_set_non_specified_password_invalid_username(self): ++ # Make a POST request to set the password ++ data = { ++ "username": "invalidusername", ++ "password": "newpassword", ++ "facility": self.facility.id, ++ } ++ response = self.client.post(self.url, data) ++ ++ # Check that the response has a 404 Not Found status code ++ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) ++ ++ def test_set_non_specified_password_missing_username(self): ++ # Make a POST request to set the password ++ data = { ++ "password": "newpassword", ++ "facility": self.facility.id, ++ } ++ response = self.client.post(self.url, data) ++ ++ # Check that the response has a 400 Bad Request status code ++ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) ++ ++ def test_set_non_specified_password_missing_password(self): ++ # Make a POST request to set the password ++ data = { ++ "username": "testuser", ++ "facility": self.facility.id, ++ } ++ response = self.client.post(self.url, data) ++ ++ # Check that the response has a 400 Bad Request status code ++ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) ++ ++ def test_set_non_specified_password_password_is_specified(self): ++ self.user.set_password("password") ++ self.user.save() ++ ++ # Make a POST request to set the password ++ data = { ++ "username": "testuser", ++ "password": "newpassword", ++ "facility": self.facility.id, ++ } ++ response = self.client.post(self.url, data) ++ ++ # Check that the response has a 404 Not Found status code ++ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) ++ ++ def test_set_non_specified_password_user_is_os_user(self): ++ OSUser.objects.create(user=self.user, os_username="osuser") ++ ++ # Make a POST request to set the password ++ data = { ++ "username": "testuser", ++ "password": "newpassword", ++ "facility": self.facility.id, ++ } ++ response = self.client.post(self.url, data) ++ ++ # Check that the response has a 400 Bad Request status code ++ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) +-- +2.44.0 + diff --git a/build-aux/flatpak/modules/python3-kolibri-pytz.json b/build-aux/flatpak/modules/python3-kolibri-pytz.json index cea19241..06b6f4a8 100644 --- a/build-aux/flatpak/modules/python3-kolibri-pytz.json +++ b/build-aux/flatpak/modules/python3-kolibri-pytz.json @@ -2,13 +2,17 @@ "name": "python3-pytz", "buildsystem": "simple", "build-commands": [ - "pip3 install --exists-action=i --no-index --find-links=\"file://${PWD}\" pytz==2022.7.1 --upgrade --target=\"${KOLIBRI_MODULE_PATH}/dist/\"" + "pip3 install --exists-action=i --no-index --find-links=\"file://${PWD}\" pytz==2024.1 --upgrade --target=\"${KOLIBRI_MODULE_PATH}/dist/\"" ], "sources": [ { "type": "file", - "url": "https://files.pythonhosted.org/packages/2e/09/fbd3c46dce130958ee8e0090f910f1fe39e502cc5ba0aadca1e8a2b932e5/pytz-2022.7.1-py2.py3-none-any.whl", - "sha256": "78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a" + "url": "https://files.pythonhosted.org/packages/90/26/9f1f00a5d021fff16dee3de13d43e5e978f3d58928e129c3a62cf7eb9738/pytz-2024.1.tar.gz", + "sha256": "2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812", + "x-checker-data": { + "type": "pypi", + "name": "pytz" + } } ] } diff --git a/build-aux/flatpak/modules/python3-kolibri.json b/build-aux/flatpak/modules/python3-kolibri.json index 30a0a586..13e55246 100644 --- a/build-aux/flatpak/modules/python3-kolibri.json +++ b/build-aux/flatpak/modules/python3-kolibri.json @@ -3,14 +3,24 @@ "buildsystem": "simple", "build-commands": [ "pip3 install --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} kolibri", - "patch -d ${KOLIBRI_MODULE_PATH} -p2 < 0001-Allow-superuser-to-be-null-in-device-provision-API.patch", + "patch -d ${KOLIBRI_MODULE_PATH} -p2 < 0001-Don-t-let-users-that-are-used-for-os-user-have-their.patch", "patch -d ${KOLIBRI_MODULE_PATH}/dist/ifcfg -p3 < dist_ifcfg/0001-Remove-needless-ifcfg-warning.patch" ], "sources": [ { "type": "file", - "url": "https://files.pythonhosted.org/packages/10/7d/8bfa283b1f89c2e4c442da3aff8eea119403609f176a97157454851733a7/kolibri-0.15.12-py2.py3-none-any.whl", - "sha256": "66871d3780263c3f5b5562c9821e803952edc0da594036dfb532fa25f5917c04" + "url": "https://files.pythonhosted.org/packages/49/c6/1ddd5696f192b273b6d4d959c18a6ed02fac543abefad6b893868091f980/kolibri-0.16.1-py2.py3-none-any.whl", + "sha256": "d5c7fdd3af22ab00e9eb52b895b4ee5c3aa91ccf540a9738adc65ef94202524e", + "x-checker-data": { + "type": "pypi", + "name": "kolibri", + "packagetype": "bdist_wheel", + "versions": { + ">=": "0.16.0", + "<": "0.17.0" + }, + "stable-only": true + } }, { "type": "dir", diff --git a/build-aux/flatpak/modules/python3-markdown.json b/build-aux/flatpak/modules/python3-markdown.json deleted file mode 100644 index 87eac09d..00000000 --- a/build-aux/flatpak/modules/python3-markdown.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "python3-markdown", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --ignore-installed --no-deps --no-index --no-build-isolation --find-links=\"file://${PWD}\" --target=\"${KOLIBRI_MODULE_PATH}/dist/\" markdown==3.3.7" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/f3/df/ca72f352e15b6f8ce32b74af029f1189abffb906f7c137501ffe69c98a65/Markdown-3.3.7-py3-none-any.whl", - "sha256": "f5da449a6e1c989a4cea2631aa8ee67caa5a2ef855d551c88f9e309f4634c621" - } - ] -} diff --git a/build-aux/flatpak/modules/python3-setproctitle.json b/build-aux/flatpak/modules/python3-setproctitle.json index eabff3e6..a0c50fdf 100644 --- a/build-aux/flatpak/modules/python3-setproctitle.json +++ b/build-aux/flatpak/modules/python3-setproctitle.json @@ -7,8 +7,12 @@ "sources": [ { "type": "file", - "url": "https://files.pythonhosted.org/packages/78/9a/cf6bf4c472b59aef3f3c0184233eeea8938d3366bcdd93d525261b1b9e0a/setproctitle-1.2.3.tar.gz", - "sha256": "ecf28b1c07a799d76f4326e508157b71aeda07b84b90368ea451c0710dbd32c0" + "url": "https://files.pythonhosted.org/packages/ff/e1/b16b16a1aa12174349d15b73fd4b87e641a8ae3fb1163e80938dbbf6ae98/setproctitle-1.3.3.tar.gz", + "sha256": "c913e151e7ea01567837ff037a23ca8740192880198b7fbb90b16d181607caae", + "x-checker-data": { + "type": "pypi", + "name": "setproctitle" + } } ] } diff --git a/build-aux/flatpak/modules/python3-virtualenv-api.json b/build-aux/flatpak/modules/python3-virtualenv-api.json deleted file mode 100644 index 9e6fe0f9..00000000 --- a/build-aux/flatpak/modules/python3-virtualenv-api.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "python3-virtualenv-api", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} virtualenv-api" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/ee/ff/48bde5c0f013094d729fe4b0316ba2a24774b3ff1c52d924a8a4cb04078a/six-1.15.0-py2.py3-none-any.whl", - "sha256": "8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/ee/cb/197bc3ba2a5f1d3af520f1acfb124dd696a63561c360abadd86619f54fad/virtualenv_api-2.1.18-py2.py3-none-any.whl", - "sha256": "7b9227a32b97dfddfc1f362cbd78ab768226be663c2ae20c7182e6d428268c28" - } - ] -} diff --git a/build-aux/flatpak/org.learningequality.Kolibri.Devel.json b/build-aux/flatpak/org.learningequality.Kolibri.Devel.json index c50052ba..1efa50c2 100644 --- a/build-aux/flatpak/org.learningequality.Kolibri.Devel.json +++ b/build-aux/flatpak/org.learningequality.Kolibri.Devel.json @@ -1,10 +1,10 @@ { "app-id" : "org.learningequality.Kolibri.Devel", "runtime" : "org.gnome.Platform", - "runtime-version" : "44", + "runtime-version" : "45", "sdk" : "org.gnome.Sdk", "command" : "/app/bin/kolibri-gnome", - "desktop-file-name-suffix" : " ☢️", + "desktop-file-name-suffix" : " (Dev)", "finish-args" : [ "--device=dri", "--share=ipc", @@ -12,7 +12,6 @@ "--socket=fallback-x11", "--socket=pulseaudio", "--socket=wayland", - "--socket=x11", "--system-talk-name=org.learningequality.Kolibri.Devel.Daemon", "--env=KOLIBRI_HOME=~/.var/app/org.learningequality.Kolibri.Devel/data/kolibri", "--env=KOLIBRI_HTTP_PORT=0", @@ -35,39 +34,44 @@ }, "build-options": { "env": { - "KOLIBRI_MODULE_PATH": "/app/lib/python3.10/site-packages/kolibri" + "KOLIBRI_MODULE_PATH": "/app/lib/python3.11/site-packages/kolibri" }, "test-args" : [ "--socket=fallback-x11", "--socket=wayland" ] }, + "cleanup-commands" : [ + "find ${KOLIBRI_MODULE_PATH}/dist/cext -type d -name 'cp*' -not -path ${KOLIBRI_MODULE_PATH}/dist/cext/cp311 -exec rm -rf '{}' '+'", + "rm -rf ${KOLIBRI_MODULE_PATH}/dist/cext/*/Windows", + "find ${KOLIBRI_MODULE_PATH} -type d -name 'test*' -not -path ${KOLIBRI_MODULE_PATH}/dist/django/test -exec rm -rf '{}' '+'" + ], "modules" : [ "modules/iproute2.json", - "modules/python3-markdown.json", "modules/python3-kolibri.json", "modules/python3-kolibri-pytz.json", "modules/python3-kolibri-app-desktop-xdg-plugin.json", - "modules/python3-kolibri-desktop-auth-plugin.json", - "modules/kolibri-home-template.json", "modules/python3-setproctitle.json", - "modules/python3-virtualenv-api.json", "modules/kolibri-content-dir.json", "modules/kolibri-plugins-dir.json", { "name" : "kolibri-gnome", "buildsystem" : "meson", "builddir" : true, - "run-tests" : true, "config-opts" : [ - "-Dkolibri_home_template_dir=/app/share/kolibri-home-template", "-Dprofile=development" ], "sources" : [ { - "type" : "git", - "url" : "https://github.com/learningequality/kolibri-installer-gnome.git", - "branch" : "master" + "type" : "dir", + "path" : "../..", + "skip" : [ + ".git", + ".flatpak-builder", + "build", + "flatpak_app", + "repo" + ] } ] } diff --git a/data/applications/meson.build b/data/applications/meson.build index 46b5a7cf..846d8f24 100644 --- a/data/applications/meson.build +++ b/data/applications/meson.build @@ -27,6 +27,7 @@ if desktop_file_validate.found() test( 'Validate desktop file', desktop_file_validate, - args: [desktop_file, launcher_file] + args: [desktop_file, launcher_file], + workdir: meson.current_build_dir() ) endif diff --git a/data/applications/org.learningequality.Kolibri.Frontend.desktop.in.in b/data/applications/org.learningequality.Kolibri.Frontend.desktop.in.in index 8b91eb9a..333fb586 100644 --- a/data/applications/org.learningequality.Kolibri.Frontend.desktop.in.in +++ b/data/applications/org.learningequality.Kolibri.Frontend.desktop.in.in @@ -14,5 +14,5 @@ DBusActivatable=true # TRANSLATORS: This is an icon file name. # Do not translate or transliterate this text! Icon=@FRONTEND_APPLICATION_ID@ -MimeType=x-scheme-handler/kolibri;x-scheme-handler/x-kolibri-app; +MimeType=x-scheme-handler/kolibri;x-scheme-handler/@APP_URI_SCHEME@; X-Endless-LaunchMaximized=true diff --git a/data/applications/org.learningequality.Kolibri.Launcher.desktop.in b/data/applications/org.learningequality.Kolibri.Launcher.desktop.in index ab3fbcb6..2423585a 100644 --- a/data/applications/org.learningequality.Kolibri.Launcher.desktop.in +++ b/data/applications/org.learningequality.Kolibri.Launcher.desktop.in @@ -6,4 +6,4 @@ Exec=@KOLIBRI_APP_LIBEXECDIR@/kolibri-gnome-launcher %U NoDisplay=true StartupNotify=false DBusActivatable=true -MimeType=x-scheme-handler/x-kolibri-dispatch; +MimeType=x-scheme-handler/@DISPATCH_URI_SCHEME@; diff --git a/data/kolibri-gnome/meson.build b/data/kolibri-gnome/meson.build index d9dfc966..17510d57 100644 --- a/data/kolibri-gnome/meson.build +++ b/data/kolibri-gnome/meson.build @@ -1 +1 @@ -install_subdir('assets', install_dir: kolibri_app_datadir) +install_subdir('assets', install_dir: kolibri_app_data_dir) diff --git a/data/meson.build b/data/meson.build index e15a900a..96c00082 100644 --- a/data/meson.build +++ b/data/meson.build @@ -5,3 +5,19 @@ subdir('icons') subdir('kolibri-gnome') subdir('media-icons') subdir('metainfo') + +gresource_file = configure_file( + input: 'org.learningequality.Kolibri.gresource.xml.in', + output: base_application_id + '.gresource.xml', + configuration: kolibri_app_config +) + +gnome.compile_resources( + 'kolibri-app', + gresource_file, + source_dir: meson.current_build_dir(), + dependencies: metainfo_file, + gresource_bundle: true, + install: true, + install_dir: kolibri_app_data_dir +) diff --git a/data/metainfo/meson.build b/data/metainfo/meson.build index 5689fcf6..c9d1b8be 100644 --- a/data/metainfo/meson.build +++ b/data/metainfo/meson.build @@ -13,12 +13,28 @@ metainfo_file = i18n.merge_file( install_dir: metainfo_dir ) -appstream_util = find_program('appstream-util', required: false) +appstreamcli = find_program('appstreamcli', required: false) + +if appstreamcli.found() + # FIXME: We have to temporarily remove the desktop ID from the metainfo + # file, as until https://github.com/ximion/appstream/pull/522 is shipped, + # appstreamcli won’t accept `endless` as a desktop ID. + metainfo_file_for_validation = custom_target( + 'org.learningequality.Kolibri.Temp.metainfo.xml', + output: 'org.learningequality.Kolibri.Temp.metainfo.xml', + input: metainfo_file, + command: [ + 'sed', + 's|Endless||g', + '@INPUT@', + ], + capture: true, + ) -if appstream_util.found() test( 'Validate metainfo file', - appstream_util, - args: ['validate', '--nonet', metainfo_file] + appstreamcli, + args: ['validate', '--no-net', '--explain', metainfo_file_for_validation], + workdir: meson.current_build_dir() ) endif diff --git a/data/metainfo/org.learningequality.Kolibri.metainfo.xml.in.in b/data/metainfo/org.learningequality.Kolibri.metainfo.xml.in.in index 1f1db11f..b9cc2228 100644 --- a/data/metainfo/org.learningequality.Kolibri.metainfo.xml.in.in +++ b/data/metainfo/org.learningequality.Kolibri.metainfo.xml.in.in @@ -2,7 +2,9 @@ @BASE_APPLICATION_ID@ Kolibri - Learning Equality + + Learning Equality + Offline education technology platform CC0-1.0 MIT @@ -22,6 +24,16 @@ + + +

Changes included in this release:

+
    +
  • + Kolibri is updated to 0.16.0. +
  • +
+
+

Changes included in this release:

@@ -203,5 +215,5 @@ info_at_learningequality.org kolibri-gnome - endless + Endless
diff --git a/data/org.learningequality.Kolibri.gresource.xml.in b/data/org.learningequality.Kolibri.gresource.xml.in new file mode 100644 index 00000000..b08a8ea2 --- /dev/null +++ b/data/org.learningequality.Kolibri.gresource.xml.in @@ -0,0 +1,6 @@ + + + + metainfo/@BASE_APPLICATION_ID@.metainfo.xml + + diff --git a/meson.build b/meson.build index 7f2579bb..35026fd2 100644 --- a/meson.build +++ b/meson.build @@ -1,6 +1,6 @@ project('kolibri-gnome', ['c'], meson_version: '>= 0.56.0', - version: '2.3.4' + version: run_command('cat', 'VERSION').stdout().strip() ) package_string = '@0@-@1@'.format(meson.project_name(), meson.project_version()) @@ -11,23 +11,31 @@ i18n = import('i18n') python_installation = python.find_installation('python3') +git_program = find_program('git', required : false, disabler : true) +git_describe_command = run_command(git_program, 'describe', '--dirty') + +if git_program.found() and git_describe_command.returncode() == 0 + git_commit = git_describe_command.stdout().strip() +else + git_commit = meson.project_version() +endif + bindir = join_paths(get_option('prefix'), get_option('bindir')) libexecdir = join_paths(get_option('prefix'), get_option('libexecdir')) locale_dir = join_paths(get_option('prefix'), get_option('localedir')) datadir = join_paths(get_option('prefix'), get_option('datadir')) -kolibri_home_template_dir = get_option('kolibri_home_template_dir') - build_profile = get_option('profile') - -if build_profile == 'default' - base_application_id = 'org.learningequality.Kolibri' - base_object_path = '/org/learningequality/Kolibri' - profile_env_prefix = 'KOLIBRI_' -elif build_profile == 'development' - base_application_id = 'org.learningequality.Kolibri.Devel' - base_object_path = '/org/learningequality/Kolibri/Devel' - profile_env_prefix = 'KOLIBRI_DEVEL_' +base_application_id = get_option('base_application_id') +base_object_path = get_option('base_object_path') +profile_env_prefix = get_option('env_prefix') +profile_uri_prefix = get_option('uri_prefix') + +if build_profile == 'development' + base_application_id += '.Devel' + base_object_path += '/Devel' + profile_env_prefix += 'DEVEL_' + profile_uri_prefix += '-devel' endif frontend_application_id = base_application_id @@ -45,23 +53,30 @@ daemon_private_object_path = daemon_object_path + '/Private' search_provider_application_id = base_application_id + '.SearchProvider' search_provider_object_path = base_object_path + '/SearchProvider' +kolibri_uri_scheme = 'kolibri' +app_uri_scheme = profile_uri_prefix + '-app' +dispatch_uri_scheme = profile_uri_prefix + '-dispatch' + po_dir = join_paths(meson.project_source_root(), 'po') kolibri_app_libexecdir = join_paths(libexecdir, 'kolibri-app') -kolibri_app_datadir = join_paths(datadir, 'kolibri-app') +kolibri_app_data_dir = join_paths(datadir, 'kolibri-app') kolibri_app_config = configuration_data() kolibri_app_config.set('BINDIR', bindir) kolibri_app_config.set('PYTHON', 'python3') kolibri_app_config.set('PACKAGE_STRING', package_string) kolibri_app_config.set('PROJECT_VERSION', meson.project_version()) +kolibri_app_config.set('VCS_TAG', git_commit) kolibri_app_config.set('GETTEXT_PACKAGE', meson.project_name()) kolibri_app_config.set('LOCALE_DIR', locale_dir) kolibri_app_config.set('BUILD_PROFILE', build_profile) kolibri_app_config.set('PROFILE_ENV_PREFIX', profile_env_prefix) +kolibri_app_config.set('KOLIBRI_URI_SCHEME', kolibri_uri_scheme) +kolibri_app_config.set('APP_URI_SCHEME', app_uri_scheme) +kolibri_app_config.set('DISPATCH_URI_SCHEME', dispatch_uri_scheme) kolibri_app_config.set('KOLIBRI_APP_LIBEXECDIR', kolibri_app_libexecdir) -kolibri_app_config.set('KOLIBRI_APP_DATADIR', kolibri_app_datadir) -kolibri_app_config.set('KOLIBRI_HOME_TEMPLATE_DIR', kolibri_home_template_dir) +kolibri_app_config.set('KOLIBRI_APP_DATA_DIR', kolibri_app_data_dir) kolibri_app_config.set('BASE_APPLICATION_ID', base_application_id) kolibri_app_config.set('BASE_OBJECT_PATH', base_object_path) kolibri_app_config.set('FRONTEND_APPLICATION_ID', frontend_application_id) diff --git a/meson_options.txt b/meson_options.txt index 1b33d571..4060732d 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -9,8 +9,29 @@ option( ) option( - 'kolibri_home_template_dir', + 'base_application_id', type: 'string', - value: '', - description: 'Directory where a KOLIBRI_HOME template is located' + value: 'org.learningequality.Kolibri', + description: 'Base application ID. When profile is "development", ".Devel" is appended' +) + +option( + 'base_object_path', + type: 'string', + value: '/org/learningequality/Kolibri', + description: 'Base D-Bus object path. When profile is "development", "/Devel" is appended' +) + +option( + 'env_prefix', + type: 'string', + value: 'KOLIBRI_', + description: 'Prefix for environment variables. When profile is "development", "DEVEL_" is appended' +) + +option( + 'uri_prefix', + type: 'string', + value: 'x-kolibri', + description: 'Prefix for custom URI schemes. When profile is "development", "-devel" is appended' ) diff --git a/po/POTFILES.in b/po/POTFILES.in index 22e14d34..eaaa65d5 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -1,5 +1,8 @@ # List of source files containing translatable strings. # Please keep this file sorted alphabetically. -data/applications/org.learningequality.Kolibri.desktop.in -data/metainfo/org.learningequality.Kolibri.metainfo.xml.in -src/kolibri_gnome/desktop_launcher/application.py +data/applications/org.learningequality.Kolibri.Frontend.desktop.in.in +data/metainfo/org.learningequality.Kolibri.metainfo.xml.in.in +src/kolibri_app/globals.py +src/kolibri_gnome/application.py +src/kolibri_gnome/kolibri_context.py +src/kolibri_gnome/kolibri_window.py diff --git a/src/kolibri_app/config.py.in b/src/kolibri_app/config.py.in index 7b768b8b..94c0b887 100644 --- a/src/kolibri_app/config.py.in +++ b/src/kolibri_app/config.py.in @@ -1,6 +1,10 @@ BUILD_PROFILE = "@BUILD_PROFILE@" PROFILE_ENV_PREFIX = "@PROFILE_ENV_PREFIX@" +KOLIBRI_URI_SCHEME = "@KOLIBRI_URI_SCHEME@" +APP_URI_SCHEME = "@APP_URI_SCHEME@" +DISPATCH_URI_SCHEME = "@DISPATCH_URI_SCHEME@" PROJECT_VERSION = "@PROJECT_VERSION@" +BASE_OBJECT_PATH = "@BASE_OBJECT_PATH@" BASE_APPLICATION_ID = "@BASE_APPLICATION_ID@" DAEMON_APPLICATION_ID = "@DAEMON_APPLICATION_ID@" DAEMON_OBJECT_PATH = "@DAEMON_OBJECT_PATH@" @@ -12,7 +16,7 @@ FRONTEND_OBJECT_PATH = "@FRONTEND_OBJECT_PATH@" FRONTEND_CHANNEL_APPLICATION_ID_PREFIX = "@FRONTEND_CHANNEL_APPLICATION_ID_PREFIX@" SEARCH_PROVIDER_APPLICATION_ID = "@SEARCH_PROVIDER_APPLICATION_ID@" SEARCH_PROVIDER_OBJECT_PATH = "@SEARCH_PROVIDER_OBJECT_PATH@" -DATA_DIR = "@KOLIBRI_APP_DATADIR@" +KOLIBRI_APP_DATA_DIR = "@KOLIBRI_APP_DATA_DIR@" GETTEXT_PACKAGE = "@GETTEXT_PACKAGE@" -KOLIBRI_HOME_TEMPLATE_DIR = "@KOLIBRI_HOME_TEMPLATE_DIR@" LOCALE_DIR = "@LOCALE_DIR@" +VCS_TAG = "@VCS_TAG@" diff --git a/src/kolibri_app/globals.py b/src/kolibri_app/globals.py index ee7452e8..e6bde0b6 100644 --- a/src/kolibri_app/globals.py +++ b/src/kolibri_app/globals.py @@ -4,18 +4,29 @@ import logging import os import typing +from gettext import gettext as _ from pathlib import Path from . import config +from .utils import getenv_as_bool logger = logging.getLogger(__name__) -APP_DEVELOPER_EXTRAS = os.environ.get( - config.PROFILE_ENV_PREFIX + "APP_DEVELOPER_EXTRAS" +USE_SYSTEM_INSTANCE = getenv_as_bool( + config.PROFILE_ENV_PREFIX + "USE_SYSTEM_INSTANCE", default=False ) -APP_FORCE_AUTOMATIC_LOGIN = os.environ.get( - config.PROFILE_ENV_PREFIX + "APP_FORCE_AUTOMATIC_LOGIN" +APP_DEVELOPER_EXTRAS = getenv_as_bool( + config.PROFILE_ENV_PREFIX + "APP_DEVELOPER_EXTRAS", + default=config.BUILD_PROFILE == "development", +) + +APP_AUTOMATIC_LOGIN = getenv_as_bool( + config.PROFILE_ENV_PREFIX + "APP_AUTOMATIC_LOGIN", default=True +) + +APP_AUTOMATIC_PROVISION = getenv_as_bool( + config.PROFILE_ENV_PREFIX + "APP_AUTOMATIC_PROVISION", default=USE_SYSTEM_INSTANCE ) XDG_CURRENT_DESKTOP = os.environ.get("XDG_CURRENT_DESKTOP") @@ -62,6 +73,26 @@ def init_logging(log_file_name: str = "kolibri-app.txt", level: int = logging.DE return logs_dir_path +def get_version(kolibri_version: str) -> str: + if config.BUILD_PROFILE == "development": + return _("{kolibri_version} ({vcs_tag})").format( + vcs_tag=config.VCS_TAG, + kolibri_version=kolibri_version, + ) + else: + return _("{kolibri_version} ({app_version})").format( + app_version=config.PROJECT_VERSION, + kolibri_version=kolibri_version, + ) + + +def get_release_notes_version() -> str: + if config.BUILD_PROFILE == "development": + return config.PROJECT_VERSION + "+next" + else: + return config.PROJECT_VERSION + + def get_current_language() -> typing.Optional[str]: try: translations = gettext.translation( diff --git a/src/kolibri_app/meson.build b/src/kolibri_app/meson.build index f9bb0705..b7b072f0 100644 --- a/src/kolibri_app/meson.build +++ b/src/kolibri_app/meson.build @@ -3,15 +3,16 @@ python_installation.install_sources( 'globals.py', '__init__.py', 'kolibri_settings.py', + 'utils.py', ], subdir: 'kolibri_app' ) configure_file( - input : 'config.py.in', - output : 'config.py', - configuration : kolibri_app_config, - install_dir : join_paths( + input: 'config.py.in', + output: 'config.py', + configuration: kolibri_app_config, + install_dir: join_paths( python_installation.get_install_dir( subdir: 'kolibri_app', pure: false @@ -39,3 +40,9 @@ configure_file( output: 'kolibri_settings.py', copy: true ) + +configure_file( + input: 'utils.py', + output: 'utils.py', + copy: true +) diff --git a/src/kolibri_app/utils.py b/src/kolibri_app/utils.py new file mode 100644 index 00000000..2a6014c3 --- /dev/null +++ b/src/kolibri_app/utils.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import os +import typing + + +_APP_MODULES_LIST = [ + "kolibri", + "kolibri_app_desktop_xdg_plugin", +] + + +def getenv_as_bool(key: str, default: bool = False) -> bool: + # List of strings to interpret as True or False, copied from strtobool() in + # distutils. The distutils module is deprecated since Python 3.10. + TRUTHY_STRINGS = ("y", "yes", "t", "true", "on", "1") + FALSY_STRINGS = ("n", "no", "f", "false", "off", "0") + + value = os.getenv(key) + + if value is None: + return default + + value = value.strip().lower() + + if value in TRUTHY_STRINGS: + return True + elif value in FALSY_STRINGS: + return False + + return default + + +def get_app_modules_debug_info() -> dict: + debug_info = {} + + for module_name in _APP_MODULES_LIST: + debug_info[module_name] = _get_module_debug_info(module_name) + + return debug_info + + +def _get_module_debug_info(module_name: str) -> typing.Optional[dict]: + from importlib.metadata import PackageNotFoundError + from importlib.util import find_spec + from importlib.metadata import version + + module_spec = find_spec(module_name) + + if module_spec is None: + return None + + try: + return { + "version": version(module_name), + "origin": module_spec.origin, + } + except PackageNotFoundError: + return None diff --git a/src/kolibri_daemon/application.py b/src/kolibri_daemon/application.py index a7f34b76..dabf6800 100644 --- a/src/kolibri_daemon/application.py +++ b/src/kolibri_daemon/application.py @@ -12,6 +12,7 @@ from kolibri_app.config import DAEMON_APPLICATION_ID from kolibri_app.config import DAEMON_MAIN_OBJECT_PATH from kolibri_app.config import DAEMON_PRIVATE_OBJECT_PATH +from kolibri_app.globals import APP_AUTOMATIC_PROVISION from .dbus_helpers import DBusManagerProxy from .desktop_users import AccountsServiceManager @@ -32,12 +33,18 @@ class LoginToken(typing.NamedTuple): key: str expires: int + @classmethod + def with_no_expiry(cls, **kwargs) -> LoginToken: + return cls(expires=0, **kwargs) + @classmethod def with_expire_time(cls, expires_in: int, **kwargs) -> LoginToken: expires = int(time.monotonic() + expires_in) return cls(expires=expires, **kwargs) def is_expired(self) -> bool: + if self.expires == 0: + return False return self.expires < time.monotonic() @@ -365,7 +372,7 @@ def __on_check_login_token( token_key: str, ) -> bool: self.__application.reset_inactivity_timeout() - login_token = self.__application.pop_login_token(token_key) + login_token = self.__application.get_login_token(token_key) if login_token: result_dict = login_token.user._asdict() else: @@ -376,8 +383,6 @@ def __on_check_login_token( class LoginTokenManager(object): - TOKEN_EXPIRE_TIME = 60 - def __init__(self): self.__login_tokens = dict() self.__expire_tokens_timeout_source = None @@ -386,16 +391,18 @@ def generate_for_user(self, user_info: UserInfo) -> str: self.__revoke_expired_tokens() return self.__add_login_token(user_info) - def pop_login_token(self, token_key: str) -> typing.Optional[LoginToken]: + def get_login_token(self, token_key: str) -> typing.Optional[LoginToken]: self.__revoke_expired_tokens() - return self.__pop_login_token(token_key) + return self.__get_login_token(token_key) def __add_login_token(self, user_info: UserInfo) -> str: user_id = str(user_info.user_id) token_key = self.__generate_token_key(user_id) - login_token = LoginToken.with_expire_time( - self.TOKEN_EXPIRE_TIME, user=user_info, key=token_key - ) + # We are unable to predict when Kolibri will attempt to authenticate + # using a login token, so we request a token that can be reused and + # has no expiry time. This is usually fine, because the login token + # generates a session token which lasts for the same length of time. + login_token = LoginToken.with_no_expiry(user=user_info, key=token_key) # We only allow one token at a time to be associated with a particular # user. Using a dictionary provides that for free. self.__login_tokens[user_id] = login_token @@ -404,11 +411,11 @@ def __add_login_token(self, user_info: UserInfo) -> str: def __generate_token_key(self, user_id: str) -> str: return ":".join([user_id, uuid4().hex]) - def __pop_login_token(self, token_key: str) -> typing.Optional[LoginToken]: + def __get_login_token(self, token_key: str) -> typing.Optional[LoginToken]: user_id, _sep, _uuid = token_key.partition(":") login_token = self.__login_tokens.get(user_id, None) if login_token and login_token.key == token_key: - self.__login_tokens.pop(user_id, None) + self.__login_tokens.get(user_id, None) return login_token else: return None @@ -519,8 +526,8 @@ def release_with_token(self, token: typing.Hashable): def generate_login_token(self, user_info: UserInfo) -> str: return self.__login_token_manager.generate_for_user(user_info) - def pop_login_token(self, token_key: str) -> typing.Optional[LoginToken]: - return self.__login_token_manager.pop_login_token(token_key) + def get_login_token(self, token_key: str) -> typing.Optional[LoginToken]: + return self.__login_token_manager.get_login_token(token_key) def get_item_ids_for_search(self, search: str) -> list: return self.__search_handler.get_item_ids_for_search(search) @@ -571,6 +578,15 @@ def do_handle_local_options(self, options: GLib.VariantDict) -> int: def do_startup(self): if self.use_system_bus: Gio.bus_get(Gio.BusType.SYSTEM, None, self.__system_bus_on_get) + + # If kolibri-daemon is running as a system service, start automatic + # provisioning regardless of the value of APP_AUTOMATIC_PROVISION. This + # works around an issue in eos-kolibri where that environment variable + # is unset for the Kolibri system service. + # FIXME: Remove the special case once the eos-kolibri issue is resolved. + if APP_AUTOMATIC_PROVISION or self.use_system_bus: + self.__kolibri_service.automatic_provision() + Gio.Application.do_startup(self) def do_shutdown(self): diff --git a/src/kolibri_daemon/desktop_users.py b/src/kolibri_daemon/desktop_users.py index df2ce490..2dd6c23c 100644 --- a/src/kolibri_daemon/desktop_users.py +++ b/src/kolibri_daemon/desktop_users.py @@ -50,7 +50,9 @@ def from_pwd_user(cls, user: pwd.struct_passwd, is_admin: bool = False) -> UserI @classmethod def from_user_id_future( - cls, user_id: int, accounts_service: AccountsServiceManager = None + cls, + user_id: int, + accounts_service: typing.Optional[AccountsServiceManager] = None, ) -> Future[UserInfo]: out_future: Future[UserInfo] = Future() diff --git a/src/kolibri_daemon/futures.py b/src/kolibri_daemon/futures.py index 9bfad74a..3d63516a 100644 --- a/src/kolibri_daemon/futures.py +++ b/src/kolibri_daemon/futures.py @@ -8,7 +8,7 @@ def future_chain( from_future: typing.Any, to_future: typing.Optional[Future] = None, - map_fn: typing.Callable = None, + map_fn: typing.Optional[typing.Callable] = None, ) -> Future: """ This is an attempt to build a simple way of chaining together Future @@ -33,7 +33,9 @@ def future_chain( def _future_chain_from_future_done_cb( - from_future: Future, to_future: Future, map_fn: typing.Callable = None + from_future: Future, + to_future: Future, + map_fn: typing.Optional[typing.Callable] = None, ): try: result = from_future.result() diff --git a/src/kolibri_daemon/kolibri_app_interface.py b/src/kolibri_daemon/kolibri_app_interface.py new file mode 100644 index 00000000..ba012c40 --- /dev/null +++ b/src/kolibri_daemon/kolibri_app_interface.py @@ -0,0 +1,64 @@ +import typing + +from gi.repository import Gio +from kolibri_app.config import DAEMON_APPLICATION_ID +from kolibri_app.config import DAEMON_PRIVATE_OBJECT_PATH + + +class KolibriAppInterface(object): + _instance = None + + def __init__(self): + pass + + @classmethod + def get_default(cls): + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def register(self): + from kolibri.plugins.app.utils import interface + + interface.register( + get_os_user=self.__app_interface_get_os_user, + check_is_metered=self.__app_interface_check_is_metered, + ) + + def __app_interface_get_os_user( + self, auth_token: str + ) -> typing.Tuple[typing.Optional[str], bool]: + user_details = self._get_user_details(auth_token) + + if not user_details: + return None, False + + # The user details object also includes user_id and full_name, but at + # the moment we have no way to communicate this to Kolibri. + + return ( + user_details.get("user_name", None), + user_details.get("is_admin", False), + ) + + def __app_interface_check_is_metered(self) -> bool: + return Gio.NetworkMonitor.get_default().get_network_metered() + + def _get_user_details(self, auth_token: str) -> typing.Optional[dict]: + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + proxy = Gio.DBusProxy.new_sync( + bus, + 0, + None, + DAEMON_APPLICATION_ID, + DAEMON_PRIVATE_OBJECT_PATH, + "org.learningequality.Kolibri.Daemon.Private", + None, + ) + + try: + details = proxy.CheckLoginToken("(s)", auth_token) + except Exception: + return None + + return details diff --git a/src/kolibri_daemon/kolibri_http_process.py b/src/kolibri_daemon/kolibri_http_process.py index c8123338..21a44641 100644 --- a/src/kolibri_daemon/kolibri_http_process.py +++ b/src/kolibri_daemon/kolibri_http_process.py @@ -9,9 +9,11 @@ from kolibri.dist.magicbus.plugins import SimplePlugin from kolibri_app.globals import KOLIBRI_HOME_PATH +from .kolibri_app_interface import KolibriAppInterface from .kolibri_service_context import KolibriServiceContext from .kolibri_service_context import KolibriServiceProcess from .kolibri_utils import init_kolibri +from .kolibri_utils import kolibri_automatic_provision logger = logging.getLogger(__name__) @@ -32,6 +34,7 @@ class KolibriHttpProcess(KolibriServiceProcess): __kolibri_bus: ProcessBus class Command(Enum): + AUTOMATIC_PROVISION = auto() START_KOLIBRI = auto() STOP_KOLIBRI = auto() SHUTDOWN = auto() @@ -43,6 +46,7 @@ def __init__( self.__command_rx = command_rx self.__keep_alive = True self.__commands = { + self.Command.AUTOMATIC_PROVISION: self.__automatic_provision, self.Command.START_KOLIBRI: self.__start_kolibri, self.Command.STOP_KOLIBRI: self.__stop_kolibri, self.Command.SHUTDOWN: self.__shutdown, @@ -58,10 +62,11 @@ def run(self): self.__update_kolibri_context() + KolibriAppInterface.get_default().register() + self.__kolibri_bus = KolibriProcessBus( port=OPTIONS["Deployment"]["HTTP_PORT"], zip_port=OPTIONS["Deployment"]["ZIP_CONTENT_PORT"], - background=False, ) kolibri_daemon_plugin = _KolibriDaemonPlugin(self.__kolibri_bus, self.context) @@ -99,6 +104,9 @@ def __run_command(self, command: KolibriHttpProcess.Command): return fn() + def __automatic_provision(self): + kolibri_automatic_provision() + def __start_kolibri(self): if _process_bus_has_transition(self.__kolibri_bus, "START"): self.context.is_starting = True diff --git a/src/kolibri_daemon/kolibri_search_handler.py b/src/kolibri_daemon/kolibri_search_handler.py index 9384d52b..efad456d 100644 --- a/src/kolibri_daemon/kolibri_search_handler.py +++ b/src/kolibri_daemon/kolibri_search_handler.py @@ -134,7 +134,7 @@ def get_item_ids_for_search(self, search: str) -> list: args = (search,) future = self.__executor.submit( - LocalSearchHandler._get_item_ids_for_search, args + LocalSearchHandler._get_item_ids_for_search, *args ) return future.result() diff --git a/src/kolibri_daemon/kolibri_service_context.py b/src/kolibri_daemon/kolibri_service_context.py index 8e38e198..bb00109d 100644 --- a/src/kolibri_daemon/kolibri_service_context.py +++ b/src/kolibri_daemon/kolibri_service_context.py @@ -124,7 +124,9 @@ def is_bus_ready(self, is_bus_ready: typing.Optional[bool]): self.__is_bus_ready_set_event.set() self.push_has_changes() - def await_is_bus_ready(self, timeout: int = None) -> typing.Optional[bool]: + def await_is_bus_ready( + self, timeout: typing.Optional[int] = None + ) -> typing.Optional[bool]: self.__is_bus_ready_set_event.wait(timeout) return self.is_bus_ready @@ -145,7 +147,9 @@ def is_starting(self, is_starting: typing.Optional[bool]): self.__is_starting_set_event.set() self.push_has_changes() - def await_is_starting(self, timeout: int = None) -> typing.Optional[bool]: + def await_is_starting( + self, timeout: typing.Optional[int] = None + ) -> typing.Optional[bool]: self.__is_starting_set_event.wait(timeout) return self.is_starting @@ -166,7 +170,9 @@ def is_started(self, is_started: typing.Optional[bool]): self.__is_started_set_event.set() self.push_has_changes() - def await_is_started(self, timeout: int = None) -> typing.Optional[bool]: + def await_is_started( + self, timeout: typing.Optional[int] = None + ) -> typing.Optional[bool]: self.__is_started_set_event.wait(timeout) return self.is_started @@ -190,7 +196,7 @@ def start_error( self.push_has_changes() def await_start_error( - self, timeout: int = None + self, timeout: typing.Optional[int] = None ) -> typing.Optional[KolibriServiceContext.StartError]: self.__start_error_set_event.wait(timeout) return self.start_error @@ -212,7 +218,9 @@ def app_key(self, app_key: typing.Optional[str]): self.__app_key_set_event.set() self.push_has_changes() - def await_app_key(self, timeout: int = None) -> typing.Optional[str]: + def await_app_key( + self, timeout: typing.Optional[int] = None + ) -> typing.Optional[str]: self.__app_key_set_event.wait(timeout) return self.app_key @@ -233,7 +241,9 @@ def base_url(self, base_url: typing.Optional[str]): self.__base_url_set_event.set() self.push_has_changes() - def await_base_url(self, timeout: int = None) -> typing.Optional[str]: + def await_base_url( + self, timeout: typing.Optional[int] = None + ) -> typing.Optional[str]: self.__base_url_set_event.wait(timeout) return self.base_url @@ -254,7 +264,9 @@ def extra_url(self, extra_url: typing.Optional[str]): self.__extra_url_set_event.set() self.push_has_changes() - def await_extra_url(self, timeout: int = None) -> typing.Optional[str]: + def await_extra_url( + self, timeout: typing.Optional[int] = None + ) -> typing.Optional[str]: self.__extra_url_set_event.wait(timeout) return self.extra_url @@ -275,7 +287,9 @@ def kolibri_home(self, kolibri_home: typing.Optional[str]): self.__kolibri_home_set_event.set() self.push_has_changes() - def await_kolibri_home(self, timeout: int = None) -> typing.Optional[str]: + def await_kolibri_home( + self, timeout: typing.Optional[int] = None + ) -> typing.Optional[str]: self.__kolibri_home_set_event.wait(timeout) return self.kolibri_home @@ -296,7 +310,9 @@ def kolibri_version(self, kolibri_version: typing.Optional[str]): self.__kolibri_version_set_event.set() self.push_has_changes() - def await_kolibri_version(self, timeout: int = None) -> typing.Optional[str]: + def await_kolibri_version( + self, timeout: typing.Optional[int] = None + ) -> typing.Optional[str]: self.__kolibri_version_set_event.wait(timeout) return self.kolibri_version diff --git a/src/kolibri_daemon/kolibri_service_manager.py b/src/kolibri_daemon/kolibri_service_manager.py index 0dd16da1..146d1de7 100644 --- a/src/kolibri_daemon/kolibri_service_manager.py +++ b/src/kolibri_daemon/kolibri_service_manager.py @@ -34,6 +34,9 @@ def init(self): def __send_command(self, command: KolibriHttpProcess.Command): self.__command_tx.send(command) + def automatic_provision(self): + self.__send_command(KolibriHttpProcess.Command.AUTOMATIC_PROVISION) + def start_kolibri(self): self.__send_command(KolibriHttpProcess.Command.START_KOLIBRI) diff --git a/src/kolibri_daemon/kolibri_utils.py b/src/kolibri_daemon/kolibri_utils.py index 8e656b68..3c50e54a 100644 --- a/src/kolibri_daemon/kolibri_utils.py +++ b/src/kolibri_daemon/kolibri_utils.py @@ -1,17 +1,14 @@ from __future__ import annotations -import filecmp import importlib.util import json import logging import os -import shutil -import typing +import platform +import tempfile +from gettext import gettext as _ from pathlib import Path -from kolibri_app.config import KOLIBRI_HOME_TEMPLATE_DIR -from kolibri_app.globals import KOLIBRI_HOME_PATH - from .content_extensions_manager import ContentExtensionsManager logger = logging.getLogger(__name__) @@ -24,7 +21,6 @@ # These Kolibri plugins will be automatically enabled if they are available: OPTIONAL_PLUGINS = [ "kolibri_app_desktop_xdg_plugin", - "kolibri_desktop_auth_plugin", "kolibri_dynamic_collections_plugin", "kolibri_zim_plugin", ] @@ -33,8 +29,6 @@ def init_kolibri(**kwargs): - _kolibri_update_from_home_template() - _init_kolibri_env() from kolibri.utils.main import initialize @@ -50,29 +44,19 @@ def init_kolibri(**kwargs): def _init_kolibri_env(): os.environ["DJANGO_SETTINGS_MODULE"] = "kolibri_app.kolibri_settings" + os.environ["KOLIBRI_PROJECT"] = "kolibri-gnome" # Kolibri defaults to a very large thread pool. Because we expect this # application to be used in a single user environment with a limited # workload, we can use a smaller number of threads. os.environ.setdefault("KOLIBRI_CHERRYPY_THREAD_POOL", "10") - # Automatically provision with $KOLIBRI_HOME/automatic_provision.json if it - # exists. - # TODO: Once kolibri-gnome supports automatic login for all cases, use an - # included automatic provision file by default. - automatic_provision_path = _get_automatic_provision_path() - if automatic_provision_path: - os.environ.setdefault( - "KOLIBRI_AUTOMATIC_PROVISION_FILE", automatic_provision_path.as_posix() - ) - content_extensions_manager = ContentExtensionsManager() content_extensions_manager.apply(os.environ) def _enable_kolibri_plugin(plugin_name: str, optional=False) -> bool: from kolibri.plugins import config as plugins_config - from kolibri.plugins.registry import registered_plugins from kolibri.plugins.utils import enable_plugin if optional and not importlib.util.find_spec(plugin_name): @@ -80,69 +64,48 @@ def _enable_kolibri_plugin(plugin_name: str, optional=False) -> bool: if plugin_name not in plugins_config.ACTIVE_PLUGINS: logger.info(f"Enabling plugin {plugin_name}") - registered_plugins.register_plugins([plugin_name]) enable_plugin(plugin_name) return True -def _get_automatic_provision_path() -> typing.Optional[Path]: - path = KOLIBRI_HOME_PATH.joinpath("automatic_provision.json") - - if not path.is_file(): - return None - - with path.open("r") as in_file: - try: - data = json.load(in_file) - except json.JSONDecodeError as error: - logger.warning( - f"Error reading automatic provision data from '{path.as_posix()}': {error}" - ) - return None - - if not data.keys().isdisjoint(["facility", "superusername", "superuserpassword"]): - # If a file has an attribute unique to the old format, we will asume it - # is outdated. - return None - - return path - - -def _kolibri_update_from_home_template(): - """ - Construct a Kolibri home directory based on the Kolibri home template, if - necessary. - """ +def kolibri_automatic_provision(): + from kolibri.core.device.utils import device_provisioned + from kolibri.core.device.utils import provision_from_file - # TODO: This code should probably be in Kolibri itself - - kolibri_home_template_dir = Path(KOLIBRI_HOME_TEMPLATE_DIR) - - if not kolibri_home_template_dir.is_dir(): - return - - if not KOLIBRI_HOME_PATH.is_dir(): - KOLIBRI_HOME_PATH.mkdir(parents=True, exist_ok=True) - - compare = filecmp.dircmp( - kolibri_home_template_dir, - KOLIBRI_HOME_PATH, - ignore=["logs", "job_storage.sqlite3"], - ) - - if len(compare.common) > 0: + if device_provisioned(): return - # If Kolibri home was not already initialized, copy files from the - # template directory to the new home directory. - - logger.info(f"Copying KOLIBRI_HOME template to '{KOLIBRI_HOME_PATH.as_posix()}'") - - for filename in compare.left_only: - left_file = Path(compare.left, filename) - right_file = Path(compare.right, filename) - if left_file.is_dir(): - shutil.copytree(left_file, right_file) - else: - shutil.copy2(left_file, right_file) + # It is better to create a TemporaryDirectory containing a file, because + # provision_from_file deals with file paths instead of open files, and it + # deletes the provided file, which confuses tempfile.NamedTemporaryFile. + with tempfile.TemporaryDirectory() as directory: + file = Path(directory, "automatic_provision.json").open("w") + json.dump(_get_automatic_provision_data(), file) + file.flush() + provision_from_file(file.name) + + +def _get_automatic_provision_data() -> dict: + facility_name = _("Kolibri on {host}").format(host=platform.node() or "localhost") + return { + "facility_name": facility_name, + "preset": "formal", + "facility_settings": { + "learner_can_login_with_no_password": False, + }, + "device_settings": { + # Kolibri interprets None as "the system language at setup time", + # while an empty string causes Kolibri to always use the current + # browser language: + # + "language_id": "", + "landing_page": "learn", + "allow_guest_access": False, + "allow_other_browsers_to_connect": False, + }, + "superuser": { + "username": None, + "password": None, + }, + } diff --git a/src/kolibri_daemon/meson.build b/src/kolibri_daemon/meson.build index b317fc66..d4053388 100644 --- a/src/kolibri_daemon/meson.build +++ b/src/kolibri_daemon/meson.build @@ -8,6 +8,7 @@ python_installation.install_sources( 'futures.py', 'glib_helpers.py', '__init__.py', + 'kolibri_app_interface.py', 'kolibri_http_process.py', 'kolibri_search_handler.py', 'kolibri_service_context.py', diff --git a/src/kolibri_gnome/application.py b/src/kolibri_gnome/application.py index f743b214..1b7264d3 100644 --- a/src/kolibri_gnome/application.py +++ b/src/kolibri_gnome/application.py @@ -1,9 +1,11 @@ from __future__ import annotations +import json import logging import typing from functools import partial from gettext import gettext as _ +from pathlib import Path from urllib.parse import urlsplit from gi.repository import Adw @@ -12,8 +14,13 @@ from gi.repository import GObject from gi.repository import Gtk from gi.repository import WebKit +from kolibri_app.config import APP_URI_SCHEME from kolibri_app.config import BASE_APPLICATION_ID -from kolibri_app.config import PROJECT_VERSION +from kolibri_app.config import BASE_OBJECT_PATH +from kolibri_app.config import KOLIBRI_APP_DATA_DIR +from kolibri_app.config import KOLIBRI_URI_SCHEME +from kolibri_app.globals import get_release_notes_version +from kolibri_app.globals import get_version from kolibri_app.globals import KOLIBRI_HOME_PATH from kolibri_app.globals import XDG_CURRENT_DESKTOP @@ -30,9 +37,16 @@ class Application(Adw.Application): application_name = GObject.Property(type=str, default=_("Kolibri")) - def __init__(self, *args, context: KolibriContext = None, **kwargs): + def __init__( + self, *args, context: typing.Optional[KolibriContext] = None, **kwargs + ): super().__init__(*args, flags=Gio.ApplicationFlags.HANDLES_OPEN, **kwargs) + resource = Gio.Resource.load( + Path(KOLIBRI_APP_DATA_DIR, "kolibri-app.gresource").as_posix() + ) + resource._register() + self.__context = context or KolibriContext() self.__context.connect("download-started", self.__context_on_download_started) self.__context.connect("open-external-url", self.__context_on_open_external_url) @@ -41,10 +55,6 @@ def __init__(self, *args, context: KolibriContext = None, **kwargs): action.connect("activate", self.__on_open_documentation) self.add_action(action) - action = Gio.SimpleAction.new("open-forums", None) - action.connect("activate", self.__on_open_forums) - self.add_action(action) - action = Gio.SimpleAction.new("new-window", None) action.connect("activate", self.__on_new_window) self.add_action(action) @@ -96,9 +106,6 @@ def __on_open_documentation(self, action, *args): "https://kolibri.readthedocs.io/en/latest/" ) - def __on_open_forums(self, action, *args): - self.open_url_in_external_application("https://community.learningequality.org/") - def __on_new_window(self, action, *args): self.open_kolibri_window() @@ -109,22 +116,26 @@ def __on_open_kolibri_home(self, action, *args): self.open_url_in_external_application(KOLIBRI_HOME_PATH.as_uri()) def __on_about(self, action, *args): - about_window = Adw.AboutWindow( - transient_for=self.get_active_window(), - modal=True, - application_name=_("Kolibri"), - application_icon=BASE_APPLICATION_ID, - copyright=_("© 2022 Learning Equality"), - version=_("{kolibri_version} ({app_version})").format( - app_version=PROJECT_VERSION, - kolibri_version=self.__context.kolibri_version, - ), - license_type=Gtk.License.MIT_X11, - website="https://learningequality.org", - issue_url="https://community.learningequality.org/", + about_window = Adw.AboutWindow.new_from_appdata( + f"{BASE_OBJECT_PATH}/{BASE_APPLICATION_ID}.metainfo.xml", + get_release_notes_version(), + ) + about_window.set_version(get_version(self.__context.kolibri_version)) + about_window.add_link( + _("Community Forums"), "https://community.learningequality.org/" ) + about_window.set_debug_info(self.__format_debug_info()) + about_window.set_debug_info_filename("kolibri-gnome-debug-info.json") + about_window.set_transient_for(self.get_active_window()) + about_window.set_modal(True) about_window.present() + def __format_debug_info(self): + return json.dumps( + self.__context.get_debug_info(), + indent=4, + ) + def __on_quit(self, action, *args): self.quit() @@ -134,7 +145,7 @@ def open_url_in_external_application(self, url: str): file_launcher.launch(None, None, None) def open_kolibri_window( - self, target_url: str = None, **kwargs + self, target_url: typing.Optional[str] = None, **kwargs ) -> typing.Optional[KolibriWindow]: target_url = target_url or self.__context.default_url @@ -148,8 +159,9 @@ def open_kolibri_window( window.connect("open-new-window", self.__window_on_open_new_window) window.load_kolibri_url(target_url, present=True) - # Maximize windows on Endless OS - if XDG_CURRENT_DESKTOP == "endless:GNOME": + # Maximize windows on Endless OS. Typically $XDG_CURRENT_DESKTOP will be + # `endless:GNOME` or `Endless:GNOME`. + if XDG_CURRENT_DESKTOP and "endless" in XDG_CURRENT_DESKTOP.lower().split(":"): window.maximize() window.connect("auto-close", self.__kolibri_window_on_auto_close) @@ -251,7 +263,7 @@ def __window_on_open_new_window( return new_window.get_main_webview() if new_window else None def __handle_open_file_url(self, url: str): - valid_url_schemes = ("kolibri", "x-kolibri-app") + valid_url_schemes = (KOLIBRI_URI_SCHEME, APP_URI_SCHEME) url_tuple = urlsplit(url) @@ -284,7 +296,7 @@ def __context_on_kolibri_ready(self, context: KolibriContext): result_cb=self.__on_kolibri_api_channel_response, ) - def __on_kolibri_api_channel_response(self, data: typing.Any): + def __on_kolibri_api_channel_response(self, data: typing.Any, **kwargs): if not isinstance(data, dict): return diff --git a/src/kolibri_gnome/kolibri_context.py b/src/kolibri_gnome/kolibri_context.py index 00f52f63..bded24ec 100644 --- a/src/kolibri_gnome/kolibri_context.py +++ b/src/kolibri_gnome/kolibri_context.py @@ -1,22 +1,27 @@ from __future__ import annotations import logging -import platform import re import typing -from gettext import gettext as _ +from functools import partial from pathlib import Path from urllib.parse import parse_qs from urllib.parse import SplitResult from urllib.parse import urlencode from urllib.parse import urlsplit -from gi.repository import Gio from gi.repository import GLib from gi.repository import GObject +from gi.repository import Soup from gi.repository import WebKit -from kolibri_app.config import DATA_DIR +from kolibri_app.config import APP_URI_SCHEME +from kolibri_app.config import BUILD_PROFILE from kolibri_app.config import FRONTEND_APPLICATION_ID +from kolibri_app.config import KOLIBRI_APP_DATA_DIR +from kolibri_app.config import KOLIBRI_URI_SCHEME +from kolibri_app.config import PROJECT_VERSION +from kolibri_app.config import VCS_TAG +from kolibri_app.utils import get_app_modules_debug_info from .kolibri_daemon_manager import KolibriDaemonManager from .utils import await_properties @@ -26,6 +31,12 @@ logger = logging.getLogger(__name__) +LEARN_PATH_PREFIX = "/learn/#/" + +STATIC_PATHS_RE = r"^(app|static|downloadcontent|content\/storage|content\/static|content\/zipcontent)\/?" +SYSTEM_PATHS_RE = r"^(?P[\w\-]+\/)?(user|logout|redirectuser|learn\/app)\/?" +CONTENT_PATHS_RE = r"^(?P[\w\-]+\/)?learn\/?" + class KolibriContext(GObject.GObject): """ @@ -66,8 +77,8 @@ def __init__(self): ) loader_path = get_localized_file( - Path(DATA_DIR, "assets", "_load-{}.html").as_posix(), - Path(DATA_DIR, "assets", "_load.html"), + Path(KOLIBRI_APP_DATA_DIR, "assets", "_load-{}.html").as_posix(), + Path(KOLIBRI_APP_DATA_DIR, "assets", "_load.html"), ) self.__loader_url = loader_path.as_uri() @@ -95,7 +106,7 @@ def kolibri_version(self) -> str: @property def default_url(self) -> str: - return "x-kolibri-app:/" + return f"{APP_URI_SCHEME}:/" @property def webkit_web_context(self) -> WebKit.WebContext: @@ -109,10 +120,10 @@ def shutdown(self): def get_absolute_url(self, url: str) -> typing.Optional[str]: url_tuple = urlsplit(url) - if url_tuple.scheme == "kolibri": + if url_tuple.scheme == KOLIBRI_URI_SCHEME: target_url = self.parse_kolibri_url_tuple(url_tuple) return self.__kolibri_daemon.get_absolute_url(target_url) - elif url_tuple.scheme == "x-kolibri-app": + elif url_tuple.scheme == APP_URI_SCHEME: target_url = self.parse_x_kolibri_app_url_tuple(url_tuple) return self.__kolibri_daemon.get_absolute_url(target_url) return url @@ -126,7 +137,8 @@ def kolibri_api_get_async(self, *args, **kwargs): def should_open_url(self, url: str) -> bool: return ( url == self.default_url - or urlsplit(url).scheme in ("kolibri", "x-kolibri-app", "about", "blob") + or urlsplit(url).scheme + in (KOLIBRI_URI_SCHEME, APP_URI_SCHEME, "about", "blob") or self.is_url_in_scope(url) ) @@ -144,6 +156,21 @@ def default_is_url_in_scope(self, url: str) -> bool: def is_url_in_scope(self, url: str) -> bool: return self.default_is_url_in_scope(url) + def get_debug_info(self) -> dict: + # FIXME: It would be better to call `get_app_modules_debug_info()` from` + # the kolibri_daemon service and include the output here. In some + # rare cases, its Python environment may differ. + return { + "app": { + "project_version": PROJECT_VERSION, + "vcs_tag": VCS_TAG, + "build_profile": BUILD_PROFILE, + "do_automatic_login": self.__kolibri_daemon.do_automatic_login, + }, + "kolibri_daemon": self.__kolibri_daemon.get_debug_info(), + "python_modules": get_app_modules_debug_info(), + } + def get_loader_url(self, state: str) -> str: return self.__loader_url + "#" + state @@ -177,29 +204,33 @@ def parse_kolibri_url_tuple(self, url_tuple: SplitResult) -> str: else: return self._get_kolibri_library_path(url_search) - def _get_kolibri_content_path(self, node_id: str, search: str = None) -> str: + def _get_kolibri_content_path( + self, node_id: str, search: typing.Optional[str] = None + ) -> str: if search: query = {"keywords": search, "last": "TOPICS_TOPIC_SEARCH"} - return f"/learn#/topics/c/{node_id}?{urlencode(query)}" + return f"{LEARN_PATH_PREFIX}topics/c/{node_id}?{urlencode(query)}" else: - return f"/learn#/topics/c/{node_id}" + return f"{LEARN_PATH_PREFIX}topics/c/{node_id}" - def _get_kolibri_topic_path(self, node_id: str, search: str = None) -> str: + def _get_kolibri_topic_path( + self, node_id: str, search: typing.Optional[str] = None + ) -> str: if search: query = {"keywords": search} - return f"/learn#/topics/t/{node_id}/search?{urlencode(query)}" + return f"{LEARN_PATH_PREFIX}topics/t/{node_id}/search?{urlencode(query)}" else: - return f"/learn#/topics/t/{node_id}" + return f"{LEARN_PATH_PREFIX}topics/t/{node_id}" - def _get_kolibri_library_path(self, search: str = None) -> str: + def _get_kolibri_library_path(self, search: typing.Optional[str] = None) -> str: if search: query = {"keywords": search} - return f"/learn#/library?{urlencode(query)}" + return f"{LEARN_PATH_PREFIX}library?{urlencode(query)}" else: - return "/learn/#/home" + return f"{LEARN_PATH_PREFIX}home" def url_to_x_kolibri_app(self, url: str) -> str: - return urlsplit(url)._replace(scheme="x-kolibri-app", netloc="").geturl() + return urlsplit(url)._replace(scheme=APP_URI_SCHEME, netloc="").geturl() def parse_x_kolibri_app_url_tuple(self, url_tuple: SplitResult) -> str: """ @@ -236,17 +267,13 @@ class _KolibriSetupHelper(GObject.GObject): __webkit_web_context: WebKit.WebContext __kolibri_daemon: KolibriDaemonManager + __cookies_to_add: set - __login_webview: WebKit.WebView - - AUTOLOGIN_URL_TEMPLATE = "kolibri_desktop_auth_plugin/login/{token}" - - login_token = GObject.Property(type=str, default=None) - - is_app_key_cookie_ready = GObject.Property(type=bool, default=False) - is_session_cookie_ready = GObject.Property(type=bool, default=False) - is_facility_ready = GObject.Property(type=bool, default=False) + INITIALIZE_API_PATH = "/app/api/initialize" + auth_token = GObject.Property(type=str, default=None) + is_auth_token_ready = GObject.Property(type=bool, default=False) + is_cookie_manager_ready = GObject.Property(type=bool, default=False) is_setup_complete = GObject.Property(type=bool, default=False) def __init__( @@ -259,161 +286,110 @@ def __init__( self.__webkit_web_context = webkit_web_context self.__kolibri_daemon = kolibri_daemon - self.__login_webview = WebKit.WebView(web_context=self.__webkit_web_context) - self.__login_webview.connect( - "load-changed", self.__login_webview_on_load_changed - ) - self.__kolibri_daemon.connect( "dbus-owner-changed", self.__kolibri_daemon_on_dbus_owner_changed ) - self.__kolibri_daemon.connect( - "notify::app-key-cookie", self.__kolibri_daemon_on_notify_app_key_cookie - ) - self.__kolibri_daemon.connect( - "notify::is-started", self.__kolibri_daemon_on_notify_is_started - ) await_properties( [ - (self, "is-facility-ready"), - (self, "login-token"), + (self.__kolibri_daemon, "is-started"), + (self.__kolibri_daemon, "app-key"), + (self, "is-auth-token-ready"), ], - self.__on_await_facility_ready_and_login_token, + self.__initialize_kolibri_session, ) map_properties( [ - (self, "is-app-key-cookie-ready"), - (self, "is-session-cookie-ready"), - (self, "is-facility-ready"), + (self, "is-cookie-manager-ready"), ], self.__update_is_setup_complete, ) - self.__kolibri_daemon_on_notify_app_key_cookie(self.__kolibri_daemon) - - def __login_webview_on_load_changed( - self, webview: WebKit.WebView, load_event: WebKit.LoadEvent - ): - # Show the main webview once it finishes loading. - if load_event == WebKit.LoadEvent.FINISHED: - self.props.is_session_cookie_ready = True - self.props.login_token = None - def __kolibri_daemon_on_dbus_owner_changed( self, kolibri_daemon: KolibriDaemonManager ): - self.props.is_session_cookie_ready = False - - if kolibri_daemon.do_automatic_login: - kolibri_daemon.get_login_token(self.__kolibri_daemon_on_login_token_ready) + # Reset the auth token cookie: it is no longer valid + self.props.is_cookie_manager_ready = False + + if self.__kolibri_daemon.do_automatic_login: + self.props.is_auth_token_ready = False + kolibri_daemon.get_login_token( + self.__kolibri_daemon_on_get_login_token_ready + ) else: - self.props.is_session_cookie_ready = True + self.props.auth_token = None + self.props.is_auth_token_ready = True - def __kolibri_daemon_on_notify_is_started( - self, kolibri_daemon: KolibriDaemonManager, pspec: GObject.ParamSpec = None + def __kolibri_daemon_on_get_login_token_ready( + self, kolibri_daemon: KolibriDaemonManager, login_token: typing.Optional[str] ): - self.props.is_facility_ready = False - - if not self.__kolibri_daemon.props.is_started: - return + self.props.auth_token = login_token + self.props.is_auth_token_ready = True - if not self.__kolibri_daemon.do_automatic_login: - # No automatic login so we don't need a facility: - self.props.is_facility_ready = True + def __initialize_kolibri_session( + self, is_started: bool, app_key: str, is_auth_token_ready: bool + ): + initialize_query = {} + if self.props.auth_token: + initialize_query["auth_token"] = self.props.auth_token + initialize_url = self.__kolibri_daemon.get_absolute_url( + f"{self.INITIALIZE_API_PATH}/{app_key}?{urlencode(initialize_query)}" + ) + if not initialize_url: + logging.error("Kolibri initialize URL is not set") return - self.__kolibri_daemon.kolibri_api_get_async( - "/api/public/v1/facility/", - result_cb=self.__on_kolibri_api_facility_response, + initialize_url, + self.__on_kolibri_initialize_api_ready, + flags=Soup.MessageFlags.NO_REDIRECT, + parse_json=False, ) - def __on_kolibri_api_facility_response(self, data: typing.Any): - if isinstance(data, list) and data: - self.props.is_facility_ready = True - return - - # There is no facility, so automatically provision the device: - self.__automatic_device_provision() - - def __automatic_device_provision(self): - # TODO: In the future, this could be done in kolibri-daemon itself by - # using a simple automatic_provision.json file in the Kolibri home - # template. We need to do it here for now because we are only - # using this configuration with automatic login, which is only - # enabled in certain cases. - logger.info("Provisioning device…") - facility_name = _("Kolibri on {host}").format( - host=platform.node() or "localhost" + def __on_kolibri_initialize_api_ready( + self, data: typing.Any, soup_message: Soup.Message = None + ): + website_data_manager = ( + WebKit.NetworkSession.get_default().get_website_data_manager() ) - request_body_data = { - "facility": { - "name": facility_name, - "learner_can_login_with_no_password": False, - }, - "preset": "formal", - "superuser": None, - "language_id": None, - "device_name": None, - "settings": { - "landing_page": "learn", - "allow_other_browsers_to_connect": False, - }, - "allow_guest_access": False, - } - self.__kolibri_daemon.kolibri_api_post_async( - "/api/device/deviceprovision/", - result_cb=self.__on_kolibri_api_deviceprovision_response, - request_body=request_body_data, + website_data_manager.clear( + WebKit.WebsiteDataTypes.COOKIES, + 0, + None, + self.__on_website_data_clear_finished, + partial(self.__copy_cookies_from_soup_message_response, soup_message), ) - def __on_kolibri_api_deviceprovision_response(self, data: dict): - logger.info("Device provisioned.") - self.props.is_facility_ready = True - - def __on_await_facility_ready_and_login_token( - self, is_facility_ready: bool, login_token: str - ): - if self.props.is_session_cookie_ready: + def __on_website_data_clear_finished(self, website_data_manager, result, next_fn): + try: + website_data_manager.clear_finish(result) + except GLib.Error as error: + logger.error(f"Error clearing cookies: {error}") return - login_url = self.__kolibri_daemon.get_absolute_url( - self.AUTOLOGIN_URL_TEMPLATE.format(token=login_token) - ) - - self.__login_webview.load_uri(login_url) - - def __kolibri_daemon_on_login_token_ready( - self, kolibri_daemon: KolibriDaemonManager, login_token: typing.Optional[str] - ): - self.props.login_token = login_token - - if login_token is None: - # If we are unable to get a login token, pretend the session cookie - # is ready so the app will proceed as usual. This should only happen - # in an edge case where kolibri-daemon is running on the system bus - # but is unable to communicate with AccountsService. - self.props.is_session_cookie_ready = True - - def __kolibri_daemon_on_notify_app_key_cookie( - self, kolibri_daemon: KolibriDaemonManager, pspec: GObject.ParamSpec = None - ): - self.props.is_app_key_cookie_ready = False - - if not self.__kolibri_daemon.props.app_key_cookie: + next_fn() + + def __copy_cookies_from_soup_message_response(self, soup_message: Soup.Message): + cookie_manager = WebKit.NetworkSession.get_default().get_cookie_manager() + cookies = Soup.cookies_from_response(soup_message) + self.__cookies_to_add = set(cookies) + for cookie in cookies: + # FIXME: We should really be using cookie_manager.replace_cookies(), + # but something is causing the cookies to not be added unless + # we call add_cookie one at a time. + cookie_manager.add_cookie( + cookie, None, self.__on_webkit_add_cookie_ready, cookie + ) + + def __on_webkit_add_cookie_ready(self, cookie_manager, result, cookie): + try: + cookie_manager.add_cookie_finish(result) + except GLib.Error as error: + logger.error(f"Error adding cookie from API response: {error}") return - - WebKit.NetworkSession.get_default().get_cookie_manager().add_cookie( - self.__kolibri_daemon.props.app_key_cookie, - None, - self.__on_app_key_cookie_ready, - ) - - def __on_app_key_cookie_ready( - self, cookie_manager: WebKit.CookieManager, result: Gio.Task - ): - self.props.is_app_key_cookie_ready = True + self.__cookies_to_add.remove(cookie) + if len(self.__cookies_to_add) == 0: + self.props.is_cookie_manager_ready = True def __update_is_setup_complete(self, *setup_flags): self.props.is_setup_complete = all(setup_flags) @@ -435,13 +411,13 @@ def __init__(self, channel_id: str): @property def default_url(self) -> str: - return f"x-kolibri-app:{self.__default_path}" + return f"{APP_URI_SCHEME}:{self.__default_path}" @property def __default_path(self) -> str: - return f"/learn#/topics/t/{self.__channel_id}" + return f"{LEARN_PATH_PREFIX}topics/t/{self.__channel_id}" - def _get_kolibri_library_path(self, search: str = None) -> str: + def _get_kolibri_library_path(self, search: typing.Optional[str] = None) -> str: if search: query = {"keywords": search} return f"{self.__default_path}/search?{urlencode(query)}" @@ -463,22 +439,11 @@ def is_url_in_scope(self, url: str) -> bool: url_tuple = urlsplit(url) url_path = url_tuple.path.lstrip("/") - if re.match( - r"^(app|static|downloadcontent|content\/storage|content\/static|content\/zipcontent)\/?", - url_path, - ): - return True - elif re.match( - r"^(?P[\w\-]+\/)?(user|logout|redirectuser|learn\/app)\/?", - url_path, - ): + if re.match(STATIC_PATHS_RE, url_path): return True - elif re.match( - r"^(?P[\w\-]+\/)?kolibri_desktop_auth_plugin\/?", - url_path, - ): + elif re.match(SYSTEM_PATHS_RE, url_path): return True - elif re.match(r"^(?P[\w\-]+\/)?learn\/?", url_path): + elif re.match(CONTENT_PATHS_RE, url_path): return self.__is_learn_fragment_in_channel(url_tuple.fragment) else: return False diff --git a/src/kolibri_gnome/kolibri_daemon_manager.py b/src/kolibri_gnome/kolibri_daemon_manager.py index 393e0d88..6000181e 100644 --- a/src/kolibri_gnome/kolibri_daemon_manager.py +++ b/src/kolibri_gnome/kolibri_daemon_manager.py @@ -5,7 +5,6 @@ import typing from functools import partial from urllib.parse import urljoin -from urllib.parse import urlsplit from gi.repository import Gio from gi.repository import GLib @@ -14,7 +13,8 @@ from gi.repository import Soup from kolibri_app.config import DAEMON_APPLICATION_ID from kolibri_app.config import DAEMON_MAIN_OBJECT_PATH -from kolibri_app.globals import APP_FORCE_AUTOMATIC_LOGIN +from kolibri_app.globals import APP_AUTOMATIC_LOGIN +from kolibri_app.globals import APP_AUTOMATIC_PROVISION from .utils import GioInputStreamIO @@ -22,6 +22,7 @@ APP_KEY_COOKIE_NAME = "app_key_cookie" +AUTH_TOKEN_COOKIE_NAME = "app_auth_token_cookie" class KolibriDaemonManager(GObject.GObject): @@ -30,16 +31,20 @@ class KolibriDaemonManager(GObject.GObject): stops Kolibri, and provides some helpers to access Kolibri's HTTP API. """ + __bus_type: Gio.BusType __dbus_proxy: KolibriDaemonDBus.MainProxy - __do_automatic_login: bool = False __did_init: bool = False __dbus_proxy_owner: typing.Optional[str] = None + __soup_session: Soup.Session = None + __last_status: typing.Optional[str] = None + is_stopped = GObject.Property(type=bool, default=False) is_started = GObject.Property(type=bool, default=False) has_error = GObject.Property(type=bool, default=False) - app_key_cookie = GObject.Property(type=Soup.Cookie, default=None) + base_url = GObject.Property(type=str, default=None) + app_key = GObject.Property(type=str, default=None) __gsignals__ = { "dbus-owner-changed": (GObject.SIGNAL_RUN_FIRST, None, ()), @@ -48,18 +53,17 @@ class KolibriDaemonManager(GObject.GObject): def __init__(self): GObject.GObject.__init__(self) - g_bus_type = KolibriDaemonDBus.get_default_bus_type() - - self.__do_automatic_login = ( - g_bus_type == Gio.BusType.SYSTEM or APP_FORCE_AUTOMATIC_LOGIN - ) + self.__bus_type = KolibriDaemonDBus.get_default_bus_type() self.__dbus_proxy = KolibriDaemonDBus.MainProxy( - g_bus_type=g_bus_type, + g_bus_type=self.__bus_type, g_name=DAEMON_APPLICATION_ID, g_object_path=DAEMON_MAIN_OBJECT_PATH, g_interface_name=KolibriDaemonDBus.main_interface_info().name, ) + + self.__soup_session = Soup.Session.new() + self.__dbus_proxy.connect( "notify::g-name-owner", self.__dbus_proxy_on_notify_g_name_owner ) @@ -69,7 +73,7 @@ def __init__(self): @property def do_automatic_login(self) -> bool: - return self.__do_automatic_login + return APP_AUTOMATIC_LOGIN @property def kolibri_version(self) -> str: @@ -112,27 +116,37 @@ def get_absolute_url(self, url: str = "") -> typing.Optional[str]: else: return None + def get_debug_info(self) -> dict: + return { + "g_bus_type": self.__bus_type.value_name, + "status": self.__dbus_proxy.props.status, + "base_url": self.__dbus_proxy.props.base_url, + "kolibri_home": self.__dbus_proxy.props.kolibri_home, + "kolbri_version": self.__dbus_proxy.props.kolibri_version, + "do_automatic_provision": APP_AUTOMATIC_PROVISION, + } + def kolibri_api_get(self, path: str) -> typing.Any: url = self.get_absolute_url(path) if not url: return None - soup_session = Soup.Session.new() soup_message = Soup.Message.new("GET", url) - stream = soup_session.send(soup_message, None) + stream = self.__soup_session.send(soup_message, None) return _read_json_from_input_stream(stream) - def kolibri_api_get_async(self, path: str, result_cb: typing.Callable): - self.__kolibri_api_call_async(path, "GET", result_cb) + def kolibri_api_get_async(self, path: str, result_cb: typing.Callable, **kwargs): + self.__kolibri_api_call_async(path, "GET", result_cb, **kwargs) def kolibri_api_post_async( self, path: str, result_cb: typing.Callable, request_body: typing.Optional[dict] = None, + **kwargs, ): - self.__kolibri_api_call_async(path, "POST", result_cb, request_body) + self.__kolibri_api_call_async(path, "POST", result_cb, request_body, **kwargs) def __request_body_object_to_bytes(self, request_body: dict): return bytes(json.dumps(request_body), "utf8") @@ -143,6 +157,8 @@ def __kolibri_api_call_async( method: str, result_cb: typing.Callable, request_body: typing.Optional[dict] = None, + flags: Soup.MessageFlags = None, + parse_json: bool = True, ): url = self.get_absolute_url(path) @@ -152,14 +168,18 @@ def __kolibri_api_call_async( result_cb(None) return - soup_session = Soup.Session.new() soup_message = Soup.Message.new(method, url) + + if flags is not None: + soup_message.add_flags(flags) + if request_body is not None: soup_message.set_request_body_from_bytes( "application/json", GLib.Bytes(self.__request_body_object_to_bytes(request_body)), ) - soup_session.send_async( + + self.__soup_session.send_async( soup_message, GLib.PRIORITY_DEFAULT, None, @@ -167,6 +187,7 @@ def __kolibri_api_call_async( self.__kolibri_api_get_async_on_soup_send, result_cb=result_cb, soup_message=soup_message, + parse_json=parse_json, ), ) @@ -176,18 +197,24 @@ def __kolibri_api_get_async_on_soup_send( result: Gio.AsyncResult, result_cb: typing.Callable, soup_message: Soup.Message, + parse_json: bool = True, ): # On HTTP client (4xx) or server (5xx) errors: if soup_message.get_status() >= Soup.Status.BAD_REQUEST: # FIXME: It would be better to raise an exception, and # handle it in the other side to set SESSION_STATUS_ERROR. logger.warning(f"Error calling Kolibri API: {soup_message.get_status()}") - result_cb(None) + result_cb(None, soup_message=soup_message) return stream = session.send_finish(result) - data = _read_json_from_input_stream(stream) - result_cb(data) + + if parse_json: + data = _read_json_from_input_stream(stream) + else: + data = None + + result_cb(data, soup_message=soup_message) def get_login_token(self, login_token_ready_cb: typing.Callable): self.__dbus_proxy.GetLoginToken( @@ -229,32 +256,26 @@ def __dbus_proxy_on_notify_g_name_owner( if dbus_proxy_owner_changed: dbus_proxy.Hold(result_handler=self.__dbus_proxy_default_result_handler) + self.__last_status = None self.emit("dbus-owner-changed") def __dbus_proxy_on_notify( self, dbus_proxy: KolibriDaemonDBus.MainProxy, param_spec: GObject.ParamSpec ): - is_stopped = dbus_proxy.props.status in ("STOPPED", "") - is_started = dbus_proxy.props.status == "STARTED" - has_error = dbus_proxy.props.status == "ERROR" - - if self.props.is_stopped != is_stopped: - self.props.is_stopped = is_stopped + if dbus_proxy.props.status != self.__last_status: + self.__update_from_status_text(dbus_proxy.props.status) + self.__last_status = dbus_proxy.props.status - if self.props.is_started != is_started: - self.props.is_started = is_started + if self.props.base_url != dbus_proxy.props.base_url: + self.props.base_url = dbus_proxy.props.base_url - if self.props.has_error != has_error: - self.props.has_error = has_error + if self.props.app_key != dbus_proxy.props.app_key: + self.props.app_key = dbus_proxy.props.app_key - cookie = self.__create_app_key_cookie() - - if self.props.app_key_cookie and cookie: - if not self.props.app_key_cookie.equal(cookie): - self.props.app_key_cookie = cookie - else: - if self.props.app_key_cookie != cookie: - self.props.app_key_cookie = cookie + def __update_from_status_text(self, status): + self.props.is_stopped = status in ("STOPPED", "") + self.props.is_started = status == "STARTED" + self.props.has_error = status == "ERROR" def __on_notify_is_stopped( self, kolibri_daemon: KolibriDaemonManager, pspec: GObject.ParamSpec @@ -264,20 +285,6 @@ def __on_notify_is_stopped( result_handler=self.__dbus_proxy_default_result_handler ) - def __create_app_key_cookie(self) -> typing.Optional[Soup.Cookie]: - if not self.__dbus_proxy.props.base_url or not self.__dbus_proxy.props.app_key: - return None - - url_tuple = urlsplit(self.__dbus_proxy.props.base_url) - - return Soup.Cookie.new( - name=APP_KEY_COOKIE_NAME, - value=self.__dbus_proxy.props.app_key, - domain=url_tuple.hostname, - path="", - max_age=-1, - ) - def __dbus_proxy_default_result_handler( self, dbus_proxy: KolibriDaemonDBus.MainProxy, diff --git a/src/kolibri_gnome/kolibri_webview.py b/src/kolibri_gnome/kolibri_webview.py index 0954ec96..aa6b69a5 100644 --- a/src/kolibri_gnome/kolibri_webview.py +++ b/src/kolibri_gnome/kolibri_webview.py @@ -5,9 +5,9 @@ from gi.repository import GObject from gi.repository import Gtk from gi.repository import WebKit -from kolibri_app.globals import APP_DEVELOPER_EXTRAS from .kolibri_context import KolibriContext +from .utils import map_properties MOUSE_BUTTON_BACK = 8 @@ -35,9 +35,6 @@ def __init__(self, context: KolibriContext, *args, **kwargs): self.__context = context - if APP_DEVELOPER_EXTRAS: - self.get_settings().set_enable_developer_extras(True) - self.__context.connect("kolibri-ready", self.__context_on_kolibri_ready) click_back_gesture = Gtk.GestureClick( @@ -164,6 +161,8 @@ class KolibriWebViewStack(Gtk.Stack): ZOOM_STEPS = [0.5, 0.75, 1.0, 1.25, 1.5] + enable_developer_extras = GObject.Property(type=bool, default=False) + show_web_inspector = GObject.Property(type=bool, default=False) is_main_visible = GObject.Property(type=bool, default=False) can_go_back = GObject.Property(type=bool, default=False) can_go_forward = GObject.Property(type=bool, default=False) @@ -223,6 +222,35 @@ def __init__( "changed", self.__main_webview_back_forward_list_on_changed ) + self.bind_property( + "enable_developer_extras", + self.__loading_webview.get_settings(), + "enable_developer_extras", + GObject.BindingFlags.SYNC_CREATE, + ) + + self.bind_property( + "enable_developer_extras", + self.__main_webview.get_settings(), + "enable_developer_extras", + GObject.BindingFlags.SYNC_CREATE, + ) + + map_properties( + [ + (self, "show-web-inspector"), + (self, "visible-child"), + ], + self.__update_web_inspectors, + ) + + self.__loading_webview.get_inspector().connect( + "closed", self.__on_inspector_closed + ) + self.__main_webview.get_inspector().connect( + "closed", self.__on_inspector_closed + ) + self.show_loading() @property @@ -247,6 +275,18 @@ def set_zoom_step(self, zoom_step: int): self.__main_webview.set_zoom_level(zoom_level) self.__loading_webview.set_zoom_level(zoom_level) + def __update_web_inspectors(self, show_web_inspector, visible_child): + if not show_web_inspector: + self.__loading_webview.get_inspector().close() + self.__main_webview.get_inspector().close() + elif visible_child == self.__loading_webview: + self.__loading_webview.get_inspector().show() + else: + self.__main_webview.get_inspector().show() + + def __on_inspector_closed(self, web_inspector: WebKit.WebInspector): + self.show_web_inspector = False + def show_loading(self): self.is_main_visible = False loader_url = self.__context.get_loader_url("loading") diff --git a/src/kolibri_gnome/kolibri_window.py b/src/kolibri_gnome/kolibri_window.py index 1d2bd5ee..7f7aabb9 100644 --- a/src/kolibri_gnome/kolibri_window.py +++ b/src/kolibri_gnome/kolibri_window.py @@ -5,9 +5,12 @@ from gi.repository import Adw from gi.repository import Gio +from gi.repository import GLib from gi.repository import GObject from gi.repository import Gtk from gi.repository import WebKit +from kolibri_app.config import BUILD_PROFILE +from kolibri_app.globals import APP_DEVELOPER_EXTRAS from .kolibri_context import KolibriContext from .kolibri_webview import KolibriWebView @@ -67,11 +70,15 @@ def __init__( ("zoom-reset", self.__on_zoom_reset), ("zoom-in", self.__on_zoom_in), ("zoom-out", self.__on_zoom_out), + ("show-web-inspector", None, None, "false", None), ] ) self.set_default_size(DEFAULT_WIDTH, DEFAULT_HEIGHT) + if BUILD_PROFILE == "development": + self.add_css_class("devel") + content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.set_content(content_box) @@ -86,7 +93,11 @@ def __init__( self.__header_bar.show() content_box.append(self.__header_bar) - menu_button = Gtk.MenuButton(direction=Gtk.ArrowType.NONE) + menu_button = Gtk.MenuButton( + direction=Gtk.ArrowType.NONE, + tooltip_text=_("Main Menu"), + primary=True, + ) self.__header_bar.pack_end(menu_button) menu_popover = Gtk.PopoverMenu.new_from_model(_KolibriWindowMenu()) @@ -127,6 +138,7 @@ def __init__( transition_duration=300, vexpand=True, hexpand=True, + enable_developer_extras=APP_DEVELOPER_EXTRAS, ) content_box.append(self.__webview_stack) @@ -136,10 +148,29 @@ def __init__( bubble_signal(self.__webview_stack, "open-new-window", self) bubble_signal(self.__webview_stack, "main-webview-blank", self, "auto-close") + # These two properties are different types (GVariant and boolean), so we + # need to convert between them. Unfortunately, Object.bind_property_full + # isn't available with PyGObject, so we need two signal handlers. + self.lookup_action("show-web-inspector").connect( + "notify::state", + self.__show_web_inspector_action_on_notify_show_web_inspector, + ) + self.__webview_stack.connect( + "notify::show-web-inspector", + self.__webview_stack_on_notify_show_web_inspector, + ) + self.__webview_stack.connect( "main-webview-ready", self.__webview_stack_on_main_webview_ready ) + self.__webview_stack.bind_property( + "enable_developer_extras", + self.lookup_action("show-web-inspector"), + "enabled", + GObject.BindingFlags.SYNC_CREATE, + ) + self.__webview_stack.bind_property( "can_go_back", self.lookup_action("navigate-back"), @@ -197,6 +228,7 @@ def set_accels(application: Adw.Application): application.set_accels_for_action("win.zoom-reset", ["0"]) application.set_accels_for_action("win.zoom-in", ["plus"]) application.set_accels_for_action("win.zoom-out", ["minus"]) + application.set_accels_for_action("win.show-web-inspector", ["F12"]) application.set_accels_for_action("win.close", ["w"]) def load_kolibri_url(self, url: str, present=False): @@ -242,6 +274,18 @@ def __on_zoom_out(self, action, *args): self.__webview_stack.set_zoom_step(self.__webview_stack.zoom_step - 1) self.__update_zoom_actions() + def __webview_stack_on_notify_show_web_inspector( + self, webview_stack: KolibriWebViewStack, pspec: GObject.ParamSpec + ): + self.lookup_action("show-web-inspector").set_state( + GLib.Variant.new_boolean(webview_stack.show_web_inspector) + ) + + def __show_web_inspector_action_on_notify_show_web_inspector( + self, action: Gio.Action, pspec: GObject.ParamSpec + ): + self.__webview_stack.show_web_inspector = action.get_state().get_boolean() + def __update_zoom_actions(self): self.lookup_action("zoom-reset").set_enabled( self.__webview_stack.zoom_step != self.__webview_stack.default_zoom_step @@ -287,12 +331,14 @@ def __init__(self, *args, **kwargs): ) self.append_section(None, view_section) + if APP_DEVELOPER_EXTRAS: + dev_section = Gio.Menu() + dev_section.append_item( + Gio.MenuItem.new(_("Show Developer Tools"), "win.show-web-inspector") + ) + self.append_section(None, dev_section) + help_section = Gio.Menu() - help_section.append_item( - Gio.MenuItem.new(_("Documentation"), "app.open-documentation") - ) - help_section.append_item( - Gio.MenuItem.new(_("Community Forums"), "app.open-forums") - ) + help_section.append_item(Gio.MenuItem.new(_("Help"), "app.open-documentation")) help_section.append_item(Gio.MenuItem.new(_("About"), "app.about")) self.append_section(None, help_section) diff --git a/src/kolibri_gnome/utils.py b/src/kolibri_gnome/utils.py index 4d5dcf83..c944ea3f 100644 --- a/src/kolibri_gnome/utils.py +++ b/src/kolibri_gnome/utils.py @@ -69,7 +69,7 @@ def bubble_signal( source: GObject.Object, source_signal: str, next: GObject.Object, - next_signal: str = None, + next_signal: typing.Optional[str] = None, ): next_signal = next_signal or source_signal diff --git a/src/kolibri_gnome_launcher/application.py b/src/kolibri_gnome_launcher/application.py index 3418ea8b..76d5a552 100644 --- a/src/kolibri_gnome_launcher/application.py +++ b/src/kolibri_gnome_launcher/application.py @@ -4,6 +4,8 @@ from urllib.parse import urlunparse from gi.repository import Gio +from kolibri_app.config import DISPATCH_URI_SCHEME +from kolibri_app.config import KOLIBRI_URI_SCHEME from kolibri_app.config import LAUNCHER_APPLICATION_ID logger = logging.getLogger(__name__) @@ -41,7 +43,7 @@ def do_open(self, files: list, n_files: int, hint: str): def handle_uri(self, uri: str): url_tuple = urlsplit(uri) - if url_tuple.scheme == "x-kolibri-dispatch": + if url_tuple.scheme == DISPATCH_URI_SCHEME: channel_id = url_tuple.netloc node_path = url_tuple.path node_query = url_tuple.query @@ -59,7 +61,7 @@ def handle_uri(self, uri: str): if node_path or node_query: kolibri_node_url = urlunparse( - ("kolibri", node_path, "", None, node_query, None) + (KOLIBRI_URI_SCHEME, node_path, "", None, node_query, None) ) kolibri_gnome_args.append(kolibri_node_url) diff --git a/src/kolibri_gnome_search_provider/kolibri-gnome-search-provider.c b/src/kolibri_gnome_search_provider/kolibri-gnome-search-provider.c index ece1c7ca..cbb28c6f 100644 --- a/src/kolibri_gnome_search_provider/kolibri-gnome-search-provider.c +++ b/src/kolibri_gnome_search_provider/kolibri-gnome-search-provider.c @@ -266,7 +266,7 @@ build_kolibri_dispatch_uri(const gchar *channel_id, uri_path = g_strconcat("/", node_path, NULL); kolibri_uri = g_uri_build(G_URI_FLAGS_NONE, - "x-kolibri-dispatch", + DISPATCH_URI_SCHEME, NULL, channel_id, -1, diff --git a/src/kolibri_gnome_search_provider/meson.build b/src/kolibri_gnome_search_provider/meson.build index ddcd9917..9f5a7c5d 100644 --- a/src/kolibri_gnome_search_provider/meson.build +++ b/src/kolibri_gnome_search_provider/meson.build @@ -5,6 +5,7 @@ gobject_dep = dependency('gobject-2.0') _c_config = configuration_data() _c_config.set_quoted('PACKAGE_STRING', package_string) +_c_config.set_quoted('DISPATCH_URI_SCHEME', dispatch_uri_scheme) _c_config.set_quoted('FRONTEND_APPLICATION_ID', frontend_application_id) _c_config.set_quoted('FRONTEND_OBJECT_PATH', frontend_object_path) _c_config.set_quoted('FRONTEND_CHANNEL_APPLICATION_ID_PREFIX', frontend_channel_application_id_prefix) diff --git a/tests/kolibri_gnome_tests/test_kolibri_context.py b/tests/kolibri_gnome_tests/test_kolibri_context.py index 6b047382..489a1158 100644 --- a/tests/kolibri_gnome_tests/test_kolibri_context.py +++ b/tests/kolibri_gnome_tests/test_kolibri_context.py @@ -4,6 +4,7 @@ from kolibri_gnome.kolibri_context import KolibriChannelContext from kolibri_gnome.kolibri_context import KolibriContext +from kolibri_gnome.kolibri_context import LEARN_PATH_PREFIX class KolibriContextTestCase(unittest.TestCase): @@ -40,7 +41,7 @@ def test_parse_kolibri_url_tuple_content(self): self.kolibri_context.parse_kolibri_url_tuple( urlsplit(f"kolibri:c/{self.CONTENT_ID}") ), - f"/learn#/topics/c/{self.CONTENT_ID}", + f"{LEARN_PATH_PREFIX}topics/c/{self.CONTENT_ID}", ) def test_parse_kolibri_url_tuple_content_with_search(self): @@ -48,7 +49,7 @@ def test_parse_kolibri_url_tuple_content_with_search(self): self.kolibri_context.parse_kolibri_url_tuple( urlsplit(f"kolibri:c/{self.CONTENT_ID}?search=addition") ), - f"/learn#/topics/c/{self.CONTENT_ID}?keywords=addition&last=TOPICS_TOPIC_SEARCH", + f"{LEARN_PATH_PREFIX}topics/c/{self.CONTENT_ID}?keywords=addition&last=TOPICS_TOPIC_SEARCH", ) def test_parse_kolibri_url_tuple_topic(self): @@ -56,7 +57,7 @@ def test_parse_kolibri_url_tuple_topic(self): self.kolibri_context.parse_kolibri_url_tuple( urlsplit(f"kolibri:t/{self.TOPIC_ID}") ), - f"/learn#/topics/t/{self.TOPIC_ID}", + f"{LEARN_PATH_PREFIX}topics/t/{self.TOPIC_ID}", ) def test_parse_kolibri_url_tuple_topic_with_search(self): @@ -64,7 +65,7 @@ def test_parse_kolibri_url_tuple_topic_with_search(self): self.kolibri_context.parse_kolibri_url_tuple( urlsplit(f"kolibri:t/{self.TOPIC_ID}?search=addition") ), - f"/learn#/topics/t/{self.TOPIC_ID}", + f"{LEARN_PATH_PREFIX}topics/t/{self.TOPIC_ID}", ) def test_parse_kolibri_url_tuple_base_with_search(self): @@ -72,14 +73,14 @@ def test_parse_kolibri_url_tuple_base_with_search(self): self.kolibri_context.parse_kolibri_url_tuple( urlsplit("kolibri:?search=addition") ), - "/learn#/library?keywords=addition", + f"{LEARN_PATH_PREFIX}library?keywords=addition", ) self.assert_kolibri_path_equal( self.kolibri_context.parse_kolibri_url_tuple( urlsplit("kolibri:?search=addition+and+subtraction") ), - "/learn#/library?keywords=addition+and+subtraction", + f"{LEARN_PATH_PREFIX}library?keywords=addition+and+subtraction", ) @@ -108,14 +109,14 @@ def test_parse_kolibri_url_tuple_base_with_search(self): self.kolibri_context.parse_kolibri_url_tuple( urlsplit("kolibri:?search=addition") ), - f"/learn#/topics/t/{self.CHANNEL_ID}/search?keywords=addition", + f"{LEARN_PATH_PREFIX}topics/t/{self.CHANNEL_ID}/search?keywords=addition", ) self.assert_kolibri_path_equal( self.kolibri_context.parse_kolibri_url_tuple( urlsplit("kolibri:?search=addition+and+subtraction") ), - f"/learn#/topics/t/{self.CHANNEL_ID}/search?keywords=addition+and+subtraction", + f"{LEARN_PATH_PREFIX}topics/t/{self.CHANNEL_ID}/search?keywords=addition+and+subtraction", )