macOS: detect session lock and feed the session-state seam
Follow-up to #160, which carries the protocol (StateMessage), the client lock view, the daemon-side input gate, and the session_lock / session_blank capability plumbing. This issue is detection only: implement the macOS half behind that seam.
Depends on #160 landing first.
What happens on a Mac today
A different failure mode from GNOME, and slightly worse. _FOCUS_SCRIPT (platform_macos.py:103) asks System Events for the frontmost process. Under the lock screen that's loginwindow — a non-null identity, not GNOME's all-nulls. It matches no layout, so the phone still lands on Home, but focused_app.app_id = "loginwindow" flows through and the is_default program-suffix (App.tsx:486) renders the badge as "Home (loginwindow)". So the Mac doesn't merely fail to say "locked" — it puts a mystery process name on screen.
Detection: CGSessionCopyCurrentDictionary
Use Quartz.CGSessionCopyCurrentDictionary() and check for the CGSSessionScreenIsLocked key. It fits this backend unusually well:
- Quartz is already lazily imported (
_load_quartz, platform_macos.py:56) — no new dependency.
- No permission grant, unlike the Accessibility consent dance
MacKeySink._check_accessibility has to do for injection.
- It's a cheap dict read, so it hangs off the existing ~100ms focus poll rather than needing new machinery.
The push-based alternative is NSDistributedNotificationCenter with com.apple.screenIsLocked / com.apple.screenIsUnlocked. Skip it — it needs a live CFRunLoop, and bridging that into the asyncio daemon costs more than it saves when the focus loop is already polling.
Do not implement blanked on macOS
There's no equivalent dict key for "screensaver running". It would need com.apple.screensaver.didstart (same runloop problem) or shelling out to pgrep ScreenSaverEngine. Advertise session_lock only and let the absence of session_blank mean the Mac never shows the "asleep" view — the repo's own rule that absence is a designed empty state, not a crash.
Note this makes macOS's screensaver/lock split genuinely independent in a way GNOME's isn't: with "require password after screensaver" set to anything but immediately, the screensaver runs while the session stays unlocked and input just dismisses it. Gating on locked alone is the correct behaviour there.
Fast user switching — cheap win, same dict
While in that dictionary: kCGSSessionOnConsoleKey false means another user is at the console. deckd's session is alive but nothing it does reaches a screen. Same UI takeover, different copy — "Another user is signed in." One extra key lookup, and it closes a state that would otherwise be indistinguishable from a freeze.
Security note: half of this is already handled
macOS Secure Event Input blocks osascript keystroke and synthetic CGEvents from unprivileged processes while the lock screen's password field has focus. So the keystroke-injection half of #160's threat model is already covered by the OS — the Linux uinput exposure is the sharper one.
But shell: and url: actions are identical on both platforms: subprocesses the daemon spawns, never touching the event system, running fine under a lock screen. The daemon-side gate still earns its place on macOS; it's the command-execution half it's buying.
Verification
Everything here is unverified. It was designed from the code on a Linux box; nobody has run CGSessionCopyCurrentDictionary against a live locked Mac. Per docs/PLATFORM-PARITY.md's evidence levels this lands as unverified and needs a dated hardware run in the macOS table before it can be treated as working. Apply human-verification-required when the code is complete.
Acceptance
macOS: detect session lock and feed the session-state seam
Follow-up to #160, which carries the protocol (
StateMessage), the client lock view, the daemon-side input gate, and thesession_lock/session_blankcapability plumbing. This issue is detection only: implement the macOS half behind that seam.Depends on #160 landing first.
What happens on a Mac today
A different failure mode from GNOME, and slightly worse.
_FOCUS_SCRIPT(platform_macos.py:103) asks System Events for the frontmost process. Under the lock screen that'sloginwindow— a non-null identity, not GNOME's all-nulls. It matches no layout, so the phone still lands on Home, butfocused_app.app_id = "loginwindow"flows through and theis_defaultprogram-suffix (App.tsx:486) renders the badge as "Home (loginwindow)". So the Mac doesn't merely fail to say "locked" — it puts a mystery process name on screen.Detection:
CGSessionCopyCurrentDictionaryUse
Quartz.CGSessionCopyCurrentDictionary()and check for theCGSSessionScreenIsLockedkey. It fits this backend unusually well:_load_quartz,platform_macos.py:56) — no new dependency.MacKeySink._check_accessibilityhas to do for injection.The push-based alternative is
NSDistributedNotificationCenterwithcom.apple.screenIsLocked/com.apple.screenIsUnlocked. Skip it — it needs a liveCFRunLoop, and bridging that into the asyncio daemon costs more than it saves when the focus loop is already polling.Do not implement
blankedon macOSThere's no equivalent dict key for "screensaver running". It would need
com.apple.screensaver.didstart(same runloop problem) or shelling out topgrep ScreenSaverEngine. Advertisesession_lockonly and let the absence ofsession_blankmean the Mac never shows the "asleep" view — the repo's own rule that absence is a designed empty state, not a crash.Note this makes macOS's screensaver/lock split genuinely independent in a way GNOME's isn't: with "require password after screensaver" set to anything but immediately, the screensaver runs while the session stays unlocked and input just dismisses it. Gating on
lockedalone is the correct behaviour there.Fast user switching — cheap win, same dict
While in that dictionary:
kCGSSessionOnConsoleKeyfalse means another user is at the console. deckd's session is alive but nothing it does reaches a screen. Same UI takeover, different copy — "Another user is signed in." One extra key lookup, and it closes a state that would otherwise be indistinguishable from a freeze.Security note: half of this is already handled
macOS Secure Event Input blocks
osascript keystrokeand synthetic CGEvents from unprivileged processes while the lock screen's password field has focus. So the keystroke-injection half of #160's threat model is already covered by the OS — the Linuxuinputexposure is the sharper one.But
shell:andurl:actions are identical on both platforms: subprocesses the daemon spawns, never touching the event system, running fine under a lock screen. The daemon-side gate still earns its place on macOS; it's the command-execution half it's buying.Verification
Everything here is unverified. It was designed from the code on a Linux box; nobody has run
CGSessionCopyCurrentDictionaryagainst a live locked Mac. Perdocs/PLATFORM-PARITY.md's evidence levels this lands as unverified and needs a dated hardware run in the macOS table before it can be treated as working. Applyhuman-verification-requiredwhen the code is complete.Acceptance
MacFocusBackend.capabilities()advertisessession_lockand notsession_blank.shell:button is refused daemon-side while locked.docs/PLATFORM-PARITY.md.