Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions aw_qt/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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):
Comment thread
TimeToBuildBob marked this conversation as resolved.
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
Expand Down
81 changes: 54 additions & 27 deletions aw_qt/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@

import aw_core

from .wayland import WAYLAND_WATCHER # noqa: F401 - retained for existing imports
Comment thread
TimeToBuildBob marked this conversation as resolved.
from .wayland import (
WAYLAND_WATCHERS,
_WAYLAND_REPLACED_MODULES,
_is_wayland,
)

logger = logging.getLogger(__name__)

# The path of aw_qt
Expand All @@ -33,55 +40,75 @@
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
Comment thread
TimeToBuildBob marked this conversation as resolved.
for name in modules:
if name in WAYLAND_WATCHERS and name != watcher:
Comment thread
TimeToBuildBob marked this conversation as resolved.
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:
Expand All @@ -90,7 +117,7 @@
logger.info(
"Wayland session detected: substituted %s with %s",
list(_WAYLAND_REPLACED_MODULES),
WAYLAND_WATCHER,
watcher,
)
return result

Expand Down
19 changes: 19 additions & 0 deletions aw_qt/wayland.py
Original file line number Diff line number Diff line change
@@ -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")
Comment thread
TimeToBuildBob marked this conversation as resolved.


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
69 changes: 69 additions & 0 deletions tests/test_persist_toggles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Comment thread
TimeToBuildBob marked this conversation as resolved.
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])
Expand Down
91 changes: 91 additions & 0 deletions tests/test_wayland.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading