diff --git a/Justfile b/Justfile index ef26bc6..6a4451f 100644 --- a/Justfile +++ b/Justfile @@ -256,6 +256,7 @@ test-all: test: pytest node scripts/test_focus_wire_shape.mjs + node scripts/test_kwin_focus_bridge.mjs # Protocol drift guard (#76): regenerate the TypeScript wire types from # daemon/deckd/protocol.py and fail if the checked-in @@ -274,9 +275,10 @@ check-protocol: gen-protocol: python scripts/codegen_protocol_ts.py --out client/src/protocol.generated.ts -# Run the GNOME focus JSON producer contract independently. +# Run the GNOME + KDE focus JSON producer contracts independently. test-focus-wire: node scripts/test_focus_wire_shape.mjs + node scripts/test_kwin_focus_bridge.mjs # Live-bus MPRIS smoke test — NOT part of `test` / CI. Publishes a real # MPRIS player on the session bus and asserts the production diff --git a/daemon/deckd/layouts.py b/daemon/deckd/layouts.py index b253e85..8d1f0a0 100644 --- a/daemon/deckd/layouts.py +++ b/daemon/deckd/layouts.py @@ -439,11 +439,28 @@ def matches_identity(self, app: AppInfo) -> bool: is that two layouts whose tokens differ only in case now collide; that has never been a supported distinction (ids slugify to lowercase, and ``resolve_id`` already matches case-insensitively). + + Tokens are additionally matched against the *last dotted segment* + of each identity, so a bare token (``konsole``) covers its + reverse-DNS form (``org.kde.konsole``) — the shape KDE's + ``resourceClass`` and GNOME's ``get_wm_class`` report for + Wayland-native windows (docs/PLATFORM-PARITY.md, KDE backend + note). A full token (``org.gnome.Console``) still matches the full + identity exactly; this only widens the exact path, it never + narrows it. """ if not self.match or self.match == ["default"]: return False - identities = {value.casefold() for value in (app.app_id, app.wm_class) if value} - return any(token.casefold() in identities for token in self.match) + tokens = {token.casefold() for token in self.match} + identities = [value.casefold() for value in (app.app_id, app.wm_class) if value] + for identity in identities: + if identity in tokens: + return True + # Reverse-DNS short name: ``org.kde.konsole`` -> ``konsole``. + short = identity.rsplit(".", 1)[-1] + if short in tokens: + return True + return False def load_layout(path: Path) -> Layout: @@ -576,6 +593,14 @@ def _window_to_app(win: WindowInfo) -> AppInfo: def _humanize_identity(identity: str) -> str: """Turn a machine app identity into a readable window label.""" + # Reverse-DNS ids occasionally carry a packaging-suffix segment + # (``org.telegram.desktop``) that is not part of the app's name — + # drop it before taking the last meaningful dotted segment so the + # label reads ``Telegram``, not ``Desktop``. + for suffix in (".desktop", ".app"): + if identity.endswith(suffix): + identity = identity[: -len(suffix)] + break name = identity.rsplit(".", 1)[-1] name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", name) name = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name) diff --git a/daemon/deckd/platform.py b/daemon/deckd/platform.py index 24c8547..d9e3e8b 100644 --- a/daemon/deckd/platform.py +++ b/daemon/deckd/platform.py @@ -672,9 +672,35 @@ class DeckdFocusCache: EMPTY_PAYLOAD = json.dumps( {"app_id": None, "wm_class": None, "title": None, "pid": None} ) + #: The enumeration surface's empty default (#133 follow-up). An + #: empty JSON array is the "no windows / not yet pushed" state the + #: inherited ``watch_windows`` gdbus-poll reads back before the KWin + #: script's first ``UpdateWindowList`` push — the same designed empty + #: state the GNOME extension yields from ``ListWindows`` on a fresh + #: session (issue #120, decision 8). + EMPTY_WINDOWS_PAYLOAD = json.dumps([]) + #: Safety cap on the pending-raise queue. A KWin script that pushes a + #: window list is the same script that drains raises on its timer, so + #: a backlog only accrues if the script wedged between the two — keep + #: the most recent handful and drop the stale rest so the queue can't + #: grow without bound (the newest tap is the one the user meant). + MAX_PENDING_RAISES = 16 - def __init__(self, payload: str | None = None) -> None: + def __init__( + self, payload: str | None = None, windows_payload: str | None = None + ) -> None: self.payload: str = payload if payload is not None else self.EMPTY_PAYLOAD + self.windows_payload: str = ( + windows_payload + if windows_payload is not None + else self.EMPTY_WINDOWS_PAYLOAD + ) + #: FIFO of window ids the daemon asked KWin to raise, waiting for + #: the KWin script's next ``DrainPendingRaises`` poll (#133 + #: follow-up). The daemon owns the enqueue side (``enqueue_raise`` + #: from ``KdeFocusBackend.raise_window`` / ``raise_app``); the + #: script owns the drain side. + self._pending_raises: list[str] = [] def update(self, payload: str) -> None: """Store a new JSON payload. Validates JSON so a malformed KWin @@ -689,6 +715,27 @@ def update(self, payload: str) -> None: json.loads(payload) # raises json.JSONDecodeError on bad input self.payload = payload + def update_windows(self, payload: str) -> None: + """Store a new JSON window-list payload from the KWin script's + ``UpdateWindowList`` push (#133 follow-up). + + Same last-good discipline as :meth:`update`: a malformed push + (bad JSON, or a JSON value that isn't an array) is rejected and + the previous good list is preserved, so a truncated ``callDBus`` + arg cannot blank the running-windows list. Validating the *shape* + (must be a list) in addition to the JSON matters here because the + enumeration wire contract is a JSON array — a bare object would + otherwise reach ``_window_info_from_payload`` per-entry and crash + the reader; rejecting it at the push boundary keeps that failure + off the hot path. + """ + parsed = json.loads(payload) # raises json.JSONDecodeError on bad input + if not isinstance(parsed, list): + raise ValueError( + f"window-list push must be a JSON array, got {type(parsed).__name__}" + ) + self.windows_payload = payload + def to_app_info(self) -> AppInfo: """Inspection helper for tests / diagnostics. The production poll path goes through :class:`GnomeShellFocusBackend`'s @@ -697,18 +744,58 @@ def to_app_info(self) -> AppInfo: hot path.""" return _app_info_from_payload(json.loads(self.payload)) + def to_window_infos(self) -> list[WindowInfo]: + """Inspection helper mirroring :meth:`to_app_info` for the window + list. The production enumeration path goes through + :class:`GnomeShellFocusBackend`'s inherited ``watch_windows`` + gdbus poll (``ListWindows`` against the daemon-owned bus), which + re-derives ``WindowInfo`` from the wire reply, so this is not on + the hot path — it exists for tests / diagnostics.""" + return [_window_info_from_payload(entry) for entry in json.loads(self.windows_payload)] + + def enqueue_raise(self, window_id: str) -> None: + """Queue ``window_id`` for the KWin script's next raise poll + (#133 follow-up). Bounded by :attr:`MAX_PENDING_RAISES` — the + oldest ids fall off the front so a wedged script can't grow the + queue unbounded; the most recent tap is always retained.""" + self._pending_raises.append(window_id) + if len(self._pending_raises) > self.MAX_PENDING_RAISES: + del self._pending_raises[: -self.MAX_PENDING_RAISES] + + def drain_pending_raises(self) -> str: + """Return the queued raise ids as a JSON array string and clear + the queue — a raise is consumed exactly once. This is the KWin + script's ``DrainPendingRaises`` poll target: on a non-empty + reply the script activates each matching window + (``workspace.activeWindow = win``).""" + payload = json.dumps(self._pending_raises) + self._pending_raises = [] + return payload + class DeckdFocusDBusService: """Session-bus service that owns ``org.deckd.Focus`` on KDE. Methods mirror the GNOME Shell extension's contract exactly so external consumers (``scripts/watch_focus.py``, ``gdbus`` probes, - tests) call the same interface on either desktop. Two methods: - - * ``GetActiveWindow() -> s`` — returns the cached JSON payload - (byte-identical wire shape to the GNOME extension). - * ``UpdateActiveWindow(s) -> ()`` — the KWin script's push target; - writes through to the shared :class:`DeckdFocusCache`. + tests) call the same interface on either desktop. Four methods — + two ``Get``/``List`` read methods the daemon's watchers poll, and + two ``Update`` push targets the KWin script feeds: + + * ``GetActiveWindow() -> s`` — returns the cached active-window JSON + payload (byte-identical wire shape to the GNOME extension). + * ``UpdateActiveWindow(s) -> ()`` — the KWin script's focus push + target; writes through to the shared :class:`DeckdFocusCache`. + * ``ListWindows() -> s`` — returns the cached window-list JSON array + (enumeration parity, #133 follow-up); the inherited + ``GnomeShellFocusBackend.watch_windows`` gdbus-polls it. + * ``UpdateWindowList(s) -> ()`` — the KWin script's window-list push + target; writes through to the same cache. + * ``DrainPendingRaises() -> s`` — the KWin script's raise-poll + target; returns the queued window ids (JSON array) and clears the + queue. The inversion that makes raise possible despite KWin scripts + being outbound-only (#133 follow-up): the daemon enqueues, the + script drains on a ``QTimer`` tick and sets ``workspace.activeWindow``. The class is wrapped lazily so import-time never depends on ``dbus_fast`` (a Linux-only dependency the macOS / X11 paths do not @@ -749,6 +836,33 @@ def GetActiveWindow(self) -> "s": # type: ignore[name-defined] def UpdateActiveWindow(self, payload: "s") -> "": # type: ignore[name-defined] cache.update(payload) + @dbus_method() + def ListWindows(self) -> "s": # type: ignore[name-defined] + # Enumeration surface (#133 follow-up). Byte-identical wire + # shape to the GNOME extension's ``ListWindows``: a single + # JSON-array string the daemon's inherited + # ``watch_windows`` gdbus-poll parses per entry. Served from + # the same cache the KWin script's ``UpdateWindowList`` push + # writes through. + return cache.windows_payload + + @dbus_method() + def UpdateWindowList(self, payload: "s") -> "": # type: ignore[name-defined] + # The KWin script's window-list push target — the + # enumeration counterpart of ``UpdateActiveWindow``. + cache.update_windows(payload) + + @dbus_method() + def DrainPendingRaises(self) -> "s": # type: ignore[name-defined] + # The KWin script's raise-poll target (#133 follow-up). + # KWin scripts can only ``callDBus`` outbound and cannot + # receive inbound methods, so the daemon can't push a raise + # command into the compositor; instead the script polls + # this on a ``QTimer`` tick and activates each returned + # window id. Returns the queued ids as a JSON array and + # clears the queue. + return cache.drain_pending_raises() + return _DeckdFocusInterface() @property @@ -808,23 +922,33 @@ def __init__( self._started = False def capabilities(self) -> frozenset[str]: - """Focus-only — the honest surface for KDE today (#133). - - ``KdeFocusBackend`` subclasses :class:`GnomeShellFocusBackend` - for the *poll* path only. The KWin script can push focus in - (``UpdateActiveWindow``) but there is no KDE-side implementation - of enumeration or raise, so advertising the GNOME backend's - ``watch_windows`` / ``raise_window`` / ``raise_app`` flags would - be dishonest advertisement: the daemon would start those - surfaces and the client would sit on a perpetually-empty list - (issue #120, decision 8). Override back down to focus-only so - each unsupported surface stays in its designed empty state. - Matches the ``## Capability matrix`` in - ``docs/PLATFORM-PARITY.md``; ``tests/test_platform_parity.py`` - enforces the agreement. Enumeration/raise parity is future work - (the eventual KWin-side implementation re-adds the flags here). + """Full GNOME parity (#133 follow-up). + + #133 forced this down to focus-only because the KWin script + could only *push focus in* — there was no KDE-side enumeration or + raise, so advertising ``watch_windows`` / ``raise_window`` / + ``raise_app`` would have been dishonest (the daemon would start + surfaces the client could never populate or act on). The + follow-up implements both directions the KWin-script push model + allows: + + * **Enumeration** — the KWin script pushes the full window list + (``UpdateWindowList``) into the same cache the daemon serves + back on ``ListWindows``; the inherited + :meth:`GnomeShellFocusBackend.watch_windows` gdbus-polls it + unchanged. + * **Raise** — KWin scripts can't receive inbound D-Bus, so the + daemon enqueues a raise (:meth:`raise_window` / :meth:`raise_app`) + and the script drains it on a ``QTimer`` poll + (``DrainPendingRaises``) and sets ``workspace.activeWindow``. + + So the honest surface is now the full set. Matches the + ``## Capability matrix`` in ``docs/PLATFORM-PARITY.md``; + ``tests/test_platform_parity.py`` enforces the agreement. """ - return frozenset({"watch_active_app"}) + return frozenset( + {"watch_active_app", "watch_windows", "raise_window", "raise_app"} + ) @property def cache(self) -> DeckdFocusCache: @@ -834,6 +958,55 @@ def cache(self) -> DeckdFocusCache: backend's inherited ``gdbus`` poll path, via the bus).""" return self._cache + async def raise_window(self, window_id: str) -> None: + """Enqueue ``window_id`` for the KWin script's raise poll (#133 + follow-up). + + Overrides the inherited GNOME path (which gdbus-calls a + daemon-side ``RaiseWindow`` method that does not exist on KDE): + the daemon has no way to *call into* KWin, so it resolves the id + against its own cached window list and, when the id is still + live, enqueues it for the script to drain + (:meth:`DeckdFocusCache.drain_pending_raises`) and activate. + + An id absent from the current snapshot retired between + enumeration and the user's tap — mirror the GNOME backend's + ``false`` return by raising :class:`RaiseWindowFailed` (the + server turns it into a diagnostic ``raise_failed`` / ``declined`` + event, #122) and enqueue nothing, so the script never chases a + dead window. Fire-and-forget otherwise: a live enqueue always + "succeeds" from the caller's view; whether KWin then honours it + is not observable on the push model, matching the fire-and-forget + contract in :meth:`PlatformBackend.raise_window`. + """ + live_ids = {w.window_id for w in self._cache.to_window_infos()} + if window_id not in live_ids: + raise RaiseWindowFailed(window_id) + self._cache.enqueue_raise(window_id) + + async def raise_app(self, identity: str) -> bool: + """Raise the first cached window matching ``identity`` (#133 + follow-up). + + The GNOME extension resolves ``RaiseApp`` compositor-side; on KDE + the daemon already holds the enumerated window list, so it does + the identity match itself (against the same three identity keys + the layout matcher uses — ``wm_class`` / ``gtk_application_id`` / + ``sandboxed_app_id``) and enqueues the winner's id. Returns + ``True`` when a match was found and queued, ``False`` when no open + window carries the identity — the same contract the inherited + gdbus path returned. + """ + for window in self._cache.to_window_infos(): + if identity in ( + window.wm_class, + window.gtk_application_id, + window.sandboxed_app_id, + ): + self._cache.enqueue_raise(window.window_id) + return True + return False + async def start(self) -> None: """Own ``org.deckd.Focus`` and export the push surface. diff --git a/docs/PLATFORM-PARITY.md b/docs/PLATFORM-PARITY.md index 1bc5f29..f7d014e 100644 --- a/docs/PLATFORM-PARITY.md +++ b/docs/PLATFORM-PARITY.md @@ -19,10 +19,10 @@ this table exists to make drift between backends visible at a glance. | Capability | GNOME (Wayland/X11) | KDE Plasma (Wayland) | X11 (generic) | macOS | |---|---|---|---|---| | Focus detection (`watch_active_app`) | ✓ GNOME Shell extension over `org.deckd.Focus` | ✓ KWin script pushes into daemon-owned cache (#31) | ✓ `xdotool` poll | ✓ `osascript` + System Events | -| Window enumeration (`watch_windows`) | ✓ extension `ListWindows` | ✗ — not advertised; KWin-side impl is future work ([#133](https://github.com/jonocodes/deckd/issues/133)) | ✗ | ✓ Quartz `CGWindowList` — app names only (titles need Screen Recording) | -| Raise window (`raise_window`) | ✓ extension `RaiseWindow` (#127) | ✗ — not advertised; KWin-side impl is future work ([#133](https://github.com/jonocodes/deckd/issues/133)) | ✗ | ✓ AppKit + Accessibility (AX half needs the grant) | -| Window row → layout match (icon / display name) | ✓ `wm_class` matches the layout token | n/a — no enumeration | n/a | ✓ identity matching is case-insensitive (#140), so `CGWindowList`'s `Firefox` matches the `firefox` token | -| Raise app (`raise:`) | ✓ extension `RaiseApp` (#137) | ✗ | ✗ | ✗ | +| Window enumeration (`watch_windows`) | ✓ extension `ListWindows` | ✓ KWin script pushes `UpdateWindowList` → daemon `ListWindows` (#133) | ✗ | ✓ Quartz `CGWindowList` — app names only (titles need Screen Recording) | +| Raise window (`raise_window`) | ✓ extension `RaiseWindow` (#127) | ✓ daemon enqueues → KWin script `QTimer`-polls `DrainPendingRaises`, sets `workspace.activeWindow` (#133) | ✗ | ✓ AppKit + Accessibility (AX half needs the grant) | +| Window row → layout match (icon / display name) | ✓ `wm_class` matches the layout token | ✓ `resourceClass` → `wm_class`, `desktopFileName` → `sandboxed_app_id` (#133) | n/a | ✓ identity matching is case-insensitive (#140), so `CGWindowList`'s `Firefox` matches the `firefox` token | +| Raise app (`raise:`) | ✓ extension `RaiseApp` (#137) | ✓ daemon matches identity in its cached list, enqueues the winner (#133) | ✗ | ✗ | | MPRIS media (chrome media icon + `nowplaying`) | ✓ session-bus MPRIS | ✓ | ✓ | ✗ — no session bus; the daemon sends `chrome_media.supported = false` and the view says "unsupported on this platform". A MediaRemote-based equivalent is [#56](https://github.com/jonocodes/deckd/issues/56) | | `media` widget (VLC HTTP backend) | ✓ | ✓ | ✓ | ◑ unverified — plain HTTP to VLC's web interface, no platform-specific path | | `dbus:` action | ✓ | ✓ | ✓ | ✗ — a Mac has no GNOME/KDE services to call | @@ -87,22 +87,45 @@ the enumeration/raise tests (#129–#131) are GNOME-first. capability simply never produces the corresponding wire frame; the client surfaces the "unsupported on this platform" empty state (issue #120, decision 8). The daemon gates each optional surface on `capabilities()` — so the honest -move for a backend that can't do something is to *not advertise it*. #133 is a -case where KDE advertises two capabilities it can't fulfil. +move for a backend that can't do something is to *not advertise it*. #133 was a +case where KDE advertised two capabilities it couldn't fulfil; the short-term +fix dropped them to focus-only, and the follow-up (this doc's current KDE +column) taught the KWin script + daemon cache to honour enumeration and raise, +so KDE could honestly re-advertise them. ## Backend notes -- **GNOME** — richest backend. The `deckd-focus@local` Shell extension owns - `org.deckd.Focus` and answers `GetActiveWindow` / `ListWindows` / - `RaiseWindow`. Enumeration + raise are GNOME-only today. -- **KDE Plasma** — `KdeFocusBackend` subclasses the GNOME backend and reuses its - poll path, but the daemon (not a KDE extension) owns `org.deckd.Focus`; the - KWin script can only *push* focus in (`UpdateActiveWindow`), so the exported - interface implements focus only, so `KdeFocusBackend.capabilities()` - overrides the inherited GNOME set back down to focus-only. Enumeration/raise - parity is future work - ([#133](https://github.com/jonocodes/deckd/issues/133) tracks the eventual - KWin-side implementation that would re-add the flags). +- **GNOME** — the `deckd-focus@local` Shell extension owns `org.deckd.Focus` + and answers `GetActiveWindow` / `ListWindows` / `RaiseWindow` / `RaiseApp` + directly (pull model — the daemon gdbus-calls the extension). +- **KDE Plasma** — reaches GNOME parity on all four compositor-axis surfaces + ([#133](https://github.com/jonocodes/deckd/issues/133) follow-up), but by the + opposite plumbing: the **daemon** owns `org.deckd.Focus` and the KWin script + feeds it, because KWin scripts can only `callDBus` *outbound* — they cannot + own a bus name or receive inbound methods (spike #30). + - *Focus + enumeration* invert to **push**: the KWin script pushes the active + window (`UpdateActiveWindow`) and the full window list (`UpdateWindowList`) + into the daemon cache; the daemon serves `GetActiveWindow` / `ListWindows` + from it, so `KdeFocusBackend` reuses the inherited GNOME gdbus **poll** path + unchanged (byte-identical wire shape). + - *Raise* inverts to **enqueue-and-poll**: since the daemon can't call into + KWin, `raise_window` / `raise_app` resolve the id against the daemon's own + cached window list and enqueue it; the persistent KWin script drains the + queue on a `QTimer` tick (`DrainPendingRaises`) and sets + `workspace.activeWindow`. Raise is fire-and-forget on this model — a retired + id is caught daemon-side (it's absent from the cached list → `raise_failed` + / `declined`), but a live enqueue's ultimate success isn't observable. + - Per-window identity maps `resourceClass` → `wm_class` and `desktopFileName` + → `sandboxed_app_id` (KWin's desktop-file id is the closest analogue to the + GNOME extension's `Meta.App` id), so the layout matcher has both an X11-class + and a desktop-file token to compare, matching GNOME behaviour. Wayland-native + windows report the full reverse-DNS id as their class (`org.kde.konsole`, + `org.telegram.desktop`); `Layout.matches_identity` matches a bare layout + token (`konsole`) against the last dotted segment, so a `match: [konsole]` + layout covers KDE's class and GNOME's `get_wm_class` alike. The running- + windows label humanizer drops a trailing `.desktop`/`.app` packaging + segment before taking the last dotted segment (`org.telegram.desktop` → + `Telegram`, not `Desktop`). - **X11** — `xdotool`-based focus polling; no enumeration/raise. Input via `uinput` like every Linux path. - **macOS** — `osascript` + System Events focus detection; Quartz supplies diff --git a/docs/TESTING.md b/docs/TESTING.md index 5b0564d..f5b150c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -95,8 +95,17 @@ No — they sit on a spectrum: - **#131 (nested compositor)** is written with a GNOME path and a later sway / labwc / weston path for the KWin / generic backend (#31). -**Caveat:** enumeration + raise (`ListWindows` / `RaiseWindow`) only *exist* on the -GNOME backend today — KDE inherits focus-only, and X11 / macOS have no enumeration -— so in practice these run GNOME-first until KDE catches up. macOS focus +**Caveat:** enumeration + raise (`ListWindows` / `RaiseWindow`) exist on the GNOME +*and* KDE backends now (#133 taught the KWin script to push a window list and the +daemon to enqueue raises the script drains on a `QTimer` — see +`daemon/deckd/platform.py` and `packaging/kwin-script/deckd-focus/`), but X11 / +macOS still have no enumeration. The KDE producer is exercised without a +compositor by `scripts/test_kwin_focus_bridge.mjs` (the KWin counterpart of the +GNOME `scripts/test_focus_wire_shape.mjs`); a live KWin session is still needed to +machine-verify it end-to-end. Per `docs/PLATFORM-PARITY.md`, a ✓ means "the code +advertises and implements it" — the Linux columns (GNOME and now KDE) are +code-and-CI truth with no dated hardware run behind them, so treat the KDE +enumeration/raise rows as unverified until someone repeats a live-session check +and dates it there. macOS focus (osascript / Quartz, no D-Bus) would need a different harness entirely, out of scope for these four. diff --git a/packaging/kwin-script/deckd-focus/contents/code/main.js b/packaging/kwin-script/deckd-focus/contents/code/main.js index d63c7fc..1597b11 100644 --- a/packaging/kwin-script/deckd-focus/contents/code/main.js +++ b/packaging/kwin-script/deckd-focus/contents/code/main.js @@ -1,31 +1,47 @@ -// deckd-focus — KWin focus bridge for the deckd daemon (issue #31). +// deckd-focus — KWin focus/window bridge for the deckd daemon (#31, #133). // -// Pushes the active window over the session D-Bus into a daemon-owned -// cache at org.deckd.Focus.UpdateActiveWindow(s). The daemon's -// KdeFocusBackend (daemon/deckd/platform.py) owns the org.deckd.Focus -// name, serves GetActiveWindow(s) with byte-identical wire shape to -// the GNOME Shell extension's deckd-focus@local, and the daemon's -// focus watcher reads the cache on every 100ms poll. +// Pushes the active window AND the full open-window list over the session +// D-Bus into a daemon-owned cache at org.deckd.Focus (UpdateActiveWindow / +// UpdateWindowList), and drives window raises the daemon can't issue +// itself. The daemon's KdeFocusBackend (daemon/deckd/platform.py) owns the +// org.deckd.Focus name, serves GetActiveWindow / ListWindows with wire +// shape byte-identical to the GNOME Shell extension deckd-focus@local, and +// its watchers read the cache on every 100ms poll. // // KWin scripts run inside the compositor process and can callDBus OUT // only — they cannot own a D-Bus name or expose inbound method slots -// (src/scripting/scripting.cpp; develop.kde.org KWin scripting API). -// See docs/spike-kde-wayland-focus.md §"Recommended path" for the -// architecture rationale and §"Open questions" for the -// daemon-vs-script ownership split. +// (src/scripting/scripting.cpp; develop.kde.org KWin scripting API). That +// one constraint shapes the whole design: +// * focus + enumeration invert to PUSH (script → daemon cache); +// * raise inverts to ENQUEUE-AND-POLL — the daemon can't call into +// KWin, so it queues a window id and this script drains the queue on +// a QTimer tick (DrainPendingRaises) and sets workspace.activeWindow. +// See docs/spike-kde-wayland-focus.md §"Recommended path" and +// docs/PLATFORM-PARITY.md (KDE backend note) for the full rationale. // -// KWin API surface used (stable since KWin 6.0; develop.kde.org/docs/ -// plasma/kwin/api/, "KWin::Window" + "Global / Functions"): -// workspace.activeWindow — KWin::Window * (the focused window) +// KWin API surface used (all verified against invent.kde.org/plasma/kwin +// master — see the #133 research notes; stable since KWin 6.0): +// workspace.activeWindow — KWin::Window * (focused); WRITABLE: +// assigning raises + focuses the window +// workspace.windowList() — every managed window (Plasma 6 name; +// the Plasma 5 clientList() was removed) // workspace.windowActivated — signal fired on every focus change +// workspace.windowAdded/Removed — signals fired on open/close // win.desktopFileName — .desktop basename (KDE's app_id); // empty for some XWayland clients → // fall back to resourceClass // win.resourceClass — WM_CLASS class slot // win.caption — WM_NAME without hostname suffix // win.pid — process pid (KWin 5.20+) -// win.internalId — QUuid (diagnostic only) -// callDBus(svc, path, iface, method, args…) — outbound D-Bus call +// win.internalId — QUuid; stringified as the stable, +// opaque window_id shared with the daemon +// win.minimized / win.desktops — window state for the enumeration wire +// win.skipTaskbar — filters Plasma panels / OSDs out +// win.{caption,minimized,desktops}Changed — per-window state signals +// QTimer — exposed to the script sandbox by +// scripting.cpp; drives the raise poll +// callDBus(svc, path, iface, method, args…, cb?) — outbound D-Bus call; +// the trailing callable is a reply cb // // Install / hot-start (consumers should use `just install-focus-kwin`, // which wraps these three steps): @@ -49,6 +65,13 @@ const BUS_NAME = "org.deckd.Focus"; const OBJ_PATH = "/org/deckd/Focus"; const IFACE = "org.deckd.Focus"; const METHOD_PUSH = "UpdateActiveWindow"; +const METHOD_PUSH_LIST = "UpdateWindowList"; +const METHOD_DRAIN_RAISES = "DrainPendingRaises"; +// Raise-poll cadence. The daemon enqueues a raise the instant a +// running-windows row is tapped; this is the worst-case lag before the +// window activates. 200ms keeps the tap feeling responsive without +// busying the session bus (cf. the daemon's own 100ms focus poll). +const RAISE_POLL_MS = 200; function snapshot(win) { // The daemon's DeckdFocusCache.update validates JSON and tolerates @@ -94,15 +117,191 @@ function push(win) { } } -// Initial state so GetActiveWindow is non-empty before the first -// alt-tab. If the daemon isn't up yet this push is silently dropped -// (see push() comment); re-running install-focus-kwin re-fires it. +// --- Window enumeration (#133) ------------------------------------------- +// +// The enumeration counterpart of the focus push: build a JSON array of +// every open application window and push it via UpdateWindowList. The +// daemon serves it back on ListWindows(), and its inherited +// GnomeShellFocusBackend.watch_windows gdbus-polls that unchanged — so the +// running-windows list lights up with the same plumbing as GNOME. + +function windowId(win) { + return win && win.internalId ? String(win.internalId) : null; +} + +function windowSnapshot(win) { + // Keys match the daemon's _window_info_from_payload and the GNOME + // extension's per-window shape (wire-shape.js windowPayload). Unknown + // keys are ignored by the daemon, missing ones tolerated via .get(). + const desktops = win.desktops; + // 0-based to match the GNOME extension's get_workspace().index wire + // value — KWin's x11DesktopNumber is 1-based, so subtract one. The + // field is unrendered in v1's chrome, but keep the wire semantics + // identical across backends. Empty desktops list ⇒ on all desktops ⇒ + // null, mirroring GNOME's null-workspace case. + const ws = + desktops && desktops.length && typeof desktops[0].x11DesktopNumber === "number" + ? desktops[0].x11DesktopNumber - 1 + : null; + return { + window_id: windowId(win), + // resourceClass is the WM_CLASS class slot — the primary identity + // token the layout matcher compares against. + wm_class: win.resourceClass || null, + // KDE exposes no GTK application id; leave it null (GNOME fills it + // for GTK apps). desktopFileName — KDE's .desktop id — is the + // closest analogue to the GNOME extension's Meta.App id, so it + // rides in sandboxed_app_id to give the matcher a desktop-file + // token too (see docs/PLATFORM-PARITY.md, KDE backend note). + gtk_application_id: null, + sandboxed_app_id: win.desktopFileName || null, + app_name: null, + title: win.caption || null, + workspace: ws, + minimized: win.minimized === true, + }; +} + +function enumerableWindows() { + // windowList() is the Plasma 6 accessor (the Plasma 5 clientList() + // was removed). typeof-guarded so a KWin without it degrades to an + // empty list rather than throwing every tick. + const all = typeof workspace.windowList === "function" ? workspace.windowList() : []; + const out = []; + for (let i = 0; i < all.length; i++) { + const win = all[i]; + if (!win) continue; + // skipTaskbar is KWin's native "don't show in the task switcher" + // flag — exactly the running-windows semantics — so it's the only + // filter we apply: it drops Plasma panels / docks / OSDs while + // keeping every real app window, INCLUDING the rare Wayland client + // with no resourceClass (which the GNOME extension's ListWindows + // also lists, wm_class null). Requiring a resourceClass here would + // silently drop such a window and diverge from GNOME. An undefined + // skipTaskbar is falsy, so a KWin lacking the property just widens + // the list rather than emptying it. + if (win.skipTaskbar) continue; + out.push(win); + } + return out; +} + +// id -> true for windows whose state signals we've already wired, so a +// window is connected once no matter how many enumerations it appears in. +const trackedWindows = {}; + +function trackWindow(win, id) { + if (trackedWindows[id]) return; + trackedWindows[id] = true; + // Re-push when a tracked window's label-relevant state changes, so the + // running-windows list reflects title / minimize / desktop moves + // without waiting for the next add/remove/activate (GNOME's + // ListWindows is live-polled, so this keeps parity). These per-window + // connections aren't explicitly disconnected — windowRemoved only + // forgets the tracking flag; KWin drops the signal connections when the + // window object itself is destroyed, so no handler leaks past a close. + if (win.captionChanged) win.captionChanged.connect(pushWindowList); + if (win.minimizedChanged) win.minimizedChanged.connect(pushWindowList); + if (win.desktopsChanged) win.desktopsChanged.connect(pushWindowList); +} + +function pushWindowList() { + const wins = enumerableWindows(); + const activeId = windowId(workspace.activeWindow); + const entries = []; + for (let i = 0; i < wins.length; i++) { + const entry = windowSnapshot(wins[i]); + if (entry.window_id === null) continue; + entries.push(entry); + trackWindow(wins[i], entry.window_id); + } + // Focused-first (the MRU primary sort the GNOME extension applies), so + // the active window heads the chrome list. + entries.sort(function (a, b) { + if (a.window_id === activeId) return -1; + if (b.window_id === activeId) return 1; + return 0; + }); + try { + callDBus(BUS_NAME, OBJ_PATH, IFACE, METHOD_PUSH_LIST, JSON.stringify(entries)); + } catch (err) { + console.error("deckd-focus: callDBus UpdateWindowList failed:", err); + } +} + +// --- Raise (#133) -------------------------------------------------------- +// +// KWin scripts can't receive inbound D-Bus, so the daemon can't tell KWin +// to raise a window. Instead it enqueues the target window id and this +// script polls for the queue on a QTimer tick: DrainPendingRaises returns +// a JSON array of ids (and clears the queue daemon-side); we activate each +// by assigning workspace.activeWindow (which raises + focuses). + +function activateById(id) { + const wins = typeof workspace.windowList === "function" ? workspace.windowList() : []; + for (let j = 0; j < wins.length; j++) { + if (String(wins[j].internalId) === id) { + workspace.activeWindow = wins[j]; + return; + } + } + // No match: the id retired between enumeration and the drain. The + // daemon already declines raises for ids absent from its cache, so + // this is a rare race — drop it silently (fire-and-forget). +} + +function drainRaises() { + try { + callDBus(BUS_NAME, OBJ_PATH, IFACE, METHOD_DRAIN_RAISES, function (reply) { + if (!reply) return; + let ids; + try { + ids = JSON.parse(reply); + } catch (e) { + return; + } + if (!ids || !ids.length) return; + for (let k = 0; k < ids.length; k++) activateById(String(ids[k])); + }); + } catch (err) { + console.error("deckd-focus: callDBus DrainPendingRaises failed:", err); + } +} + +// --- Wiring -------------------------------------------------------------- + +// Initial state so GetActiveWindow / ListWindows are non-empty before the +// first alt-tab. If the daemon isn't up yet these pushes are silently +// dropped (see push() comment); re-running install-focus-kwin re-fires them. push(workspace.activeWindow); +pushWindowList(); + +// Focus changes (Workspace::windowActivated). Both the active-window +// snapshot and the window list get pushed: activation reorders the +// focused-first list even when the window set is unchanged. +workspace.windowActivated.connect(function (win) { + push(win); + pushWindowList(); +}); + +// Open / close change the window set. windowRemoved also forgets the +// window's tracking flag so trackedWindows doesn't grow across a long +// session of opening and closing windows. +if (workspace.windowAdded) { + workspace.windowAdded.connect(pushWindowList); +} +if (workspace.windowRemoved) { + workspace.windowRemoved.connect(function (win) { + const id = windowId(win); + if (id) delete trackedWindows[id]; + pushWindowList(); + }); +} -// Focus changes (Workspace::windowActivated signal). Title-only -// updates (captionChanged on an already-focused window) are NOT wired -// here: the 100ms poll interval is short enough that a stale caption -// is invisible, and connecting captionChanged would double the push -// frequency on tab switches inside a focused browser. See spike #30 -// open question #6 for the deferred consideration. -workspace.windowActivated.connect(push); \ No newline at end of file +// Raise poll. QTimer is exposed to the script sandbox by scripting.cpp; +// the persistent timer is what lets a daemon-initiated raise reach KWin +// despite the outbound-only D-Bus constraint. +const raiseTimer = new QTimer(); +raiseTimer.interval = RAISE_POLL_MS; +raiseTimer.timeout.connect(drainRaises); +raiseTimer.start(); \ No newline at end of file diff --git a/scripts/test_kwin_focus_bridge.mjs b/scripts/test_kwin_focus_bridge.mjs new file mode 100644 index 0000000..25158cd --- /dev/null +++ b/scripts/test_kwin_focus_bridge.mjs @@ -0,0 +1,216 @@ +// Contract test for the KWin focus/window bridge (#31, #133). +// +// The KWin script runs inside KWin's QJSEngine, which — unlike GNOME's GJS — +// has NO ES module loader, so the script can't be split into an importable +// pure module the way the GNOME extension factors out wire-shape.js. Instead +// we test the REAL packaging/kwin-script/.../main.js by evaluating it in a +// node:vm sandbox that supplies fakes for the KWin globals it touches +// (workspace, callDBus, QTimer, console), then assert on: +// * the initial UpdateActiveWindow + UpdateWindowList pushes, +// * the per-window enumeration wire shape (matches the GNOME window shape), +// * the taskbar/resourceClass filter (panels excluded), +// * focused-first ordering, +// * the QTimer raise poll: a DrainPendingRaises reply activates the +// matching window via workspace.activeWindow. +// +// This is the KDE counterpart of scripts/test_focus_wire_shape.mjs and runs +// in the same `just test` step — no compositor required. + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import vm from "node:vm"; + +const SOURCE = fs.readFileSync( + new URL("../packaging/kwin-script/deckd-focus/contents/code/main.js", import.meta.url), + "utf8", +); + +// --- KWin global fakes --------------------------------------------------- + +function makeSignal() { + const handlers = []; + return { + connect: (fn) => handlers.push(fn), + emit: (...args) => handlers.forEach((h) => h(...args)), + }; +} + +// A fake KWin::Window. `id` stands in for the QUuid internalId; the script +// stringifies it into the opaque window_id. +function fakeWindow(id, resourceClass, caption, opts = {}) { + return { + internalId: id, + resourceClass, + caption, + desktopFileName: opts.desktopFileName ?? "", + pid: opts.pid ?? 1000, + minimized: opts.minimized ?? false, + skipTaskbar: opts.skipTaskbar ?? false, + desktops: opts.desktops ?? [{x11DesktopNumber: 1}], + captionChanged: makeSignal(), + minimizedChanged: makeSignal(), + desktopsChanged: makeSignal(), + }; +} + +// Build the sandbox and evaluate main.js in it. Returns handles for driving +// signals and inspecting captured D-Bus traffic. +function loadBridge(windows, activeIndex) { + const dbusPushes = []; // {method, payload} + let pendingRaiseReply = "[]"; + let timerHandler = null; + let activeWindow = windows[activeIndex] ?? null; + const activatedTo = []; + + function callDBus(...args) { + const method = args[3]; + const last = args[args.length - 1]; + if (typeof last === "function") { + // reply-callback form — DrainPendingRaises + last(pendingRaiseReply); + return; + } + dbusPushes.push({method, payload: args[4]}); + } + + class QTimer { + constructor() { + this.interval = 0; + this.timeout = {connect: (fn) => (timerHandler = fn)}; + } + start() { + this.started = true; + } + } + + const workspace = { + windowList: () => windows, + get activeWindow() { + return activeWindow; + }, + set activeWindow(win) { + activeWindow = win; + activatedTo.push(win); + }, + windowActivated: makeSignal(), + windowAdded: makeSignal(), + windowRemoved: makeSignal(), + }; + + const sandbox = {workspace, callDBus, QTimer, console: {error: () => {}, log: () => {}}}; + vm.createContext(sandbox); + vm.runInContext(SOURCE, sandbox); + + return { + dbusPushes, + workspace, + activatedTo, + tick: () => timerHandler(), + // Move focus the way KWin would (updates activeWindow) WITHOUT + // recording to activatedTo, which is reserved for script-driven raises. + setActive: (win) => (activeWindow = win), + setRaiseReply: (r) => (pendingRaiseReply = r), + lastPush: (method) => [...dbusPushes].reverse().find((c) => c.method === method), + }; +} + +// --- Fixtures ------------------------------------------------------------ + +const dolphin = fakeWindow("w-dolphin", "dolphin", "Dolphin — Home", { + desktopFileName: "org.kde.dolphin", +}); +const firefox = fakeWindow("w-firefox", "firefox", "deckd — GitHub", { + desktopFileName: "org.mozilla.firefox", + minimized: true, + desktops: [{x11DesktopNumber: 2}], +}); +const panel = fakeWindow("w-panel", "plasmashell", "Panel", {skipTaskbar: true}); +// A classless-but-taskbar-visible window: GNOME's ListWindows lists such a +// window (wm_class null), so the KDE bridge must too — skipTaskbar, not +// resourceClass, is the filter. This locks that parity decision. +const classless = fakeWindow("w-none", "", "Some Dialog"); + +const windows = [dolphin, firefox, panel, classless]; +const bridge = loadBridge(windows, /* active */ 1 /* firefox */); + +// --- 1. Initial pushes --------------------------------------------------- + +const activePush = bridge.lastPush("UpdateActiveWindow"); +assert.ok(activePush, "expected an UpdateActiveWindow push on load"); +assert.deepEqual(JSON.parse(activePush.payload), { + app_id: "org.mozilla.firefox", // desktopFileName → app_id (active-window shape) + wm_class: "firefox", + title: "deckd — GitHub", + pid: 1000, + uuid: "w-firefox", +}); + +const listPush = bridge.lastPush("UpdateWindowList"); +assert.ok(listPush, "expected an UpdateWindowList push on load"); +const list = JSON.parse(listPush.payload); + +// --- 2. Filtering: only skipTaskbar (panels) excluded; focused-first ----- + +assert.deepEqual( + list.map((w) => w.window_id), + ["w-firefox", "w-dolphin", "w-none"], + "taskbar-visible windows only (panel dropped), focused (firefox) first", +); + +// The classless window is listed with wm_class null — GNOME-parity +// inclusiveness, not silently dropped. +const classlessEntry = list.find((w) => w.window_id === "w-none"); +assert.equal(classlessEntry.wm_class, null); +assert.equal(classlessEntry.title, "Some Dialog"); + +// --- 3. Per-window wire shape (matches _window_info_from_payload keys) ---- + +const firefoxEntry = list.find((w) => w.window_id === "w-firefox"); +assert.deepEqual(firefoxEntry, { + window_id: "w-firefox", + wm_class: "firefox", + gtk_application_id: null, + sandboxed_app_id: "org.mozilla.firefox", // desktopFileName → sandboxed_app_id + app_name: null, + title: "deckd — GitHub", + workspace: 1, // x11DesktopNumber 2 → 0-based 1, matching GNOME's index + minimized: true, +}); +const dolphinEntry = list.find((w) => w.window_id === "w-dolphin"); +assert.equal(dolphinEntry.minimized, false); +assert.equal(dolphinEntry.workspace, 0); // x11DesktopNumber 1 → 0-based 0 + +// --- 4. Re-push on focus change, focused-first reorders ------------------ + +// KWin moves focus (activeWindow) and fires windowActivated together. +bridge.setActive(dolphin); +bridge.workspace.windowActivated.emit(dolphin); +const reordered = JSON.parse(bridge.lastPush("UpdateWindowList").payload); +assert.deepEqual( + reordered.map((w) => w.window_id), + ["w-dolphin", "w-firefox", "w-none"], + "windowActivated re-pushes the list, now focused-first on dolphin", +); + +// --- 5. Raise poll: a DrainPendingRaises reply activates the window ------ + +bridge.setRaiseReply(JSON.stringify(["w-dolphin"])); +bridge.tick(); +assert.equal( + bridge.activatedTo[bridge.activatedTo.length - 1], + dolphin, + "a queued raise id activates the matching window via workspace.activeWindow", +); + +// A retired id (not in windowList) is a silent no-op, not a throw. +const before = bridge.activatedTo.length; +bridge.setRaiseReply(JSON.stringify(["w-gone"])); +bridge.tick(); +assert.equal(bridge.activatedTo.length, before, "unknown raise id is dropped silently"); + +// An empty queue does nothing. +bridge.setRaiseReply("[]"); +bridge.tick(); +assert.equal(bridge.activatedTo.length, before, "empty raise queue is a no-op"); + +console.log("kwin-focus-bridge: all assertions passed"); diff --git a/tests/test_layouts.py b/tests/test_layouts.py index f158299..7abeed2 100644 --- a/tests/test_layouts.py +++ b/tests/test_layouts.py @@ -255,6 +255,28 @@ def test_matches_identity_is_case_insensitive() -> None: assert not layout.matches_identity(AppInfo(app_id="chrome", wm_class=None)) +def test_matches_identity_matches_reverse_dns_short_name() -> None: + """A bare token matches its reverse-DNS form — ``konsole`` covers + ``org.kde.konsole``, the identity KDE's ``resourceClass`` (and GNOME's + ``get_wm_class``) report for Wayland-native windows. Without this, + Konsole falls to the default layout on KDE (#133 follow-up field bug).""" + layout = Layout(match=["konsole"]) + assert layout.matches_identity(AppInfo(app_id="org.kde.konsole", wm_class=None)) + assert layout.matches_identity(AppInfo(app_id=None, wm_class="org.kde.konsole")) + + +def test_matches_identity_reverse_dns_does_not_widen_short_token() -> None: + """The short-name widening only covers a token that IS the last segment + — ``firefox`` never matches an unrelated reverse-DNS id, and a full + token still matches only its own exact identity.""" + layout = Layout(match=["firefox"]) + assert not layout.matches_identity(AppInfo(app_id="org.kde.dolphin", wm_class=None)) + assert not layout.matches_identity(AppInfo(app_id=None, wm_class="com.github.firefoxtools")) + full = Layout(match=["org.gnome.Console"]) + assert full.matches_identity(AppInfo(app_id="org.gnome.Console", wm_class=None)) + assert not full.matches_identity(AppInfo(app_id="org.gnome.console2", wm_class=None)) + + def test_resolve_falls_back_to_default_layout(tmp_path: Path) -> None: _write(tmp_path, "firefox.yaml", FIREFOX_LAYOUT) _write(tmp_path, "default.yaml", DEFAULT_LAYOUT) diff --git a/tests/test_platform_kde.py b/tests/test_platform_kde.py index 7d8fa6d..86281e6 100644 --- a/tests/test_platform_kde.py +++ b/tests/test_platform_kde.py @@ -119,6 +119,120 @@ def test_cache_identity_falls_back_to_wm_class() -> None: assert cache.to_app_info().identity == "firefox" +# --------------------------------------------------------------------------- +# DeckdFocusCache — the window-list snapshot fed by the KWin script's +# UpdateWindowList push (enumeration parity, #133 follow-up). Mirrors the +# active-window cache above: a separate JSON payload the daemon serves back +# on ListWindows() so the inherited GnomeShellFocusBackend.watch_windows +# gdbus-poll is answered from cache instead of failing UnknownMethod. +# --------------------------------------------------------------------------- + + +def test_cache_default_windows_payload_is_empty_list() -> None: + cache = DeckdFocusCache() + assert json.loads(cache.windows_payload) == [] + assert cache.to_window_infos() == [] + + +def test_cache_update_windows_stores_and_renders_window_infos() -> None: + cache = DeckdFocusCache() + cache.update_windows( + json.dumps( + [ + { + "window_id": "42", + "wm_class": "dolphin", + "gtk_application_id": None, + "sandboxed_app_id": None, + "app_name": "Dolphin", + "title": "Dolphin — Home", + "workspace": 1, + "minimized": False, + }, + { + "window_id": "43", + "wm_class": "firefox", + "gtk_application_id": None, + "sandboxed_app_id": None, + "app_name": "Firefox", + "title": "deckd — GitHub", + "workspace": 2, + "minimized": True, + }, + ] + ) + ) + windows = cache.to_window_infos() + assert [w.window_id for w in windows] == ["42", "43"] + assert windows[0].wm_class == "dolphin" + assert windows[0].minimized is False + assert windows[1].minimized is True + assert windows[1].title == "deckd — GitHub" + + +def test_cache_update_windows_rejects_non_list_without_overwriting() -> None: + """A window-list push must be a JSON array. A hostile / malformed + object push is rejected and the previous good list is preserved — + same last-good discipline as the active-window ``update``.""" + cache = DeckdFocusCache() + cache.update_windows(json.dumps([{"window_id": "1", "wm_class": "kate"}])) + with pytest.raises(ValueError): + cache.update_windows(json.dumps({"not": "a list"})) + assert cache.to_window_infos()[0].window_id == "1" + + +def test_cache_update_windows_rejects_invalid_json_without_overwriting() -> None: + cache = DeckdFocusCache() + cache.update_windows(json.dumps([{"window_id": "1", "wm_class": "kate"}])) + with pytest.raises(json.JSONDecodeError): + cache.update_windows("not-json") + assert cache.to_window_infos()[0].window_id == "1" + + +def test_cache_update_windows_empty_list_clears_snapshot() -> None: + cache = DeckdFocusCache() + cache.update_windows(json.dumps([{"window_id": "1", "wm_class": "kate"}])) + cache.update_windows("[]") + assert cache.to_window_infos() == [] + + +# --------------------------------------------------------------------------- +# DeckdFocusCache — the pending-raise queue (raise parity, #133 follow-up). +# +# KWin scripts can only ``callDBus`` OUTBOUND, so the daemon cannot push a +# raise command into the compositor. The verified-clean inversion (KWin 6 +# exposes a ``QTimer`` global): the daemon enqueues a window id here, and the +# persistent KWin script drains the queue on a timer tick via +# ``DrainPendingRaises`` and sets ``workspace.activeWindow``. +# --------------------------------------------------------------------------- + + +def test_cache_drain_pending_raises_default_empty() -> None: + cache = DeckdFocusCache() + assert json.loads(cache.drain_pending_raises()) == [] + + +def test_cache_enqueue_and_drain_pending_raises_fifo_then_clears() -> None: + cache = DeckdFocusCache() + cache.enqueue_raise("42") + cache.enqueue_raise("43") + assert json.loads(cache.drain_pending_raises()) == ["42", "43"] + # Draining clears the queue — a raise is consumed exactly once. + assert json.loads(cache.drain_pending_raises()) == [] + + +def test_cache_enqueue_raise_is_bounded() -> None: + """A pathological backlog (script not draining) can't grow without + bound — the queue keeps only the most recent ``MAX_PENDING_RAISES``.""" + cache = DeckdFocusCache() + for i in range(DeckdFocusCache.MAX_PENDING_RAISES + 25): + cache.enqueue_raise(str(i)) + drained = json.loads(cache.drain_pending_raises()) + assert len(drained) == DeckdFocusCache.MAX_PENDING_RAISES + # The oldest were dropped; the most recent survive. + assert drained[-1] == str(DeckdFocusCache.MAX_PENDING_RAISES + 24) + + # --------------------------------------------------------------------------- # DeckdFocusDBusService — the org.deckd.Focus service interface # --------------------------------------------------------------------------- @@ -180,6 +294,73 @@ def test_dbus_service_update_writes_through_to_shared_cache() -> None: assert json.loads(cache.payload)["app_id"] == "k" +def _service_method(svc, name): + from dbus_fast.service import ServiceInterface + + return next( + m for m in ServiceInterface._get_methods(svc.interface) if m.name == name + ) + + +def test_dbus_service_exposes_list_and_update_window_methods() -> None: + """Enumeration parity (#133 follow-up): the daemon-owned service + gains ``ListWindows() -> s`` (the enumeration surface the inherited + ``watch_windows`` gdbus-polls) and ``UpdateWindowList(s)`` (the KWin + script's list push target). Byte-identical wire shape to the GNOME + extension's ``ListWindows``.""" + from dbus_fast.service import ServiceInterface + + svc = DeckdFocusDBusService() + names = {m.name for m in ServiceInterface._get_methods(svc.interface)} + assert "ListWindows" in names + assert "UpdateWindowList" in names + list_m = _service_method(svc, "ListWindows") + update_m = _service_method(svc, "UpdateWindowList") + assert list_m.in_signature == "" + assert list_m.out_signature == "s" + assert update_m.in_signature == "s" + assert update_m.out_signature == "" + + +def test_dbus_service_list_windows_returns_cache_windows_payload() -> None: + cache = DeckdFocusCache() + cache.update_windows(json.dumps([{"window_id": "9", "wm_class": "kate"}])) + svc = DeckdFocusDBusService(cache=cache) + method = _service_method(svc, "ListWindows") + assert method.fn(svc.interface) == cache.windows_payload + + +def test_dbus_service_update_window_list_writes_through_to_shared_cache() -> None: + cache = DeckdFocusCache() + svc = DeckdFocusDBusService(cache=cache) + method = _service_method(svc, "UpdateWindowList") + method.fn(svc.interface, json.dumps([{"window_id": "5", "wm_class": "okular"}])) + assert cache.to_window_infos()[0].window_id == "5" + + +def test_dbus_service_exposes_drain_pending_raises() -> None: + """The KWin script's raise-poll target (#133 follow-up): the script's + ``QTimer`` tick calls ``DrainPendingRaises() -> s`` and activates each + returned window id.""" + from dbus_fast.service import ServiceInterface + + svc = DeckdFocusDBusService() + names = {m.name for m in ServiceInterface._get_methods(svc.interface)} + assert "DrainPendingRaises" in names + drain_m = _service_method(svc, "DrainPendingRaises") + assert drain_m.in_signature == "" + assert drain_m.out_signature == "s" + + +def test_dbus_service_drain_pending_raises_returns_and_clears_queue() -> None: + cache = DeckdFocusCache() + cache.enqueue_raise("7") + svc = DeckdFocusDBusService(cache=cache) + method = _service_method(svc, "DrainPendingRaises") + assert json.loads(method.fn(svc.interface)) == ["7"] + assert json.loads(method.fn(svc.interface)) == [] + + # --------------------------------------------------------------------------- # KdeFocusBackend — the gdbus poll path is inherited from # GnomeShellFocusBackend; we pin it against a fake `_run` the same way @@ -251,6 +432,102 @@ async def fake_run(*args: str) -> str: assert app == AppInfo(None, None, None, None) +# --------------------------------------------------------------------------- +# KdeFocusBackend — enumeration + raise parity (#133 follow-up). +# +# watch_windows is inherited verbatim from GnomeShellFocusBackend (it +# gdbus-polls ListWindows against the now-answering daemon-owned bus), so +# there's no KDE override to test for it beyond the capability flag. raise_window +# / raise_app ARE overridden: they resolve against the daemon's own cached +# window list and enqueue into the pending-raise queue the KWin script drains. +# --------------------------------------------------------------------------- + + +def test_kde_backend_capabilities_reach_gnome_parity() -> None: + """With enumeration (push) and raise (enqueue-and-poll) both wired, + KDE re-advertises the surfaces #133 forced it to drop — the KWin-side + implementation the issue named as the trigger to re-add the flags.""" + caps = KdeFocusBackend().capabilities() + assert caps == frozenset( + {"watch_active_app", "watch_windows", "raise_window", "raise_app"} + ) + # And the parity is with the GNOME backend it subclasses. + assert caps == GnomeShellFocusBackend().capabilities() + + +@pytest.mark.asyncio +async def test_kde_backend_raise_window_enqueues_id_present_in_snapshot() -> None: + cache = DeckdFocusCache() + cache.update_windows( + json.dumps([{"window_id": "42", "wm_class": "dolphin"}]) + ) + backend = KdeFocusBackend(cache=cache) + await backend.raise_window("42") + assert json.loads(cache.drain_pending_raises()) == ["42"] + + +@pytest.mark.asyncio +async def test_kde_backend_raise_window_retired_id_raises_failed_without_enqueue() -> None: + """An id that retired between the enumeration snapshot and the tap is + not in the cached window list: mirror the GNOME backend's ``false`` + return by raising :class:`RaiseWindowFailed` (the server turns it into + a diagnostic ``raise_failed`` / ``declined`` event, #122) and enqueue + nothing so the KWin script never chases a dead window.""" + from deckd.platform import RaiseWindowFailed + + cache = DeckdFocusCache() + cache.update_windows(json.dumps([{"window_id": "42", "wm_class": "dolphin"}])) + backend = KdeFocusBackend(cache=cache) + with pytest.raises(RaiseWindowFailed): + await backend.raise_window("99") + assert json.loads(cache.drain_pending_raises()) == [] + + +@pytest.mark.asyncio +async def test_kde_backend_raise_app_enqueues_matching_window() -> None: + cache = DeckdFocusCache() + cache.update_windows( + json.dumps( + [ + {"window_id": "1", "wm_class": "konsole"}, + {"window_id": "2", "wm_class": "firefox", "sandboxed_app_id": "org.mozilla.firefox"}, + ] + ) + ) + backend = KdeFocusBackend(cache=cache) + # Matches on wm_class... + assert await backend.raise_app("konsole") is True + assert json.loads(cache.drain_pending_raises()) == ["1"] + # ...and on the desktop-file identity carried in sandboxed_app_id. + assert await backend.raise_app("org.mozilla.firefox") is True + assert json.loads(cache.drain_pending_raises()) == ["2"] + + +@pytest.mark.asyncio +async def test_kde_backend_raise_app_no_match_returns_false_without_enqueue() -> None: + cache = DeckdFocusCache() + cache.update_windows(json.dumps([{"window_id": "1", "wm_class": "konsole"}])) + backend = KdeFocusBackend(cache=cache) + assert await backend.raise_app("inkscape") is False + assert json.loads(cache.drain_pending_raises()) == [] + + +@pytest.mark.asyncio +async def test_kde_backend_raise_window_and_app_do_not_shell_out(monkeypatch) -> None: + """The KDE raise path is cache-local (enqueue for the script to + drain) — unlike the inherited GNOME path it must never fork ``gdbus`` + (there is no daemon-side ``RaiseWindow`` method to call).""" + async def boom(*args: str) -> str: + raise AssertionError(f"raise must not shell out on KDE: {args!r}") + + monkeypatch.setattr(plat, "_run", boom) + cache = DeckdFocusCache() + cache.update_windows(json.dumps([{"window_id": "1", "wm_class": "kate"}])) + backend = KdeFocusBackend(cache=cache) + await backend.raise_window("1") + await backend.raise_app("kate") + + @pytest.mark.asyncio async def test_kde_backend_is_a_platform_backend_for_dispatch_compat() -> None: assert isinstance(KdeFocusBackend(), PlatformBackend) diff --git a/tests/test_running_windows_labels.py b/tests/test_running_windows_labels.py index d0904ad..9658f32 100644 --- a/tests/test_running_windows_labels.py +++ b/tests/test_running_windows_labels.py @@ -123,6 +123,9 @@ def test_label_for_window_identity_match_falls_back_to_layout_id() -> None: ("thunderbird", "Thunderbird"), ("firefox-esr", "Firefox Esr"), ("my_app", "My App"), + # A trailing packaging suffix is not part of the app's name — + # ``org.telegram.desktop`` labels ``Telegram``, not ``Desktop``. + ("org.telegram.desktop", "Telegram"), ], ) def test_humanize_identity(identity: str, expected: str) -> None: