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
4 changes: 3 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
29 changes: 27 additions & 2 deletions daemon/deckd/layouts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
219 changes: 196 additions & 23 deletions daemon/deckd/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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.

Expand Down
Loading
Loading