diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 212fbc7a..e5f17803 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -20,6 +20,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 4C4F4F50544553540000000A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A8E59C2D297F5E9A0064D4BA /* Project object */; + proxyType = 1; + remoteGlobalIDString = A8E59C34297F5E9A0064D4BA; + remoteInfo = Loop; + }; 2A8689102F809800005B521B /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = A8E59C2D297F5E9A0064D4BA /* Project object */; @@ -60,6 +67,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 4C4F4F505445535400000002 /* LoopTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LoopTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 2A8689072F809625005B521B /* LoopDockTile.plugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LoopDockTile.plugin; sourceTree = BUILT_PRODUCTS_DIR; }; A848130C2BD1A8D100B02E93 /* .github */ = {isa = PBXFileReference; lastKnownFileType = folder; path = .github; sourceTree = ""; }; A86AFD7529888B29008F4892 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; @@ -109,6 +117,7 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 4C4F4F50544553540000000B /* LoopTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LoopTests; sourceTree = ""; }; 2A6A87F02F4D20D2004E995D /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2A6A87F22F4D20F4004E995D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = ""; }; 2A86890E2F809700005B521B /* LoopDockTile */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LoopDockTile; sourceTree = ""; }; A8C751AC2D7BA98600B58784 /* Loop */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (A8C751FF2D7BA98600B58784 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Loop; sourceTree = ""; }; @@ -116,6 +125,13 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 4C4F4F505445535400000004 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 2A8689042F809625005B521B /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -166,6 +182,7 @@ A86AFD7529888B29008F4892 /* README.md */, A894B0C52C4B31AA00B4CE6F /* CONTRIBUTING.md */, A8C751AC2D7BA98600B58784 /* Loop */, + 4C4F4F50544553540000000B /* LoopTests */, B1AA00022F30000100AABBCC /* LoopUpdaterHelper */, 2A86890E2F809700005B521B /* LoopDockTile */, A8E59C36297F5E9A0064D4BA /* Products */, @@ -178,6 +195,7 @@ isa = PBXGroup; children = ( A8E59C35297F5E9A0064D4BA /* Loop.app */, + 4C4F4F505445535400000002 /* LoopTests.xctest */, B1AA00012F30000100AABBCC /* LoopUpdaterHelper */, 2A8689072F809625005B521B /* LoopDockTile.plugin */, ); @@ -187,6 +205,29 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 4C4F4F505445535400000001 /* LoopTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4C4F4F50544553540000000C /* Build configuration list for PBXNativeTarget "LoopTests" */; + buildPhases = ( + 4C4F4F505445535400000003 /* Sources */, + 4C4F4F505445535400000004 /* Frameworks */, + 4C4F4F505445535400000005 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 4C4F4F505445535400000009 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 4C4F4F50544553540000000B /* LoopTests */, + ); + name = LoopTests; + packageProductDependencies = ( + ); + productName = LoopTests; + productReference = 4C4F4F505445535400000002 /* LoopTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 2A8689062F809625005B521B /* LoopDockTile */ = { isa = PBXNativeTarget; buildConfigurationList = 2A8689082F809625005B521B /* Build configuration list for PBXNativeTarget "LoopDockTile" */; @@ -275,6 +316,10 @@ LastSwiftUpdateCheck = 2620; LastUpgradeCheck = 2620; TargetAttributes = { + 4C4F4F505445535400000001 = { + CreatedOnToolsVersion = 26.4; + TestTargetID = A8E59C34297F5E9A0064D4BA; + }; 2A8689062F809625005B521B = { CreatedOnToolsVersion = 26.4; }; @@ -318,6 +363,7 @@ projectRoot = ""; targets = ( A8E59C34297F5E9A0064D4BA /* Loop */, + 4C4F4F505445535400000001 /* LoopTests */, B1AA00112F30000100AABBCC /* LoopUpdaterHelper */, 2A8689062F809625005B521B /* LoopDockTile */, ); @@ -325,6 +371,13 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 4C4F4F505445535400000005 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 2A8689052F809625005B521B /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -349,6 +402,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 4C4F4F505445535400000003 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 2A8689032F809625005B521B /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -373,6 +433,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 4C4F4F505445535400000009 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A8E59C34297F5E9A0064D4BA /* Loop */; + targetProxy = 4C4F4F50544553540000000A /* PBXContainerItemProxy */; + }; 2A8689112F809800005B521B /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 2A8689062F809625005B521B /* LoopDockTile */; @@ -386,6 +451,51 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 4C4F4F505445535400000006 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 5F967GYF84; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.LoopTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Loop.app/Contents/MacOS/Loop"; + }; + name = Debug; + }; + 4C4F4F505445535400000007 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 5F967GYF84; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.LoopTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Loop.app/Contents/MacOS/Loop"; + }; + name = Release; + }; + 4C4F4F505445535400000008 /* Development */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 5F967GYF84; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.LoopTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Loop.app/Contents/MacOS/Loop"; + }; + name = Development; + }; 2A8689092F809625005B521B /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -867,6 +977,16 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 4C4F4F50544553540000000C /* Build configuration list for PBXNativeTarget "LoopTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4C4F4F505445535400000006 /* Debug */, + 4C4F4F505445535400000007 /* Release */, + 4C4F4F505445535400000008 /* Development */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 2A8689082F809625005B521B /* Build configuration list for PBXNativeTarget "LoopDockTile" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Loop.xcodeproj/xcshareddata/xcschemes/Loop.xcscheme b/Loop.xcodeproj/xcshareddata/xcschemes/Loop.xcscheme index 58f3c857..e35db503 100644 --- a/Loop.xcodeproj/xcshareddata/xcschemes/Loop.xcscheme +++ b/Loop.xcodeproj/xcshareddata/xcschemes/Loop.xcscheme @@ -60,12 +60,25 @@ + + + + + Void) -> Void private let openCallback: (WindowAction) -> () private let closeCallback: (Bool) -> () private let checkIfLoopOpen: () -> Bool @@ -24,6 +35,18 @@ final class KeybindTrigger { private(set) var effectiveEventFlags: CGEventFlags = [] private var eventMonitor: ActiveEventMonitor? + /// Rebuilds Carbon registrations whenever a setting that shapes a shortcut changes. + private var systemHotKeyDefaultsObservationTask: Task? + + /// Tracks presses across the main-thread Carbon callback and event-tap-thread action handler. + /// It prevents duplicate non-repeatable presses while retaining Carbon key-repeat behavior. + private let activeSystemHotKeyPresses: OSAllocatedUnfairLock> = .init( + initialState: [] + ) + + /// Invalidates callbacks queued before a settings rebuild or `stop`. + private let systemHotKeyGeneration: OSAllocatedUnfairLock = .init(initialState: 0) + private var systemKeybindCache: Set> = [] private var keybindCacheUpdatedAt: ContinuousClock.Instant? private let keybindCacheLifetime: ContinuousClock.Duration = .seconds(30) @@ -55,20 +78,43 @@ final class KeybindTrigger { } } - /// Initializes a ``KeybindObserver``. + /// Schedules fallback input on the same run loop as normal event-tap input. + /// + /// Keeping both sources on one thread preserves the ordering assumptions in `pressedKeys`, + /// trigger-delay handling, and the open/close callbacks. + private static func scheduleOnEventTapThread(_ operation: @escaping () -> Void) -> Void { + let runLoop: CFRunLoop = EventTapThread.shared.runLoop + CFRunLoopPerformBlock(runLoop, CFRunLoopMode.commonModes.rawValue) { + operation() + } + CFRunLoopWakeUp(runLoop) + } + + /// Initializes a keyboard trigger with event-tap input and the compatible Carbon fallback. /// - Parameters: + /// - windowActionCache: resolves normal and trigger-bypassing key sets to Loop actions. + /// - systemHotKeyMonitor: owns Carbon registrations; injectable for deterministic tests. + /// - systemHotKeyScheduler: transfers Carbon callbacks to the event-tap thread. /// - openCallback: what to do when the trigger key is pressed, and Loop should be activated. /// - closeCallback: what to do when the trigger key is released, and Loop should be closed. + /// - checkIfLoopOpen: returns the current Loop presentation state. init( windowActionCache: WindowActionCache, + systemHotKeyMonitor: SystemHotKeyMonitor = .init(), + systemHotKeyScheduler: @escaping (@escaping () -> Void) -> Void = KeybindTrigger.scheduleOnEventTapThread, openCallback: @escaping (WindowAction) -> (), closeCallback: @escaping (Bool) -> (), checkIfLoopOpen: @escaping () -> Bool ) { self.windowActionCache = windowActionCache + self.systemHotKeyMonitor = systemHotKeyMonitor + self.systemHotKeyScheduler = systemHotKeyScheduler self.openCallback = openCallback self.closeCallback = closeCallback self.checkIfLoopOpen = checkIfLoopOpen + systemHotKeyMonitor.eventCallback = { [weak self] binding, phase in + self?.scheduleSystemHotKey(binding: binding, phase: phase) + } } func start() async { @@ -76,6 +122,26 @@ final class KeybindTrigger { return } + await MainActor.run { + // A Carbon registration encodes the complete chord, so every setting that changes the + // chord's shape requires the registrations to be rebuilt. + let updates: AsyncStream<(Set, Bool, [WindowAction], Bool)> = Defaults.updates( + .triggerKey, + .sideDependentTriggerKey, + .keybinds, + .cycleBackwardsOnShiftPressed, + initial: false + ) + rebuildSystemHotKeys() + systemHotKeyDefaultsObservationTask?.cancel() + systemHotKeyDefaultsObservationTask = Task { @MainActor [weak self] in + for await _ in updates { + guard !Task.isCancelled, let self else { break } + rebuildSystemHotKeys() + } + } + } + eventMonitor?.stop() let eventMonitor = ActiveEventMonitor( @@ -108,6 +174,16 @@ final class KeybindTrigger { return canPassthrough ? .forward : .ignore } + let pressedKeyCodes: Set = pressedKeys + .union(filteredFlags.keyCodes) + .baseModifiers + + // Carbon and the event tap both see registered shortcuts when Secure Input is off. + // Forward the event-tap copy so one physical key press performs exactly one action. + if shouldDeferToSystemHotKey(type: event.type, keyCodes: pressedKeyCodes) { + return .forward + } + // If this is a valid event, don't passthrough let result = performKeybind( type: event.type, @@ -136,6 +212,15 @@ final class KeybindTrigger { } func stop() { + precondition(Thread.isMainThread) + + // Invalidate queued work before tearing down registrations so no late Carbon callback can + // reopen Loop after this trigger has stopped. + invalidateSystemHotKeyEvents() + systemHotKeyDefaultsObservationTask?.cancel() + systemHotKeyDefaultsObservationTask = nil + systemHotKeyMonitor.stop() + activeSystemHotKeyPresses.withLock { $0.removeAll() } eventMonitor?.stop() eventMonitor = nil @@ -144,6 +229,90 @@ final class KeybindTrigger { canPassthroughNextSpecialEvent = true } + /// Replaces Carbon registrations with the currently configured compatible shortcut subset. + /// + /// Active presses are cleared because releases from the old registration set must not affect + /// actions registered after the settings change. + func rebuildSystemHotKeys() -> Void { + precondition(Thread.isMainThread) + invalidateSystemHotKeyEvents() + activeSystemHotKeyPresses.withLock { $0.removeAll() } + + let bindings: [SystemHotKeyBinding] = SystemHotKeyBindingResolver.resolve( + keybinds: Defaults[.keybinds], + triggerKey: Defaults[.triggerKey], + sideDependentTriggerKey: Defaults[.sideDependentTriggerKey], + cycleBackwardsOnShiftPressed: Defaults[.cycleBackwardsOnShiftPressed] + ) + systemHotKeyMonitor.rebuild(bindings: bindings) + } + + /// Returns whether a key-down event has already been claimed by a Carbon registration. + /// + /// Only key-down is deferred. The existing event-tap release and modifier processing remains + /// available for Loop's normal trigger-key lifecycle. + func shouldDeferToSystemHotKey(type: CGEventType, keyCodes: Set) -> Bool { + guard type == .keyDown, let shortcut: SystemHotKeyShortcut = .init(keyCodes: keyCodes) else { + return false + } + return systemHotKeyMonitor.isRegistered(shortcut: shortcut) + } + + /// Advances the generation captured by scheduled callbacks, making all older work inert. + private func invalidateSystemHotKeyEvents() -> Void { + systemHotKeyGeneration.withLock { $0 &+= 1 } + } + + /// Captures main-thread Carbon state before handing the event to `EventTapThread`. + private func scheduleSystemHotKey(binding: SystemHotKeyBinding, phase: SystemHotKeyPhase) -> Void { + precondition(Thread.isMainThread) + let generation: UInt64 = systemHotKeyGeneration.withLock { $0 } + + // Carbon supplies the registered key and generic modifiers, but not a `CGEvent`. Capture + // current flags here so downstream action selection sees the same session state it would + // have received from the event tap. + let eventFlags: CGEventFlags = CGEventSource.flagsState(.combinedSessionState) + + systemHotKeyScheduler { [weak self] in + // Rebuild and stop both advance the generation before queued work can run. + guard let self, systemHotKeyGeneration.withLock({ $0 }) == generation else { return } + handleSystemHotKey(binding: binding, phase: phase, eventFlags: eventFlags) + } + } + + /// Applies Carbon press/release semantics through the existing Loop action lifecycle. + private func handleSystemHotKey( + binding: SystemHotKeyBinding, + phase: SystemHotKeyPhase, + eventFlags: CGEventFlags + ) -> Void { + switch phase { + case .pressed: + effectiveEventFlags = eventFlags + + // Carbon may emit repeated press events while a key is held. Match the event-tap path + // by accepting those only for actions whose direction explicitly supports repetition. + let shouldOpen: Bool = activeSystemHotKeyPresses.withLock { shortcuts in + shortcuts.insert(binding.shortcut).inserted || binding.action.canRepeat + } + guard shouldOpen else { return } + openLoop( + startingAction: binding.action, + overrideExistingTriggerDelayTimerAction: true + ) + + case .released: + // Ignore unmatched releases, including releases from a registration invalidated by a + // rebuild. Only bypass actions own their full lifecycle; normal actions still follow + // the trigger key managed by the event-tap path. + let wasActive: Bool = activeSystemHotKeyPresses.withLock { shortcuts in + shortcuts.remove(binding.shortcut) != nil + } + guard wasActive, binding.bypassesTrigger else { return } + closeLoopPreservingEventTapPressedKeys(forceClose: false) + } + } + enum PerformKeybindResult { case consume case forward @@ -239,9 +408,17 @@ final class KeybindTrigger { } private func closeLoop(forceClose: Bool) { + closeLoopPreservingEventTapPressedKeys(forceClose: forceClose) + pressedKeys = [] + } + + /// Closes a Carbon bypass action without erasing keys owned by the event-tap input stream. + /// + /// Clearing `pressedKeys` here would lose unrelated physical keys that remain held and corrupt + /// the next event-tap chord. Existing event-tap callers use `closeLoop`, which still resets it. + private func closeLoopPreservingEventTapPressedKeys(forceClose: Bool) { triggerDelayTimer.cancel() closeCallback(forceClose) - pressedKeys = [] } private func startTriggerDelayTimer( diff --git a/Loop/Core/Observers/System Hotkeys/SystemHotKeyBinding.swift b/Loop/Core/Observers/System Hotkeys/SystemHotKeyBinding.swift new file mode 100644 index 00000000..e62c6694 --- /dev/null +++ b/Loop/Core/Observers/System Hotkeys/SystemHotKeyBinding.swift @@ -0,0 +1,143 @@ +import Carbon.HIToolbox + +/// A Loop key chord in the shape accepted by Carbon's `RegisterEventHotKey` API. +/// +/// Loop normally observes shortcuts through a `CGEventTap`, but macOS stops delivering ordinary +/// key events to that tap while Secure Input is active. Carbon hotkeys keep working in that state, +/// so representable shortcuts are registered through both paths. Carbon accepts exactly one +/// ordinary virtual key plus a side-independent modifier mask; more complex Loop chords remain on +/// the event-tap path. +struct SystemHotKeyShortcut: Hashable { + /// The one non-modifier virtual key passed to Carbon. + let keyCode: CGKeyCode + + /// Carbon's bitmask representation of the shortcut modifiers. + let modifiers: UInt32 + + /// The normalized Loop representation used to match and deduplicate event-tap input. + let keyCodes: Set + + /// Converts a Loop key set into Carbon's restricted hotkey representation. + /// + /// Left and right variants are intentionally normalized because Carbon cannot distinguish + /// modifier sides. Returning `nil` leaves unsupported chords on Loop's existing event-tap path + /// instead of registering a shortcut with different semantics. + init?(keyCodes: Set) { + let normalizedKeyCodes: Set = keyCodes.baseModifiers + let ordinaryKeys: Set = normalizedKeyCodes.filter { !$0.isModifier } + + guard ordinaryKeys.count == 1, let keyCode: CGKeyCode = ordinaryKeys.first else { + return nil + } + + var modifiers: UInt32 = 0 + for modifier: CGKeyCode in normalizedKeyCodes.filter(\.isModifier) { + switch modifier { + case .kVK_Command: + modifiers |= UInt32(cmdKey) + case .kVK_Option: + modifiers |= UInt32(optionKey) + case .kVK_Control: + modifiers |= UInt32(controlKey) + case .kVK_Shift: + modifiers |= UInt32(shiftKey) + case .kVK_Function: + modifiers |= UInt32(kEventKeyModifierFnMask) + default: + return nil + } + } + + self.keyCode = keyCode + self.modifiers = modifiers + self.keyCodes = normalizedKeyCodes + } +} + +/// Connects a registered Carbon shortcut to the Loop action it should perform. +struct SystemHotKeyBinding: Hashable { + let shortcut: SystemHotKeyShortcut + let action: WindowAction + + /// Whether the action works without Loop's trigger key. + /// + /// Bypass actions must close Loop when Carbon reports their key release. Normal actions remain + /// governed by the trigger-key lifecycle, matching the existing event-tap behavior. + let bypassesTrigger: Bool +} + +/// Produces only the subset of configured Loop keybinds that Carbon can represent faithfully. +enum SystemHotKeyBindingResolver { + /// Resolves normal, reverse-cycle, and trigger-bypassing actions into Carbon registrations. + /// + /// Side-dependent trigger configurations are excluded wholesale because Carbon reports only a + /// generic modifier bit. Registering those chords would make a left-only or right-only setting + /// fire from either side. When multiple actions resolve to the same Carbon chord, the first + /// configured action wins, matching the lookup behavior of Loop's existing keybind cache. + static func resolve( + keybinds: [WindowAction], + triggerKey: Set, + sideDependentTriggerKey: Bool, + cycleBackwardsOnShiftPressed: Bool + ) -> [SystemHotKeyBinding] { + guard !sideDependentTriggerKey else { return [] } + + let normalActions: [WindowAction] = keybinds.filter { + $0.bypassTriggerKey != true && !$0.keybind.isEmpty + } + let bypassActions: [WindowAction] = keybinds.filter { + $0.bypassTriggerKey == true && !$0.keybind.isEmpty + } + var bindings: [SystemHotKeyBinding] = [] + var registeredShortcuts: Set = [] + + // Keep conversion and duplicate rejection identical for every binding category below. + func append( + action: WindowAction, + keyCodes: Set, + bypassesTrigger: Bool + ) -> Void { + guard + let shortcut: SystemHotKeyShortcut = .init(keyCodes: keyCodes), + registeredShortcuts.insert(shortcut).inserted + else { + return + } + + bindings.append(.init( + shortcut: shortcut, + action: action, + bypassesTrigger: bypassesTrigger + )) + } + + for action: WindowAction in normalActions { + append( + action: action, + keyCodes: triggerKey.union(action.keybind), + bypassesTrigger: false + ) + } + + if cycleBackwardsOnShiftPressed { + // Reverse cycling is the normal cycle chord plus Shift, so it needs its own Carbon ID. + for action: WindowAction in normalActions where action.direction == .cycle { + append( + action: action, + keyCodes: triggerKey.union(action.keybind).union([.kVK_Shift]), + bypassesTrigger: false + ) + } + } + + for action: WindowAction in bypassActions { + append( + action: action, + keyCodes: action.keybind, + bypassesTrigger: true + ) + } + + return bindings + } +} diff --git a/Loop/Core/Observers/System Hotkeys/SystemHotKeyMonitor.swift b/Loop/Core/Observers/System Hotkeys/SystemHotKeyMonitor.swift new file mode 100644 index 00000000..b35a917b --- /dev/null +++ b/Loop/Core/Observers/System Hotkeys/SystemHotKeyMonitor.swift @@ -0,0 +1,273 @@ +import Carbon.HIToolbox +import os +import Scribe + +/// The two lifecycle events emitted by Carbon for a registered global hotkey. +enum SystemHotKeyPhase: Equatable { + case pressed + case released +} + +/// Carbon reports only a numeric registration ID, which the monitor later maps back to a binding. +struct SystemHotKeyEvent: Equatable { + let id: UInt32 + let phase: SystemHotKeyPhase +} + +/// Isolates the Carbon registration API from binding lifecycle and makes failure paths testable. +/// +/// All lifecycle methods are called on the main thread because Carbon's application event target +/// belongs to the main event loop. Implementations report raw `OSStatus` values so the monitor can +/// log failures while retaining the event-tap fallback for registrations that did not succeed. +protocol SystemHotKeyRegistrationBackend: AnyObject { + var eventCallback: ((SystemHotKeyEvent) -> Void)? { get set } + + func start() -> OSStatus + func register(shortcut: SystemHotKeyShortcut, id: UInt32) -> OSStatus + func unregisterAll() -> Bool + func stop() -> Bool +} + +/// Owns Carbon's event handler and the `EventHotKeyRef` values needed to unregister every shortcut. +@Loggable +private final class CarbonSystemHotKeyRegistrationBackend: SystemHotKeyRegistrationBackend { + var eventCallback: ((SystemHotKeyEvent) -> Void)? + + /// FourCC `Loop`, used to reject hotkey events created by another Carbon client. + private static let signature: OSType = 0x4C6F6F70 + + /// Carbon callbacks cannot capture Swift objects, so `userData` carries an unretained `self`. + /// The backend outlives the installed handler and removes it during `stop`, making that bridge + /// valid for the complete handler lifetime. + private static let eventHandlerCallback: EventHandlerUPP = { _, event, userData in + guard let event, let userData else { return OSStatus(eventNotHandledErr) } + + let backend: CarbonSystemHotKeyRegistrationBackend = Unmanaged + .fromOpaque(userData) + .takeUnretainedValue() + return backend.handle(event: event) + } + + private var eventHandler: EventHandlerRef? + + /// Carbon requires the returned references for explicit unregistration. + private var hotKeyReferences: [UInt32: EventHotKeyRef] = [:] + + deinit { + // Normal ownership calls `stop` on the main thread. This is a best-effort safety net that + // avoids invoking main-event-loop APIs if an unexpected background release occurs. + if Thread.isMainThread { + _ = stop() + } + } + + /// Installs one application-level handler shared by every registered press and release event. + func start() -> OSStatus { + precondition(Thread.isMainThread) + guard eventHandler == nil else { return noErr } + + var eventTypes: [EventTypeSpec] = [ + .init( + eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed) + ), + .init( + eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyReleased) + ) + ] + var installedHandler: EventHandlerRef? + let status: OSStatus = InstallEventHandler( + GetApplicationEventTarget(), + Self.eventHandlerCallback, + eventTypes.count, + &eventTypes, + Unmanaged.passUnretained(self).toOpaque(), + &installedHandler + ) + + guard status == noErr, let installedHandler else { + return status == noErr ? OSStatus(eventNotHandledErr) : status + } + + eventHandler = installedHandler + return noErr + } + + /// Registers one exclusive global hotkey and retains its reference for later cleanup. + /// + /// Exclusive delivery matches the existing event-tap path, which consumes handled Loop + /// shortcuts rather than forwarding them to the frontmost application. + func register(shortcut: SystemHotKeyShortcut, id: UInt32) -> OSStatus { + precondition(Thread.isMainThread) + + let hotKeyID: EventHotKeyID = .init(signature: Self.signature, id: id) + var reference: EventHotKeyRef? + let status: OSStatus = RegisterEventHotKey( + UInt32(shortcut.keyCode), + shortcut.modifiers, + hotKeyID, + GetApplicationEventTarget(), + OptionBits(kEventHotKeyExclusive), + &reference + ) + + guard status == noErr, let reference else { + return status == noErr ? OSStatus(eventNotHandledErr) : status + } + + hotKeyReferences[id] = reference + return noErr + } + + /// Unregisters every successful registration, preserving failed references for a later retry. + func unregisterAll() -> Bool { + precondition(Thread.isMainThread) + + // Iterate over a snapshot because successful removals mutate `hotKeyReferences`. + let references: [UInt32: EventHotKeyRef] = hotKeyReferences + for (id, reference): (UInt32, EventHotKeyRef) in references { + if UnregisterEventHotKey(reference) == noErr { + hotKeyReferences.removeValue(forKey: id) + } + } + return hotKeyReferences.isEmpty + } + + /// Removes all registrations before removing their shared Carbon event handler. + func stop() -> Bool { + precondition(Thread.isMainThread) + guard unregisterAll() else { return false } + guard let eventHandler else { return true } + guard RemoveEventHandler(eventHandler) == noErr else { return false } + + self.eventHandler = nil + return true + } + + /// Validates and translates a raw Carbon event into the monitor's small typed event model. + private func handle(event: EventRef) -> OSStatus { + var hotKeyID: EventHotKeyID = .init(signature: 0, id: 0) + let status: OSStatus = GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &hotKeyID + ) + guard status == noErr, hotKeyID.signature == Self.signature else { + return OSStatus(eventNotHandledErr) + } + + let phase: SystemHotKeyPhase + switch GetEventKind(event) { + case UInt32(kEventHotKeyPressed): + phase = .pressed + case UInt32(kEventHotKeyReleased): + phase = .released + default: + return OSStatus(eventNotHandledErr) + } + + eventCallback?(.init(id: hotKeyID.id, phase: phase)) + return noErr + } +} + +/// Maintains Carbon registrations and maps their numeric callbacks back to Loop actions. +/// +/// Rebuilds happen on the main thread when shortcut settings change. `isRegistered` is also read +/// from `EventTapThread`, so the registered-shortcut set is protected independently from the +/// main-thread-only ID map. +@Loggable +final class SystemHotKeyMonitor { + /// Called on the main thread after a Carbon ID has been resolved to its current binding. + var eventCallback: ((SystemHotKeyBinding, SystemHotKeyPhase) -> Void)? + + private let backend: SystemHotKeyRegistrationBackend + private let registeredShortcuts: OSAllocatedUnfairLock> = .init( + initialState: [] + ) + private var bindingsByID: [UInt32: SystemHotKeyBinding] = [:] + + /// IDs increase for the monitor's lifetime so callbacks from an older rebuild cannot resolve + /// to an unrelated new binding. Zero is skipped because it is also the invalid/default ID. + private var nextRegistrationID: UInt32 = 1 + + /// The injectable backend keeps Carbon itself out of deterministic unit tests. + init(backend: SystemHotKeyRegistrationBackend = CarbonSystemHotKeyRegistrationBackend()) { + self.backend = backend + backend.eventCallback = { [weak self] event in + self?.handle(event: event) + } + } + + /// Replaces the monitor's view of configured system hotkeys. + /// + /// A binding is published only after Carbon accepts its registration. Rejected or unsupported + /// shortcuts therefore remain absent from `isRegistered`, allowing the event tap to keep + /// handling them whenever macOS delivers their events. + func rebuild(bindings: [SystemHotKeyBinding]) -> Void { + precondition(Thread.isMainThread) + + // Clear mappings before touching Carbon so late callbacks from the old set are ignored. + clearBindings() + guard backend.unregisterAll() else { + log.error("Failed to unregister existing system hotkeys") + return + } + let startStatus: OSStatus = backend.start() + guard startStatus == noErr else { + log.error("Failed to start system hotkey monitor: \(startStatus)") + return + } + + for binding: SystemHotKeyBinding in bindings { + let id: UInt32 = nextRegistrationID + nextRegistrationID &+= 1 + if nextRegistrationID == 0 { + nextRegistrationID = 1 + } + + let status: OSStatus = backend.register(shortcut: binding.shortcut, id: id) + guard status == noErr else { + log.warn("Failed to register system hotkey \(binding.shortcut.keyCodes): \(status)") + continue + } + + bindingsByID[id] = binding + _ = registeredShortcuts.withLock { $0.insert(binding.shortcut) } + } + } + + /// Stops Carbon delivery and clears lookup state so late callbacks cannot execute an action. + func stop() -> Void { + precondition(Thread.isMainThread) + clearBindings() + if !backend.stop() { + log.error("Failed to stop system hotkey monitor") + } + } + + /// Reports whether the event-tap path should defer a chord to a successful Carbon registration. + func isRegistered(shortcut: SystemHotKeyShortcut) -> Bool { + registeredShortcuts.withLock { $0.contains(shortcut) } + } + + /// Resolves only IDs from the current successful registration set. + /// + /// Missing IDs are intentionally ignored because Carbon may deliver a callback that was queued + /// immediately before a rebuild removed its old binding. + private func handle(event: SystemHotKeyEvent) -> Void { + guard let binding: SystemHotKeyBinding = bindingsByID[event.id] else { return } + eventCallback?(binding, event.phase) + } + + /// Makes both callback routing and event-tap deduplication forget the current registration set. + private func clearBindings() -> Void { + bindingsByID.removeAll() + registeredShortcuts.withLock { $0.removeAll() } + } +} diff --git a/LoopTests/KeybindTriggerSystemHotKeyTests.swift b/LoopTests/KeybindTriggerSystemHotKeyTests.swift new file mode 100644 index 00000000..2ff2a0d6 --- /dev/null +++ b/LoopTests/KeybindTriggerSystemHotKeyTests.swift @@ -0,0 +1,177 @@ +import Foundation +import XCTest + +@testable import Loop + +/// Verifies that Carbon callbacks preserve `KeybindTrigger` behavior and do not race its lifecycle. +final class KeybindTriggerSystemHotKeyTests: XCTestCase { + private var originalArgumentDomain: [String: Any] = [:] + + override func setUp() -> Void { + super.setUp() + + // Override only the timing features that would make callback assertions asynchronous. + // The volatile argument domain avoids writing test values into the developer's real Loop + // preferences and is restored verbatim in `tearDown`. + let defaults: UserDefaults = .standard + originalArgumentDomain = defaults.volatileDomain(forName: UserDefaults.argumentDomain) + var arguments: [String: Any] = originalArgumentDomain + arguments["doubleClickToTrigger"] = false + arguments["triggerDelay"] = 0.0 + defaults.setVolatileDomain(arguments, forName: UserDefaults.argumentDomain) + } + + override func tearDown() -> Void { + UserDefaults.standard.setVolatileDomain( + originalArgumentDomain, + forName: UserDefaults.argumentDomain + ) + super.tearDown() + } + + func testNormalPressOpensWithoutClosingOnRelease() -> Void { + let setup: KeybindTriggerSetup = makeSetup(binding: makeNormalBinding()) + + setup.backend.emit(id: 1, phase: .pressed) + setup.backend.emit(id: 1, phase: .released) + + XCTAssertEqual(setup.spy.opened, [setup.binding.action]) + XCTAssertTrue(setup.spy.closes.isEmpty) + } + + func testBypassPressOpensAndReleaseCloses() -> Void { + let setup: KeybindTriggerSetup = makeSetup(binding: makeBypassBinding()) + + setup.backend.emit(id: 1, phase: .pressed) + setup.backend.emit(id: 1, phase: .released) + + XCTAssertEqual(setup.spy.opened, [setup.binding.action]) + XCTAssertEqual(setup.spy.closes, [false]) + } + + func testDuplicatePressOnlyRepeatsRepeatableActions() -> Void { + let regular: KeybindTriggerSetup = makeSetup(binding: makeNormalBinding()) + regular.backend.emit(id: 1, phase: .pressed) + regular.backend.emit(id: 1, phase: .pressed) + + let repeatableAction: WindowAction = .init(.growLeft, keybind: [.kVK_LeftArrow]) + let repeatable: KeybindTriggerSetup = makeSetup( + binding: makeNormalBinding(action: repeatableAction) + ) + repeatable.backend.emit(id: 1, phase: .pressed) + repeatable.backend.emit(id: 1, phase: .pressed) + + XCTAssertEqual(regular.spy.opened.count, 1) + XCTAssertTrue(repeatableAction.canRepeat) + XCTAssertEqual(repeatable.spy.opened.count, 2) + } + + func testOnlyExactRegisteredKeyDownDefersToCarbon() -> Void { + let setup: KeybindTriggerSetup = makeSetup(binding: makeNormalBinding()) + let shortcut: Set = setup.binding.shortcut.keyCodes + + XCTAssertTrue(setup.trigger.shouldDeferToSystemHotKey( + type: .keyDown, + keyCodes: shortcut + )) + XCTAssertFalse(setup.trigger.shouldDeferToSystemHotKey( + type: .keyUp, + keyCodes: shortcut + )) + XCTAssertFalse(setup.trigger.shouldDeferToSystemHotKey( + type: .keyDown, + keyCodes: shortcut.union([.kVK_UpArrow]) + )) + } + + func testQueuedPressIsDiscardedAfterStop() -> Void { + var scheduled: [() -> Void] = [] + let setup: KeybindTriggerSetup = makeSetup( + binding: makeNormalBinding(), + // Holding the block simulates Carbon input queued behind `stop` on the event-tap + // thread, where the generation check must discard it. + scheduler: { scheduled.append($0) } + ) + + setup.backend.emit(id: 1, phase: .pressed) + setup.trigger.stop() + scheduled.forEach { $0() } + + XCTAssertTrue(setup.spy.opened.isEmpty) + } + + private func makeSetup( + binding: SystemHotKeyBinding, + scheduler: @escaping (@escaping () -> Void) -> Void = { $0() } + ) -> KeybindTriggerSetup { + // The backend assigns IDs from 1, so rebuilding with one binding gives every test a stable + // callback ID without invoking the process-global Carbon API. + let backend: SystemHotKeyBackendSpy = .init() + let monitor: SystemHotKeyMonitor = .init(backend: backend) + let spy: KeybindCallbackSpy = .init() + let trigger: KeybindTrigger = .init( + windowActionCache: .init(), + systemHotKeyMonitor: monitor, + systemHotKeyScheduler: scheduler, + openCallback: { action in + spy.opened.append(action) + spy.isOpen = true + }, + closeCallback: { forceClose in + spy.closes.append(forceClose) + spy.isOpen = false + }, + checkIfLoopOpen: { spy.isOpen } + ) + monitor.rebuild(bindings: [binding]) + return .init( + trigger: trigger, + monitor: monitor, + backend: backend, + binding: binding, + spy: spy + ) + } + + private func makeNormalBinding( + action: WindowAction = .init(.leftHalf, keybind: [.kVK_LeftArrow]) + ) -> SystemHotKeyBinding { + .init( + shortcut: .init(keyCodes: [ + .kVK_Command, .kVK_Option, .kVK_LeftArrow + ])!, + action: action, + bypassesTrigger: false + ) + } + + private func makeBypassBinding() -> SystemHotKeyBinding { + .init( + shortcut: .init(keyCodes: [ + .kVK_Command, .kVK_Control, .kVK_RightArrow + ])!, + action: .init( + .nextScreen, + keybind: [.kVK_Command, .kVK_Control, .kVK_RightArrow], + bypassTriggerKey: true + ), + bypassesTrigger: true + ) + } +} + +/// Records user-visible callbacks without constructing Loop's window-management UI. +private final class KeybindCallbackSpy { + var opened: [WindowAction] = [] + var closes: [Bool] = [] + var isOpen: Bool = false +} + +/// Keeps the collaborating trigger, monitor, backend, binding, and callback spy together. +private struct KeybindTriggerSetup { + let trigger: KeybindTrigger + let monitor: SystemHotKeyMonitor + let backend: SystemHotKeyBackendSpy + let binding: SystemHotKeyBinding + let spy: KeybindCallbackSpy +} diff --git a/LoopTests/SystemHotKeyBindingTests.swift b/LoopTests/SystemHotKeyBindingTests.swift new file mode 100644 index 00000000..435eb234 --- /dev/null +++ b/LoopTests/SystemHotKeyBindingTests.swift @@ -0,0 +1,109 @@ +import Carbon.HIToolbox +import XCTest + +@testable import Loop + +/// Documents the exact boundary between Loop's flexible key chords and Carbon-compatible hotkeys. +final class SystemHotKeyBindingTests: XCTestCase { + func testResolvesNormalAndBypassShortcuts() throws -> Void { + let normal: WindowAction = .init(.leftHalf, keybind: [.kVK_LeftArrow]) + let bypass: WindowAction = .init( + .nextScreen, + keybind: [.kVK_Command, .kVK_Control, .kVK_RightArrow], + bypassTriggerKey: true + ) + + let bindings: [SystemHotKeyBinding] = SystemHotKeyBindingResolver.resolve( + keybinds: [normal, bypass], + triggerKey: [.kVK_Command, .kVK_Option], + sideDependentTriggerKey: false, + cycleBackwardsOnShiftPressed: false + ) + + XCTAssertEqual(bindings.count, 2) + XCTAssertEqual(bindings[0].action, normal) + XCTAssertEqual(bindings[0].shortcut.keyCodes, [ + .kVK_Command, .kVK_Option, .kVK_LeftArrow + ]) + XCTAssertFalse(bindings[0].bypassesTrigger) + XCTAssertEqual(bindings[1].action, bypass) + XCTAssertEqual(bindings[1].shortcut.keyCodes, bypass.keybind) + XCTAssertTrue(bindings[1].bypassesTrigger) + } + + func testAddsReverseCycleShiftVariant() -> Void { + let action: WindowAction = .init( + cycle: [.init(.leftHalf), .init(.leftThird)], + keybind: [.kVK_LeftArrow] + ) + + let bindings: [SystemHotKeyBinding] = SystemHotKeyBindingResolver.resolve( + keybinds: [action], + triggerKey: [.kVK_Command, .kVK_Option], + sideDependentTriggerKey: false, + cycleBackwardsOnShiftPressed: true + ) + + XCTAssertEqual(bindings.map(\.shortcut.keyCodes), [ + [.kVK_Command, .kVK_Option, .kVK_LeftArrow], + [.kVK_Command, .kVK_Option, .kVK_Shift, .kVK_LeftArrow] + ]) + } + + func testMapsCarbonModifiersAndNormalizesSides() throws -> Void { + let shortcut: SystemHotKeyShortcut = try XCTUnwrap(.init(keyCodes: [ + .kVK_RightCommand, + .kVK_RightOption, + .kVK_RightControl, + .kVK_RightShift, + .kVK_Function, + .kVK_Space + ])) + + XCTAssertEqual(shortcut.keyCode, .kVK_Space) + XCTAssertEqual( + shortcut.modifiers, + UInt32(cmdKey | optionKey | controlKey | shiftKey) + | UInt32(kEventKeyModifierFnMask) + ) + XCTAssertEqual(shortcut.keyCodes, [ + .kVK_Command, + .kVK_Option, + .kVK_Control, + .kVK_Shift, + .kVK_Function, + .kVK_Space + ]) + } + + func testRejectsUnsupportedShapesAndSideDependentTriggers() -> Void { + XCTAssertNil(SystemHotKeyShortcut(keyCodes: [ + .kVK_Command, .kVK_LeftArrow, .kVK_RightArrow + ])) + XCTAssertNil(SystemHotKeyShortcut(keyCodes: [ + .kVK_CapsLock, .kVK_Space + ])) + + let bindings: [SystemHotKeyBinding] = SystemHotKeyBindingResolver.resolve( + keybinds: [.init(.leftHalf, keybind: [.kVK_LeftArrow])], + triggerKey: [.kVK_RightCommand], + sideDependentTriggerKey: true, + cycleBackwardsOnShiftPressed: false + ) + XCTAssertTrue(bindings.isEmpty) + } + + func testKeepsFirstDuplicateBinding() -> Void { + let first: WindowAction = .init(.leftHalf, keybind: [.kVK_LeftArrow]) + let second: WindowAction = .init(.leftThird, keybind: [.kVK_LeftArrow]) + + let bindings: [SystemHotKeyBinding] = SystemHotKeyBindingResolver.resolve( + keybinds: [first, second], + triggerKey: [.kVK_Command, .kVK_Option], + sideDependentTriggerKey: false, + cycleBackwardsOnShiftPressed: false + ) + + XCTAssertEqual(bindings.map(\.action), [first]) + } +} diff --git a/LoopTests/SystemHotKeyMonitorTests.swift b/LoopTests/SystemHotKeyMonitorTests.swift new file mode 100644 index 00000000..7cfbaf73 --- /dev/null +++ b/LoopTests/SystemHotKeyMonitorTests.swift @@ -0,0 +1,140 @@ +import Carbon.HIToolbox +import XCTest + +@testable import Loop + +/// Verifies registration lifecycle, partial failures, and stale Carbon callback rejection. +final class SystemHotKeyMonitorTests: XCTestCase { + func testRoutesRegisteredPressAndRelease() -> Void { + let backend: SystemHotKeyBackendSpy = .init() + let monitor: SystemHotKeyMonitor = .init(backend: backend) + let bindings: [SystemHotKeyBinding] = makeBindings() + var received: [(SystemHotKeyBinding, SystemHotKeyPhase)] = [] + monitor.eventCallback = { received.append(($0, $1)) } + + monitor.rebuild(bindings: bindings) + backend.emit(id: 1, phase: .pressed) + backend.emit(id: 1, phase: .released) + + XCTAssertEqual(backend.registrationIDs, [1, 2]) + XCTAssertEqual(received.map(\.0), [bindings[0], bindings[0]]) + XCTAssertEqual(received.map(\.1), [.pressed, .released]) + } + + func testSkipsFailedRegistrationAndContinues() -> Void { + let backend: SystemHotKeyBackendSpy = .init() + backend.rejectedIDs = [1] + let monitor: SystemHotKeyMonitor = .init(backend: backend) + let bindings: [SystemHotKeyBinding] = makeBindings() + var received: [SystemHotKeyBinding] = [] + monitor.eventCallback = { binding, _ in received.append(binding) } + + monitor.rebuild(bindings: bindings) + backend.emit(id: 1, phase: .pressed) + backend.emit(id: 2, phase: .pressed) + + XCTAssertFalse(monitor.isRegistered(shortcut: bindings[0].shortcut)) + XCTAssertTrue(monitor.isRegistered(shortcut: bindings[1].shortcut)) + XCTAssertEqual(received, [bindings[1]]) + } + + func testStartFailureRegistersNothing() -> Void { + let backend: SystemHotKeyBackendSpy = .init() + backend.startStatus = OSStatus(eventNotHandledErr) + let monitor: SystemHotKeyMonitor = .init(backend: backend) + let binding: SystemHotKeyBinding = makeBindings()[0] + + monitor.rebuild(bindings: [binding]) + + XCTAssertTrue(backend.registrationIDs.isEmpty) + XCTAssertFalse(monitor.isRegistered(shortcut: binding.shortcut)) + } + + func testRebuildIgnoresStaleRegistrationIDs() -> Void { + let backend: SystemHotKeyBackendSpy = .init() + let monitor: SystemHotKeyMonitor = .init(backend: backend) + let bindings: [SystemHotKeyBinding] = makeBindings() + var received: [SystemHotKeyBinding] = [] + monitor.eventCallback = { binding, _ in received.append(binding) } + + monitor.rebuild(bindings: [bindings[0]]) + monitor.rebuild(bindings: [bindings[1]]) + backend.emit(id: 1, phase: .pressed) + backend.emit(id: 2, phase: .pressed) + + XCTAssertEqual(received, [bindings[1]]) + } + + func testStopClearsRegistrationsAndCallbacks() -> Void { + let backend: SystemHotKeyBackendSpy = .init() + let monitor: SystemHotKeyMonitor = .init(backend: backend) + let binding: SystemHotKeyBinding = makeBindings()[0] + var callbackCount: Int = 0 + monitor.eventCallback = { _, _ in callbackCount += 1 } + + monitor.rebuild(bindings: [binding]) + monitor.stop() + backend.emit(id: 1, phase: .pressed) + + XCTAssertFalse(monitor.isRegistered(shortcut: binding.shortcut)) + XCTAssertEqual(backend.stopCallCount, 1) + XCTAssertEqual(callbackCount, 0) + } + + private func makeBindings() -> [SystemHotKeyBinding] { + [ + .init( + shortcut: .init(keyCodes: [ + .kVK_Command, .kVK_Option, .kVK_LeftArrow + ])!, + action: .init(.leftHalf, keybind: [.kVK_LeftArrow]), + bypassesTrigger: false + ), + .init( + shortcut: .init(keyCodes: [ + .kVK_Command, .kVK_Control, .kVK_RightArrow + ])!, + action: .init( + .nextScreen, + keybind: [.kVK_Command, .kVK_Control, .kVK_RightArrow], + bypassTriggerKey: true + ), + bypassesTrigger: true + ) + ] + } +} + +/// Deterministic in-memory substitute for the process-global Carbon registration API. +/// +/// Tests can reject selected IDs or fail startup without reserving real global shortcuts. Emitted +/// events still travel through the monitor's production ID-to-binding callback path. +final class SystemHotKeyBackendSpy: SystemHotKeyRegistrationBackend { + var eventCallback: ((SystemHotKeyEvent) -> Void)? + var startStatus: OSStatus = noErr + var rejectedIDs: Set = [] + private(set) var registrationIDs: [UInt32] = [] + private(set) var stopCallCount: Int = 0 + + func start() -> OSStatus { + startStatus + } + + func register(shortcut: SystemHotKeyShortcut, id: UInt32) -> OSStatus { + registrationIDs.append(id) + return rejectedIDs.contains(id) ? OSStatus(eventHotKeyExistsErr) : noErr + } + + func unregisterAll() -> Bool { + true + } + + func stop() -> Bool { + stopCallCount += 1 + return true + } + + func emit(id: UInt32, phase: SystemHotKeyPhase) -> Void { + eventCallback?(.init(id: id, phase: phase)) + } +}