The app menu ate ⌘Q, so the host's compositor never saw the chord #236

Merged
enricobuehler merged 1 commits from worktree-apple-cmd-passthrough into main 2026-08-14 20:10:17 +00:00
Owner

With the default modifier layout ⌘ is Super on the host, which makes ⌘Q the chord a Hyprland/KDE/GNOME user reaches for first. AppKit dispatches menu key equivalents before the stream view ever sees a keyDown, so it quit the client instead. Not Hyprland-specific.

InputCapture's local keyDown monitor now claims every ⌘ chord while input is captured and forwards it to the host itself.

The assumption that had to be checked first, and was wrong

The obvious fix — return nil for ⌘ chords — could never have worked. On macOS the host's key path IS the NSEvent responder chain: attach(keyboard:) installs no GCKeyboard handler there (iOS-only since e414ec0), so keys reach the host via StreamLayerView.keyDown → InputCapture.sendKey. The monitor runs upstream of that, so returning nil to keep the menu out deletes the host's copy with it. Anything swallowed there must be sent from inside the monitor.

The file's own comment said the opposite — that swallowing keys "risks starving GC's own delivery". On macOS there is no GC delivery to starve. Comment corrected.

Proven, not assumed

A standalone AppKit harness (accessory app, real main menu with a ⌘Q item, NSApp.postEvent(_:atStart:) to inject) confirms the monitor sees ⌘Q before the menu and that returning nil does stop the menu item firing — with a passed-through ⌘W as a live control. Posting into the app's own queue exercises the real dispatch chain, so this is checkable headlessly in seconds.

Scope

  • Kept client-local, whatever the setting says: ⌘⎋ (capture toggle) and ⌃⌘F (fullscreen). Forward those and a captured stream is a room with no door.
  • Out of reach and not attempted: ⌘Tab, ⌘Space, Mission Control. macOS claims them before any app sees them; catching them needs a CGEventTap and an Accessibility prompt — a product decision rather than a code one.

Answers to the cross-client setting

This is the Apple client's half of Settings::inhibit_shortcuts ("Capture system shortcuts"), which it had no answer to because SDL's keyboard grab is what implements it everywhere else. Default on, profileable like its siblings, and — matching the SDL clients — inert under the desktop mouse model, which is something you ⌘Tab away from.

Two adjacent defects fixed on the way

Both the same root cause: macOS stops delivering keyUp while ⌘ is held.

  1. A forwarded chord key is released when the last ⌘ comes up, rather than waiting for an up that may never arrive — otherwise it sticks down host-side for the rest of the session.
  2. The one-shot suppressedVK latch is cleared in the same place. Left pending (⌃⌘F's F, ⌘⎋'s Esc) it would go on to eat the next press of that key.

Separately: chord matching stopped comparing the raw deviceIndependentFlagsMask, which also carries Caps Lock and the arrows' .function/.numericPad bits. With Caps Lock on, ⌘⎋ and ⌃⌥⇧Q — both escape hatches — were not recognized at all.

Verification

swift build + swift test (304 tests, 0 failures) on macOS, plus iOS and tvOS typechecks — the CI job here is exactly swift build + swift test. 9 new tests pin the rule: ⌘Q forwards; ⌘⎋/⌃⌘F never do in any combination; released capture leaves the menu alone; the setting off and the desktop model each disable it; Caps Lock and arrow bits don't change a chord.

On glass still owed — a clean compile proves nothing for an input grab. Wants ⌘Q against a real compositor, ⌘⎋ still releasing capture, and both capture states.

Docs updated: the "Capture system shortcuts" row and the macOS chord list both said this was a Linux/Windows-only setting.

