diff --git a/aw_qt/config.py b/aw_qt/config.py index 1593f32..90a3a68 100644 --- a/aw_qt/config.py +++ b/aw_qt/config.py @@ -9,6 +9,7 @@ from aw_core.config import load_config_toml from .profile import DEFAULT_PROFILE, ENV_VAR, TESTING_PROFILE, is_testing +from .wayland import WAYLAND_WATCHERS, _WAYLAND_REPLACED_MODULES, _is_wayland logger = logging.getLogger(__name__) @@ -378,6 +379,19 @@ def persist_module_autostart( try: updated = set_module_autostart(source, section, name, enabled) + # A stopped Wayland replacement must also disable the selections that + # would recreate it on restart. Apply all removals before one atomic + # write, preserving the profile, comments and unrelated modules. + if not enabled and name in WAYLAND_WATCHERS and _is_wayland(): + for original in (*_WAYLAND_REPLACED_MODULES, *WAYLAND_WATCHERS): + changed = set_module_autostart( + updated if updated is not None else source, + section, + original, + False, + ) + if changed is not None: + updated = changed except ValueError as e: logger.warning(f"Not saving autostart change for {name} to {path}: {e}") return False diff --git a/aw_qt/manager.py b/aw_qt/manager.py index d905b9c..2680759 100644 --- a/aw_qt/manager.py +++ b/aw_qt/manager.py @@ -14,6 +14,13 @@ import aw_core +from .wayland import WAYLAND_WATCHER # noqa: F401 - retained for existing imports +from .wayland import ( + WAYLAND_WATCHERS, + _WAYLAND_REPLACED_MODULES, + _is_wayland, +) + logger = logging.getLogger(__name__) # The path of aw_qt @@ -33,55 +40,75 @@ def _log_modules(modules: List["Module"]) -> None: NOTIFY_MODULE = "aw-notify" NOTIFY_SETTINGS_KEY = "aw-notify" -# On Wayland, aw-watcher-window and aw-watcher-afk are replaced by this single -# watcher, which reports to both buckets via Wayland protocols. -WAYLAND_WATCHER = "aw-watcher-window-wayland" -# These X11/xcb-based modules do not work on Wayland and are substituted out. -_WAYLAND_REPLACED_MODULES = ("aw-watcher-window", "aw-watcher-afk") - -def _is_wayland() -> bool: - """Return True when the current session is running under Wayland.""" - if os.environ.get("WAYLAND_DISPLAY"): - return True - if os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland": - return True - return False +def _select_wayland_watcher(available_names: Set[str]) -> Optional[str]: + """Return the first available Wayland watcher implementation, if any.""" + for name in WAYLAND_WATCHERS: + if name in available_names: + return name + return None def _apply_wayland_substitutions( modules: List[str], available_names: Set[str] ) -> List[str]: - """On Wayland, replace X11-only watchers with aw-watcher-window-wayland. + """On Wayland, replace X11-only watchers with an available Wayland watcher. - aw-watcher-window-wayland reports to both aw-watcher-window and - aw-watcher-afk buckets, so the two X11 modules are dropped and the - Wayland watcher is inserted in the position of the first replaced module. - When the Wayland watcher is not installed, the original list is returned - unchanged (the X11 modules will fail to capture anything on Wayland, but - the rest of ActivityWatch still works). + aw-watcher-window-wayland (and aw-awatcher, which the Linux desktop bundle + ships) report to both aw-watcher-window and aw-watcher-afk buckets, so the + two X11 modules are dropped and the Wayland watcher is inserted in the + position of the first replaced module. When no Wayland watcher is + installed, the original list is returned unchanged (the X11 modules will + fail to capture anything on Wayland, but the rest of ActivityWatch still + works). Only modules the user actually selected are replaced. An empty or server-only list is returned unchanged: explicitly disabling tracking (``--autostart-modules none`` or an empty config) must not start a watcher. + + An explicit Wayland selection that is not installed is never *replaced by a + different Wayland watcher*: if another Wayland watcher happens to be + installed, it is not selected on the user's behalf. Two cases follow from + that: + + - No X11 watcher is also selected: there is nothing to substitute, so the + original list is returned unchanged and the unavailable selection is + still there (it may start working once installed). + - X11 watchers are also selected: those are replaced by an installed + Wayland watcher, and the unavailable explicit selection is dropped from + the returned list because it cannot run. Only the return value is + affected -- the persisted ``autostart_modules`` config is untouched, so + the user's choice is not lost. """ if not _is_wayland(): return modules - if WAYLAND_WATCHER not in available_names: + # The first installed explicit selection takes precedence over the automatic + # preference. Only that implementation may run, even if both are selected. + explicit = [name for name in modules if name in WAYLAND_WATCHERS] + if explicit and not any(name in available_names for name in explicit): + logger.warning("Selected Wayland watcher is not installed: %s", explicit) + if not any(name in _WAYLAND_REPLACED_MODULES for name in modules): + return modules + watcher = next( + (name for name in explicit if name in available_names), + _select_wayland_watcher(available_names), + ) + if watcher is None: logger.warning( - "Running on Wayland but %s is not installed; " + "Running on Wayland but no Wayland watcher is installed; " "window and AFK tracking will not work. " - "Install aw-watcher-window-wayland for Wayland support.", - WAYLAND_WATCHER, + "Install aw-watcher-window-wayland or aw-awatcher for Wayland support.", ) return modules result: List[str] = [] - inserted = False + inserted = watcher in modules for name in modules: + if name in WAYLAND_WATCHERS and name != watcher: + continue if name in _WAYLAND_REPLACED_MODULES: if not inserted: - result.append(WAYLAND_WATCHER) + result.append(watcher) inserted = True # Drop the X11-only module else: @@ -90,7 +117,7 @@ def _apply_wayland_substitutions( logger.info( "Wayland session detected: substituted %s with %s", list(_WAYLAND_REPLACED_MODULES), - WAYLAND_WATCHER, + watcher, ) return result diff --git a/aw_qt/wayland.py b/aw_qt/wayland.py new file mode 100644 index 0000000..a82a37c --- /dev/null +++ b/aw_qt/wayland.py @@ -0,0 +1,19 @@ +"""Shared Wayland session detection and watcher identities.""" + +import os + +# Both implementations report window and AFK activity. Prefer the standalone +# watcher for automatic selection; aw-awatcher ships in the Linux desktop bundle. +WAYLAND_WATCHER = "aw-watcher-window-wayland" +WAYLAND_WATCHERS = (WAYLAND_WATCHER, "aw-awatcher") +# These X11/xcb-based modules do not work on Wayland and are substituted out. +_WAYLAND_REPLACED_MODULES = ("aw-watcher-window", "aw-watcher-afk") + + +def _is_wayland() -> bool: + """Return True when the current session is running under Wayland.""" + if os.environ.get("WAYLAND_DISPLAY"): + return True + if os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland": + return True + return False diff --git a/tests/test_persist_toggles.py b/tests/test_persist_toggles.py index c974ece..94c50f8 100644 --- a/tests/test_persist_toggles.py +++ b/tests/test_persist_toggles.py @@ -323,6 +323,75 @@ def test_click_persists_new_state(self): persist.assert_called_once_with("aw-watcher-afk", False, DEFAULT_PROFILE) assert "aw-watcher-afk" not in fake._restart_timestamps + @pytest.mark.parametrize("watcher", ["aw-watcher-window-wayland", "aw-awatcher"]) + def test_wayland_stop_stays_disabled_after_reload(self, watcher, monkeypatch): + from aw_qt.manager import _apply_wayland_substitutions + + monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0") + + path = _config_path(DEFAULT_PROFILE) + path.write_text(USER_CONFIG) + module = Module(watcher, Path(f"/bundled/{watcher}"), "bundled") + module.toggle = MagicMock(return_value=False) + _, click = self._tray(True, [module]) + with patch("aw_qt.manager._is_wayland", return_value=True): + click(module) + saved = AwQtSettings().autostart_modules + assert _apply_wayland_substitutions(saved, {"aw-server", watcher}) == [ + "aw-server" + ] + assert "# My ActivityWatch setup" in path.read_text() + assert tomlkit.parse(path.read_text())["aw-qt-testing"][ + "autostart_modules" + ] == ["aw-server"] + + @pytest.mark.parametrize("watcher", ["aw-watcher-window-wayland", "aw-awatcher"]) + def test_wayland_stop_disables_other_configured_implementation(self, watcher, monkeypatch): + from aw_qt.manager import WAYLAND_WATCHERS, _apply_wayland_substitutions + + monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0") + + path = _config_path(DEFAULT_PROFILE) + path.write_text( + '[aw-qt]\nautostart_modules = ["aw-server", "aw-watcher-input", ' + '"aw-awatcher", "aw-watcher-window-wayland", ' + '"aw-watcher-afk", "aw-watcher-window"]\n' + ) + module = Module(watcher, Path(f"/bundled/{watcher}"), "bundled") + module.toggle = MagicMock(return_value=False) + _, click = self._tray(True, [module]) + with patch("aw_qt.manager._is_wayland", return_value=True): + click(module) + saved = AwQtSettings().autostart_modules + assert saved == ["aw-server", "aw-watcher-input"] + assert _apply_wayland_substitutions(saved, set(WAYLAND_WATCHERS)) == saved + + @pytest.mark.parametrize("profile", ["testing", "research"]) + def test_wayland_stop_uses_profile_and_first_run_defaults(self, profile, monkeypatch): + from aw_qt.manager import _apply_wayland_substitutions + + monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0") + + AwQtSettings(profile) # commented-out first-run defaults + module = Module("aw-awatcher", Path("/bundled/aw-awatcher"), "bundled") + module.toggle = MagicMock(return_value=False) + fake, click = self._tray(True, [module]) + fake.profile = profile + with patch("aw_qt.manager._is_wayland", return_value=True): + click(module) + saved = AwQtSettings(profile).autostart_modules + assert _apply_wayland_substitutions(saved, {"aw-awatcher"}) == ["aw-server"] + assert AwQtSettings().autostart_modules == [ + "aw-server", "aw-watcher-afk", "aw-watcher-window" + ] + + def test_wayland_stop_on_x11_keeps_x11_watchers(self): + path = _config_path(DEFAULT_PROFILE) + path.write_text(USER_CONFIG) + with patch("aw_qt.config._is_wayland", return_value=False): + assert not persist_module_autostart("aw-awatcher", False) + assert path.read_text() == USER_CONFIG + def test_cli_override_only_changes_running_state(self): module = self._module(return_value=True) _, click = self._tray(False, [module]) diff --git a/tests/test_wayland.py b/tests/test_wayland.py index f8c09ce..8f243eb 100644 --- a/tests/test_wayland.py +++ b/tests/test_wayland.py @@ -111,3 +111,94 @@ def test_wayland_watcher_already_in_list_not_duplicated(self): with patch("aw_qt.manager._is_wayland", return_value=True): result = _apply_wayland_substitutions(modules, self._AVAILABLE) assert result.count(WAYLAND_WATCHER) == 1 + + +class TestAwatcherAsWaylandWatcher: + """aw-awatcher (shipped in the Linux desktop bundle) is a valid substitute.""" + + _DEFAULT_MODULES = ["aw-server", "aw-watcher-afk", "aw-watcher-window"] + + def test_awatcher_used_when_only_it_is_available(self): + available = {*self._DEFAULT_MODULES, "aw-awatcher"} + with patch("aw_qt.manager._is_wayland", return_value=True): + result = _apply_wayland_substitutions(self._DEFAULT_MODULES, available) + assert "aw-awatcher" in result + assert "aw-watcher-window" not in result + assert "aw-watcher-afk" not in result + assert result.count("aw-awatcher") == 1 + + def test_window_wayland_preferred_when_both_available(self): + available = {*self._DEFAULT_MODULES, WAYLAND_WATCHER, "aw-awatcher"} + with patch("aw_qt.manager._is_wayland", return_value=True): + result = _apply_wayland_substitutions(self._DEFAULT_MODULES, available) + assert WAYLAND_WATCHER in result + assert "aw-awatcher" not in result + + def test_awatcher_inserted_at_first_replaced_position(self): + available = {*self._DEFAULT_MODULES, "aw-awatcher"} + with patch("aw_qt.manager._is_wayland", return_value=True): + result = _apply_wayland_substitutions(self._DEFAULT_MODULES, available) + assert result.index("aw-awatcher") == 1 + + def test_explicit_awatcher_is_reused_before_or_after_x11_selection(self): + available = {*self._DEFAULT_MODULES, WAYLAND_WATCHER, "aw-awatcher"} + for modules in ( + ["aw-server", "aw-awatcher", "aw-watcher-window"], + ["aw-server", "aw-watcher-afk", "aw-awatcher"], + ): + with patch("aw_qt.manager._is_wayland", return_value=True): + result = _apply_wayland_substitutions(modules, available) + assert result == ["aw-server", "aw-awatcher"] + + def test_both_explicit_wayland_watchers_keep_only_first_selection(self): + available = {*self._DEFAULT_MODULES, WAYLAND_WATCHER, "aw-awatcher"} + for first, second in ( + ("aw-awatcher", WAYLAND_WATCHER), + (WAYLAND_WATCHER, "aw-awatcher"), + ): + for originals in ([], ["aw-watcher-window", "aw-watcher-afk"]): + modules = ["aw-server", first, *originals, second, "aw-watcher-input"] + with patch("aw_qt.manager._is_wayland", return_value=True): + result = _apply_wayland_substitutions(modules, available) + assert result == ["aw-server", first, "aw-watcher-input"] + + def test_unavailable_explicit_watcher_does_not_select_another(self): + for selected, installed in ( + (WAYLAND_WATCHER, "aw-awatcher"), + ("aw-awatcher", WAYLAND_WATCHER), + ): + modules = ["aw-server", selected] + with patch("aw_qt.manager._is_wayland", return_value=True): + result = _apply_wayland_substitutions(modules, {installed}) + assert result == modules + + def test_missing_explicit_watcher_does_not_block_x11_substitution(self): + for selected, installed in ( + (WAYLAND_WATCHER, "aw-awatcher"), + ("aw-awatcher", WAYLAND_WATCHER), + ): + for originals in ( + ["aw-watcher-window"], + ["aw-watcher-afk"], + ["aw-watcher-window", "aw-watcher-afk"], + ): + for selections in ([selected, *originals], [*originals, selected]): + modules = ["aw-server", *selections, "aw-watcher-input"] + available = {*modules, installed} - {selected} + with patch("aw_qt.manager._is_wayland", return_value=True): + result = _apply_wayland_substitutions(modules, available) + assert result == ["aw-server", installed, "aw-watcher-input"] + assert modules == ["aw-server", *selections, "aw-watcher-input"] + + def test_missing_explicit_and_no_alternative_keeps_original(self): + modules = ["aw-server", "aw-awatcher", "aw-watcher-window"] + with patch("aw_qt.manager._is_wayland", return_value=True): + result = _apply_wayland_substitutions(modules, {"aw-server"}) + assert result == modules + + def test_no_wayland_watcher_keeps_original(self): + with patch("aw_qt.manager._is_wayland", return_value=True): + result = _apply_wayland_substitutions( + self._DEFAULT_MODULES, set(self._DEFAULT_MODULES) + ) + assert result == self._DEFAULT_MODULES