diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 8bf0e82fb..593970e98 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 7C5AF14C2F15041600DE21B0 /* MediaRemoteAdapter in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 7C5AF14A2F15041600DE21B0 /* MediaRemoteAdapter */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 7C9A71022F58B00000FB7CAF /* TranscribeCpp in Frameworks */ = {isa = PBXBuildFile; productRef = 7C9A71012F58B00000FB7CAF /* TranscribeCpp */; }; 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */; }; + A872F1000000000000000001 /* RegisteredHotkeysTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A872F1000000000000000002 /* RegisteredHotkeysTests.swift */; }; C0DE89000000000000000002 /* SettingsNavigationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE89000000000000000001 /* SettingsNavigationStateTests.swift */; }; 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */; }; B52000000000000000000002 /* PrivateAIDictationTokenBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52000000000000000000001 /* PrivateAIDictationTokenBudgetTests.swift */; }; @@ -80,6 +81,7 @@ CACEC0010000000000000001 /* KeychainServiceCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainServiceCacheTests.swift; sourceTree = ""; }; 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = ""; }; + A872F1000000000000000002 /* RegisteredHotkeysTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisteredHotkeysTests.swift; sourceTree = ""; }; C0DE89000000000000000001 /* SettingsNavigationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsNavigationStateTests.swift; sourceTree = ""; }; 7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = ""; }; @@ -159,6 +161,7 @@ 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */, CD1C7A0000000000000000B1 /* CustomDictionaryManualEntryTests.swift */, 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */, + A872F1000000000000000002 /* RegisteredHotkeysTests.swift */, C0DE89000000000000000001 /* SettingsNavigationStateTests.swift */, 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */, 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */, @@ -336,6 +339,7 @@ B52000000000000000000002 /* PrivateAIDictationTokenBudgetTests.swift in Sources */, CD1C7A0000000000000000B2 /* CustomDictionaryManualEntryTests.swift in Sources */, 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */, + A872F1000000000000000001 /* RegisteredHotkeysTests.swift in Sources */, C0DE89000000000000000002 /* SettingsNavigationStateTests.swift in Sources */, 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */, 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */, diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 79700192c..d8cd72734 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -530,7 +530,7 @@ struct ContentView: View { SettingsStore.shared.selectedProviderID = newValue } .onChange(of: self.activeShortcutRecordingTarget) { _, _ in - self.hotkeyManager?.resetModifierOnlyShortcutTracking() + self.hotkeyManager?.shortcutCaptureDidChange() } .onChange(of: self.commandModeHotkeyShortcut) { _, newValue in SettingsStore.shared.commandModeHotkeyShortcut = newValue diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index c213affac..f6e5a6c9e 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -1,4 +1,5 @@ import AppKit +import Carbon import Foundation nonisolated enum HotkeyHoldModeType: Hashable { @@ -444,8 +445,10 @@ final class GlobalHotkeyManager: NSObject { } } - private func clearAutomaticPressTracking() { - self.cancelPendingReleaseStops() + private func clearAutomaticPressTracking(cancelPendingStops: Bool = true) { + if cancelPendingStops { + self.cancelPendingReleaseStops() + } self.state.withLock { self.state.holdModeStartTriggeredTypes.removeAll() self.state.automaticPressStartTimes.removeAll() @@ -464,6 +467,20 @@ final class GlobalHotkeyManager: NSObject { private var retryDelay: TimeInterval = 0.5 private var healthCheckInterval: TimeInterval = 30.0 private var activeShortcutLogScheduled = false + private var secureInputWasEnabled: Bool? + private lazy var registeredHotkeys: RegisteredHotkeys = { + let hotkeys = RegisteredHotkeys(driver: CarbonHotkeyDriver()) + hotkeys.onEvent = { [weak self] shortcut, down in + self?.handleRegisteredHotkey(shortcut, down: down) + } + hotkeys.onFailure = { shortcut, status in + DebugLogger.shared.warning( + "System hotkey registration failed for \(shortcut.displayString) (OSStatus \(status)); using event tap, which may be blocked by Secure Input", + source: "GlobalHotkeyManager" + ) + } + return hotkeys + }() init( asrService: ASRService, @@ -539,6 +556,7 @@ final class GlobalHotkeyManager: NSObject { } func updatePrimaryShortcuts(_ newShortcuts: [HotkeyShortcut]) { + self.registeredHotkeys.releaseAll() self.primaryShortcuts = newShortcuts DebugLogger.shared.info("Updated transcription hotkeys", source: "GlobalHotkeyManager") self.refreshMouseShortcutTapIfNeeded() @@ -553,6 +571,7 @@ final class GlobalHotkeyManager: NSObject { } func updateCommandModeShortcut(_ newShortcut: HotkeyShortcut?) { + self.registeredHotkeys.releaseAll() self.commandModeShortcut = newShortcut DebugLogger.shared.info("Updated command mode hotkey", source: "GlobalHotkeyManager") self.scheduleActiveShortcutLog(reason: "shortcuts updated") @@ -563,12 +582,14 @@ final class GlobalHotkeyManager: NSObject { } func updateRewriteModeShortcut(_ newShortcut: HotkeyShortcut) { + self.registeredHotkeys.releaseAll() self.rewriteModeShortcut = newShortcut DebugLogger.shared.info("Updated rewrite mode hotkey", source: "GlobalHotkeyManager") self.scheduleActiveShortcutLog(reason: "shortcuts updated") } func updateCommandModeShortcutEnabled(_ enabled: Bool) { + self.registeredHotkeys.releaseAll() self.commandModeShortcutEnabled = enabled if !enabled { self.isCommandModeKeyPressed = false @@ -581,6 +602,7 @@ final class GlobalHotkeyManager: NSObject { } func updateRewriteModeShortcutEnabled(_ enabled: Bool) { + self.registeredHotkeys.releaseAll() self.rewriteModeShortcutEnabled = enabled if !enabled { self.isRewriteKeyPressed = false @@ -597,12 +619,14 @@ final class GlobalHotkeyManager: NSObject { } func updatePromptModeShortcut(_ newShortcut: HotkeyShortcut) { + self.registeredHotkeys.releaseAll() self.promptModeShortcut = newShortcut DebugLogger.shared.info("Updated prompt mode hotkey", source: "GlobalHotkeyManager") self.scheduleActiveShortcutLog(reason: "shortcuts updated") } func updatePromptModeShortcutEnabled(_ enabled: Bool) { + self.registeredHotkeys.releaseAll() self.promptModeShortcutEnabled = enabled if !enabled { self.isPromptModeKeyPressed = false @@ -615,6 +639,7 @@ final class GlobalHotkeyManager: NSObject { } func updatePromptShortcutAssignments(_ assignments: [(selection: SettingsStore.DictationPromptSelection, shortcut: HotkeyShortcut)]) { + self.registeredHotkeys.releaseAll() self.promptShortcutAssignments = assignments DebugLogger.shared.info("Updated prompt shortcut assignments", source: "GlobalHotkeyManager") self.scheduleActiveShortcutLog(reason: "shortcuts updated") @@ -656,8 +681,10 @@ final class GlobalHotkeyManager: NSObject { @discardableResult private func setupGlobalHotkey() -> Bool { + self.refreshRegisteredHotkeys() + self.logSecureInputState() self.finishInterruptedMouseShortcutPress(reason: "hotkey tap reinitialized") - self.cleanupEventTap() + self.cleanupEventTap(preserveRegisteredPress: self.registeredHotkeys.hasPressedShortcut) if !AXIsProcessTrusted() { DebugLogger.shared.debug("Accessibility permissions not granted", source: "GlobalHotkeyManager") @@ -669,11 +696,11 @@ final class GlobalHotkeyManager: NSObject { place: .headInsertEventTap, options: .defaultTap, eventsOfInterest: Self.keyboardEventMask(), - callback: { proxy, type, event, refcon -> Unmanaged? in + callback: { _, type, event, refcon -> Unmanaged? in guard let refcon = refcon else { return Unmanaged.passUnretained(event) } let manager = Unmanaged.fromOpaque(refcon) .takeUnretainedValue() - return manager.handleKeyEvent(proxy: proxy, type: type, event: event) + return manager.handleKeyEvent(type: type, event: event) }, userInfo: Unmanaged.passUnretained(self).toOpaque() ) @@ -704,11 +731,13 @@ final class GlobalHotkeyManager: NSObject { return true } - private nonisolated func cleanupEventTap() { + private nonisolated func cleanupEventTap(preserveRegisteredPress: Bool = false) { Self.tearDown(tap: self.eventTap, source: self.runLoopSource) self.eventTap = nil self.runLoopSource = nil - self.clearPrimaryShortcutPressState() + if !preserveRegisteredPress { + self.clearPrimaryShortcutPressState() + } self.cleanupMouseTaps() } @@ -795,6 +824,7 @@ final class GlobalHotkeyManager: NSObject { Task { @MainActor [weak self] in guard let self else { return } self.activeShortcutLogScheduled = false + self.refreshRegisteredHotkeys() self.logActiveShortcuts(reason: reason) } } @@ -1143,7 +1173,7 @@ final class GlobalHotkeyManager: NSObject { ) } - private func handleKeyEvent(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged? { + private func handleKeyEvent(type: CGEventType, event: CGEvent, registered: Bool = false) -> Unmanaged? { if let tapRecoveryResult = self.handleTapDisableEvent(type: type, event: event) { return tapRecoveryResult } @@ -1152,7 +1182,7 @@ final class GlobalHotkeyManager: NSObject { return Unmanaged.passUnretained(event) } - if self.isShortcutCaptureActiveProvider?() ?? false { + if self.isShortcutCaptureActiveProvider?() ?? false, !(registered && type == .keyUp) { self.resetModifierOnlyShortcutTracking() return Unmanaged.passUnretained(event) } @@ -1160,6 +1190,18 @@ final class GlobalHotkeyManager: NSObject { let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode)) let eventModifiers = Self.modifierFlags(from: event.flags) + if !registered, type == .keyDown || type == .keyUp, + self.registeredHotkeys.shouldBypassEventTap( + keyCode: keyCode, modifiers: eventModifiers, down: type == .keyDown + ) + { + if type == .keyDown { + self.markOtherInputDuringModifierOnly() + } + // Carbon must receive this event; consuming it here defeats registration. + return Unmanaged.passUnretained(event) + } + switch type { case .keyDown: self.markOtherInputDuringModifierOnly() @@ -1534,7 +1576,9 @@ final class GlobalHotkeyManager: NSObject { let reason = (type == .tapDisabledByTimeout) ? "timeout" : "user input" DebugLogger.shared.warning("Event tap disabled by \(reason) — attempting immediate re-enable", source: "GlobalHotkeyManager") - self.resetModifierOnlyShortcutTracking(reason: .tapDisabled) + if !self.registeredHotkeys.hasPressedShortcut { + self.resetModifierOnlyShortcutTracking(reason: .tapDisabled) + } if let tap = self.eventTap { CGEvent.tapEnable(tap: tap, enable: true) @@ -1608,7 +1652,7 @@ final class GlobalHotkeyManager: NSObject { let press = self.finishAutomaticPress(for: type) let duration = String(format: "%.2f", press.duration) - if press.duration < self.automaticTapThresholdSeconds { + if !self.registeredHotkeys.isInterruptingPress, press.duration < self.automaticTapThresholdSeconds { if press.wasTargetActive { DebugLogger.shared.info("\(label) tap (\(duration)s) - stopping", source: "GlobalHotkeyManager") self.stopRecordingIfNeeded() @@ -1874,7 +1918,10 @@ final class GlobalHotkeyManager: NSObject { } } - func resetModifierOnlyShortcutTracking(reason: ModifierTrackingResetReason = .shortcutCapture) { + func resetModifierOnlyShortcutTracking( + reason: ModifierTrackingResetReason = .shortcutCapture, + preservePendingReleaseStops: Bool = false + ) { let shouldStopActiveHold = self.hotkeyMode != .toggle && self.asrService.isRunning && (self.isKeyPressed || self.isPromptModeKeyPressed || self.isCommandModeKeyPressed || self.isRewriteKeyPressed || self.isPromptAssignmentKeyPressed) @@ -1884,7 +1931,7 @@ final class GlobalHotkeyManager: NSObject { self.activeModifierOnlyType = nil self.otherKeyPressedDuringModifier = false self.modifierPressStartTime = nil - self.clearAutomaticPressTracking() + self.clearAutomaticPressTracking(cancelPendingStops: !preservePendingReleaseStops) self.isKeyPressed = false self.isPromptModeKeyPressed = false self.isCommandModeKeyPressed = false @@ -2368,10 +2415,11 @@ final class GlobalHotkeyManager: NSObject { func reinitialize() { DebugLogger.shared.info("Manual reinitialization requested", source: "GlobalHotkeyManager") + self.registeredHotkeys.releaseAll(interrupted: true) self.initializationTask?.cancel() self.healthCheckTask?.cancel() - self.resetModifierOnlyShortcutTracking(reason: .reinitialize) + self.resetModifierOnlyShortcutTracking(reason: .reinitialize, preservePendingReleaseStops: true) self.isInitialized = false self.initializeWithDelay() } @@ -2391,6 +2439,7 @@ final class GlobalHotkeyManager: NSObject { await MainActor.run { [weak self] in guard let self else { return } + self.logSecureInputState() if !self.validateEventTapHealth() { DebugLogger.shared.warning("Health check failed, attempting to recover", source: "GlobalHotkeyManager") @@ -2415,3 +2464,52 @@ final class GlobalHotkeyManager: NSObject { cleanupEventTap() } } + +/// Registration is separate from event-tap health: an enabled tap can still be +/// unable to receive keys while another process holds Secure Event Input. +extension GlobalHotkeyManager { + private func refreshRegisteredHotkeys() { + guard !(self.isShortcutCaptureActiveProvider?() ?? false) else { + self.registeredHotkeys.update(shortcuts: []) + return + } + var shortcuts = self.primaryShortcuts + self.promptShortcutAssignments.map(\.shortcut) + if self.promptModeShortcutEnabled { + shortcuts.append(self.promptModeShortcut) + } + if self.commandModeShortcutEnabled, let shortcut = self.commandModeShortcut { + shortcuts.append(shortcut) + } + if self.rewriteModeShortcutEnabled { + shortcuts.append(self.rewriteModeShortcut) + } + // Cancel and paste keep their context-sensitive event-tap behavior: unlike + // recording triggers, they must not unconditionally claim a global chord. + self.registeredHotkeys.update(shortcuts: shortcuts) + } + + func shortcutCaptureDidChange() { + self.registeredHotkeys.releaseAll(interrupted: true) + self.resetModifierOnlyShortcutTracking(preservePendingReleaseStops: true) + self.refreshRegisteredHotkeys() + } + + private func handleRegisteredHotkey(_ shortcut: HotkeyShortcut, down: Bool) { + guard !down || !Self.currentSessionIsLocked() else { return } + // Construct an input for the existing routing/state machine. Never post it + // into the system event stream or synthesize a keystroke in another app. + guard let event = CGEvent(keyboardEventSource: nil, virtualKey: shortcut.keyCode, keyDown: down) else { return } + event.flags = CGEventFlags(rawValue: UInt64(shortcut.relevantModifierFlags.rawValue)) + _ = self.handleKeyEvent(type: down ? .keyDown : .keyUp, event: event, registered: true) + } + + private func logSecureInputState() { + let enabled = IsSecureEventInputEnabled() + guard self.secureInputWasEnabled != enabled else { return } + self.secureInputWasEnabled = enabled + DebugLogger.shared.info( + "Secure Input \(enabled ? "enabled: event-tap shortcuts may be unavailable; registered hotkeys remain registered" : "disabled")", + source: "GlobalHotkeyManager" + ) + } +} diff --git a/Sources/Fluid/Services/RegisteredHotkeys.swift b/Sources/Fluid/Services/RegisteredHotkeys.swift new file mode 100644 index 000000000..3229ea9e2 --- /dev/null +++ b/Sources/Fluid/Services/RegisteredHotkeys.swift @@ -0,0 +1,234 @@ +import AppKit +import Carbon + +/// Only chords Carbon can represent belong here. Plain keys must remain available to +/// other apps, and Fn / side-specific modifiers still need the event-tap backend. +struct RegisteredHotkeyChord: Hashable { + let keyCode: UInt16 + let modifiers: UInt32 + + init?(_ shortcut: HotkeyShortcut) { + let flags = shortcut.relevantModifierFlags + guard !shortcut.isMouseShortcut, !shortcut.isModifierOnlyShortcut, + shortcut.normalizedModifierKeyCodes.isEmpty, + !flags.isEmpty, !flags.contains(.function) + else { return nil } + self.keyCode = shortcut.keyCode + self.modifiers = (flags.contains(.command) ? UInt32(cmdKey) : 0) + | (flags.contains(.option) ? UInt32(optionKey) : 0) + | (flags.contains(.control) ? UInt32(controlKey) : 0) + | (flags.contains(.shift) ? UInt32(shiftKey) : 0) + } + + var shortcut: HotkeyShortcut { + var flags: NSEvent.ModifierFlags = [] + if self.modifiers & UInt32(cmdKey) != 0 { + flags.insert(.command) + } + if self.modifiers & UInt32(optionKey) != 0 { + flags.insert(.option) + } + if self.modifiers & UInt32(controlKey) != 0 { + flags.insert(.control) + } + if self.modifiers & UInt32(shiftKey) != 0 { + flags.insert(.shift) + } + return HotkeyShortcut(keyCode: self.keyCode, modifierFlags: flags) + } +} + +@MainActor +protocol RegisteredHotkeyDriver: AnyObject { + var onEvent: ((UInt32, Bool) -> Void)? { get set } + func register(_ chord: RegisteredHotkeyChord, id: UInt32) -> OSStatus + func unregister(id: UInt32) +} + +/// Carbon delivers only the explicitly registered chord, including while Secure +/// Event Input prevents general keyboard observation. All calls run on the main loop. +@MainActor +final class CarbonHotkeyDriver: RegisteredHotkeyDriver { + typealias RegisterHotkey = (UInt32, UInt32, EventHotKeyID, EventTargetRef?, OptionBits, UnsafeMutablePointer?) -> OSStatus + + private let registerHotkey: RegisterHotkey + + init(registerHotkey: @escaping RegisterHotkey = RegisterEventHotKey) { + self.registerHotkey = registerHotkey + } + + private let signature: OSType = UInt32.random(in: 1...UInt32.max) + var onEvent: ((UInt32, Bool) -> Void)? + private nonisolated(unsafe) var handler: EventHandlerRef? + private nonisolated(unsafe) var references: [UInt32: EventHotKeyRef] = [:] + + func register(_ chord: RegisteredHotkeyChord, id: UInt32) -> OSStatus { + if self.handler == nil { + var types = [ + EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)), + EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased)), + ] + let status = InstallEventHandler( + GetApplicationEventTarget(), + { _, event, context in + guard let event, let context else { return OSStatus(eventNotHandledErr) } + return MainActor.assumeIsolated { + let driver = Unmanaged.fromOpaque(context).takeUnretainedValue() + var hotkeyID = EventHotKeyID() + let result = GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &hotkeyID + ) + guard result == noErr, hotkeyID.signature == driver.signature, + driver.references[hotkeyID.id] != nil + else { + return OSStatus(eventNotHandledErr) + } + driver.onEvent?(hotkeyID.id, GetEventKind(event) == UInt32(kEventHotKeyPressed)) + return noErr + } + }, + types.count, + &types, + Unmanaged.passUnretained(self).toOpaque(), + &self.handler + ) + guard status == noErr else { return status } + } + var reference: EventHotKeyRef? + let status = self.registerHotkey( + UInt32(chord.keyCode), + chord.modifiers, + EventHotKeyID(signature: self.signature, id: id), + GetApplicationEventTarget(), + OptionBits(kEventHotKeyExclusive), + &reference + ) + if status == noErr, let reference { + self.references[id] = reference + } + return status + } + + func unregister(id: UInt32) { + if let reference = self.references.removeValue(forKey: id) { + UnregisterEventHotKey(reference) + } + } + + deinit { + for reference in references.values { + UnregisterEventHotKey(reference) + } + if let handler { + RemoveEventHandler(handler) + } + } +} + +/// Owns routing as well as registrations: a successfully registered chord is never +/// also handled by the event tap. Failed registrations retain the legacy path. +@MainActor +final class RegisteredHotkeys { + private let driver: RegisteredHotkeyDriver + private let notificationCenter: NotificationCenter + private nonisolated(unsafe) var sessionObservers: [NSObjectProtocol] = [] + private var registrations: [RegisteredHotkeyChord: UInt32] = [:] + private var pressed: Set = [] + private var bypassedKeyCodes: Set = [] + private var nextID: UInt32 = 1 + private(set) var isInterruptingPress = false + var onEvent: ((HotkeyShortcut, Bool) -> Void)? + var onFailure: ((HotkeyShortcut, OSStatus) -> Void)? + + init(driver: RegisteredHotkeyDriver, notificationCenter: NotificationCenter = NSWorkspace.shared.notificationCenter) { + self.driver = driver + self.notificationCenter = notificationCenter + driver.onEvent = { [weak self] id, down in self?.receive(id: id, down: down) } + // Key-up can be lost across sleep or fast user switching. Finish held + // actions both before leaving and on return, without rebuilding healthy + // registrations or treating the interruption as an Automatic-mode tap. + for name in [ + NSWorkspace.willSleepNotification, + NSWorkspace.didWakeNotification, + NSWorkspace.sessionDidResignActiveNotification, + NSWorkspace.sessionDidBecomeActiveNotification, + ] { + self.sessionObservers.append(notificationCenter.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + self?.releaseAll(interrupted: true) + } + }) + } + } + + deinit { + for observer in sessionObservers { + notificationCenter.removeObserver(observer) + } + } + + func update(shortcuts: [HotkeyShortcut]) { + let desired = Set(shortcuts.compactMap(RegisteredHotkeyChord.init)) + for (chord, id) in self.registrations where !desired.contains(chord) { + // Complete a held shortcut before its configuration disappears. + self.receive(id: id, down: false) + self.driver.unregister(id: id) + self.registrations.removeValue(forKey: chord) + } + for chord in desired where self.registrations[chord] == nil { + let id = self.nextID + self.nextID += 1 + let status = self.driver.register(chord, id: id) + if status == noErr { + self.registrations[chord] = id + } else { + self.onFailure?(chord.shortcut, status) + } + } + } + + func receive(id: UInt32, down: Bool) { + guard let chord = self.registrations.first(where: { $0.value == id })?.key else { return } + if down { + guard self.pressed.insert(id).inserted else { return } // no autorepeat + } else { + guard self.pressed.remove(id) != nil else { return } + } + self.onEvent?(chord.shortcut, down) + } + + func shouldBypassEventTap(keyCode: UInt16, modifiers: NSEvent.ModifierFlags, down: Bool) -> Bool { + if !down { + // Remember ownership even if modifiers are released first or the + // Carbon release callback has already run. + return self.bypassedKeyCodes.remove(keyCode) != nil + } + guard let chord = RegisteredHotkeyChord(HotkeyShortcut(keyCode: keyCode, modifierFlags: modifiers)), + self.registrations[chord] != nil + else { return false } + self.bypassedKeyCodes.insert(keyCode) + return true + } + + var hasPressedShortcut: Bool { + !self.pressed.isEmpty + } + + func releaseAll(interrupted: Bool = false) { + // The callback runs synchronously, so recording logic can distinguish an + // interruption from a physical release without losing the held state. + let previous = self.isInterruptingPress + self.isInterruptingPress = interrupted + defer { self.isInterruptingPress = previous } + for id in self.pressed { + self.receive(id: id, down: false) + } + self.bypassedKeyCodes.removeAll() + } +} diff --git a/Tests/FluidDictationIntegrationTests/RegisteredHotkeysTests.swift b/Tests/FluidDictationIntegrationTests/RegisteredHotkeysTests.swift new file mode 100644 index 000000000..43e2d6e71 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/RegisteredHotkeysTests.swift @@ -0,0 +1,275 @@ +import AppKit +import Carbon +#if !HOTKEY_STANDALONE_TESTS +@testable import FluidVoice_Debug +#endif +import XCTest + +@MainActor +private final class FakeHotkeyDriver: RegisteredHotkeyDriver { + var onEvent: ((UInt32, Bool) -> Void)? + var status: OSStatus = noErr + var registered: [UInt32: RegisteredHotkeyChord] = [:] + var removed: [UInt32] = [] + + func register(_ chord: RegisteredHotkeyChord, id: UInt32) -> OSStatus { + if self.status == noErr { + self.registered[id] = chord + } + return self.status + } + + func unregister(id: UInt32) { + self.registered.removeValue(forKey: id) + self.removed.append(id) + } +} + +final class RegisteredHotkeysTests: XCTestCase { + @MainActor + func testNativeRegistrationIsReleasedWhenDriverIsDestroyed() throws { + // Use an uncommon chord so this test never takes over a normal app action. + let chord = try XCTUnwrap(RegisteredHotkeyChord(HotkeyShortcut(keyCode: 79, modifierFlags: [.control, .option]))) + var first: CarbonHotkeyDriver? = CarbonHotkeyDriver() + let status = try XCTUnwrap(first).register(chord, id: 1) + guard status == noErr else { + throw XCTSkip("Native hotkey registration unavailable in this session: \(status)") + } + first = nil + let second = CarbonHotkeyDriver() + XCTAssertEqual(second.register(chord, id: 1), noErr) + second.unregister(id: 1) + XCTAssertEqual(second.register(chord, id: 2), noErr) + second.unregister(id: 2) + } + + @MainActor + func testRequestsExclusiveOwnershipAndFallsBackWhenDenied() { + var attempted = false + let driver = CarbonHotkeyDriver { _, _, _, _, options, _ in + attempted = true + XCTAssertEqual(options, OptionBits(kEventHotKeyExclusive)) + return OSStatus(eventHotKeyExistsErr) + } + let hotkeys = RegisteredHotkeys(driver: driver) + hotkeys.update(shortcuts: [HotkeyShortcut(keyCode: 49, modifierFlags: .option)]) + XCTAssertTrue(attempted) + XCTAssertFalse(hotkeys.shouldBypassEventTap(keyCode: 49, modifiers: .option, down: true)) + } + + @MainActor + func testNativeConflictRetainsEventTapFallbackUntilOwnerReleases() throws { + let shortcut = HotkeyShortcut(keyCode: 80, modifierFlags: [.control, .option]) + let chord = try XCTUnwrap(RegisteredHotkeyChord(shortcut)) + let owner = CarbonHotkeyDriver() + let status = owner.register(chord, id: 1) + guard status == noErr else { throw XCTSkip("Native registration unavailable: \(status)") } + defer { owner.unregister(id: 1) } + let hotkeys = RegisteredHotkeys(driver: CarbonHotkeyDriver()) + var failures: [OSStatus] = [] + hotkeys.onFailure = { _, status in failures.append(status) } + hotkeys.update(shortcuts: [shortcut]) + XCTAssertEqual(failures.count, 1) + XCTAssertFalse(hotkeys.shouldBypassEventTap(keyCode: 80, modifiers: [.control, .option], down: true)) + owner.unregister(id: 1) + hotkeys.update(shortcuts: [shortcut]) + XCTAssertTrue(hotkeys.shouldBypassEventTap(keyCode: 80, modifiers: [.control, .option], down: true)) + } + + @MainActor + func testInterruptionIsVisibleDuringReleaseAndDoesNotLeakToNextPress() throws { + let driver = FakeHotkeyDriver() + let hotkeys = RegisteredHotkeys(driver: driver) + hotkeys.update(shortcuts: [HotkeyShortcut(keyCode: 49, modifierFlags: .option)]) + let id = try XCTUnwrap(driver.registered.keys.first) + var interruptions: [Bool] = [] + hotkeys.onEvent = { _, down in + if !down { + interruptions.append(hotkeys.isInterruptingPress) + } + } + defer { hotkeys.onEvent = nil } + driver.onEvent?(id, true) + hotkeys.releaseAll(interrupted: true) + driver.onEvent?(id, false) // stale physical release must not finish twice + XCTAssertFalse(hotkeys.isInterruptingPress) + driver.onEvent?(id, true) + driver.onEvent?(id, false) + XCTAssertEqual(interruptions, [true, false]) + } + + @MainActor + func testChordEligibilityPreservesPlainKeysAndModifierOnlyShortcuts() { + let optionSpace = HotkeyShortcut(keyCode: 49, modifierFlags: .option) + XCTAssertEqual(RegisteredHotkeyChord(optionSpace)?.shortcut, optionSpace) + XCTAssertNil(RegisteredHotkeyChord(HotkeyShortcut(keyCode: 49, modifierFlags: []))) + XCTAssertNil(RegisteredHotkeyChord(HotkeyShortcut(keyCode: 61, modifierFlags: []))) + XCTAssertNil(RegisteredHotkeyChord(HotkeyShortcut(keyCode: 49, modifierFlags: [.function, .option]))) + XCTAssertNil(RegisteredHotkeyChord(HotkeyShortcut(mouseButton: 2, modifierFlags: .option))) + } + + @MainActor + func testCarbonDeliveryWorksWithoutAnyEventTapEventsAndIgnoresRepeats() throws { + let driver = FakeHotkeyDriver() + let hotkeys = RegisteredHotkeys(driver: driver) + let shortcut = HotkeyShortcut(keyCode: 49, modifierFlags: .option) + var edges: [Bool] = [] + hotkeys.onEvent = { actual, down in + XCTAssertEqual(actual, shortcut) + edges.append(down) + } + hotkeys.update(shortcuts: [shortcut]) + let id = try XCTUnwrap(driver.registered.keys.first) + driver.onEvent?(id, true) + driver.onEvent?(id, true) + driver.onEvent?(id, false) + driver.onEvent?(id, false) + XCTAssertEqual(edges, [true, false]) + } + + @MainActor + func testEventTapPassesRegisteredChordAndReleaseAfterModifierReleased() { + let driver = FakeHotkeyDriver() + let hotkeys = RegisteredHotkeys(driver: driver) + hotkeys.update(shortcuts: [HotkeyShortcut(keyCode: 49, modifierFlags: .option)]) + XCTAssertTrue(hotkeys.shouldBypassEventTap(keyCode: 49, modifiers: .option, down: true)) + XCTAssertTrue(hotkeys.shouldBypassEventTap(keyCode: 49, modifiers: [], down: false)) + XCTAssertFalse(hotkeys.shouldBypassEventTap(keyCode: 49, modifiers: [], down: true)) + XCTAssertFalse(hotkeys.shouldBypassEventTap(keyCode: 49, modifiers: [], down: false)) + } + + @MainActor + func testFailedRegistrationFallsBackAndReportsError() { + let driver = FakeHotkeyDriver() + driver.status = OSStatus(eventHotKeyExistsErr) + let hotkeys = RegisteredHotkeys(driver: driver) + var failures: [OSStatus] = [] + hotkeys.onFailure = { _, status in failures.append(status) } + hotkeys.update(shortcuts: [HotkeyShortcut(keyCode: 49, modifierFlags: .option)]) + XCTAssertEqual(failures, [OSStatus(eventHotKeyExistsErr)]) + XCTAssertFalse(hotkeys.shouldBypassEventTap(keyCode: 49, modifiers: .option, down: true)) + driver.status = noErr + hotkeys.update(shortcuts: [HotkeyShortcut(keyCode: 49, modifierFlags: .option)]) + XCTAssertTrue(hotkeys.shouldBypassEventTap(keyCode: 49, modifiers: .option, down: true)) + } + + @MainActor + func testRefreshKeepsExistingRegistrationAndDeduplicatesIdenticalChords() { + let driver = FakeHotkeyDriver() + let hotkeys = RegisteredHotkeys(driver: driver) + let shortcut = HotkeyShortcut(keyCode: 49, modifierFlags: .option) + hotkeys.update(shortcuts: [shortcut, shortcut]) + let original = driver.registered + hotkeys.update(shortcuts: [shortcut]) + XCTAssertEqual(driver.registered, original) + XCTAssertEqual(driver.registered.count, 1) + XCTAssertTrue(driver.removed.isEmpty) + } + + @MainActor + func testShortcutCaptureUnregistersAndFinishesHeldShortcutBeforeRemoval() throws { + let driver = FakeHotkeyDriver() + let hotkeys = RegisteredHotkeys(driver: driver) + let shortcut = HotkeyShortcut(keyCode: 49, modifierFlags: .option) + var edges: [Bool] = [] + hotkeys.onEvent = { _, down in edges.append(down) } + hotkeys.update(shortcuts: [shortcut]) + let id = try XCTUnwrap(driver.registered.keys.first) + driver.onEvent?(id, true) + hotkeys.update(shortcuts: []) + XCTAssertEqual(edges, [true, false]) + XCTAssertTrue(driver.registered.isEmpty) + driver.onEvent?(id, true) // queued stale event after removal + XCTAssertEqual(edges, [true, false]) + hotkeys.update(shortcuts: [shortcut]) + XCTAssertNotEqual(driver.registered.keys.first, id) + } + + @MainActor + func testRefreshDuringPressPreservesReleaseOwnership() throws { + let driver = FakeHotkeyDriver() + let hotkeys = RegisteredHotkeys(driver: driver) + let shortcut = HotkeyShortcut(keyCode: 49, modifierFlags: .option) + var edges: [Bool] = [] + hotkeys.onEvent = { _, down in edges.append(down) } + hotkeys.update(shortcuts: [shortcut]) + let id = try XCTUnwrap(driver.registered.keys.first) + driver.onEvent?(id, true) + hotkeys.update(shortcuts: [shortcut]) // event-tap recovery must not rebuild Carbon + XCTAssertTrue(hotkeys.hasPressedShortcut) + XCTAssertEqual(edges, [true]) + driver.onEvent?(id, false) + XCTAssertFalse(hotkeys.hasPressedShortcut) + XCTAssertEqual(edges, [true, false]) + } + + @MainActor + func testSessionTransitionsRecoverLostReleaseWithoutReplacingRegistration() throws { + let transitions = [ + NSWorkspace.willSleepNotification, + NSWorkspace.didWakeNotification, + NSWorkspace.sessionDidResignActiveNotification, + NSWorkspace.sessionDidBecomeActiveNotification, + ] + for transition in transitions { + let center = NotificationCenter() + let driver = FakeHotkeyDriver() + let hotkeys = RegisteredHotkeys(driver: driver, notificationCenter: center) + hotkeys.update(shortcuts: [HotkeyShortcut(keyCode: 49, modifierFlags: .option)]) + let id = try XCTUnwrap(driver.registered.keys.first) + var edges: [Bool] = [] + var interruptions: [Bool] = [] + hotkeys.onEvent = { _, down in + edges.append(down) + if !down { + interruptions.append(hotkeys.isInterruptingPress) + } + } + defer { hotkeys.onEvent = nil } + XCTAssertTrue(hotkeys.shouldBypassEventTap(keyCode: 49, modifiers: .option, down: true)) + driver.onEvent?(id, true) + // The physical release is lost during sleep/session switching. + center.post(name: transition, object: nil) + XCTAssertFalse(hotkeys.hasPressedShortcut, transition.rawValue) + XCTAssertFalse(hotkeys.shouldBypassEventTap(keyCode: 49, modifiers: [], down: false)) + driver.onEvent?(id, false) // late release after interruption + center.post(name: transition, object: nil) // repeated recovery is harmless + XCTAssertEqual(Array(driver.registered.keys), [id]) + driver.onEvent?(id, true) + driver.onEvent?(id, false) + XCTAssertEqual(edges, [true, false, true, false], transition.rawValue) + XCTAssertEqual(interruptions, [true, false], transition.rawValue) + } + } + + @MainActor + func testReleaseAllBalancesEveryPressAndRejectsLateRelease() { + let driver = FakeHotkeyDriver() + let hotkeys = RegisteredHotkeys(driver: driver) + hotkeys.update(shortcuts: [ + HotkeyShortcut(keyCode: 49, modifierFlags: .option), + HotkeyShortcut(keyCode: 15, modifierFlags: [.control, .option]), + ]) + var edges: [Bool] = [] + hotkeys.onEvent = { _, down in edges.append(down) } + for id in driver.registered.keys { + driver.onEvent?(id, true) + } + hotkeys.releaseAll() + for id in driver.registered.keys { + driver.onEvent?(id, false) + } + XCTAssertEqual(edges, [true, true, false, false]) + } +} + +#if HOTKEY_STANDALONE_TESTS +@main +struct RegisteredHotkeysTestRunner { + static func main() { + let suite = RegisteredHotkeysTests.defaultTestSuite + suite.run() + exit(suite.testRun?.hasSucceeded == true ? 0 : 1) + } +} +#endif diff --git a/Tests/RegisteredHotkeysLiveProbe.swift b/Tests/RegisteredHotkeysLiveProbe.swift new file mode 100644 index 000000000..df9f591f1 --- /dev/null +++ b/Tests/RegisteredHotkeysLiveProbe.swift @@ -0,0 +1,36 @@ +import AppKit +import Carbon + +@main +struct HotkeyDeliveryProbe { + @MainActor static func main() { + let app = NSApplication.shared + app.setActivationPolicy(.accessory) + let hotkeys = RegisteredHotkeys(driver: CarbonHotkeyDriver()) + var edges = 0 + hotkeys.onFailure = { _, status in + print("REGISTRATION_FAILED status=\(status)") + fflush(stdout) + } + hotkeys.onEvent = { _, down in + edges += 1 + print("OPTION_SPACE \(down ? "DOWN" : "UP") secureInput=\(IsSecureEventInputEnabled())") + fflush(stdout) + } + hotkeys.update(shortcuts: [HotkeyShortcut(keyCode: 49, modifierFlags: .option)]) + print("READY: Option+Space only; secureInput=\(IsSecureEventInputEnabled()); expires in 90 seconds") + fflush(stdout) + Timer.scheduledTimer(withTimeInterval: 90, repeats: false) { _ in + MainActor.assumeIsolated { + hotkeys.update(shortcuts: []) + print("DONE edges=\(edges); registration removed") + fflush(stdout) + app.stop(nil) + if let event = NSEvent.otherEvent(with: .applicationDefined, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: 0, context: nil, subtype: 0, data1: 0, data2: 0) { + app.postEvent(event, atStart: true) + } + } + } + app.run() + } +} diff --git a/Tests/run_registered_hotkey_tests.sh b/Tests/run_registered_hotkey_tests.sh new file mode 100755 index 000000000..e0775940f --- /dev/null +++ b/Tests/run_registered_hotkey_tests.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu +repo_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +developer_dir=$(xcode-select -p) +platform_dir="$developer_dir/Platforms/MacOSX.platform/Developer" +test_dir=$(mktemp -d "${TMPDIR:-/tmp}/fluidvoice-hotkeys.XXXXXX") +trap 'rm -rf "$test_dir"' EXIT +xcrun swiftc \ + -I "$platform_dir/usr/lib" -L "$platform_dir/usr/lib" \ + -F "$platform_dir/Library/Frameworks" \ + -Xlinker -rpath -Xlinker "$platform_dir/Library/Frameworks" \ + -D HOTKEY_STANDALONE_TESTS \ + "$repo_dir/Sources/Fluid/Models/HotkeyShortcut.swift" \ + "$repo_dir/Sources/Fluid/Services/RegisteredHotkeys.swift" \ + "$repo_dir/Tests/FluidDictationIntegrationTests/RegisteredHotkeysTests.swift" \ + -o "$test_dir/tests" +DYLD_FRAMEWORK_PATH="$platform_dir/Library/PrivateFrameworks" \ +DYLD_LIBRARY_PATH="$platform_dir/usr/lib" "$test_dir/tests" +if [ "${1:-}" = "--live" ]; then + xcrun swiftc \ + "$repo_dir/Sources/Fluid/Models/HotkeyShortcut.swift" \ + "$repo_dir/Sources/Fluid/Services/RegisteredHotkeys.swift" \ + "$repo_dir/Tests/RegisteredHotkeysLiveProbe.swift" \ + -o "$test_dir/live-probe" + "$test_dir/live-probe" +fi diff --git a/docs/REGISTERED_HOTKEYS.md b/docs/REGISTERED_HOTKEYS.md new file mode 100644 index 000000000..3cbe34489 --- /dev/null +++ b/docs/REGISTERED_HOTKEYS.md @@ -0,0 +1,39 @@ +# Registered recording shortcuts + +Recording chords such as Option + Space use Carbon's `RegisterEventHotKey` rather than depending exclusively on keyboard events from a session event tap. Secure Event Input can suppress ordinary keyboard events even when `CGEvent.tapIsEnabled` reports true. + +## Routing + +- `RegisteredHotkeyChord` selects keyboard shortcuts with ordinary modifiers. Plain keys, modifier-only combinations, Fn combinations, and mouse buttons remain on the existing event-tap path. +- `CarbonHotkeyDriver` owns exclusive native registrations and the pressed/released event handler. Exclusive registration makes competing owners report a failure instead of silently accepting an undeliverable registration. Its lifetime releases both. Registration failures are logged with the OSStatus and retain the event-tap fallback. +- `RegisteredHotkeys` deduplicates identical registrations and repeated pressed events, balances releases, and remembers which physical key events should pass through the event tap to Carbon. Release ownership does not depend on the modifier still being held. +- `GlobalHotkeyManager` sends registered events directly into its existing keyboard routing. The constructed CGEvent is never posted into the system event stream. Toggle, hold, automatic, and mode-selection logic stay shared. +- Editing shortcuts temporarily unregisters chords so the recorder can receive them. Configuration changes complete active registered presses before changing their meaning. Shortcut capture and manual reinitialization mark their releases as interruptions so Automatic mode stops even a short press instead of treating it as a tap to continue recording. Event-tap recovery preserves a held registered shortcut. +- Sleep/wake and session deactivate/reactivate notifications finish held registered presses as interruptions and clear event-tap release bookkeeping. Registrations remain intact, so the next physical press works even if the old key-up was lost. +- Cancel and paste-last remain context-sensitive event-tap actions. They are not newly claimed as unconditional global registrations. + +The manager logs Secure Input transitions separately from event-tap health. It does not disable Secure Input in another application or claim that restarting an event tap overrides that protection. Failed/unsupported registrations remain susceptible to Secure Input. + +## Automated validation + +Run the focused tests without downloading the speech-model dependencies: + +```sh +Tests/run_registered_hotkey_tests.sh +``` + +The same XCTest cases are included in the Xcode integration-test target. They cover eligibility, independent Carbon delivery, repeat suppression, modifier-first releases, registration failures and retry, configuration changes, stale events, interruption cleanup, and actual native registration cleanup. Native registration is skipped if the test session cannot register the uncommon test chord. + +## Physical delivery check + +```sh +Tests/run_registered_hotkey_tests.sh --live +``` + +The probe uses the production registration backend and listens only for Option + Space for 90 seconds. It does not record audio, start FluidVoice dictation, or observe ordinary typing. Each received edge reports whether Secure Input is active. It unregisters before exiting. Quit other apps using the same shortcut for an isolated test; use a normal editable field, not a password field. + +Validate both with and without Secure Input active. Press twice, hold, and release Option before Space. Expect one down and one up per press, with no repeats. An empty result is inconclusive unless the tester actually pressed the keys. Registration success alone does not establish key delivery. + +## App acceptance checks + +Before release, test the complete app in toggle, hold, and automatic modes, including quick release while recording is still starting. Check prompt/command/rewrite shortcuts, shortcut editing, external-keyboard modifiers, competing registrations, synthesized typing, sleep/wake, session lock, and VNC connect/disconnect. Repeat on Intel and Apple Silicon, including supported macOS versions where Carbon rejects some modifier combinations. Verify that each shortcut produces exactly one action and that hold recordings stop on release.