From b2146f33fe5db8c5a3534db6344af463e63f43c5 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 21:56:36 +0200 Subject: [PATCH] =?UTF-8?q?fix(apple):=20the=20app=20menu=20ate=20?= =?UTF-8?q?=E2=8C=98Q,=20so=20the=20host's=20compositor=20never=20saw=20th?= =?UTF-8?q?e=20chord?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Settings/SettingsView+Scope.swift | 5 + .../Settings/SettingsView+Sections.swift | 16 ++ .../Settings/SettingsView.swift | 4 + .../PunktfunkKit/Input/InputCapture.swift | 140 ++++++++++++++++-- .../PunktfunkKit/Views/StreamView.swift | 9 +- .../PunktfunkShared/DefaultsKeys.swift | 10 ++ .../PunktfunkShared/EffectiveSettings.swift | 5 + .../PunktfunkShared/StreamProfile.swift | 6 + .../PunktfunkKitTests/CommandChordTests.swift | 116 +++++++++++++++ docs-site/content/docs/client-settings.md | 12 +- docs-site/content/docs/input.md | 7 +- 11 files changed, 315 insertions(+), 15 deletions(-) create mode 100644 clients/apple/Tests/PunktfunkKitTests/CommandChordTests.swift diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift index 661cccbb..0cdceb57 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift @@ -114,6 +114,10 @@ enum SettingsFields { .init(name: "invert_scroll", key: DefaultsKey.invertScroll, overlay: \.invertScroll, effective: \.invertScroll) } + static var inhibitShortcuts: SettingsField { + .init(name: "inhibit_shortcuts", key: DefaultsKey.inhibitShortcuts, + overlay: \.inhibitShortcuts, effective: \.inhibitShortcuts) + } static var modifierLayout: SettingsField { .init(name: "modifier_layout", key: DefaultsKey.modifierLayout, overlay: \.modifierLayout, effective: \.modifierLayout) @@ -205,6 +209,7 @@ extension SettingsView { #endif #if os(macOS) base.mouseMode = mouseMode + base.inhibitShortcuts = inhibitShortcuts base.vsync = vsync base.windowedSafePresent = windowedSafePresent #endif diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index ddbca72a..12988207 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -515,6 +515,9 @@ extension SettingsView { Text("Desktop (absolute)").tag(MouseInputMode.desktop.rawValue) } } + described(inhibitShortcutsDescription, field: "inhibit_shortcuts") { + Toggle("Capture system shortcuts", isOn: scoped(SettingsFields.inhibitShortcuts)) + } #endif described( (ModifierLayout(rawValue: effective.modifierLayout) ?? .mac).detail, @@ -534,6 +537,19 @@ extension SettingsView { } #if os(macOS) + /// Dynamic like the captions above, because the setting genuinely has no effect under the + /// desktop mouse model (system chords stay local there on every client) — and a toggle that + /// silently does nothing should say so instead of leaving the user to find out. + private var inhibitShortcutsDescription: String { + if (MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop { + return "⌘ shortcuts stay on this Mac under the desktop mouse model. Switch Mouse " + + "input to Capture to send them to the host." + } + return "Sends ⌘ shortcuts to the host while input is captured, so ⌘Q and friends reach " + + "the remote desktop instead of this app. ⌘⎋ always stays local — it is what " + + "releases capture." + } + /// The SELECTED mouse model explained — dynamic, like the touch-mode caption. private var mouseModeDescription: String { switch MouseInputMode(rawValue: effective.mouseMode) ?? .capture { diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index a2d82720..b0095a8a 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -115,6 +115,10 @@ struct SettingsView: View { #endif #if os(macOS) @AppStorage(DefaultsKey.mouseMode) var mouseMode = MouseInputMode.capture.rawValue + /// Cross-client `inhibit_shortcuts` — here, the ⌘-chord passthrough (⌘Q & co. reach the host + /// instead of the app menu while captured). macOS-only: it is the one platform whose window + /// system hands a plain app no keyboard grab, so the client has to claim the chords itself. + @AppStorage(DefaultsKey.inhibitShortcuts) var inhibitShortcuts = true @AppStorage(DefaultsKey.speakerUID) var speakerUID = "" @AppStorage(DefaultsKey.micUID) var micUID = "" @AppStorage(DefaultsKey.micChannel) var micChannel = 0 diff --git a/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift b/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift index 0434295c..f6d60a1f 100644 --- a/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift @@ -86,6 +86,21 @@ public final class InputCapture { /// its Esc suppression need it in both states). private var cmdKeysDown: Set = [] + #if os(macOS) + /// Windows VKs the ⌘-chord passthrough sent DOWN (see the keyDown monitor). macOS stops + /// delivering keyUp for ordinary keys while Command is held, so the release half of ⌘Q/⌘W/… + /// cannot be relied on to arrive through the responder chain at all: these are flushed when + /// the last ⌘ comes up (`flushCommandChord`), which is what stands between the host and a + /// key held down for the rest of the session. + private var commandChordVKs: Set = [] + + /// Mirrors StreamLayerView's live mouse model — ⌃⌥⇧M flips it mid-session, so it can't be + /// read from the settings. The ⌘-chord passthrough stays off under the desktop model, matching + /// what the SDL clients' keyboard grab does: a remote desktop is something you ⌘Tab away from, + /// not into. + public var desktopMouse = false + #endif + #if !os(macOS) /// The key currently auto-repeating, and the timer driving it. iOS/tvOS only — see /// `startAutoRepeat`. Main-queue only, like every other field here. @@ -244,19 +259,27 @@ public final class InputCapture { ) { [weak self] _ in self?.releaseAll() }) - // ⌘⎋ — the capture toggle — is detected here so it works in both states. ONLY - // that one combo is intercepted: swallowing keys wholesale at the monitor level - // risks starving GC's own delivery, so the no-beep behavior lives in - // StreamLayerView (first responder consumes keyDown/keyUp while captured). - // (On iOS there is no NSEvent monitor — the GC key handler detects the combo.) + // This monitor is the FIRST thing in the app to see a key: AppKit calls it before + // `sendEvent:`, so before any menu key equivalent and before StreamLayerView's keyDown. + // Returning nil discards the event outright — which cuts BOTH of those off, and on macOS + // the second one is the host's only key path (the GCKeyboard send is iOS-only; see + // `attach(keyboard:)`). So the rule here is: anything swallowed must either be handled + // client-side or forwarded to the host from inside this block, because nothing downstream + // will get a second chance at it. + // + // ⌘⎋ (capture toggle) and ⌃⌥⇧M (mouse model) are client-side in BOTH states; ⌃⌥⇧Q/D/S/A + // and ⌃⌘F are client-side only while forwarding (released, the events pass through and the + // menu's identical key equivalents handle them). Every OTHER ⌘ chord is the HOST's while + // captured — see `forwardsCommandChord`. (On iOS there is no NSEvent monitor — the GC key + // handler detects the combos.) #if os(macOS) keyEventMonitor = NSEvent.addLocalMonitorForEvents( matching: [.keyDown] ) { [weak self] event in guard let self else { return event } - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let flags = Self.chordFlags(event) if event.keyCode == 53 /* Esc */, flags == .command { - self.suppressedVK = 0x1B // the same physical Esc is en route via GC + self.suppressedVK = 0x1B // VK_ESC — its keyUp still reaches the responder chain self.onToggleCapture?() return nil } @@ -266,7 +289,7 @@ public final class InputCapture { // (latched like ⌘⎋'s Esc) so it doesn't type into the host, and swallow the // event so it doesn't beep. if event.keyCode == 46 /* M */, flags == [.control, .option, .shift] { - self.suppressedVK = 0x4D // VK_M — the same physical M is en route via GC + self.suppressedVK = 0x4D // VK_M — its keyUp still reaches the responder chain self.onToggleMouseMode?() return nil } @@ -304,10 +327,34 @@ public final class InputCapture { // captured stream view swallows the menu's identical equivalent); the F is latched so its // keyUp can't type into the host. keyCode 3 = kVK_ANSI_F (layout-independent). if self.forwarding, flags == [.control, .command], event.keyCode == 3 /* F */ { - self.suppressedVK = 0x46 // VK_F — the same physical F is en route via GC + self.suppressedVK = 0x46 // VK_F — its keyUp still reaches the responder chain self.onToggleFullscreen?() return nil } + // Every OTHER ⌘ chord belongs to the HOST while captured — the cross-client "capture + // system shortcuts" setting, which the Apple client had no answer to because SDL's + // keyboard grab is what implements it everywhere else. Without this the app menu's key + // equivalents fire first, so ⌘Q quits the client instead of reaching the compositor as + // Super+Q — one of the most-bound chords on a Linux desktop, and the reported break. + // + // It has to SEND from here: returning nil is what keeps the menu out, and it takes + // StreamLayerView's keyDown — the host's only key path on macOS — out with it. + // Chords with no host VK are swallowed but not sent: doing nothing beats a menu + // opening under a captured stream. The ⌘ itself needs no handling — modifiers arrive + // as flagsChanged, which this monitor never sees, so it was already forwarded as + // VK_LWIN/VK_RWIN (or Alt, under the Windows modifier layout) when it went down. + // + // The two cheap conditions are repeated in front of the call on purpose: off-session, + // `SessionSettings.current` re-reads the whole defaults suite, and this monitor sees + // every keystroke the app receives — including the ones typed into the host list. + if self.forwarding, flags.contains(.command), Self.forwardsCommandChord( + keyCode: event.keyCode, flags: flags, forwarding: self.forwarding, + inhibitShortcuts: SessionSettings.current.inhibitShortcuts, + desktopMouse: self.desktopMouse + ) { + if let vk = Self.keyCodeToVK[event.keyCode] { self.sendCommandChordKey(vk) } + return nil + } return event } #endif @@ -358,6 +405,9 @@ public final class InputCapture { cmdKeysDown.removeAll() chordModifiersDown.removeAll() suppressedVK = nil + #if os(macOS) + commandChordVKs.removeAll() // their releases are in `pressedVKs`, flushed just below + #endif for vk in pressedVKs { emitKey(vk, down: false) } @@ -522,7 +572,15 @@ public final class InputCapture { // Keep cmdKeysDown in step (the ⌘⎋ toggle + Esc suppression read it); sendKey // adds the VK to pressedVKs so releaseAll/blur flushes a held modifier cleanly. if vk == 0x5B || vk == 0x5C { - if down { cmdKeysDown.insert(vk) } else { cmdKeysDown.remove(vk) } + if down { + cmdKeysDown.insert(vk) + } else { + cmdKeysDown.remove(vk) + // Last ⌘ up: release the chord keys whose own keyUp macOS never delivered. BEFORE + // the ⌘'s own release goes out, so the host never sees the letter outlive the + // modifier it was pressed with. + if cmdKeysDown.isEmpty { flushCommandChord() } + } } sendKey(vk, down: down) } @@ -552,6 +610,68 @@ public final class InputCapture { } return (mod.vk, down) } + + // MARK: - ⌘ chord passthrough + + /// The four modifiers a client chord is ever spelled with, isolated from the incidental bits + /// `deviceIndependentFlagsMask` also carries: Caps Lock, and the `.function`/`.numericPad` + /// pair every arrow and F-key sets. Equality against the raw masked flags meant a chord + /// stopped being recognized the moment Caps Lock was on — ⌘⎋ and ⌃⌥⇧Q, both escape hatches, + /// included. That was survivable while the monitor claimed six chords; it is not, now that it + /// swallows every ⌘ chord there is. + static let chordFlagMask: NSEvent.ModifierFlags = [.command, .control, .option, .shift] + + /// One event's chord modifiers (see `chordFlagMask`). + static func chordFlags(_ event: NSEvent) -> NSEvent.ModifierFlags { + event.modifierFlags.intersection(chordFlagMask) + } + + /// The ⌘ chords the CLIENT keeps while captured, which is to say: the way out. ⌘⎋ releases + /// the mouse/keyboard and ⌃⌘F leaves fullscreen — hand either of those to the host and a + /// captured stream becomes a room with no door. (⌃⌥⇧Q/D/S/A carry no ⌘ and never reach here.) + static func isClientReservedChord(keyCode: UInt16, flags: NSEvent.ModifierFlags) -> Bool { + if keyCode == 53, flags == .command { return true } // ⌘⎋ — capture toggle + if keyCode == 3, flags == [.control, .command] { return true } // ⌃⌘F — fullscreen + return false + } + + /// Does this keyDown get taken off AppKit and forwarded to the host instead? Only while input + /// is actually captured, only with the cross-client `inhibit_shortcuts` on, and never under the + /// desktop mouse model (where the chords stay local by design) — and never for the client's own + /// reserved chords, whatever the setting says. + static func forwardsCommandChord( + keyCode: UInt16, flags: NSEvent.ModifierFlags, + forwarding: Bool, inhibitShortcuts: Bool, desktopMouse: Bool + ) -> Bool { + guard forwarding, inhibitShortcuts, !desktopMouse else { return false } + guard flags.contains(.command) else { return false } + return !isClientReservedChord(keyCode: keyCode, flags: flags) + } + + /// Forward one key of a ⌘ chord the monitor just took off AppKit, remembering it so its + /// release can be synthesized (see `commandChordVKs`). + private func sendCommandChordKey(_ vk: UInt32) { + commandChordVKs.insert(vk) + sendKey(vk, down: true) + } + + /// Release whatever the ⌘-chord passthrough sent down and is still held — called when the last + /// physical ⌘ comes up. A keyUp that DID arrive has already taken its VK out of `pressedVKs`, + /// so this only fires for the ones macOS swallowed. + private func flushCommandChord() { + // Same cause, different victim: a one-shot latch whose key-up never arrived goes on to eat + // the NEXT press of that key (⌃⌘F's F, ⌘⎋'s Esc). Once ⌘ is up, a pending latch is stale. + suppressedVK = nil + guard !commandChordVKs.isEmpty else { return } + for vk in commandChordVKs where pressedVKs.contains(vk) { + pressedVKs.remove(vk) + emitKey(vk, down: false) + if inputDebug { + inputLog.debug("key \(vk, privacy: .public) up SYNTHESIZED (⌘ chord release)") + } + } + commandChordVKs.removeAll() + } #endif private func attach(mouse: GCMouse) { diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index a0372cc9..05b00dba 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -410,8 +410,9 @@ public final class StreamLayerView: NSView { // keycode) → Windows VK and forward via InputCapture.sendKey, then CONSUME (return without // super) to stop the responder chain's "unhandled keyDown" beep. Keys with no VK mapping // are still consumed while captured so they don't beep either. The ⌘⎋ toggle's Esc is - // swallowed upstream by InputCapture's keyDown monitor (suppressedVK), so it never gets - // here as a send; ⌘-combos still arrive via performKeyEquivalent and stay functional (⌘D). + // swallowed upstream by InputCapture's keyDown monitor (suppressedVK), so it never gets here + // as a send — and so are ⌘ combos generally while captured, which that monitor forwards to the + // host itself (`forwardsCommandChord`) rather than letting a menu key equivalent claim them. // Modifier keys never fire keyDown/keyUp — they come through flagsChanged below. public override var acceptsFirstResponder: Bool { true } // A click after the app was inactive (Cmd-Tab away and back) must reach mouseDown so the @@ -570,6 +571,9 @@ public final class StreamLayerView: NSView { let wasCaptured = captured if wasCaptured { releaseCapture() } desktopMouse = on + // The ⌘-chord passthrough is off under the desktop model (system chords stay local there, + // as on every other client) — and the model moves live, so the capture is told, not asked. + inputCapture?.desktopMouse = on if wasCaptured { engageCapture(fromClick: false) } window?.invalidateCursorRects(for: self) if on, let p = reappearAt, let sp = cgScreenPoint(forHostX: p.x, p.y) { @@ -917,6 +921,7 @@ public final class StreamLayerView: NSView { ) ?? .capture let absOK = connection.resolvedCompositor != .gamescope desktopMouse = mode == .desktop && absOK + capture.desktopMouse = desktopMouse if mode == .desktop && !absOK { streamInputLog.info("desktop mouse mode unavailable on a gamescope host (relative-only) — using capture") } diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index ff897834..de773e0b 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -157,6 +157,16 @@ public enum DefaultsKey { /// Read live at the wire boundary by `InputCapture`. Control/Shift never move (same position on /// both keyboards). public static let modifierLayout = "punktfunk.modifierLayout" + /// Send system chords to the host while input is captured — the cross-client + /// `inhibit_shortcuts`, ON by default. On the SDL clients it is SDL's keyboard grab (Alt+Tab, + /// the Windows key); macOS has no such grab from a plain app, so `InputCapture`'s keyDown + /// monitor implements it by taking every ⌘ chord off AppKit before a menu key equivalent can + /// fire and forwarding it instead — which is what makes ⌘Q reach the host's compositor rather + /// than quitting the client. Off keeps the chords local (the second-screen/work profile). + /// The client's own reserved chords (⌘⎋, ⌃⌘F, ⌃⌥⇧…) are never forwarded either way, and — as + /// on the SDL clients — the setting has no effect under the `desktop` mouse model, which is + /// something you ⌘Tab *away* from. macOS-only today; nothing reads it on iOS/tvOS. + public static let inhibitShortcuts = "punktfunk.inhibitShortcuts" /// iPad: capture the mouse/trackpad pointer (pointer lock → relative movement) for games, /// rather than forwarding an absolute cursor position. On by default. Only meaningful on iPad /// with a hardware mouse/trackpad; the system grants the lock only to a full-screen, frontmost diff --git a/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift b/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift index 8be60359..76328c48 100644 --- a/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift +++ b/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift @@ -33,6 +33,9 @@ public struct EffectiveSettings: Equatable, Sendable { public var touchMode = "trackpad" public var mouseMode = "capture" public var invertScroll = false + /// Cross-client `inhibit_shortcuts` (default on): system chords reach the host while input is + /// captured. See `DefaultsKey.inhibitShortcuts` — on macOS this is the ⌘-chord passthrough. + public var inhibitShortcuts = true public var gamepadType = 0 public var gamepadForwarding = true /// Cross-client `system_buttons`: "auto" | "forward" | "local". @@ -97,6 +100,7 @@ public struct EffectiveSettings: Equatable, Sendable { touchMode = str(DefaultsKey.touchMode, touchMode) mouseMode = str(DefaultsKey.mouseMode, mouseMode) invertScroll = bool(DefaultsKey.invertScroll, invertScroll) + inhibitShortcuts = bool(DefaultsKey.inhibitShortcuts, inhibitShortcuts) gamepadType = int(DefaultsKey.gamepadType, gamepadType) gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding) systemButtons = str(DefaultsKey.systemButtons, systemButtons) @@ -177,6 +181,7 @@ public struct EffectiveSettings: Equatable, Sendable { if let v = overlay.touchMode { s.touchMode = v } if let v = overlay.mouseMode { s.mouseMode = v } if let v = overlay.invertScroll { s.invertScroll = v } + if let v = overlay.inhibitShortcuts { s.inhibitShortcuts = v } if let v = overlay.gamepadType { s.gamepadType = v } if let v = overlay.gamepadForwarding { s.gamepadForwarding = v } if let v = overlay.systemButtons { s.systemButtons = v } diff --git a/clients/apple/Sources/PunktfunkShared/StreamProfile.swift b/clients/apple/Sources/PunktfunkShared/StreamProfile.swift index fa0252bc..a48a321c 100644 --- a/clients/apple/Sources/PunktfunkShared/StreamProfile.swift +++ b/clients/apple/Sources/PunktfunkShared/StreamProfile.swift @@ -109,6 +109,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable { public var touchMode: String? public var mouseMode: String? public var invertScroll: Bool? + public var inhibitShortcuts: Bool? public var gamepadType: Int? public var gamepadForwarding: Bool? public var systemButtons: String? @@ -153,6 +154,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable { case touchMode = "touch_mode" case mouseMode = "mouse_mode" case invertScroll = "invert_scroll" + case inhibitShortcuts = "inhibit_shortcuts" case gamepadType = "gamepad" case gamepadForwarding = "gamepad_forwarding" case systemButtons = "system_buttons" @@ -189,6 +191,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable { touchMode = str(.touchMode) mouseMode = str(.mouseMode) invertScroll = bool(.invertScroll) + inhibitShortcuts = bool(.inhibitShortcuts) gamepadType = int(.gamepadType) gamepadForwarding = bool(.gamepadForwarding) systemButtons = str(.systemButtons) @@ -227,6 +230,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable { try c.encodeIfPresent(touchMode, forKey: AnyKey(Key.touchMode.rawValue)) try c.encodeIfPresent(mouseMode, forKey: AnyKey(Key.mouseMode.rawValue)) try c.encodeIfPresent(invertScroll, forKey: AnyKey(Key.invertScroll.rawValue)) + try c.encodeIfPresent(inhibitShortcuts, forKey: AnyKey(Key.inhibitShortcuts.rawValue)) try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue)) try c.encodeIfPresent( gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue)) @@ -283,6 +287,7 @@ public enum OverlayField { case "touch_mode": overlay.touchMode = nil case "mouse_mode": overlay.mouseMode = nil case "invert_scroll": overlay.invertScroll = nil + case "inhibit_shortcuts": overlay.inhibitShortcuts = nil case "gamepad": overlay.gamepadType = nil case "gamepad_forwarding": overlay.gamepadForwarding = nil case "system_buttons": overlay.systemButtons = nil @@ -321,6 +326,7 @@ public enum OverlayField { case "touch_mode": return o.touchMode != nil case "mouse_mode": return o.mouseMode != nil case "invert_scroll": return o.invertScroll != nil + case "inhibit_shortcuts": return o.inhibitShortcuts != nil case "gamepad": return o.gamepadType != nil case "gamepad_forwarding": return o.gamepadForwarding != nil case "system_buttons": return o.systemButtons != nil diff --git a/clients/apple/Tests/PunktfunkKitTests/CommandChordTests.swift b/clients/apple/Tests/PunktfunkKitTests/CommandChordTests.swift new file mode 100644 index 00000000..571ff4c5 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/CommandChordTests.swift @@ -0,0 +1,116 @@ +#if os(macOS) +import AppKit +import XCTest + +@testable import PunktfunkKit + +/// Pins the macOS ⌘-chord passthrough — the rule deciding which keyDowns `InputCapture`'s local +/// monitor takes off AppKit and forwards to the host instead of letting a menu key equivalent +/// claim them. Two things are worth a test rather than a comment: +/// +/// * ⌘Q reaching the host at all. That is the whole point — it is the compositor chord on +/// Hyprland/KDE/GNOME, and it used to quit the client. +/// * ⌘⎋ and ⌃⌘F NOT reaching it, under every combination. They are the way out of a captured +/// stream; forward either and the user is locked in. +final class CommandChordTests: XCTestCase { + // kVK_ANSI_* — physical positions, layout-independent (the same constants the monitor uses). + private let q: UInt16 = 12, w: UInt16 = 13, h: UInt16 = 4, m: UInt16 = 46 + private let f: UInt16 = 3, esc: UInt16 = 53, leftArrow: UInt16 = 123 + + /// Captured, setting on, capture mouse model — the shipping default. + private func forwards( + _ keyCode: UInt16, _ flags: NSEvent.ModifierFlags, + forwarding: Bool = true, inhibit: Bool = true, desktop: Bool = false + ) -> Bool { + InputCapture.forwardsCommandChord( + keyCode: keyCode, flags: flags, forwarding: forwarding, + inhibitShortcuts: inhibit, desktopMouse: desktop) + } + + func testCommandChordsGoToTheHostWhileCaptured() { + XCTAssertTrue(forwards(q, .command)) // ⌘Q — the reported break + XCTAssertTrue(forwards(w, .command)) + XCTAssertTrue(forwards(h, .command)) + XCTAssertTrue(forwards(m, .command)) + XCTAssertTrue(forwards(q, [.command, .shift])) // ⇧⌘Q + XCTAssertTrue(forwards(m, [.command, .control, .option, .shift])) + } + + func testTheEscapeHatchesAreNeverForwarded() { + // ⌘⎋ releases capture, ⌃⌘F leaves fullscreen. Neither may ever reach the host. + XCTAssertFalse(forwards(esc, .command)) + XCTAssertFalse(forwards(f, [.control, .command])) + XCTAssertTrue(InputCapture.isClientReservedChord(keyCode: esc, flags: .command)) + XCTAssertTrue( + InputCapture.isClientReservedChord(keyCode: f, flags: [.control, .command])) + } + + /// The reservation is exact: it is ⌘⎋ and ⌃⌘F specifically, not "anything with Esc or F in + /// it". ⇧⌘⎋ and ⌘F are the host's like any other chord. + func testNeighbouringChordsAreNotReserved() { + XCTAssertTrue(forwards(esc, [.command, .shift])) + XCTAssertTrue(forwards(f, .command)) + XCTAssertFalse(InputCapture.isClientReservedChord(keyCode: f, flags: .command)) + } + + func testNothingWithoutCommandIsClaimedHere() { + // The ⌃⌥⇧ family and bare keys reach the monitor's earlier blocks / the responder chain. + XCTAssertFalse(forwards(q, [.control, .option, .shift])) + XCTAssertFalse(forwards(q, [])) + XCTAssertFalse(forwards(esc, [])) + } + + func testReleasedCaptureLeavesTheMenuAlone() { + // Not forwarding = the user is in the local UI: ⌘Q must quit the app, ⌘W close the window. + XCTAssertFalse(forwards(q, .command, forwarding: false)) + XCTAssertFalse(forwards(w, .command, forwarding: false)) + } + + func testTheCrossClientSettingTurnsItOff() { + XCTAssertFalse(forwards(q, .command, inhibit: false)) + } + + func testTheDesktopMouseModelKeepsChordsLocal() { + // Matches the SDL clients' keyboard grab: a remote desktop is something you ⌘Tab away from. + XCTAssertFalse(forwards(q, .command, desktop: true)) + XCTAssertFalse(forwards(q, .command, inhibit: true, desktop: true)) + } + + /// `deviceIndependentFlagsMask` also carries Caps Lock and the `.function`/`.numericPad` bits + /// every arrow key sets, so comparing it for equality made chords stop being recognized in + /// exactly the states a user does not connect to their keyboard: Caps Lock on, or the chord + /// spelled with an arrow. `chordFlags` isolates the four real modifiers. + func testCapsLockAndArrowBitsDoNotChangeAChord() throws { + let capsQ = try XCTUnwrap(keyEvent(q, [.command, .capsLock])) + XCTAssertEqual(InputCapture.chordFlags(capsQ), .command) + XCTAssertTrue(forwards(q, InputCapture.chordFlags(capsQ))) + + // ⌘⎋ with Caps Lock on is still the escape hatch, not a chord for the host. + let capsEsc = try XCTUnwrap(keyEvent(esc, [.command, .capsLock])) + XCTAssertEqual(InputCapture.chordFlags(capsEsc), .command) + XCTAssertFalse(forwards(esc, InputCapture.chordFlags(capsEsc))) + + // ⌘← — arrows set .function|.numericPad, which say nothing about the chord. + let cmdLeft = try XCTUnwrap(keyEvent(leftArrow, [.command, .function, .numericPad])) + XCTAssertEqual(InputCapture.chordFlags(cmdLeft), .command) + XCTAssertTrue(forwards(leftArrow, InputCapture.chordFlags(cmdLeft))) + } + + /// A forwarded chord is only useful if the key has a host VK — the monitor swallows either + /// way, so an unmapped one would silently do nothing. Spot-check the common ⌘ letters. + func testTheCommonChordKeysMapToHostVKs() { + XCTAssertEqual(InputCapture.keyCodeToVK[q], 0x51) // VK 'Q' + XCTAssertEqual(InputCapture.keyCodeToVK[w], 0x57) // VK 'W' + XCTAssertEqual(InputCapture.keyCodeToVK[h], 0x48) // VK 'H' + XCTAssertEqual(InputCapture.keyCodeToVK[m], 0x4D) // VK 'M' + XCTAssertEqual(InputCapture.keyCodeToVK[leftArrow], 0x25) // VK_LEFT + } + + private func keyEvent(_ keyCode: UInt16, _ flags: NSEvent.ModifierFlags) -> NSEvent? { + NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0, + windowNumber: 0, context: nil, characters: "", charactersIgnoringModifiers: "", + isARepeat: false, keyCode: keyCode) + } +} +#endif diff --git a/docs-site/content/docs/client-settings.md b/docs-site/content/docs/client-settings.md index c4e5ad5c..8c1bc468 100644 --- a/docs-site/content/docs/client-settings.md +++ b/docs-site/content/docs/client-settings.md @@ -224,8 +224,8 @@ regular pad). Automatic arms it only where the raw guide press can't reach the h Gaming Mode, iPhone/iPad, Apple TV — because the gesture has a cost: a Select *tap* arrives a beat late, and a game that expects a *held* Select would trigger it. Set **On** or **Off** to overrule. -**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps and the console -home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it +**Capture system shortcuts** — *default: on.* Offered by the Linux, Windows and macOS apps and the +console home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming Mode is gamescope, which has nothing to hold back. On, Alt+Tab and the Windows key (Super on Linux) reach the host while the stream has input captured. Off, they act on this machine @@ -234,6 +234,14 @@ back the moment you release capture with **Ctrl+Alt+Shift+Q**, the window loses ends, and [Desktop mouse mode](/docs/input#mouse-modes) never takes them at all. Leaving this on does mean **Ctrl+Alt+Shift+Q is your way out** of a captured stream, since Alt+Tab no longer is. +On macOS the chords in question are the **⌘** ones — ⌘Q above all, which reaches the host as Super+Q, +one of the most-bound chords on a Linux desktop. On, ⌘Q, ⌘W, ⌘H and the rest go to the host instead +of this app's menu bar while input is captured. Off, they act on the Mac as usual, which means ⌘Q +quits Punktfunk mid-stream. **⌘⎋ always stays local whichever way the toggle is set** — it is what +releases capture, as is ⌃⌥⇧Q, and ⌃⌘F keeps working on the window. A few chords never reach the host +either way, because macOS claims them before any app can see them: ⌘Tab, ⌘Space, and the Mission +Control keys. + On Linux this needs a compositor that supports keyboard-shortcuts-inhibit — KDE Plasma, GNOME and the wlroots compositors all do, and X11 sessions grab the keyboard directly. Under [gamescope](/docs/gamescope) there is nothing to inhibit: it hands the session everything already. diff --git a/docs-site/content/docs/input.md b/docs-site/content/docs/input.md index b3b82f1d..adb8ace6 100644 --- a/docs-site/content/docs/input.md +++ b/docs-site/content/docs/input.md @@ -68,6 +68,10 @@ to one readable line. **⌃⌥⇧Q / M / D / S** — but not the microphone mute. **⌘⎋** also toggles capture, **⌃⌘F** toggles fullscreen, and **⌃⌥⇧C** starts or stops [clipboard sharing](/docs/clipboard). The **Stream** menu lists them all except the mouse-mode combo, which works but has no menu item. + Every *other* ⌘ chord goes to the host while input is captured — ⌘Q reaches the host's compositor + rather than quitting the app — unless you turn **Capture system shortcuts** off in + [client settings](/docs/client-settings#input). ⌘⎋ and ⌃⌘F are held back either way, so there is + always a way out. - **iPhone and iPad** with a hardware keyboard: **⌃⌥⇧Q** releases input while it is captured, and **⌘⎋** toggles capture in either direction. **⌃⌥⇧D** (disconnect) and **⌃⌥⇧S** (stats) come from the app's Stream shortcuts rather than from the stream itself; if they don't respond while you're @@ -154,7 +158,8 @@ There are two, and they are a per-client setting called **Mouse input**: - **Capture (games)** — the pointer locks to the stream and only relative movement is sent. The only cursor you see is the host's. This is what mouse-look in a game needs. The session window also grabs the keyboard here, so Alt+Tab and the Windows key (Super on Linux) reach the host rather than - your own desktop — turn **Capture system shortcuts** off in + your own desktop — on macOS that is the ⌘ chords, ⌘Q included, with ⌘⎋ kept back as the way out. + Turn **Capture system shortcuts** off in [client settings](/docs/client-settings#input) to keep them local. - **Desktop (absolute)** — the pointer is not locked. It moves in and out of the stream freely and its position is sent as an absolute point — what you want for remote desktop work. Your local -- 2.54.0