diff --git a/docs/source/changelog.md b/docs/source/changelog.md index 5e6eb784..9af2ffec 100644 --- a/docs/source/changelog.md +++ b/docs/source/changelog.md @@ -1,5 +1,14 @@ # Changelog +## wakepy 0.9.0 +🗓️ unreleased + +### ✨ Features +- Support keep.running mode in KDE Plasma 5.12.90 and newer through the [org.freedesktop.PowerManagement](#keep-running-org-freedesktop-powermanagement) method. It may also be used on other DEs which implement this older freedesktop.org D-Bus interface (but not Xcfe). ([#324](https://github.com/fohrloop/wakepy/pull/324)) + +### 📖 Documentation +- Document that the [org.freedesktop.ScreenSaver](keep-presenting-org-freedesktop-screensaver) method for keep.presenting mode also supports KDE Plasma. ([#324](https://github.com/fohrloop/wakepy/pull/324)) + ## wakepy 0.8.0 🗓️ 2024-05-26 diff --git a/docs/source/methods-reference.md b/docs/source/methods-reference.md index 7e62fce9..3ba96fc8 100644 --- a/docs/source/methods-reference.md +++ b/docs/source/methods-reference.md @@ -34,6 +34,20 @@ Methods are different ways of entering in (or keeping a) Mode. A Method may supp If used hundreds or thousands of times, may slow down system. See: [wakepy/#277](https://github.com/fohrloop/wakepy/issues/277) ```` + +(keep-running-org-freedesktop-powermanagement)= +### org.freedesktop.PowerManagement +- **Name**: `org.freedesktop.PowerManagement` +- **Introduced in**: wakepy 0.9.0 +- **How it works**: Uses the Inhibit method of the org.freedesktop.PowerManagement D-Bus service when activating and saves the returned cookie on the Method instance. Uses UnInhibit method of the same service with the cookie when deactivating. The org.freedesktop.PowerManagement is an obsolete spec, but certain Desktop Environments provide that as the only option for inhibiting the suspend action. The documentation was previously hosted on [freedesktop.org](https://www.freedesktop.org/wiki/Specifications/power-management-spec/) but the links on the page are dead. The spec has three versions: 0.1, 0.2 and 0.3. The 0.3 is not found anywhere but the version 0.2 of the spec can be read in the [Internet Archive](https://web.archive.org/web/20090417010057/http://people.freedesktop.org/~hughsient/temp/power-management-spec-0.2.html) +- **Multiprocess safe?**: Yes +- **What if the process holding the lock dies?**: The lock is automatically removed. +- **How to check it?**: You may check if there are *any* inhibitors using the HasInhibit method of the `/org/freedesktop/PowerManagement/Inhibit` object on the `org.freedesktop.PowerManagement.Inhibit` interface. Note that updating the inhibit flag from `false` to `true` may take a few seconds. A good tool for this is [D-Spy](https://apps.gnome.org/Dspy/). Alternatively, you could monitor your inhibit call with [`dbus-monitor`](https://dbus.freedesktop.org/doc/dbus-monitor.1.html). +- **Requirements**: D-Bus + KDE Plasma >=5.12.90 or other DE which implements this older freedesktop D-Bus interface. Exception: Xfce is not supported. +- **About unsupported DEs** Older versions of KDE Plasma had a bug which also prevented the screenlock/screesaver activation. The bug was fixed in [D11182](https://phabricator.kde.org/D11182), commit [152400c1b688](https://phabricator.kde.org/R122:152400c1b6880506ee1395011686c2b191f419a0) and was part of the KDE Plasma to 5.12.90 ( = 5.13 Beta) [release](https://kde.org/announcements/changelogs/plasma/5/5.12.5-5.12.90/). This Method is not supported either on Xfce as it has similar bug which prevents also the automatic screenlock/screensaver (See: [xfce4-power-manager/#65](https://gitlab.xfce.org/xfce/xfce4-power-manager/-/issues/65)), hence, wakepy refuses to use org.freedesktop.PowerManagement as method for keep.running mode on KDE < 5.12.90 and on any version of Xfce. +- **Tested on**: openSUSE 15.5 with KDE Plasma 5.27.9 ([Comment in #310](https://github.com/fohrloop/wakepy/issues/310#issuecomment-2140156882) by [fohrloop](https://github.com/fohrloop/)). + + (keep-running-windows-stes)= ### SetThreadExecutionState diff --git a/src/wakepy/methods/freedesktop.py b/src/wakepy/methods/freedesktop.py index 5c5aadb2..a3803dab 100644 --- a/src/wakepy/methods/freedesktop.py +++ b/src/wakepy/methods/freedesktop.py @@ -2,6 +2,9 @@ from __future__ import annotations +import os +import re +import subprocess import typing from wakepy.core import ( @@ -15,39 +18,29 @@ ) if typing.TYPE_CHECKING: - from typing import Optional + from typing import Optional, Tuple +XDG_SESSION_DESKTOP = "XDG_SESSION_DESKTOP" +"""The environment variable for the xdg desktop of the current session. Defined +in pam_systemd manual[1]. -class FreedesktopScreenSaverInhibit(Method): - """Method using org.freedesktop.ScreenSaver D-Bus API +Note that there's also XDG_CURRENT_DESKTOP which contains a colon-separated +list of DEs, defined in [2] - https://people.freedesktop.org/~hadess/idle-inhibition-spec/re01.html - """ +[1]: https://www.freedesktop.org/software/systemd/man/pam_systemd.html +[2]: https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html +""" - name = "org.freedesktop.ScreenSaver" - mode_name = ModeName.KEEP_PRESENTING +KDE = "KDE" +"""Constant for KDE desktop environment / KDE Plasma""" +XFCE = "XFCE" +"""Constant for Xfce desktop environemtn""" - screen_saver = DBusAddress( - bus=BusType.SESSION, - service="org.freedesktop.ScreenSaver", - path="/org/freedesktop/ScreenSaver", - interface="org.freedesktop.ScreenSaver", - ) - - method_inhibit = DBusMethod( - name="Inhibit", - signature="ss", - params=("application_name", "reason_for_inhibit"), - output_signature="u", - output_params=("cookie",), - ).of(screen_saver) - method_uninhibit = DBusMethod( - name="UnInhibit", - signature="u", - params=("cookie",), - ).of(screen_saver) +class FreedesktopInhibitorWithCookieMethod(Method): + """Base class for freedesktop.org D-Bus based methods.""" + service_dbus_address: DBusAddress supported_platforms = (PlatformName.LINUX,) def __init__(self, **kwargs: object) -> None: @@ -65,9 +58,7 @@ def enter_mode(self) -> None: retval = self.process_dbus_call(call) if retval is None: - raise RuntimeError( - "Could not get inhibit cookie from org.freedesktop.ScreenSaver" - ) + raise RuntimeError(f"Could not get inhibit cookie from {self.name}") self.inhibit_cookie = retval[0] def exit_mode(self) -> None: @@ -81,3 +72,173 @@ def exit_mode(self) -> None: ) self.process_dbus_call(call) self.inhibit_cookie = None + + @property + def method_inhibit(self) -> DBusMethod: + return DBusMethod( + name="Inhibit", + signature="ss", + params=("application_name", "reason_for_inhibit"), + output_signature="u", + output_params=("cookie",), + ).of(self.service_dbus_address) + + @property + def method_uninhibit(self) -> DBusMethod: + return DBusMethod( + name="UnInhibit", + signature="u", + params=("cookie",), + ).of(self.service_dbus_address) + + +class FreedesktopScreenSaverInhibit(FreedesktopInhibitorWithCookieMethod): + """Method using org.freedesktop.ScreenSaver D-Bus API + + https://people.freedesktop.org/~hadess/idle-inhibition-spec/re01.html + """ + + name = "org.freedesktop.ScreenSaver" + mode_name = ModeName.KEEP_PRESENTING + + service_dbus_address = DBusAddress( + bus=BusType.SESSION, + service="org.freedesktop.ScreenSaver", + path="/org/freedesktop/ScreenSaver", + interface="org.freedesktop.ScreenSaver", + ) + + +class FreedesktopPowerManagementInhibit(FreedesktopInhibitorWithCookieMethod): + """Method using org.freedesktop.PowerManagement D-Bus API + + According to [1] and [2] this might be obsolete. The spec itself can be + read in the internet arhives[3]. Part of the spec (v0.2.0) copied here for + convenience: + + DBUS Interface: org.freedesktop.PowerManagement.Inhibit + DBUS Path: /org/freedesktop/PowerManagement/Inhibit + + When the power manager detects an idle session and system, it can perform a + system suspend or hibernate, known as an idle sleep action. We can prevent + the session power manager daemon from doing this action using the inhibit + interface. + + An automatic inhibit should be taken by the file manager if there is a slow + network copy that will take many minutes to complete. + + A cookie is a randomly assigned 32bit unsigned integer used to identify the + inhibit. It is required as the same application may want to call inhibit + multiple times, without managing the inhibit calls itself. + + Name Input Output Error Description + ------- ------------------ ------- ------ ----------- + Inhibit string application uint cookie PermissionDenied [D1] + string reason + UnInhibit uint cookie CookieNotFound [D2] + HasInhibit bool has_inhibit [D3] + + [D1] Inhibits the computer from performing an idle sleep action. Useful if + you want to do an operation of long duration without the computer + suspending. Reason and application should be translated strings where + possible. + + [D2] Allows the computer to perform the idle sleep or user action if the + number of inhibit calls is zero. If there are multiple cookies outstanding, + clearing one cookie does not allow the action to happen. If the program + holding the cookie exits from the session bus without calling UnInhibit() + then it is automatically removed. + + [D3] Returns false if we have no valid inhibits. This will return true if + the number of inhibit cookies is greater than zero. + + [1] https://code.videolan.org/videolan/vlc/-/issues/25785 + [2] https://www.freedesktop.org/wiki/Specifications/power-management-spec/ + [3] https://web.archive.org/web/20090417010057/http://people.freedesktop.org/~hughsient/temp/power-management-spec-0.2.html + + """ + + name = "org.freedesktop.PowerManagement" + mode_name = ModeName.KEEP_RUNNING + + service_dbus_address = DBusAddress( + bus=BusType.SESSION, + service="org.freedesktop.PowerManagement", + path="/org/freedesktop/PowerManagement/Inhibit", + interface="org.freedesktop.PowerManagement.Inhibit", + ) + + _min_kde_plasma_version = (5, 12, 90) + """The minimum KDE Plasma version which supports this method. + + In earlier versions of KDE Plasma, there was a bug which caused the + PowerManagement.Inhibit to behave similarly to the + org.freedesktop.ScreenSaver interface. This was fixed in commit + 152400c1b6880506ee1395011686c2b191f419a0 which was part of KDE Plasma + 5.12.90. + """ + + def caniuse(self) -> bool | None | str: + + current_de = _get_current_desktop_environment() + + if current_de == KDE: + kde_version = _get_kde_plasma_version() + + if kde_version is None: + raise RuntimeError( + "Running on KDE but could not detect KDE Plasma version." + ) + + if kde_version < self._min_kde_plasma_version: + min_version_str = ".".join(str(x) for x in self._min_kde_plasma_version) + raise RuntimeError( + (f"{self.name} only supports KDE >= {min_version_str}") + ) + # KDE Plasma with a supported version + return True + + elif current_de == XFCE: + raise RuntimeError( + "org.freedesktop.PowerManagemen does not support XFCE as it has a bug " + "which prevents automatic screenlock / screensaver. See: " + "https://gitlab.xfce.org/xfce/xfce4-power-manager/-/issues/65" + ) + + # Other DEs + return True + + +def _get_current_desktop_environment() -> str | None: + """Get the desktop environment of the current session. If the DE cannot be + determined, return None""" + if XDG_SESSION_DESKTOP not in os.environ: + return None + + de_from_env_var = os.environ[XDG_SESSION_DESKTOP] + if de_from_env_var.upper() == KDE: + return KDE + elif de_from_env_var.upper() == XFCE: + return XFCE + return de_from_env_var + + +def _get_kde_plasma_version() -> Optional[Tuple[int, ...]]: + """Get the KDE Plasma version as tuple. + + Returns + ------- + versiontuple: + The detected KDE Plasma version. For example: (5,27,9) + If no KDE Plasma is found, returns None. + + """ + # returns for example 'plasmashell 5.27.9' + out = subprocess.getoutput("plasmashell --version") + mtch = re.match("plasmashell ([0-9][0-9.]*)$", out) + if mtch is None: + return None + + versionstring = mtch.group(1) + versiontuple = tuple(int(x) for x in versionstring.split(".")) + return versiontuple diff --git a/tests/unit/test_methods/test_freedesktop.py b/tests/unit/test_methods/test_freedesktop.py index a2a7e4c3..51845d43 100644 --- a/tests/unit/test_methods/test_freedesktop.py +++ b/tests/unit/test_methods/test_freedesktop.py @@ -4,12 +4,19 @@ adapter is used which simply asserts the Call objects and returns what we would expect from a dbus service.""" +import os import re +from unittest.mock import patch import pytest from wakepy.core.dbus import BusType, DBusAdapter, DBusAddress, DBusMethod -from wakepy.methods.freedesktop import FreedesktopScreenSaverInhibit +from wakepy.methods.freedesktop import ( + FreedesktopPowerManagementInhibit, + FreedesktopScreenSaverInhibit, + _get_current_desktop_environment, + _get_kde_plasma_version, +) screen_saver = DBusAddress( bus=BusType.SESSION, @@ -18,23 +25,46 @@ interface="org.freedesktop.ScreenSaver", ) +power_management = DBusAddress( + bus=BusType.SESSION, + service="org.freedesktop.PowerManagement", + path="/org/freedesktop/PowerManagement/Inhibit", + interface="org.freedesktop.PowerManagement.Inhibit", +) fake_cookie = 75848243423 -def test_screensaver_enter_mode(): - # Arrange - method_inhibit = DBusMethod( - name="Inhibit", - signature="ss", - params=("application_name", "reason_for_inhibit"), - output_signature="u", - output_params=("cookie",), - ).of(screen_saver) - +def get_test_dbus_adapter(process) -> DBusAdapter: class TestAdapter(DBusAdapter): + def process(self, call): - assert call.method == method_inhibit + return process(call) + + return TestAdapter() + + +class TestFreedesktopEnterMode: + + @pytest.mark.parametrize( + "method_cls, dbus_address", + [ + (FreedesktopScreenSaverInhibit, screen_saver), + (FreedesktopPowerManagementInhibit, power_management), + ], + ) + def test_success(self, method_cls, dbus_address: DBusAddress): + + method_inhibit = DBusMethod( + name="Inhibit", + signature="ss", + params=("application_name", "reason_for_inhibit"), + output_signature="u", + output_params=("cookie",), + ) + + def process(call): + assert call.method == method_inhibit.of(dbus_address) assert call.get_kwargs() == { "application_name": "wakepy", "reason_for_inhibit": "wakelock active", @@ -42,60 +72,214 @@ def process(self, call): return (fake_cookie,) - method = FreedesktopScreenSaverInhibit(dbus_adapter=TestAdapter()) - assert method.inhibit_cookie is None + method = method_cls(dbus_adapter=get_test_dbus_adapter(process)) + # At the start, there is no inhibit cookie. + assert method.inhibit_cookie is None + + # Act + enter_retval = method.enter_mode() - # Act - enter_retval = method.enter_mode() # type: ignore[func-returns-value] + # Assert + assert enter_retval is None + # Entering mode sets a inhibit_cookie to value returned by the + # DBusAdapter + assert method.inhibit_cookie == fake_cookie - # Assert - assert enter_retval is None - # Entering mode sets a inhibit_cookie to value returned by the DBusAdapter - assert method.inhibit_cookie == fake_cookie + @pytest.mark.parametrize( + "method_cls", + [FreedesktopScreenSaverInhibit, FreedesktopPowerManagementInhibit], + ) + def test_with_dbus_adapter_which_returns_none(self, method_cls): + def process(_): + return None -def test_screensaver_exit_mode(): - # Arrange - method_uninhibit = DBusMethod( - name="UnInhibit", - signature="u", - params=("cookie",), - ).of(screen_saver) + method = method_cls(dbus_adapter=get_test_dbus_adapter(process)) - class TestAdapter(DBusAdapter): - def process(self, call): + with pytest.raises( + RuntimeError, + match=re.escape(f"Could not get inhibit cookie from {method_cls.name}"), + ): + assert method.enter_mode() is False + + +class TestFreedesktopExitMode: + + @pytest.mark.parametrize( + "method_cls, dbus_address", + [ + (FreedesktopScreenSaverInhibit, screen_saver), + (FreedesktopPowerManagementInhibit, power_management), + ], + ) + def test_successful_exit(self, method_cls, dbus_address: DBusAddress): + # Arrange + + method_uninhibit = DBusMethod( + name="UnInhibit", + signature="u", + params=("cookie",), + ).of(dbus_address) + + def process(call): assert call.method == method_uninhibit assert call.get_kwargs() == {"cookie": fake_cookie} - method = FreedesktopScreenSaverInhibit(dbus_adapter=TestAdapter()) - method.inhibit_cookie = fake_cookie + method = method_cls(dbus_adapter=get_test_dbus_adapter(process)) + method.inhibit_cookie = fake_cookie - # Act - exit_retval = method.exit_mode() # type: ignore[func-returns-value] + # Act + exit_retval = method.exit_mode() - # Assert - assert exit_retval is None - # exiting mode unsets the inhibit_cookie - assert method.inhibit_cookie is None + # Assert + assert exit_retval is None + # exiting mode unsets the inhibit_cookie + assert method.inhibit_cookie is None + @pytest.mark.parametrize( + "method_cls", + [FreedesktopScreenSaverInhibit, FreedesktopPowerManagementInhibit], + ) + def test_screensaver_exit_before_enter(self, method_cls): + method = method_cls(dbus_adapter=DBusAdapter()) + assert method.inhibit_cookie is None + assert method.exit_mode() is None -def test_screensaver_exit_before_enter(): - method = FreedesktopScreenSaverInhibit(dbus_adapter=DBusAdapter()) - assert method.inhibit_cookie is None - assert method.exit_mode() is None # type: ignore[func-returns-value] +class TestPowerManagementCanIUse: -def test_with_dbus_adapter_which_returns_none(): - class BadAdapterReturnNone(DBusAdapter): - def process(self, _): - return None + def test_on_kde_5_12_90(self, monkeypatch): + # Should support KDE 5.12.90 + + monkeypatch.setenv("XDG_SESSION_DESKTOP", "KDE") + + method = FreedesktopPowerManagementInhibit() + + with patch( + "wakepy.methods.freedesktop.subprocess.getoutput", + return_value="plasmashell 5.12.90", + ): + assert method.caniuse() is True + + def test_on_kde_6_0_0(self, monkeypatch): + # Should support KDE 5.12.90 + + monkeypatch.setenv("XDG_SESSION_DESKTOP", "KDE") + + method = FreedesktopPowerManagementInhibit() + + with patch( + "wakepy.methods.freedesktop.subprocess.getoutput", + return_value="plasmashell 6.0.0", + ): + assert method.caniuse() is True + + def test_on_kde_5_12_89(self, monkeypatch): + monkeypatch.setenv("XDG_SESSION_DESKTOP", "KDE") + + method = FreedesktopPowerManagementInhibit() + + with patch( + "wakepy.methods.freedesktop.subprocess.getoutput", + return_value="plasmashell 5.12.89", + ): + with pytest.raises( + RuntimeError, + match=re.escape( + "org.freedesktop.PowerManagement only supports KDE >= 5.12.90" + ), + ): + method.caniuse() + + def test_on_kde_version_none(self, monkeypatch): + monkeypatch.setenv("XDG_SESSION_DESKTOP", "KDE") + + method = FreedesktopPowerManagementInhibit() + + with patch( + "wakepy.methods.freedesktop.subprocess.getoutput", + return_value="noversion", + ): + with pytest.raises( + RuntimeError, + match=re.escape( + "Running on KDE but could not detect KDE Plasma version" + ), + ): + method.caniuse() + + def test_on_other_de(self, monkeypatch): + monkeypatch.setenv("XDG_SESSION_DESKTOP", "RandomDE") + + method = FreedesktopPowerManagementInhibit() + + with patch( + "wakepy.methods.freedesktop.subprocess.getoutput", + return_value="foo", + ): + assert method.caniuse() is True + + def test_on_other_xfce(self, monkeypatch): + monkeypatch.setenv("XDG_SESSION_DESKTOP", "XFCE") + + method = FreedesktopPowerManagementInhibit() + + with pytest.raises( + RuntimeError, + match=re.escape( + "org.freedesktop.PowerManagemen does not support XFCE as it has a bug " + "which prevents automatic screenlock / screensaver. See: " + "https://gitlab.xfce.org/xfce/xfce4-power-manager/-/issues/65" + ), + ): + method.caniuse() + + +class TestGetKDEPlasmaVersion: + def test_success(self): + + with patch( + "wakepy.methods.freedesktop.subprocess.getoutput", + return_value="plasmashell 1.2.3", + ): + assert _get_kde_plasma_version() == (1, 2, 3) + + def test_success2(self): + with patch( + "wakepy.methods.freedesktop.subprocess.getoutput", + return_value="plasmashell 4.5.6", + ): + assert _get_kde_plasma_version() == (4, 5, 6) + + def test_bad_output(self): + with patch( + "wakepy.methods.freedesktop.subprocess.getoutput", + return_value="foo", + ): + assert _get_kde_plasma_version() is None + + def test_unknown_command(self): + with patch( + "wakepy.methods.freedesktop.subprocess.getoutput", + return_value="If 'plasmashell' is not a typo you can use command-not-found" + " to lookup the package that contains it, like this: cnf fooo", + ): + assert _get_kde_plasma_version() is None + + +class TestGetCurrentDesktopEnvironment: + + def test_kde(self, monkeypatch): + monkeypatch.setenv("XDG_SESSION_DESKTOP", "KDE") + assert _get_current_desktop_environment() == "KDE" + + def test_xfce(self, monkeypatch): + monkeypatch.setenv("XDG_SESSION_DESKTOP", "xfce") + assert _get_current_desktop_environment() == "XFCE" - method = FreedesktopScreenSaverInhibit(dbus_adapter=BadAdapterReturnNone()) + def test_not_set(self, monkeypatch): + if os.environ.get("XDG_SESSION_DESKTOP"): + monkeypatch.delenv("XDG_SESSION_DESKTOP") + assert _get_current_desktop_environment() is None - with pytest.raises( - RuntimeError, - match=re.escape( - "Could not get inhibit cookie from org.freedesktop.ScreenSaver" - ), - ): - assert method.enter_mode() is False # type: ignore[func-returns-value] + def test_other(self, monkeypatch): + monkeypatch.setenv("XDG_SESSION_DESKTOP", "FOO") + assert _get_current_desktop_environment() == "FOO"