Session lock/blank awareness: protocol, client UI, and input gate (GNOME)
When GNOME locks or blanks the screen, deckd has no idea. global.display.focus_window goes null, the extension's activeWindowPayload (packaging/gnome-shell/deckd-focus@local/wire-shape.js:26) returns all-nulls, the daemon misses every layout and serves default with is_default: true. The phone shows Home and every press silently does nothing. The client cannot distinguish "screen locked" from "desktop with nothing focused".
Two things make this more than cosmetic:
shell: / url: actions still execute while locked. They're subprocesses the daemon spawns; they never need window focus. Launching an app "works" — it just opens behind the shield, so it reads as broken while actually being a lock-screen bypass for arbitrary command execution from any phone that can reach the daemon.
key: / type: injection lands on the unlock prompt. uinput is a kernel-level virtual device; the shield cannot tell it from a real keyboard.
So the fix is a daemon-side policy that the UI honestly mirrors — not a client-side banner.
This issue carries the platform-neutral core (protocol, client UI, daemon gate, capability plumbing) plus the GNOME detection. macOS detection is a follow-up that plugs into the same seam.
Two states, not one
The obvious design — one locked boolean driven by org.gnome.ScreenSaver.GetActive — is wrong, because GetActive goes true for a blank that hasn't locked yet, and stays true forever when Automatic Screen Lock is off. Gating input on it would mean deckd refuses all input whenever the screen is blank, and you'd lose the ability to wake your desktop from your phone, which works today.
Split them:
blanked — the session isn't on the screen. Drives the client UI takeover only.
locked — credentials required. Drives the daemon-side refusal.
Blanked-but-not-locked keeps input enabled: the first press wakes the machine (uinput events are indistinguishable from real hardware to the shield). Show a soft "Screen asleep — press anything to wake" rather than the lock takeover.
|
blanked |
locked |
| GNOME |
org.gnome.ScreenSaver ActiveChanged (prime with GetActive) |
org.freedesktop.login1.Session LockedHint |
| KDE |
org.freedesktop.ScreenSaver |
login1 LockedHint |
| macOS |
— (see follow-up) |
— (see follow-up) |
login1's LockedHint is the cross-DE half and also catches loginctl lock-session. Verified present on a live GNOME session: gdbus call --session --dest org.gnome.ScreenSaver … GetActive answers.
Wire
protocol.py:95 already defines StateMessage { locked: bool } — exported to TS, wired to nothing. Use it; add blanked. Push on transition and in the initial connect snapshot, so a phone that joins mid-lock isn't stuck showing a stale layout.
Naming collision — fix this first
App.tsx:65 already maps socket status unauthorized → the label "locked" (the deckd password gate). Adding screen-lock with the same word makes "Locked" ambiguous between type the deck password and unlock your desktop. Rename the auth one to "sign-in needed"; reserve "Screen locked" for the session state. Add both terms to CONTEXT.md's Language section.
Client UI
Full-surface takeover, not a banner — the grid is entirely dead, and a greyed grid still invites taps.
- Lock glyph, "Screen locked", and "Controls resume when you unlock ‹host›". Name the host: it matters when driving two machines from one phone.
- Keep the connection dot green. The socket is fine. Reusing the disconnected treatment sends people off to restart the daemon.
- App badge reads "Screen locked" in place of the app name.
Gate per-capability, not globally — split by whether the surface targets the focused window:
| Blocked while locked |
Still works |
| Layout buttons, jogstrip, trackpad/keyboard, raise-window |
Now playing (MPRIS), settings, layout editor |
Now Playing is deliberate, not an oversight: media players keep running, MPRIS still accepts commands, and a locked screen is exactly when you want play/pause from your phone — same as hardware media keys, which work on a lock screen. Settings is device-local and the editor writes YAML to disk; both are unaffected by lock.
Running programs list: keep the button, disable it, reuse the existing windows-empty slot (RunningWindowsList.tsx:47) with "Screen locked — unlock ‹host› to switch windows." ListWindows still enumerates fine while locked, so a populated list that silently refuses to raise anything is the worst option — but a control that vanishes and reappears reads as a bug. A disabled control with a stated reason teaches the model.
Enforcement
Refuse in _dispatch_press and the key/type/pad/jog paths, next to the existing _injection_blocked guard (server.py:2454) — that guard is already the precedent for "pressed but suppressed" and records a guard_dropped outcome for diagnostics. Reply with an ErrorMessage so a client that missed the state transition still surfaces something real. Add --allow-while-locked to opt out.
Capability plumbing
Add session_lock / session_blank to PlatformBackend.capabilities() and a row to the capability matrix in docs/PLATFORM-PARITY.md. A backend that can't observe lock state must not advertise it, so the client never strands itself in a lock view it can't clear.
tests/test_platform_parity.py only guards the three compositor-axis rows via its _AXIS map, so a new row won't be checked by default — extend _AXIS to cover it, otherwise this adds exactly the doc/code drift that test exists to catch.
Acceptance
Session lock/blank awareness: protocol, client UI, and input gate (GNOME)
When GNOME locks or blanks the screen, deckd has no idea.
global.display.focus_windowgoes null, the extension'sactiveWindowPayload(packaging/gnome-shell/deckd-focus@local/wire-shape.js:26) returns all-nulls, the daemon misses every layout and servesdefaultwithis_default: true. The phone shows Home and every press silently does nothing. The client cannot distinguish "screen locked" from "desktop with nothing focused".Two things make this more than cosmetic:
shell:/url:actions still execute while locked. They're subprocesses the daemon spawns; they never need window focus. Launching an app "works" — it just opens behind the shield, so it reads as broken while actually being a lock-screen bypass for arbitrary command execution from any phone that can reach the daemon.key:/type:injection lands on the unlock prompt.uinputis a kernel-level virtual device; the shield cannot tell it from a real keyboard.So the fix is a daemon-side policy that the UI honestly mirrors — not a client-side banner.
This issue carries the platform-neutral core (protocol, client UI, daemon gate, capability plumbing) plus the GNOME detection. macOS detection is a follow-up that plugs into the same seam.
Two states, not one
The obvious design — one
lockedboolean driven byorg.gnome.ScreenSaver.GetActive— is wrong, becauseGetActivegoes true for a blank that hasn't locked yet, and stays true forever when Automatic Screen Lock is off. Gating input on it would mean deckd refuses all input whenever the screen is blank, and you'd lose the ability to wake your desktop from your phone, which works today.Split them:
blanked— the session isn't on the screen. Drives the client UI takeover only.locked— credentials required. Drives the daemon-side refusal.Blanked-but-not-locked keeps input enabled: the first press wakes the machine (uinput events are indistinguishable from real hardware to the shield). Show a soft "Screen asleep — press anything to wake" rather than the lock takeover.
blankedlockedorg.gnome.ScreenSaverActiveChanged(prime withGetActive)org.freedesktop.login1.SessionLockedHintorg.freedesktop.ScreenSaverlogin1LockedHintlogin1'sLockedHintis the cross-DE half and also catchesloginctl lock-session. Verified present on a live GNOME session:gdbus call --session --dest org.gnome.ScreenSaver … GetActiveanswers.Wire
protocol.py:95already definesStateMessage { locked: bool }— exported to TS, wired to nothing. Use it; addblanked. Push on transition and in the initial connect snapshot, so a phone that joins mid-lock isn't stuck showing a stale layout.Naming collision — fix this first
App.tsx:65already maps socket statusunauthorized→ the label "locked" (the deckd password gate). Adding screen-lock with the same word makes "Locked" ambiguous between type the deck password and unlock your desktop. Rename the auth one to "sign-in needed"; reserve "Screen locked" for the session state. Add both terms to CONTEXT.md's Language section.Client UI
Full-surface takeover, not a banner — the grid is entirely dead, and a greyed grid still invites taps.
Gate per-capability, not globally — split by whether the surface targets the focused window:
Now Playing is deliberate, not an oversight: media players keep running, MPRIS still accepts commands, and a locked screen is exactly when you want play/pause from your phone — same as hardware media keys, which work on a lock screen. Settings is device-local and the editor writes YAML to disk; both are unaffected by lock.
Running programs list: keep the button, disable it, reuse the existing
windows-emptyslot (RunningWindowsList.tsx:47) with "Screen locked — unlock ‹host› to switch windows."ListWindowsstill enumerates fine while locked, so a populated list that silently refuses to raise anything is the worst option — but a control that vanishes and reappears reads as a bug. A disabled control with a stated reason teaches the model.Enforcement
Refuse in
_dispatch_pressand the key/type/pad/jog paths, next to the existing_injection_blockedguard (server.py:2454) — that guard is already the precedent for "pressed but suppressed" and records aguard_droppedoutcome for diagnostics. Reply with anErrorMessageso a client that missed the state transition still surfaces something real. Add--allow-while-lockedto opt out.Capability plumbing
Add
session_lock/session_blanktoPlatformBackend.capabilities()and a row to the capability matrix indocs/PLATFORM-PARITY.md. A backend that can't observe lock state must not advertise it, so the client never strands itself in a lock view it can't clear.tests/test_platform_parity.pyonly guards the three compositor-axis rows via its_AXISmap, so a new row won't be checked by default — extend_AXISto cover it, otherwise this adds exactly the doc/code drift that test exists to catch.Acceptance
shell:button is refused daemon-side and the client shows why.--allow-while-lockedrestores today's behaviour._AXIS.