deckd runs one daemon against several desktop platforms, each with a different
focus/enumeration mechanism and input-injection path. This doc is the single
reconciled view of what works where — the source of truth is the code
(PlatformBackend.capabilities() in daemon/deckd/platform.py and the
injection sinks in daemon/deckd/input.py / daemon/deckd/platform_macos.py);
this table exists to make drift between backends visible at a glance.
Keep this in sync when a backend gains or loses a capability. If a row here disagrees with
capabilities(), the code wins — and that disagreement is a bug (it's how #133 was found). The three compositor-axis rows (watch_active_app,watch_windows,raise_window) are enforced bytests/test_platform_parity.py, which parses this table and asserts it matches every backend'scapabilities()— so drift fails CI (#136).
| 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 |
✓ 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 → deck match (icon / display name) | ✓ wm_class matches the deck 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) | ✗ | ✗ |
Session lock awareness (session_lock) |
✓ login1 session LockedHint (system bus, cross-DE signal) |
✗ follow-up — plugs into the watch_session_state seam |
✗ | ✗ follow-up |
Session blank awareness (session_blank) |
✓ org.gnome.ScreenSaver.GetActive poll |
✗ (org.freedesktop.ScreenSaver would be the source — follow-up) |
✗ | ✗ follow-up |
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 |
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 |
| Key injection — printable | ✓ uinput (evdev) |
✓ uinput |
✓ uinput |
✓ osascript keystroke |
| Key injection — non-printable / special | ✓ uinput |
✓ uinput |
✓ uinput |
◑ partial — osascript key code map |
| Combo modifiers | ✓ uinput |
✓ uinput |
✓ uinput |
✓ using {command down} |
| Pointer relative motion | ✓ uinput |
✓ uinput |
✓ uinput |
✓ Quartz CG event |
| Mouse click (left/right) | ✓ uinput |
✓ uinput |
✓ uinput |
✓ Quartz CG event |
| Mouse drag (held button) | ✓ uinput |
✓ uinput |
✓ uinput |
✓ Quartz LeftMouseDragged |
| Scroll (jogstrip) | ✓ uinput |
✓ uinput |
✓ uinput |
◑ Quartz scroll-wheel event — drag scrolls, momentum is quantised away (#143) |
Legend: ✓ works · ◑ partial · ✗ not available.
A ✓ in the matrix means the code advertises and implements it. That is not
the same as someone watched it work, and the difference is where the bugs
live (#133 and the GNOME ListWindows bug both passed every test). Rows carry
one of three evidence levels:
- machine-verified — driven end-to-end against a live daemon and asserted programmatically.
- human-observed — a human used it on a real desktop and reported what they saw. Weaker than machine-verified, stronger than nothing.
- unverified — implemented, typechecked, unit-tested; nobody has watched it on hardware.
macOS, last checked 2026-08-06 (macOS 15.6.1, Apple Silicon):
| row | evidence | note |
|---|---|---|
| Focus detection | machine-verified | /diag reports app + title |
| Window enumeration | machine + human | 8 windows over a live running_windows frame; human confirms the switcher list renders |
| Window row → deck match | unit-tested; unverified on hardware (#140) | was broken (Firefox row never matched the firefox token; icon: null seen live 2026-08-06); matcher is now case-insensitive but nobody has re-read a running_windows frame on a Mac to confirm the row carries the icon |
| Raise window | machine-verified | raised Firefox, confirmed focus moved, restored |
| Pointer / drag | machine-verified | exact deltas read back off the cursor; drag lock produced 1 mousedown, 5 held moves, 1 mouseup in a browser echo page |
| Click (left / right) | machine-verified | click / contextmenu fired at the exact injected coordinates (#141) |
| Scroll (jogstrip) — drag phase | machine-verified | scrollTop moved on the element under the cursor; 120 hi-res units = one detent, symmetric both directions |
| Scroll (jogstrip) — momentum | broken (#143) | daemon decay runs, but a flick's travel (~66 units) is under one line detent, so nothing reaches the app |
| Key injection (printable / combos / special) | machine-verified | type → textarea; super+t/super+w opened and closed a tab; super+[/super+] navigated back/forward; Esc, arrows, Enter, F-key, Tab all delivered (#141) |
| Manual control (phone IME round-trip) | unverified (#141) | the wire-level type / key paths pass; typing on the phone's own IME still needs a human |
| MPRIS media browser | human-observed not working; now says so | no session bus on macOS (#56 tracks a native replacement). Verified live: the daemon's connect frame is {"supported": false, …} |
media widget (VLC HTTP) |
unverified | nobody has pointed it at a VLC on a Mac |
Packaged app (#165), checked 2026-09-23 (macOS 26.6.2, Apple Silicon): the self-contained deckd.app builds, launches as a menu-bar app, and serves the surface on loopback — see docs/GUIDE.md § "macOS app bundle" for the run and its caveats. The rows above apply to it unchanged: the same TCC grants gate the same features, and because the bundle is ad-hoc signed the grant keys on its content hash, so a rebuild may need re-granting.
The Linux columns are code-and-CI truth. No dated hardware run backs them, and the GNOME rows in particular have a history of passing tests while broken on a live session — treat them as unverified until someone repeats the exercise above on a GNOME box and dates it here.
Focus vs. input are two independent axes. Focus/enumeration/raise depend on
the compositor (each needs a different integration: a Shell extension, a KWin
script, xdotool, osascript). Input injection depends on the OS, not the
compositor: all Linux desktops share the kernel uinput/evdev path
(UinputSink), so GNOME/KDE/X11 are identical for the whole bottom block;
macOS uses PyObjC Quartz + osascript instead. This is why the testing plan
(docs/TESTING.md) calls the input echo-loop (#132) compositor-agnostic while
the enumeration/raise tests (#129–#131) are GNOME-first.
Absence is a designed empty state, not a crash. A backend that lacks a
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 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.
-
GNOME — the
deckd-focus@localShell extension ownsorg.deckd.Focusand answersGetActiveWindow/ListWindows/RaiseWindow/RaiseAppdirectly (pull model — the daemon gdbus-calls the extension). -
KDE Plasma — reaches GNOME parity on all four compositor-axis surfaces (#133 follow-up), but by the opposite plumbing: the daemon owns
org.deckd.Focusand the KWin script feeds it, because KWin scripts can onlycallDBusoutbound — 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 servesGetActiveWindow/ListWindowsfrom it, soKdeFocusBackendreuses 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_appresolve the id against the daemon's own cached window list and enqueue it; the persistent KWin script drains the queue on aQTimertick (DrainPendingRaises) and setsworkspace.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_classanddesktopFileName→sandboxed_app_id(KWin's desktop-file id is the closest analogue to the GNOME extension'sMeta.Appid), so the deck 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);Deck.matches_identitymatches a bare deck token (konsole) against the last dotted segment, so amatch: [konsole]deck covers KDE's class and GNOME'sget_wm_classalike. The running- windows label humanizer drops a trailing.desktop/.apppackaging segment before taking the last dotted segment (org.telegram.desktop→Telegram, notDesktop).
- Focus + enumeration invert to push: the KWin script pushes the active
window (
-
X11 —
xdotool-based focus polling; no enumeration/raise. Input viauinputlike every Linux path. -
macOS —
osascript+ System Events focus detection; Quartz supplies input (mouse, scroll, held-button drag) and on-screen window enumeration; AppKit + Accessibility activate and raise the selected window. osascript handles keystrokes (special keys are a partialkey codemap). Window numbers are stable for a window's lifetime and are used as opaque ids. No D-Bus. A macOS focus-integration test harness would be entirely separate from the Linux one.The macOS column is really "✓ given the TCC grants". Permissions are attributed to the responsible process — the terminal or launchd agent that started the daemon — so the same code is fully working in one process tree and silently inert in another. Three independent grants:
grant gates failure mode without it Accessibility Quartz CGEventPost(pointer, click, drag, scroll) + theAXRaisehalf ofraise_windowevents silently dropped, no error; raise activates the app then fails kAXErrorAPIDisabled(-25211)System Events focus detection + osascript keystrokeosascript exits non-zero; MacKeySink._check_accessibilitywarns at startupScreen Recording kCGWindowNameinCGWindowListevery enumerated titleisNone; rows label by app nameThis is why "it doesn't work on my Mac" is usually a process-tree question, not a code question — verify against the daemon the user actually runs (
/diag, or driveraise_window/padover its WebSocket), never from a fresh shell in some other app's tree.Enumerated rows don't resolve to decks.
CGWindowListreports the owner name (Firefox,Slack); deckmatchtokens are lowercase process names (firefox) because the focus path gets its identity from osascript, which reports the process name.Deck.matches_identityis an exactincomparison, so a row never matches — it falls back to the app name with no icon and nodisplay_name, while the same app's deck switches correctly on focus. Fixing it means either case-insensitive identity matching or normalising the owner name in the backend; both change cross-platform matching semantics, so it's a decision, not a cleanup.
docs/TESTING.md— testing layers and the desktop-integration plan that would automate verification of these rows.daemon/deckd/platform.py—capabilities()and the focus backends.daemon/deckd/platform_macos.py— the macOS injection sink and its own capability docstring.docs/spike-kde-wayland-focus.md— why KDE inverts to a push model.