With the default modifier layout ⌘ is Super on the host, which makes ⌘Q the chord a Hyprland/KDE/GNOME user reaches for first. AppKit dispatches menu key equivalents before the stream view ever sees a keyDown, so it quit the client instead. Not Hyprland-specific. `InputCapture`'s local keyDown monitor now claims every ⌘ chord while input is captured and forwards it to the host itself. ## The assumption that had to be checked first, and was wrong The obvious fix — `return nil` for ⌘ chords — could never have worked. **On macOS the host's key path IS the NSEvent responder chain**: `attach(keyboard:)` installs no GCKeyboard handler there (iOS-only since `e414ec0`), so keys reach the host via `StreamLayerView.keyDown → InputCapture.sendKey`. The monitor runs *upstream* of that, so returning nil to keep the menu out deletes the host's copy with it. Anything swallowed there must be **sent from inside the monitor**. The file's own comment said the opposite — that swallowing keys "risks starving GC's own delivery". On macOS there is no GC delivery to starve. Comment corrected. ## Proven, not assumed A standalone AppKit harness (accessory app, real main menu with a ⌘Q item, `NSApp.postEvent(_:atStart:)` to inject) confirms the monitor sees ⌘Q **before** the menu and that returning nil **does** stop the menu item firing — with a passed-through ⌘W as a live control. Posting into the app's own queue exercises the real dispatch chain, so this is checkable headlessly in seconds. ## Scope - **Kept client-local, whatever the setting says:** ⌘⎋ (capture toggle) and ⌃⌘F (fullscreen). Forward those and a captured stream is a room with no door. - **Out of reach and not attempted:** ⌘Tab, ⌘Space, Mission Control. macOS claims them before any app sees them; catching them needs a CGEventTap and an Accessibility prompt — a product decision rather than a code one. ## Answers to the cross-client setting This is the Apple client's half of `Settings::inhibit_shortcuts` ("Capture system shortcuts"), which it had no answer to because SDL's keyboard grab is what implements it everywhere else. Default on, profileable like its siblings, and — matching the SDL clients — inert under the desktop mouse model, which is something you ⌘Tab away from. ## Two adjacent defects fixed on the way Both the same root cause: **macOS stops delivering keyUp while ⌘ is held.** 1. A forwarded chord key is released when the last ⌘ comes up, rather than waiting for an up that may never arrive — otherwise it sticks down host-side for the rest of the session. 2. The one-shot `suppressedVK` latch is cleared in the same place. Left pending (⌃⌘F's F, ⌘⎋'s Esc) it would go on to eat the *next* press of that key. Separately: chord matching stopped comparing the raw `deviceIndependentFlagsMask`, which also carries Caps Lock and the arrows' `.function`/`.numericPad` bits. **With Caps Lock on, ⌘⎋ and ⌃⌥⇧Q — both escape hatches — were not recognized at all.** ## Verification `swift build` + `swift test` (304 tests, 0 failures) on macOS, plus iOS and tvOS typechecks — the CI job here is exactly `swift build` + `swift test`. 9 new tests pin the rule: ⌘Q forwards; ⌘⎋/⌃⌘F never do in any combination; released capture leaves the menu alone; the setting off and the desktop model each disable it; Caps Lock and arrow bits don't change a chord. ⏳ **On glass still owed** — a clean compile proves nothing for an input grab. Wants ⌘Q against a real compositor, ⌘⎋ still releasing capture, and both capture states. Docs updated: the "Capture system shortcuts" row and the macOS chord list both said this was a Linux/Windows-only setting.
enricobuehler added 1 commit 2026-08-14 20:05:48 +00:00
fix(apple): the app menu ate ⌘Q, so the host's compositor never saw the chord
ci / rust (pull_request) Successful in 6m40s
ci / web (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 2m13s
ci / bun-nix (pull_request) Successful in 46s
apple / swift (pull_request) Successful in 2m5s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m58s
b2146f33fe
With the default modifier layout ⌘ is Super on the host, which makes ⌘Q the chord
a Hyprland/KDE/GNOME user reaches for first. AppKit dispatches menu key
equivalents before the stream view ever sees a keyDown, so it quit the client
instead. Not Hyprland-specific.

`InputCapture`'s local keyDown monitor now claims every ⌘ chord while input is
captured and forwards it to the host itself. It has to send from there: the
monitor runs ahead of BOTH the menu and `StreamLayerView.keyDown`, and on macOS
that second one is the host's only key path (the GCKeyboard send has been
iOS-only since e414ec0) — so returning nil to keep the menu out takes the host's
copy with it. The file's own comment said the opposite, that swallowing keys
"risks starving GC's own delivery"; on macOS there is no GC delivery to starve,
which is why this could never have been a one-line `return nil`.

Verified against AppKit rather than assumed: a standalone harness posting a
synthetic ⌘Q confirms the monitor sees it first and that returning nil stops the
menu item firing, with a passed-through ⌘W as the control.

⌘⎋ and ⌃⌘F stay client-side whatever the setting says — forward those and a
captured stream is a room with no door. ⌘Tab, ⌘Space and Mission Control are out
of reach for a local monitor: macOS claims them before any app sees them, and
catching them needs a CGEventTap and an Accessibility prompt, which is a product
decision rather than a code one.

This answers to the cross-client "Capture system shortcuts"
(`Settings::inhibit_shortcuts`), which the Apple client had no answer to because
SDL's keyboard grab is what implements it everywhere else. Default on,
profileable like its siblings, and — matching the SDL clients — inert under the
desktop mouse model, which is something you ⌘Tab away from.

Two adjacent defects fixed on the way, both the same root cause. macOS stops
delivering keyUp while ⌘ is held, so a forwarded chord key is released when the
last ⌘ comes up rather than waiting for an up that may never arrive, and the
one-shot `suppressedVK` latch is cleared in the same place — left pending (⌃⌘F's
F, ⌘⎋'s Esc) it would go on to eat the next press of that key. Chord matching
also stopped comparing the raw `deviceIndependentFlagsMask`, which carries Caps
Lock and the arrows' .function/.numericPad bits: with Caps Lock on, ⌘⎋ and
⌃⌥⇧Q — both escape hatches — were not recognized at all.

On glass still owed: a clean compile proves nothing for an input grab.
enricobuehler merged commit b5cace3a00 into main 2026-08-14 20:10:17 +00:00
enricobuehler deleted branch worktree-apple-cmd-passthrough 2026-08-14 20:10:18 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#236