From efe52d6b022831727e94c7d53fe568a2a9b20182 Mon Sep 17 00:00:00 2001 From: andyhtran <76441965+andyhtran@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:15:16 -0400 Subject: [PATCH] Route key-chord shortcuts through Carbon hot keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app previously sat in the system keystroke-delivery path: one filter tap on keyDown/keyUp/flagsChanged, serviced on the main run loop, saw every keystroke in the session and could stall input everywhere if the main thread was busy. Key chords now register with RegisterEventHotKey instead. The window server matches them and delivers them as ordinary app events, so no keystroke waits on this process, and no Accessibility grant is needed — the default shortcuts work on first launch. An event tap remains only for bare-modifier shortcuts (Fn/Globe push-to-talk), which Carbon cannot express. It is created only when such a shortcut is bound, its mask is exactly .flagsChanged, and it runs on a dedicated thread. A second listen-only tap watches for ordinary keys, and only while the modifier is held, to tell Fn-as-a-shortcut from Fn+arrow. Because a Carbon registration swallows its chord unconditionally, gating a shortcut now means registering and unregistering it rather than filtering at fire time, so settings and recording-state changes re-derive the registration set. Breaking change: Fn+key chords can no longer be bound. The hot-key API has no Fn modifier, and registering the chord without it would swallow the bare key system-wide. Stored Fn chords are flagged in the UI for re-recording; arrow and F-keys saved by older builds are migrated, since those report the Fn flag intrinsically and were never real Fn chords. Co-Authored-By: Claude Opus 5 --- Sources/MiniWhisper/AppDelegate.swift | 19 +- Sources/MiniWhisper/AppState.swift | 8 + .../MiniWhisper/Models/CustomShortcut.swift | 65 ++- .../Services/Hotkeys/CarbonHotKeyCenter.swift | 233 ++++++++++ .../Hotkeys/CustomShortcutMonitor.swift | 399 +++++++++++------ .../Services/Hotkeys/EventTapManager.swift | 229 ---------- .../Services/Hotkeys/EventTapRunLoop.swift | 92 ++++ .../Services/Hotkeys/FnStateMachine.swift | 67 ++- .../Services/Hotkeys/HotkeyManager.swift | 24 +- .../Services/Hotkeys/KeyDownObserver.swift | 194 +++++++++ .../Services/Hotkeys/ModifierTapMonitor.swift | 297 +++++++++++++ .../Services/Hotkeys/ShortcutBackend.swift | 242 +++++++++++ .../Services/Hotkeys/ShortcutMatcher.swift | 44 +- .../Services/Hotkeys/TapHealthPolicy.swift | 33 ++ .../Services/PermissionsManager.swift | 7 +- Sources/MiniWhisper/Views/MenuBarView.swift | 8 + .../Views/SettingsWindowView.swift | 13 + .../Views/ShortcutRecorderView.swift | 405 ++++++++++++------ .../CustomShortcutTests.swift | 31 +- .../FnStateMachineTests.swift | 110 +++++ .../HotKeyBindingPlanTests.swift | 168 ++++++++ .../HotKeyPressTrackerTests.swift | 161 +++++++ .../ShortcutBackendTests.swift | 223 ++++++++++ .../TapHealthPolicyTests.swift | 59 +++ 24 files changed, 2539 insertions(+), 592 deletions(-) create mode 100644 Sources/MiniWhisper/Services/Hotkeys/CarbonHotKeyCenter.swift delete mode 100644 Sources/MiniWhisper/Services/Hotkeys/EventTapManager.swift create mode 100644 Sources/MiniWhisper/Services/Hotkeys/EventTapRunLoop.swift create mode 100644 Sources/MiniWhisper/Services/Hotkeys/KeyDownObserver.swift create mode 100644 Sources/MiniWhisper/Services/Hotkeys/ModifierTapMonitor.swift create mode 100644 Sources/MiniWhisper/Services/Hotkeys/ShortcutBackend.swift create mode 100644 Sources/MiniWhisper/Services/Hotkeys/TapHealthPolicy.swift create mode 100644 Tests/MiniWhisperTests/HotKeyBindingPlanTests.swift create mode 100644 Tests/MiniWhisperTests/HotKeyPressTrackerTests.swift create mode 100644 Tests/MiniWhisperTests/ShortcutBackendTests.swift create mode 100644 Tests/MiniWhisperTests/TapHealthPolicyTests.swift diff --git a/Sources/MiniWhisper/AppDelegate.swift b/Sources/MiniWhisper/AppDelegate.swift index 0a6a851..5d324ba 100644 --- a/Sources/MiniWhisper/AppDelegate.swift +++ b/Sources/MiniWhisper/AppDelegate.swift @@ -230,17 +230,26 @@ class AppDelegate: NSObject, NSApplicationDelegate { manager?.recordingDidEnd() } - // When permissions are all granted, (re)start the event tap + // Restart once permissions land so anything that needed Accessibility + // gets another attempt. permissions.onAllGranted = { [weak manager] in log.info("All permissions granted — restarting hotkey manager") manager?.stop() manager?.start() } - if permissions.accessibilityGranted { - log.info("Starting hotkey manager (accessibility already granted)") - manager.start() - } else { + // Started unconditionally: key chords register as system hot keys, which + // need no Accessibility grant, so they work on first launch. Only a + // bare-modifier shortcut needs the event tap, whose creation keeps being + // retried at watchdog cadence until the grant arrives. + manager.start() + + // Asked for unconditionally, even though a Carbon-only shortcut set does + // not need it. Gating the prompt on the current bindings would make the + // permission appear and disappear as the user edits shortcuts; one + // stable requirement is the simpler story. Do not "fix" this to match + // the comment above. + if !permissions.accessibilityGranted { permissions.openAccessibilitySettings() permissions.startPolling() } diff --git a/Sources/MiniWhisper/AppState.swift b/Sources/MiniWhisper/AppState.swift index 5a56bbb..601cd86 100644 --- a/Sources/MiniWhisper/AppState.swift +++ b/Sources/MiniWhisper/AppState.swift @@ -252,4 +252,12 @@ final class AppState: Sendable { CustomShortcutMonitor.shared.reloadShortcuts() } + /// Which shortcuts are registered depends on which features are switched + /// on, so a settings change has to re-derive them. A shortcut for a + /// disabled feature must end up unregistered — that is what lets the chord + /// reach the focused app instead of being swallowed. + func refreshShortcutRegistrations() { + CustomShortcutMonitor.shared.refresh() + } + } diff --git a/Sources/MiniWhisper/Models/CustomShortcut.swift b/Sources/MiniWhisper/Models/CustomShortcut.swift index 4814311..c5de5a8 100644 --- a/Sources/MiniWhisper/Models/CustomShortcut.swift +++ b/Sources/MiniWhisper/Models/CustomShortcut.swift @@ -8,6 +8,45 @@ enum FnKeyCode { static func isFnKey(_ keyCode: UInt16) -> Bool { keyCodes.contains(keyCode) } } +/// Keys that carry `kCGEventFlagMaskSecondaryFn` on their own. +/// +/// macOS sets that flag for the whole function-key group whether or not Fn is +/// physically held — pressing ↑ reports it exactly like Fn+↑ does. So the flag +/// cannot be read as "the user held Fn" for these key codes, and anything that +/// does read it that way must exclude them or it rejects every arrow, F-key and +/// navigation key as an unbindable Fn chord. +enum FunctionKeyGroup { + static let keyCodes: Set = { + let codes: [Int] = [ + kVK_LeftArrow, kVK_RightArrow, kVK_DownArrow, kVK_UpArrow, + kVK_F1, kVK_F2, kVK_F3, kVK_F4, kVK_F5, kVK_F6, + kVK_F7, kVK_F8, kVK_F9, kVK_F10, kVK_F11, kVK_F12, + kVK_Home, kVK_End, kVK_PageUp, kVK_PageDown, + kVK_ForwardDelete, + ] + return Set(codes.map(UInt16.init)) + }() + + /// True when the Fn flag on an event for this key says nothing about what + /// the user was holding. + static func setsSecondaryFnIntrinsically(_ keyCode: UInt16) -> Bool { + keyCodes.contains(keyCode) + } + + /// Whether an event's Fn flag can be read as "the user was holding Fn". + /// + /// `fnKeyIsDown` is the tracked state of the physical key, which the flag + /// alone cannot substitute for. Function-group keys answer false whatever is + /// held: Fn+↑ and a plain ↑ are indistinguishable in the flag, and the + /// useful reading of the pair is the plain key — an Fn chord could not be + /// bound anyway. + static func indicatesHeldFn( + keyCode: UInt16, secondaryFnFlagSet: Bool, fnKeyIsDown: Bool + ) -> Bool { + (secondaryFnFlagSet || fnKeyIsDown) && !setsSecondaryFnIntrinsically(keyCode) + } +} + extension CGEventFlags { var modifierFlags: NSEvent.ModifierFlags { var m = NSEvent.ModifierFlags() @@ -25,6 +64,12 @@ struct CustomShortcut: Codable, Equatable, Hashable { let option: Bool let control: Bool let shift: Bool + /// "Fn was physically held down as a modifier", and nothing else. + /// + /// Narrower than it looks, and kept only because persisted shortcuts encode + /// it: it is meaningless on function-group key codes, which report Fn + /// intrinsically, and it is never set when Fn *is* the bound key (see + /// `isFnOnly`). Read it through `usesFnAsModifier` rather than directly. let fn: Bool init( @@ -43,22 +88,13 @@ struct CustomShortcut: Codable, Equatable, Hashable { self.fn = fn } - func matches(keyCode: UInt16, modifiers: NSEvent.ModifierFlags, fnPressed: Bool) -> Bool { - guard self.keyCode == keyCode else { return false } - return self.command == modifiers.contains(.command) - && self.option == modifiers.contains(.option) - && self.control == modifiers.contains(.control) - && self.shift == modifiers.contains(.shift) - && self.fn == fnPressed - } - var compactDisplayString: String { var str = "" if control { str += "Ctrl+" } if option { str += "Option+" } if shift { str += "Shift+" } if command { str += "Cmd+" } - if fn { str += "Fn+" } + if usesFnAsModifier { str += "Fn+" } str += keyCodeDisplayName return str } @@ -71,6 +107,15 @@ struct CustomShortcut: Codable, Equatable, Hashable { FnKeyCode.isFnKey(keyCode) && !command && !option && !control && !shift } + /// Whether the stored `fn` flag actually records a held Fn key. + /// + /// Older builds set `fn` for every function-group key because the system + /// reports the flag for them unconditionally, so those stored flags carry no + /// information and are ignored here — the binding is a plain chord. + var usesFnAsModifier: Bool { + fn && !FunctionKeyGroup.setsSecondaryFnIntrinsically(keyCode) + } + static func keyCodeToDisplayName(_ keyCode: UInt16) -> String { switch Int(keyCode) { case kVK_ANSI_A: return "A" diff --git a/Sources/MiniWhisper/Services/Hotkeys/CarbonHotKeyCenter.swift b/Sources/MiniWhisper/Services/Hotkeys/CarbonHotKeyCenter.swift new file mode 100644 index 0000000..4b1c7f3 --- /dev/null +++ b/Sources/MiniWhisper/Services/Hotkeys/CarbonHotKeyCenter.swift @@ -0,0 +1,233 @@ +import Carbon.HIToolbox +import Foundation +import os.log + +private let log = Logger(subsystem: Logger.subsystem, category: "CarbonHotKey") + +/// Which edge of a hot key fired. Both edges are delivered so a shortcut can +/// drive hold-to-talk (start on press, stop on release), not just toggles. +enum HotKeyPhase: Sendable { + case pressed + case released +} + +/// Registers system-wide hot keys through Carbon's `RegisterEventHotKey`. +/// +/// Carbon hot keys are matched by the window server and delivered to this +/// process as ordinary application events, so this app is never part of the +/// keystroke-delivery path: no keystroke anywhere waits on this code, and a +/// hang here cannot slow input for other apps. The registration also swallows +/// the chord for us, so the focused app never sees a shortcut we handle. +/// +/// The trade-off is that a registration is unconditional — Carbon has no notion +/// of "handle this only when a feature is on". Anything that gates a shortcut +/// must therefore add or remove the registration itself; see `sync(to:)`. +@MainActor +final class CarbonHotKeyCenter { + typealias Callback = @MainActor (CustomShortcutName, HotKeyPhase) -> Void + + /// Tags this process's hot keys inside the shared Carbon dispatcher, which + /// carries every registration in the session. + private static let signature: OSType = 0x4D57_484B // 'MWHK' + + private var callback: Callback? + private var eventHandler: EventHandlerRef? + private var handlerGate = HotKeyHandlerGate() + private var registrations: [UInt32: (binding: HotKeyBinding, ref: EventHotKeyRef)] = [:] + /// Registrations the OS refused to release. Kept so they can be retried: + /// see `unregister(_:)`. + private var failedUnregistrations: [EventHotKeyRef] = [] + private var nextID: UInt32 = 1 + + func setCallback(_ callback: @escaping Callback) { + self.callback = callback + } + + /// Makes the live registrations match `desired`, adding and removing only + /// the difference so unrelated shortcuts keep working across a refresh. + /// + /// `desired` is ordered, and registration follows that order: the OS awards + /// a contested chord to whoever asks first, so the caller's precedence is + /// only honoured if this does not reorder. + /// + /// Returns the shortcuts that ended up with at least one live registration, + /// which is not always all of them — the system keeps chords it already owns. + @discardableResult + func sync(to desired: [HotKeyBinding]) -> Set { + let desiredSet = Set(desired) + for (id, entry) in registrations where !desiredSet.contains(entry.binding) { + unregister(entry.ref) + registrations[id] = nil + } + retryFailedUnregistrations() + + let current = Set(registrations.values.map(\.binding)) + for binding in desired where !current.contains(binding) { + register(binding) + } + + return Set(registrations.values.map(\.binding.name)) + } + + func unregisterAll() { + for entry in registrations.values { + unregister(entry.ref) + } + registrations.removeAll() + retryFailedUnregistrations() + } + + private func register(_ binding: HotKeyBinding) { + // Fail closed. A registration swallows its chord system-wide whether or + // not anything is listening, so registering without a live dispatcher + // handler would make the chord dead in every app with no way to notice. + guard handlerGate.ensureInstalled(installEventHandler) else { + log.error( + "Refusing to register \(binding.name.rawValue): no hot-key handler is installed" + ) + return + } + + let id = nextID + nextID += 1 + + var ref: EventHotKeyRef? + let status = RegisterEventHotKey( + UInt32(binding.keyCode), + binding.carbonModifiers, + EventHotKeyID(signature: Self.signature, id: id), + GetEventDispatcherTarget(), + 0, + &ref + ) + + guard status == noErr, let ref else { + // Expected for combinations the system already owns (Force Quit and + // friends); those simply stay with the system. + log.info( + "Hot key not registered: \(binding.name.rawValue) keyCode=\(binding.keyCode) modifiers=\(binding.carbonModifiers) status=\(status)" + ) + return + } + + registrations[id] = (binding, ref) + } + + /// Releases a hot key, keeping the reference for a later attempt if the OS + /// refuses. + /// + /// Dropping a reference that failed to unregister strands the registration + /// for the lifetime of the process: the chord stays swallowed system-wide + /// and nothing is left that could ever release it. + private func unregister(_ ref: EventHotKeyRef) { + let status = UnregisterEventHotKey(ref) + guard status != noErr else { return } + log.error("UnregisterEventHotKey failed (status \(status)); retaining for retry") + failedUnregistrations.append(ref) + } + + private func retryFailedUnregistrations() { + guard !failedUnregistrations.isEmpty else { return } + let remaining = failedUnregistrations.filter { UnregisterEventHotKey($0) != noErr } + if remaining.count != failedUnregistrations.count { + log.info("Released \(self.failedUnregistrations.count - remaining.count) retried hot key(s)") + } + failedUnregistrations = remaining + } + + private func installEventHandler() -> Bool { + guard let dispatcher = GetEventDispatcherTarget() else { + log.error("No Carbon event dispatcher available") + return false + } + + var eventTypes = [ + EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)), + EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased)), + ] + + var handler: EventHandlerRef? + let status = InstallEventHandler( + dispatcher, + carbonHotKeyEventHandler, + eventTypes.count, + &eventTypes, + Unmanaged.passUnretained(self).toOpaque(), + &handler + ) + + guard status == noErr, let handler else { + log.error("InstallEventHandler failed: \(status)") + return false + } + eventHandler = handler + return true + } + + fileprivate func handle(id: UInt32, signature: OSType, phase: HotKeyPhase) -> OSStatus { + guard signature == Self.signature, let entry = registrations[id] else { + return OSStatus(eventNotHandledErr) + } + callback?(entry.binding.name, phase) + return noErr + } +} + +/// Enforces "no chord may be registered without a live handler", and installs +/// that handler at most once. +/// +/// Split out from the registration path because the failure it guards is silent +/// and permanent: a hot key whose events nothing receives still swallows its +/// chord in every app, so the only safe response to a failed install is to +/// register nothing at all and try installing again later. +struct HotKeyHandlerGate { + private(set) var isInstalled = false + + /// Runs `install` only while no handler is live, and reports whether one is. + mutating func ensureInstalled(_ install: () -> Bool) -> Bool { + if isInstalled { return true } + isInstalled = install() + return isInstalled + } +} + +/// Carbon dispatches these from the main event loop, so main-actor isolation +/// holds even though the C signature cannot express it. +/// +/// Everything needed is read out of the `EventRef` here, before the hop: the +/// event pointer is only valid for the duration of this call and carries no +/// isolation guarantees of its own. +private func carbonHotKeyEventHandler( + _ nextHandler: EventHandlerCallRef?, + _ event: EventRef?, + _ userData: UnsafeMutableRawPointer? +) -> OSStatus { + guard let event, let userData else { return OSStatus(eventNotHandledErr) } + + let phase: HotKeyPhase + switch Int(GetEventKind(event)) { + case kEventHotKeyPressed: phase = .pressed + case kEventHotKeyReleased: phase = .released + default: return OSStatus(eventNotHandledErr) + } + + var hotKeyID = EventHotKeyID() + let status = GetEventParameter( + event, + UInt32(kEventParamDirectObject), + UInt32(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &hotKeyID + ) + guard status == noErr else { return status } + + let id = hotKeyID.id + let signature = hotKeyID.signature + let center = Unmanaged.fromOpaque(userData).takeUnretainedValue() + + return MainActor.assumeIsolated { center.handle(id: id, signature: signature, phase: phase) } +} diff --git a/Sources/MiniWhisper/Services/Hotkeys/CustomShortcutMonitor.swift b/Sources/MiniWhisper/Services/Hotkeys/CustomShortcutMonitor.swift index 676762c..298272b 100644 --- a/Sources/MiniWhisper/Services/Hotkeys/CustomShortcutMonitor.swift +++ b/Sources/MiniWhisper/Services/Hotkeys/CustomShortcutMonitor.swift @@ -1,57 +1,76 @@ -import Foundation -import CoreGraphics import AppKit +import CoreGraphics +import Foundation import os.log private let log = Logger(subsystem: Logger.subsystem, category: "ShortcutMonitor") +/// Routes each configured shortcut to the backend that can serve it. +/// +/// Key chords go to Carbon, which keeps this process out of the keystroke +/// delivery path entirely. Only bare-modifier shortcuts (Fn/Globe push-to-talk) +/// need an event tap, and that tap sees nothing but `.flagsChanged`. final class CustomShortcutMonitor: @unchecked Sendable { typealias ShortcutHandler = @Sendable @MainActor () -> Void typealias ShortcutEnabledCheck = @Sendable () -> Bool @MainActor static let shared = CustomShortcutMonitor() - private let eventTapManager: EventTapManager private let shortcutMatcher: ShortcutMatcher private let handlerRegistry: ShortcutHandlerRegistry private let fnStateMachine: FnStateMachine + private let modifierTap: ModifierTapMonitor + private let keyDownObserver: KeyDownObserver + private let carbonCenter: CarbonHotKeyCenter - private var activeShortcuts: Set = [] - private let activeShortcutsLock = NSLock() + private var pressTracker = HotKeyPressTracker() + private let pressLock = NSLock() + private var running = false + /// Last reported collisions, so a refresh that changes nothing stays quiet — + /// refresh runs on every recording edge. + private var lastShadowed: Set = [] @MainActor private init() { - self.shortcutMatcher = ShortcutMatcher() - self.handlerRegistry = ShortcutHandlerRegistry() - self.fnStateMachine = FnStateMachine() - self.eventTapManager = EventTapManager() - - installEventTapCallback() - } - - private func installEventTapCallback() { - eventTapManager.setEventCallback { [weak self] type, event in - self?.processEvent(type: type, event: event) ?? false + shortcutMatcher = ShortcutMatcher() + handlerRegistry = ShortcutHandlerRegistry() + fnStateMachine = FnStateMachine() + modifierTap = ModifierTapMonitor() + keyDownObserver = KeyDownObserver() + carbonCenter = CarbonHotKeyCenter() + + carbonCenter.setCallback { [weak self] name, phase in + self?.handleCarbonHotKey(name: name, phase: phase) + } + modifierTap.setHandler { [weak self] event in + self?.handleModifierEvent(event) ?? false + } + keyDownObserver.setHandler { [weak self] in + self?.handleKeyPressedWhileFnDown() } } @MainActor func start() { - installEventTapCallback() - let shortcuts = shortcutMatcher.getAllShortcuts() - for (name, shortcut) in shortcuts { - log.info("Loaded shortcut: \(name.rawValue) = keyCode=\(shortcut.keyCode) opt=\(shortcut.option) cmd=\(shortcut.command) display=\(shortcut.compactDisplayString)") + running = true + for (name, shortcut) in shortcutMatcher.getAllShortcuts() { + log.info("Loaded shortcut: \(name.rawValue) = \(shortcut.compactDisplayString)") } - let hasToggleHandler = handlerRegistry.getKeyDownHandler(for: .toggleRecording) != nil - log.info("Toggle recording handler registered: \(hasToggleHandler)") - eventTapManager.start() + refresh() } @MainActor func stop() { - eventTapManager.stop() + running = false + carbonCenter.unregisterAll() + modifierTap.stop() + keyDownObserver.stop() + // Everything able to deliver a release has just gone away, so held + // presses are completed rather than dropped. Dropping them would leave + // a hold-style action running with nothing left to end it — and stop() + // is called mid-session, not only at shutdown (a permission grant + // restarts the manager). + releaseStrandedPresses(keeping: []) fnStateMachine.reset() - activeShortcutsLock.lock() - activeShortcuts.removeAll() - activeShortcutsLock.unlock() + lastShadowed = [] } @MainActor @@ -64,148 +83,276 @@ final class CustomShortcutMonitor: @unchecked Sendable { handlerRegistry.setKeyUpHandler(for: name, handler: handler) } + @MainActor func setEnabledCheck(for name: CustomShortcutName, check: @escaping ShortcutEnabledCheck) { handlerRegistry.setEnabledCheck(for: name, check: check) } + @MainActor func reloadShortcuts() { shortcutMatcher.reloadShortcuts() + refresh() } - // MARK: - Event Processing + /// Re-derives every registration from the current shortcuts and their + /// enabled checks. + /// + /// Must be called whenever either can change. A Carbon registration + /// swallows its chord unconditionally, so a shortcut whose feature is + /// switched off has to be *unregistered* — leaving it registered and + /// ignoring it at fire time would silently eat the chord instead of letting + /// it reach the focused app. + @MainActor + func refresh() { + guard running else { return } - private func processEvent(type: CGEventType, event: CGEvent) -> Bool { - let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode)) + var requests: [HotKeyBindingPlan.Request] = [] + var modifierOnlyName: CustomShortcutName? + let shortcuts = shortcutMatcher.getAllShortcuts() - if type == .flagsChanged { - return handleFlagsChanged(event: event, keyCode: keyCode) + for name in CustomShortcutName.allCases { + guard let shortcut = shortcuts[name] else { continue } + + switch ShortcutBackend.classify(shortcut) { + case .modifierOnly: + // Recorded before the enabled check: the tap must still suppress + // the modifier press while the shortcut is merely inactive, or + // the system action it shadows would fire intermittently. + guard let winner = modifierOnlyName else { + modifierOnlyName = name + continue + } + // Only one shortcut can own the bare modifier — there is no + // chord to tell two of them apart — and the loser fires never. + // Silent, and indistinguishable from a broken shortcut, unless + // it is said out loud. + log.warning( + "Shortcut \(name.rawValue) is also bound to the bare modifier, which \(winner.rawValue) already owns; it will never fire" + ) + + case .carbon(let keyCode, let carbonModifiers): + guard handlerRegistry.isEnabled(name: name) else { continue } + requests.append( + .init( + name: name, + keyCode: keyCode, + carbonModifiers: carbonModifiers, + ignoresModifiers: name.ignoresModifiers)) + + case .unsupported(let reason): + log.warning( + "Shortcut \(name.rawValue) (\(shortcut.compactDisplayString)) cannot be registered: \(String(describing: reason))" + ) + } } - let flags = event.flags - let modifiers = flags.modifierFlags - let fnPressed = fnStateMachine.isFnKeyDown || flags.contains(.maskSecondaryFn) + let plan = HotKeyBindingPlan.resolve(requests) + reportShadowed(plan.shadowed) + var liveNames = carbonCenter.sync(to: plan.bindings) - if type == .keyDown { - let result = handleKeyDown(keyCode: keyCode, modifiers: modifiers, fnPressed: fnPressed) - if result { - log.info("Matched keyDown: keyCode=\(keyCode) cmd=\(modifiers.contains(.command)) opt=\(modifiers.contains(.option))") - } - return result - } else if type == .keyUp { - return handleKeyUp(keyCode: keyCode) + // Which press the modifier tap is *actually* holding, as opposed to which + // shortcut is merely bound to the bare modifier. The two backends can + // hold the same name at different times, and only the real holder tells + // us whether the mechanism able to release it still exists. + let heldByModifierTap = fnStateMachine.activeFnOnlyShortcutName + + if ModifierTapPolicy.needsTap(hasModifierOnlyShortcut: modifierOnlyName != nil) { + modifierTap.start() + keyDownObserver.start() + if let heldByModifierTap { liveNames.insert(heldByModifierTap) } + } else { + modifierTap.stop() + keyDownObserver.stop() + // Re-recorded as a key chord mid-hold: a fresh hot-key registration + // under the same name makes it look live, but it cannot release a + // press the tap took. Drop it so the drain below completes it. + if let heldByModifierTap { liveNames.remove(heldByModifierTap) } + fnStateMachine.reset() } - return false + releaseStrandedPresses(keeping: liveNames) } - private func handleKeyDown(keyCode: UInt16, modifiers: NSEvent.ModifierFlags, fnPressed: Bool) -> Bool { - // If Fn is held and a non-Fn key goes down, mark as modifier combo - if fnPressed && !FnKeyCode.isFnKey(keyCode) { - if let cancelledName = fnStateMachine.markUsedAsModifier() { - if let handler = handlerRegistry.getKeyUpHandler(for: cancelledName) { - Task { @MainActor in handler() } - } - } + /// A registration that disappears under a held key takes its release with + /// it, so anything still marked pressed has to be completed by hand or a + /// hold-style action runs forever. + /// + /// Note this *performs* the key-up action, which for some shortcuts is + /// their whole point rather than a wind-down (cancel and edit-selection both + /// fire on release). Cancel no-ops when nothing is recording; edit-selection + /// only checks its own setting, so a stranded release can genuinely start an + /// edit flow. That stays acceptable only because reaching it means holding + /// the chord across a registration teardown — practically, `stop()` at the + /// moment Accessibility is granted. + @MainActor + private func releaseStrandedPresses(keeping live: Set) { + pressLock.lock() + let stranded = pressTracker.drainStranded(keeping: live) + pressLock.unlock() + + for name in stranded { + log.info("Registration for \(name.rawValue) went away mid-press; releasing it") + dispatchToMain(handlerRegistry.getKeyUpHandler(for: name)) } + } - // cancelRecording: modifier-insensitive when enabled. - // Consume keyDown so other apps don't see it; actual cancel fires on keyUp. - if let cancelShortcut = shortcutMatcher.getAllShortcuts()[.cancelRecording], - cancelShortcut.keyCode == keyCode, - handlerRegistry.isEnabled(name: .cancelRecording) { - return true + @MainActor + private func reportShadowed(_ shadowed: [HotKeyBinding]) { + let current = Set(shadowed) + guard current != lastShadowed else { return } + lastShadowed = current + + for binding in shadowed { + log.warning( + "Shortcut \(binding.name.rawValue) keyCode=\(binding.keyCode) modifiers=\(binding.carbonModifiers) is shadowed by a higher-precedence shortcut and was not registered" + ) } + } - guard let match = shortcutMatcher.findMatch(keyCode: keyCode, modifiers: modifiers, fnPressed: fnPressed) else { - return false - } + // MARK: - Carbon events - if !handlerRegistry.isEnabled(name: match.name) { return false } + @MainActor + private func handleCarbonHotKey(name: CustomShortcutName, phase: HotKeyPhase) { + switch phase { + case .pressed: + guard handlerRegistry.isEnabled(name: name) else { return } + + // Fn held while another shortcut fires means Fn is being used as a + // modifier, not as push-to-talk. Retire the in-flight Fn shortcut so + // it cannot leave a recording running with no release to end it. + // The key-down observer normally gets here first; this still matters + // when the observer has no tap (Accessibility not granted). + if fnStateMachine.isFnKeyDown, let cancelled = fnStateMachine.markUsedAsModifier() { + fireRelease(for: cancelled) + } - activeShortcutsLock.lock() - let alreadyActive = activeShortcuts.contains(match.name) - if !alreadyActive { activeShortcuts.insert(match.name) } - activeShortcutsLock.unlock() + pressLock.lock() + let isNewPress = pressTracker.press(name) + pressLock.unlock() + guard isNewPress else { return } - guard !alreadyActive else { return true } + dispatchToMain(handlerRegistry.getKeyDownHandler(for: name)) - if let handler = handlerRegistry.getKeyDownHandler(for: match.name) { - Task { @MainActor in handler() } + case .released: + // Deliberately not gated on the enabled check. The tracker already + // guarantees this only fires for a press that was accepted, and + // dropping the release because a setting flipped mid-hold would + // strand the press — leaving the shortcut unable to fire again. + fireRelease(for: name) } + } + /// Completes a press: clears the tracker and runs the key-up action, if the + /// press was ever accepted. + @discardableResult + private func fireRelease(for name: CustomShortcutName) -> Bool { + pressLock.lock() + let wasPressed = pressTracker.release(name) + pressLock.unlock() + guard wasPressed else { return false } + + dispatchToMain(handlerRegistry.getKeyUpHandler(for: name)) return true } - private func handleKeyUp(keyCode: UInt16) -> Bool { - // cancelRecording: stateless and modifier-insensitive on keyUp. - // Only check keyCode match, ignoring modifiers. - if let cancelShortcut = shortcutMatcher.getAllShortcuts()[.cancelRecording], - cancelShortcut.keyCode == keyCode { - if handlerRegistry.isEnabled(name: .cancelRecording), - let handler = handlerRegistry.getKeyUpHandler(for: .cancelRecording) { - activeShortcutsLock.lock() - activeShortcuts.remove(.cancelRecording) - activeShortcutsLock.unlock() + /// The single way a handler reaches the main actor. + /// + /// Both backends go through it so that handler order is decided in one + /// place. Running one backend's handlers synchronously while hopping for the + /// other's guaranteed inversions — a release overtaking its own press — and + /// this at least reduces it to the main actor's own ordering, which is FIFO + /// per enqueue in practice though not promised by the language. + private func dispatchToMain(_ handler: ShortcutHandler?) { + guard let handler else { return } + Task { @MainActor in handler() } + } - Task { @MainActor in handler() } - return true - } - return false + // MARK: - Modifier events + + /// Runs on the modifier tap's thread. Returns true to swallow the event. + private func handleModifierEvent(_ event: ModifierTapMonitor.FlagsEvent) -> Bool { + guard FnKeyCode.isFnKey(event.keyCode) else { return false } + + if event.fnPressed { + return handleModifierDown(event) } + return handleModifierUp(event) + } - guard let match = shortcutMatcher.findByKeyCode(keyCode) else { return false } + private func handleModifierDown(_ event: ModifierTapMonitor.FlagsEvent) -> Bool { + let isNewPress = fnStateMachine.processFnKeyDown( + captureTime: event.captureTime, hwTimestamp: event.hwTimestamp) + guard isNewPress else { return false } + + // A press left over from a release macOS never delivered still holds the + // tracker. Complete it first: otherwise this press is rejected as a + // repeat and the user's tap does nothing at all. + if let abandoned = fnStateMachine.clearActiveFnOnlyShortcut() { + log.info("Completing abandoned \(abandoned.rawValue) press before a recovered Fn press") + fireRelease(for: abandoned) + } - activeShortcutsLock.lock() - let wasActive = activeShortcuts.remove(match.name) != nil - activeShortcutsLock.unlock() + // From here until the release, watch for an ordinary key: that is what + // tells Fn-as-a-shortcut apart from Fn held to reach Fn+←. + keyDownObserver.setActive(true) - if !handlerRegistry.isEnabled(name: match.name) { return false } - guard wasActive else { return false } + // One lookup, reused: a match existing is exactly the condition that + // makes the press worth swallowing at all. + guard let match = shortcutMatcher.findFnOnlyShortcut() else { + fnStateMachine.recordPressSwallowed(false) + return false + } - if let handler = handlerRegistry.getKeyUpHandler(for: match.name) { - Task { @MainActor in handler() } + guard handlerRegistry.isEnabled(name: match.name) else { + // Still swallowed while the shortcut is bound but inactive, so the + // system action it shadows stays consistently suppressed rather than + // firing only some of the time. + fnStateMachine.recordPressSwallowed(true) + return true } + fnStateMachine.setActiveFnOnlyShortcut(match.name) + + pressLock.lock() + let accepted = pressTracker.press(match.name) + pressLock.unlock() + + if accepted { + dispatchToMain(handlerRegistry.getKeyDownHandler(for: match.name)) + } + fnStateMachine.recordPressSwallowed(true) return true } - private func handleFlagsChanged(event: CGEvent, keyCode: UInt16) -> Bool { - let captureTime = CFAbsoluteTimeGetCurrent() - let hwTimestamp = event.timestamp - let flags = event.flags - let fnPressed = flags.contains(.maskSecondaryFn) - let isFnKey = FnKeyCode.isFnKey(keyCode) - - guard isFnKey else { return false } + private func handleModifierUp(_ event: ModifierTapMonitor.FlagsEvent) -> Bool { + let result = fnStateMachine.processFnKeyUp( + captureTime: event.captureTime, hwTimestamp: event.hwTimestamp) + keyDownObserver.setActive(false) - if fnPressed { - let isNewPress = fnStateMachine.processFnKeyDown(captureTime: captureTime, hwTimestamp: hwTimestamp) - guard isNewPress else { return false } + // Read before any early exit: whether the release is hidden follows from + // whether the press was, not from what the release happens to trigger. + let swallowRelease = fnStateMachine.consumePressSwallowed() - if let match = shortcutMatcher.findFnOnlyShortcut(), - handlerRegistry.isEnabled(name: match.name) { - fnStateMachine.setActiveFnOnlyShortcut(match.name) - if let handler = handlerRegistry.getKeyDownHandler(for: match.name) { - Task { @MainActor in handler() } - } - return true - } - return shortcutMatcher.hasFnOnlyShortcut() - } else { - let result = fnStateMachine.processFnKeyUp(captureTime: captureTime, hwTimestamp: hwTimestamp) - switch result { - case .fnKeyUp: - if let name = fnStateMachine.clearActiveFnOnlyShortcut(), - let handler = handlerRegistry.getKeyUpHandler(for: name) { - Task { @MainActor in handler() } - return true - } - return false - case .usedAsModifier: - return false - default: - return false - } + if result == .fnKeyUp, let name = fnStateMachine.clearActiveFnOnlyShortcut() { + fireRelease(for: name) } + return swallowRelease + } + + /// An ordinary key went down while the bare modifier was held. + /// + /// Runs on the observer tap's thread. The modifier was a modifier, so the + /// action its press started must be retired now — otherwise Fn+← leaves a + /// recording running with no release left to end it, since the Fn release + /// will report itself as modifier use. + private func handleKeyPressedWhileFnDown() { + // One sighting settles the question for the whole hold — the state + // machine latches it — so stop observing immediately. That also bounds + // how long the observer can stay on if the release is ever lost: to the + // next keystroke, rather than to the rest of the session. + keyDownObserver.setActive(false) + + guard let retired = fnStateMachine.markUsedAsModifier() else { return } + fireRelease(for: retired) } } diff --git a/Sources/MiniWhisper/Services/Hotkeys/EventTapManager.swift b/Sources/MiniWhisper/Services/Hotkeys/EventTapManager.swift deleted file mode 100644 index e8b2ed2..0000000 --- a/Sources/MiniWhisper/Services/Hotkeys/EventTapManager.swift +++ /dev/null @@ -1,229 +0,0 @@ -import Foundation -import CoreGraphics -import ApplicationServices -import os.log - -private let log = Logger(subsystem: Logger.subsystem, category: "EventTap") - -final class EventTapManager: @unchecked Sendable { - typealias EventCallback = (CGEventType, CGEvent) -> Bool - - private var eventTap: CFMachPort? - private var runLoopSource: CFRunLoopSource? - private var retryTimer: Timer? - private var watchdogTimer: Timer? - private var shouldRun = false - private let callbackLock = NSLock() - private var callback: EventCallback? - // Written from the tap callback, read from the watchdog — both on the main - // run loop, like the rest of this class's mutable state. - private var lastEventTime: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() - private var starvedRebuilds = 0 - - // Shared between tap creation and the watchdog's CGGetEventTapList lookup: - // the mask is how we find our own tap among this process's taps (the - // shortcut recorder creates ephemeral taps with a different mask). - private static let eventMask: CGEventMask = (1 << CGEventType.keyDown.rawValue) - | (1 << CGEventType.keyUp.rawValue) - | (1 << CGEventType.flagsChanged.rawValue) - - private static let watchdogInterval: TimeInterval = 30 - // Silence alone can't prove tap death (identical to the user not touching - // the keyboard), so the starvation check requires both prolonged silence - // AND WindowServer reporting queued-but-unserviced events. - private static let staleTapInterval: CFTimeInterval = 90 - // Healthy taps report µs–ms queue latency; WindowServer's own per-event - // tap timeout is single-digit seconds. Past 5s, events are rotting in the - // queue while tapIsEnabled still says true. - private static let starvedTapLatencyUs: Float = 5_000_000 - - func setEventCallback(_ callback: @escaping EventCallback) { - callbackLock.lock() - self.callback = callback - callbackLock.unlock() - } - - @MainActor - func start() { - shouldRun = true - guard eventTap == nil else { - log.info("start() called but tap already exists") - return - } - log.info("Starting event tap creation...") - log.info("AXIsProcessTrusted: \(AXIsProcessTrusted())") - log.info("CGPreflightListenEventAccess: \(CGPreflightListenEventAccess())") - createEventTap() - startWatchdog() - } - - @MainActor - func stop() { - shouldRun = false - retryTimer?.invalidate() - retryTimer = nil - watchdogTimer?.invalidate() - watchdogTimer = nil - - if let source = runLoopSource { - CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) - runLoopSource = nil - } - if let tap = eventTap { - CGEvent.tapEnable(tap: tap, enable: false) - CFMachPortInvalidate(tap) - eventTap = nil - } - clearEventCallback() - log.info("Event tap stopped") - } - - private func currentCallback() -> EventCallback? { - callbackLock.lock() - defer { callbackLock.unlock() } - return callback - } - - private func clearEventCallback() { - callbackLock.lock() - callback = nil - callbackLock.unlock() - } - - @MainActor - func reenable() { - guard shouldRun else { return } - if let tap = eventTap { - CGEvent.tapEnable(tap: tap, enable: true) - log.info("Event tap re-enabled") - } - } - - @MainActor - private func createEventTap(retryCount: Int = 0) { - guard shouldRun else { return } - - let refcon = Unmanaged.passUnretained(self).toOpaque() - - guard let tap = CGEvent.tapCreate( - tap: .cgSessionEventTap, - place: .headInsertEventTap, - options: .defaultTap, - eventsOfInterest: Self.eventMask, - callback: { proxy, type, event, refcon -> Unmanaged? in - guard let refcon = refcon else { return Unmanaged.passUnretained(event) } - let manager = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - manager.lastEventTime = CFAbsoluteTimeGetCurrent() - - if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { - Task { @MainActor in - log.warning("Event tap disabled by system, re-enabling") - manager.reenable() - } - return Unmanaged.passUnretained(event) - } - - if let callback = manager.currentCallback() { - let consumed = callback(type, event) - return consumed ? nil : Unmanaged.passUnretained(event) - } - - return Unmanaged.passUnretained(event) - }, - userInfo: refcon - ) else { - log.error("Failed to create event tap (attempt \(retryCount + 1)/10)") - if retryCount < 10 { - retryTimer?.invalidate() - retryTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { [weak self] _ in - Task { @MainActor [weak self] in - self?.createEventTap(retryCount: retryCount + 1) - } - } - } else { - log.error("Giving up after 10 retries. Check Accessibility permission in System Settings.") - } - return - } - - retryTimer?.invalidate() - retryTimer = nil - eventTap = tap - let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) - runLoopSource = source - CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) - CGEvent.tapEnable(tap: tap, enable: true) - lastEventTime = CFAbsoluteTimeGetCurrent() - log.info("Event tap created and enabled successfully") - } - - private func startWatchdog() { - watchdogTimer?.invalidate() - watchdogTimer = Timer.scheduledTimer(withTimeInterval: Self.watchdogInterval, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - self?.checkTapHealth() - } - } - } - - // The tapDisabledBy* callbacks only fire while the tap is being serviced. - // A tap can instead starve silently: still registered, tapIsEnabled still - // true, but its events queue in WindowServer unserviced. Only a full teardown - // + recreate recovers from that, so the watchdog checks WindowServer's view - // of our tap rather than trusting local state. - @MainActor - private func checkTapHealth() { - guard shouldRun else { return } - guard let tap = eventTap else { return } // creation retries handle nil - - if !CGEvent.tapIsEnabled(tap: tap) { - log.warning("Watchdog found tap disabled; re-enabling") - CGEvent.tapEnable(tap: tap, enable: true) - if !CGEvent.tapIsEnabled(tap: tap) { - log.error("Re-enable did not stick; recreating tap") - recreateTap() - } - return - } - - let silent = CFAbsoluteTimeGetCurrent() - lastEventTime - if silent > Self.staleTapInterval, - let latencyUs = reportedTapLatencyUs(), latencyUs > Self.starvedTapLatencyUs { - starvedRebuilds += 1 - log.error("Tap starved — enabled but WindowServer queue latency \(Int(latencyUs / 1_000_000))s; recreating (rebuild #\(self.starvedRebuilds) since last healthy tick)") - recreateTap() - return - } - starvedRebuilds = 0 - } - - @MainActor - private func recreateTap() { - if let source = runLoopSource { - CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) - runLoopSource = nil - } - if let tap = eventTap { - CGEvent.tapEnable(tap: tap, enable: false) - CFMachPortInvalidate(tap) - eventTap = nil - } - createEventTap() - } - - // WindowServer's per-tap queue latency, matched to our tap by pid + event - // mask (other app features can create ephemeral taps with different masks). - // It grows in lockstep with wall clock while an event sits undelivered — - // the external signal that distinguishes a starved tap from an idle one. - private func reportedTapLatencyUs() -> Float? { - var count: UInt32 = 0 - guard CGGetEventTapList(0, nil, &count) == .success, count > 0 else { return nil } - var taps = [CGEventTapInformation](repeating: CGEventTapInformation(), count: Int(count)) - guard CGGetEventTapList(count, &taps, &count) == .success else { return nil } - let pid = getpid() - return taps.prefix(Int(count)) - .filter { $0.tappingProcess == pid && $0.eventsOfInterest == Self.eventMask } - .map(\.avgUsecLatency) - .max() - } -} diff --git a/Sources/MiniWhisper/Services/Hotkeys/EventTapRunLoop.swift b/Sources/MiniWhisper/Services/Hotkeys/EventTapRunLoop.swift new file mode 100644 index 0000000..e1e6d20 --- /dev/null +++ b/Sources/MiniWhisper/Services/Hotkeys/EventTapRunLoop.swift @@ -0,0 +1,92 @@ +import CoreGraphics +import Foundation + +/// Hosts a CGEventTap's run-loop source on a private thread. +/// +/// A filter tap is serviced synchronously: the window server hands the event to +/// the tap and holds it until the callback returns or the tap times out. If the +/// source lives on the main run loop, every keystroke in the session — in every +/// app — waits behind whatever else the main thread is doing, and a long enough +/// stall gets the tap silently disabled. A thread that does nothing but service +/// the tap removes that coupling. +/// +/// Warning: the callback still runs on this thread, so it must not block on +/// another one. Blocking here reintroduces exactly the stall the thread exists +/// to prevent. +/// A tap being handed to the thread that will service it. +/// +/// `CFMachPort` is not `Sendable`, but the handoff this box marks is a transfer, +/// not sharing: the port is created, then given to exactly one servicing thread +/// before that thread starts. The operations the owner keeps performing on it +/// afterwards — enable, check enabled, invalidate — are thread-safe CF calls +/// that carry no per-thread state, which is what makes the transfer sound. +struct EventTapHandle: @unchecked Sendable { + let port: CFMachPort +} + +final class EventTapRunLoop: @unchecked Sendable { + private let lock = NSLock() + private var runLoop: CFRunLoop? + private var stopped = false + private var running = false + + /// Adds `tap` to a freshly started thread's run loop and enables it. + func start(tap: EventTapHandle, name: String, enabled: Bool = true) { + lock.lock() + guard !running else { + lock.unlock() + return + } + running = true + stopped = false + lock.unlock() + + let worker = Thread { [weak self] in + guard let self else { return } + + let loop = CFRunLoopGetCurrent() + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap.port, 0) + CFRunLoopAddSource(loop, source, .commonModes) + + self.lock.lock() + self.runLoop = loop + self.lock.unlock() + + CGEvent.tapEnable(tap: tap.port, enable: enabled) + + while !self.isStopped() { + // Returns .stopped when `stop()` wakes the loop, and .finished + // once the port is invalidated and no sources are left — either + // way there is nothing further to service. + if CFRunLoopRunInMode(.defaultMode, 1.0e10, false) == .finished { break } + } + + CFRunLoopRemoveSource(loop, source, .commonModes) + + self.lock.lock() + self.runLoop = nil + self.running = false + self.lock.unlock() + } + + worker.name = name + worker.qualityOfService = .userInteractive + worker.stackSize = 512 * 1024 + worker.start() + } + + func stop() { + lock.lock() + stopped = true + let loop = runLoop + lock.unlock() + + if let loop { CFRunLoopStop(loop) } + } + + private func isStopped() -> Bool { + lock.lock() + defer { lock.unlock() } + return stopped + } +} diff --git a/Sources/MiniWhisper/Services/Hotkeys/FnStateMachine.swift b/Sources/MiniWhisper/Services/Hotkeys/FnStateMachine.swift index fab1207..c87df59 100644 --- a/Sources/MiniWhisper/Services/Hotkeys/FnStateMachine.swift +++ b/Sources/MiniWhisper/Services/Hotkeys/FnStateMachine.swift @@ -8,32 +8,57 @@ final class FnStateMachine: @unchecked Sendable { } private let lock = NSLock() - private(set) var isFnKeyDown = false + private var fnDown = false private var fnDownTimestamp: UInt64 = 0 private var usedAsModifier = false private var activeFnOnlyShortcut: CustomShortcutName? + private var pressWasSwallowed = false /// A down-state this stale can only mean macOS dropped the matching keyUp. private let stuckDownThresholdNs: UInt64 = 5_000_000_000 // 5s + /// Read from the main actor as well as the tap threads, so it goes through + /// the lock like every other field here. + var isFnKeyDown: Bool { + lock.lock() + defer { lock.unlock() } + return fnDown + } + + /// The in-flight bare-modifier shortcut, left in place. + /// + /// Distinct from `clearActiveFnOnlyShortcut()`, which hands over ownership + /// of completing the press; this is for deciding whether a press is still + /// being held by the modifier tap at all. + var activeFnOnlyShortcutName: CustomShortcutName? { + lock.lock() + defer { lock.unlock() } + return activeFnOnlyShortcut + } + func processFnKeyDown(captureTime: CFAbsoluteTime, hwTimestamp: UInt64) -> Bool { lock.lock() defer { lock.unlock() } // Stuck-state recovery must run before the re-entry guard: macOS // sometimes drops the Fn keyUp (app switches, sleep/wake), leaving - // isFnKeyDown stuck true — exactly the case where the guard below + // fnDown stuck true — exactly the case where the guard below // would otherwise swallow this press. - if isFnKeyDown, fnDownTimestamp > 0, + // + // `activeFnOnlyShortcut` deliberately survives: the press it names was + // never released, and only the caller can complete it. Callers must + // drain it when a press is accepted, or the recovered press collides + // with the abandoned one and neither fires. + if fnDown, fnDownTimestamp > 0, hwTimestamp - fnDownTimestamp > stuckDownThresholdNs { - isFnKeyDown = false + fnDown = false usedAsModifier = false } - guard !isFnKeyDown else { return false } + guard !fnDown else { return false } - isFnKeyDown = true + fnDown = true fnDownTimestamp = hwTimestamp usedAsModifier = false return true @@ -43,9 +68,9 @@ final class FnStateMachine: @unchecked Sendable { lock.lock() defer { lock.unlock() } - guard isFnKeyDown else { return .none } + guard fnDown else { return .none } - isFnKeyDown = false + fnDown = false if usedAsModifier { usedAsModifier = false @@ -57,6 +82,29 @@ final class FnStateMachine: @unchecked Sendable { return .fnKeyUp } + /// Remembers whether this press was hidden from the rest of the system. + /// + /// The release has to make the same choice: macOS triggers its own "press 🌐 + /// to…" action on the *release*, so a swallowed press followed by a + /// delivered release fires exactly the system action the press was hidden to + /// avoid. + func recordPressSwallowed(_ swallowed: Bool) { + lock.lock() + pressWasSwallowed = swallowed + lock.unlock() + } + + /// Whether the press this release completes was swallowed. Consumed, so a + /// release with no press behind it is never swallowed on the strength of an + /// older one. + func consumePressSwallowed() -> Bool { + lock.lock() + defer { lock.unlock() } + let swallowed = pressWasSwallowed + pressWasSwallowed = false + return swallowed + } + func markUsedAsModifier() -> CustomShortcutName? { lock.lock() defer { lock.unlock() } @@ -82,10 +130,11 @@ final class FnStateMachine: @unchecked Sendable { func reset() { lock.lock() - isFnKeyDown = false + fnDown = false fnDownTimestamp = 0 usedAsModifier = false activeFnOnlyShortcut = nil + pressWasSwallowed = false lock.unlock() } } diff --git a/Sources/MiniWhisper/Services/Hotkeys/HotkeyManager.swift b/Sources/MiniWhisper/Services/Hotkeys/HotkeyManager.swift index 6232a41..790ae7b 100644 --- a/Sources/MiniWhisper/Services/Hotkeys/HotkeyManager.swift +++ b/Sources/MiniWhisper/Services/Hotkeys/HotkeyManager.swift @@ -16,7 +16,8 @@ final class HotkeyManager { private let shortcutMonitor = CustomShortcutMonitor.shared - /// Thread-safe flag for cancel shortcut's enabled check (accessed from event tap thread) + /// Backs the cancel shortcut's enabled check, which is read from the + /// modifier tap's thread as well as the main actor. nonisolated(unsafe) var _recordingActive = false let recordingActiveLock = NSLock() @@ -36,16 +37,22 @@ final class HotkeyManager { shortcutMonitor.reloadShortcuts() } + // Recording state feeds the cancel shortcut's enabled check, and a + // registered hot key swallows its chord unconditionally — so gating means + // registering and unregistering, not filtering at fire time. Both edges + // therefore have to re-derive the registrations. func recordingDidStart() { recordingActiveLock.lock() _recordingActive = true recordingActiveLock.unlock() + shortcutMonitor.refresh() } func recordingDidEnd() { recordingActiveLock.lock() _recordingActive = false recordingActiveLock.unlock() + shortcutMonitor.refresh() } private func setupToggleRecording() { @@ -65,9 +72,10 @@ final class HotkeyManager { } private func setupAutoCleanupRecording() { - // Gate at event-tap time on the AI Editing setting so the - // shortcut passes through to the frontmost app when auto-cleanup - // isn't enabled. Mirrors editSelection's pattern. + // Gate on the AI Editing setting so the shortcut passes through to the + // frontmost app when auto-cleanup isn't enabled. Changing that setting + // must re-derive registrations, or the hot key stays registered and + // keeps swallowing the chord. Mirrors editSelection's pattern. shortcutMonitor.setEnabledCheck(for: .autoCleanupRecording) { EditModeSettings.behavior.autoCleanupEnabled } @@ -77,10 +85,10 @@ final class HotkeyManager { } private func setupEditSelection() { - // Gate at event-tap time on the persisted setting so when edit - // mode is off, ⌥E (or whatever the user bound) passes through to - // the frontmost app instead of being consumed. UserDefaults is - // thread-safe — no callback wiring needed. + // Gate on the persisted setting so when edit mode is off, whatever the + // user bound passes through to the frontmost app instead of being + // consumed. Changing that setting must re-derive registrations so the + // hot key is actually dropped. shortcutMonitor.setEnabledCheck(for: .editSelection) { EditModeSettings.behavior.selectionEnabled } diff --git a/Sources/MiniWhisper/Services/Hotkeys/KeyDownObserver.swift b/Sources/MiniWhisper/Services/Hotkeys/KeyDownObserver.swift new file mode 100644 index 0000000..4ca2cd3 --- /dev/null +++ b/Sources/MiniWhisper/Services/Hotkeys/KeyDownObserver.swift @@ -0,0 +1,194 @@ +import CoreGraphics +import Foundation +import os.log + +private let log = Logger(subsystem: Logger.subsystem, category: "KeyDownObserver") + +/// Reports that some ordinary key went down, for the span of a modifier hold. +/// +/// It exists for one question: was the modifier a shortcut, or was the user +/// holding it to reach Fn+←? Nothing else can answer it — a chord that combines +/// a held modifier with an ordinary key is not something the hot-key API can +/// match, so the key press has to be seen directly. +/// +/// Two properties keep that cheap. The tap is listen-only, so the window server +/// never waits on it and it cannot delay a keystroke for anyone. And it is +/// enabled only while the modifier is actually down, so outside those brief +/// windows this process observes no typing at all. +/// +/// Those same two properties are why this has no starvation watchdog, unlike +/// `ModifierTapMonitor`. A listen-only tap cannot stall the system, and one that +/// is disabled almost all the time gives the latency heuristic nothing to +/// measure — it would read idle as starved. The absence is deliberate. +final class KeyDownObserver: @unchecked Sendable { + typealias Handler = @Sendable () -> Void + + /// Ordinary keys only. Modifier presses arrive as `.flagsChanged` and are + /// none of this observer's business. + static let eventMask = CGEventMask(1 << CGEventType.keyDown.rawValue) + + private let lock = NSLock() + private var handler: Handler? + private var tap: EventTapHandle? + private var runLoop: EventTapRunLoop? + private var shouldRun = false + /// Whether the modifier is currently held. Toggled from the modifier tap's + /// thread, read in this tap's callback. + private var active = false + + private var retryTimer: Timer? + private static let retryInterval: TimeInterval = 30 + + func setHandler(_ handler: @escaping Handler) { + lock.lock() + self.handler = handler + lock.unlock() + } + + @MainActor + func start() { + guard !shouldRun else { return } + shouldRun = true + createTap() + } + + @MainActor + func stop() { + shouldRun = false + retryTimer?.invalidate() + retryTimer = nil + + lock.lock() + let handle = tap + let loop = runLoop + tap = nil + runLoop = nil + active = false + lock.unlock() + + if let handle { + CGEvent.tapEnable(tap: handle.port, enable: false) + CFMachPortInvalidate(handle.port) + } + loop?.stop() + } + + /// Called on each modifier transition, from the modifier tap's thread. + /// + /// Enabling here rather than hopping to another thread is deliberate: the + /// very next event may be the key press this observer exists to catch, and a + /// hop would let it slip through before the tap was listening. + /// + /// `CGEvent.tapEnable` is a round trip to the window server, so this is the + /// one place the modifier tap's callback blocks on something external. It is + /// bounded — microseconds, twice per modifier press, against a tap timeout + /// measured in seconds — and it is what buys the property that this process + /// observes no typing at all outside a hold. Delivery is gated on `active` + /// independently, so a late enable costs a missed detection, never a wrong + /// one. + /// + /// Held across the call: it must not interleave with the enable applied at + /// tap creation, or a hold that starts while the tap is being built ends up + /// with the two disagreeing. + func setActive(_ active: Bool) { + lock.lock() + defer { lock.unlock() } + self.active = active + guard let port = tap?.port else { return } + CGEvent.tapEnable(tap: port, enable: active) + } + + @MainActor + private func createTap() { + guard shouldRun else { return } + + let refcon = Unmanaged.passUnretained(self).toOpaque() + guard + let port = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .listenOnly, + eventsOfInterest: Self.eventMask, + callback: keyDownObserverCallback, + userInfo: refcon + ) + else { + log.error("Key-down observer tap creation failed; retrying") + scheduleRetry() + return + } + + retryTimer?.invalidate() + retryTimer = nil + + // Created disabled: it may only listen while the modifier is held. + CGEvent.tapEnable(tap: port, enable: false) + + let handle = EventTapHandle(port: port) + let loop = EventTapRunLoop() + lock.lock() + tap = handle + runLoop = loop + lock.unlock() + + loop.start(tap: handle, name: "app.hotkeys.keydown-observer", enabled: false) + + // Applied after the thread is up and under the lock, because a hold can + // begin while the tap is being built: reading the flag before the + // servicing thread exists would let the worker's enable overwrite a + // `setActive` that had already run. + lock.lock() + CGEvent.tapEnable(tap: port, enable: active) + lock.unlock() + + log.info("Key-down observer tap created") + } + + @MainActor + private func scheduleRetry() { + retryTimer?.invalidate() + retryTimer = Timer.scheduledTimer(withTimeInterval: Self.retryInterval, repeats: false) { + [weak self] _ in + guard let self else { return } + Task { @MainActor in + self.createTap() + } + } + } + + fileprivate func deliverKeyDown() { + lock.lock() + let handler = active ? self.handler : nil + lock.unlock() + handler?() + } + + fileprivate func handleTapDisabled() { + lock.lock() + let port = tap?.port + let shouldListen = active + lock.unlock() + + guard let port, shouldListen else { return } + log.warning("Key-down observer tap disabled by system; re-enabling") + CGEvent.tapEnable(tap: port, enable: true) + } +} + +private let keyDownObserverCallback: CGEventTapCallBack = { _, type, event, refcon in + guard let refcon else { return Unmanaged.passUnretained(event) } + let observer = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + observer.handleTapDisabled() + return Unmanaged.passUnretained(event) + } + + if type == .keyDown { + observer.deliverKeyDown() + } + + // A listen-only tap's return value is ignored; passing the event through is + // the only correct expression of that. + return Unmanaged.passUnretained(event) +} diff --git a/Sources/MiniWhisper/Services/Hotkeys/ModifierTapMonitor.swift b/Sources/MiniWhisper/Services/Hotkeys/ModifierTapMonitor.swift new file mode 100644 index 0000000..7f15e9b --- /dev/null +++ b/Sources/MiniWhisper/Services/Hotkeys/ModifierTapMonitor.swift @@ -0,0 +1,297 @@ +import ApplicationServices +import CoreGraphics +import Foundation +import os.log + +private let log = Logger(subsystem: Logger.subsystem, category: "ModifierTap") + +/// Watches bare-modifier presses for shortcuts Carbon cannot express. +/// +/// Scope is deliberately as small as it can be: the tap only exists while a +/// modifier-only shortcut is bound, and its mask is exactly `.flagsChanged`, so +/// ordinary typing never reaches this process. Key chords are handled by Carbon +/// and need no tap at all. +final class ModifierTapMonitor: @unchecked Sendable { + /// The minimum copied out of a `CGEvent` so the callback can hand off and + /// return immediately rather than keeping the event alive. + struct FlagsEvent: Sendable { + let keyCode: UInt16 + let fnPressed: Bool + let hwTimestamp: UInt64 + let captureTime: CFAbsoluteTime + } + + /// Runs on the tap thread. Returns true to hide the event from the rest of + /// the system. + /// + /// Warning: the window server holds every keystroke in the session until + /// this returns. It must stay allocation-light and must never block on + /// another thread — in particular never `DispatchQueue.main.sync`. + typealias Handler = @Sendable (FlagsEvent) -> Bool + + private let lock = NSLock() + private var handler: Handler? + private var tap: EventTapHandle? + private var runLoop: EventTapRunLoop? + private var shouldRun = false + /// Written from the tap thread, read by the watchdog; both under `lock`. + private var lastEventTime: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() + private var starvedRebuilds = 0 + + private var watchdogTimer: Timer? + private var retryTimer: Timer? + + private static let watchdogInterval: TimeInterval = 30 + /// Only the burst of fast retries. Creation keeps being retried at watchdog + /// cadence afterwards, because the usual reason it fails is an Accessibility + /// grant that has not been given yet and may be given minutes later. + private static let maxCreateAttempts = 10 + + /// Whether a bare modifier press must be hidden from the rest of the + /// system, which is the only thing that justifies a filter tap here. + /// + /// It is load-bearing for push-to-talk: while a modifier-only shortcut is + /// held, the press must not also reach whatever the system's "Press 🌐 to…" + /// setting is bound to, or every recording would additionally switch input + /// source or open the emoji picker. + private let suppressesModifierPress: Bool + + init(suppressesModifierPress: Bool = true) { + self.suppressesModifierPress = suppressesModifierPress + } + + func setHandler(_ handler: @escaping Handler) { + lock.lock() + self.handler = handler + lock.unlock() + } + + @MainActor + func start() { + guard !shouldRun else { return } + shouldRun = true + // Started here rather than at each tap creation: a rebuild must not + // reset the activity clock, or a tap that starves again immediately + // would look freshly healthy and the rebuild counter could never read + // higher than one. + lock.lock() + lastEventTime = CFAbsoluteTimeGetCurrent() + lock.unlock() + createTap() + startWatchdog() + } + + @MainActor + func stop() { + shouldRun = false + retryTimer?.invalidate() + retryTimer = nil + watchdogTimer?.invalidate() + watchdogTimer = nil + teardownTap() + } + + // MARK: - Tap lifecycle + + @MainActor + private func createTap(attempt: Int = 0) { + guard shouldRun else { return } + + let refcon = Unmanaged.passUnretained(self).toOpaque() + let options = ModifierTapPolicy.tapOption( + suppressesModifierPress: suppressesModifierPress) + + guard + let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: options, + eventsOfInterest: ModifierTapPolicy.eventMask, + callback: modifierTapCallback, + userInfo: refcon + ) + else { + log.error("Modifier tap creation failed (attempt \(attempt + 1)/\(Self.maxCreateAttempts))") + guard attempt + 1 < Self.maxCreateAttempts else { + log.error( + "Modifier tap creation still failing; falling back to watchdog-paced retries. Check Accessibility permission in System Settings." + ) + return + } + retryTimer?.invalidate() + retryTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in + Task { @MainActor [weak self] in + self?.createTap(attempt: attempt + 1) + } + } + return + } + + retryTimer?.invalidate() + retryTimer = nil + + let handle = EventTapHandle(port: tap) + let loop = EventTapRunLoop() + lock.lock() + self.tap = handle + self.runLoop = loop + lock.unlock() + + loop.start(tap: handle, name: "app.hotkeys.modifier-tap") + log.info("Modifier tap created (filter: \(self.suppressesModifierPress))") + } + + @MainActor + private func teardownTap() { + lock.lock() + let tap = self.tap + let loop = self.runLoop + self.tap = nil + self.runLoop = nil + lock.unlock() + + if let tap { + CGEvent.tapEnable(tap: tap.port, enable: false) + CFMachPortInvalidate(tap.port) + } + loop?.stop() + } + + @MainActor + private func recreate() { + guard shouldRun else { return } + teardownTap() + createTap() + } + + @MainActor + private func startWatchdog() { + watchdogTimer?.invalidate() + // The timer retains its block, and the block is what would otherwise + // retain this monitor for as long as the timer lives. + watchdogTimer = Timer.scheduledTimer( + withTimeInterval: Self.watchdogInterval, repeats: true + ) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + self.checkTapHealth() + } + } + } + + /// The `tapDisabledBy*` callbacks only arrive while the tap is still being + /// serviced, so a tap that dies quietly is only visible by asking. + @MainActor + private func checkTapHealth() { + guard shouldRun else { return } + lock.lock() + let handle = self.tap + let silent = CFAbsoluteTimeGetCurrent() - lastEventTime + lock.unlock() + + guard let tap = handle?.port else { + // Creation's own retry budget is short, and the reason it usually + // fails — no Accessibility grant yet — can take arbitrarily long to + // resolve. Without a retry here nothing would rebuild the tap short + // of restarting the app. + log.info("Modifier tap missing while it should be running; retrying creation") + createTap() + return + } + + if !CGEvent.tapIsEnabled(tap: tap) { + log.warning("Watchdog found modifier tap disabled; re-enabling") + CGEvent.tapEnable(tap: tap, enable: true) + if !CGEvent.tapIsEnabled(tap: tap) { + log.error("Re-enable did not stick; recreating modifier tap") + recreate() + } + return + } + + // Reaching here means the tap claims to be enabled, which a starved tap + // also does — hence the second, external opinion below. + let latencyUs = reportedTapLatencyUs() + if TapStarvationPolicy.isStarved(silentFor: silent, reportedLatencyUs: latencyUs) { + starvedRebuilds += 1 + let latencySeconds = Int((latencyUs ?? 0) / 1_000_000) + log.error( + "Tap starved — enabled but WindowServer queue latency \(latencySeconds)s; recreating (rebuild #\(self.starvedRebuilds) since last healthy tick)" + ) + recreate() + return + } + starvedRebuilds = 0 + } + + /// The window server's queue latency for this tap, matched by tapping pid + /// plus event mask — the process can host other taps (the shortcut recorder + /// and the Fn companion observer both use different masks), and only this + /// one's health is being judged. + /// + /// The value grows in lockstep with wall clock while an event sits + /// undelivered, which is what separates a starved tap from an idle one. + private func reportedTapLatencyUs() -> Float? { + var count: UInt32 = 0 + guard CGGetEventTapList(0, nil, &count) == .success, count > 0 else { return nil } + var taps = [CGEventTapInformation](repeating: CGEventTapInformation(), count: Int(count)) + guard CGGetEventTapList(count, &taps, &count) == .success else { return nil } + + let pid = getpid() + return taps.prefix(Int(count)) + .filter { $0.tappingProcess == pid && $0.eventsOfInterest == ModifierTapPolicy.eventMask } + .map(\.avgUsecLatency) + .max() + } + + // MARK: - Callback entry points + + fileprivate func deliver(_ event: FlagsEvent) -> Bool { + lock.lock() + let handler = self.handler + // Proof the tap is still being serviced. Its absence is what the + // watchdog's starvation check keys off. + lastEventTime = event.captureTime + lock.unlock() + return handler?(event) ?? false + } + + fileprivate func handleTapDisabled() { + lock.lock() + let handle = self.tap + lastEventTime = CFAbsoluteTimeGetCurrent() + lock.unlock() + guard let tap = handle?.port else { return } + + log.warning("Modifier tap disabled by system; re-enabling") + CGEvent.tapEnable(tap: tap, enable: true) + guard CGEvent.tapIsEnabled(tap: tap) else { + log.error("Re-enable did not stick; scheduling modifier tap recreate") + Task { @MainActor [weak self] in + self?.recreate() + } + return + } + } +} + +private let modifierTapCallback: CGEventTapCallBack = { _, type, event, refcon in + guard let refcon else { return Unmanaged.passUnretained(event) } + let monitor = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + monitor.handleTapDisabled() + return Unmanaged.passUnretained(event) + } + + guard type == .flagsChanged else { return Unmanaged.passUnretained(event) } + + let facts = ModifierTapMonitor.FlagsEvent( + keyCode: UInt16(event.getIntegerValueField(.keyboardEventKeycode)), + fnPressed: event.flags.contains(.maskSecondaryFn), + hwTimestamp: event.timestamp, + captureTime: CFAbsoluteTimeGetCurrent() + ) + + return monitor.deliver(facts) ? nil : Unmanaged.passUnretained(event) +} diff --git a/Sources/MiniWhisper/Services/Hotkeys/ShortcutBackend.swift b/Sources/MiniWhisper/Services/Hotkeys/ShortcutBackend.swift new file mode 100644 index 0000000..c6d1524 --- /dev/null +++ b/Sources/MiniWhisper/Services/Hotkeys/ShortcutBackend.swift @@ -0,0 +1,242 @@ +import Carbon.HIToolbox +import CoreGraphics +import Foundation + +/// Carbon's four modifier bits, the only ones `RegisterEventHotKey` understands. +/// Fn is deliberately absent — the OS hot-key API has no representation for it. +enum CarbonModifierMask { + static func from(command: Bool, option: Bool, control: Bool, shift: Bool) -> UInt32 { + var mask: UInt32 = 0 + if command { mask |= UInt32(cmdKey) } + if option { mask |= UInt32(optionKey) } + if control { mask |= UInt32(controlKey) } + if shift { mask |= UInt32(shiftKey) } + return mask + } + + /// Every combination of the four modifiers. A hot key registered once per + /// entry fires no matter what the user happens to be holding, which is how + /// modifier-insensitive shortcuts are expressed against an API that only + /// matches exact chords. + static let allCombinations: [UInt32] = { + let bits = [UInt32(cmdKey), UInt32(optionKey), UInt32(controlKey), UInt32(shiftKey)] + return (0..<(1 << bits.count)).map { combination in + bits.enumerated().reduce(UInt32(0)) { mask, entry in + combination & (1 << entry.offset) != 0 ? mask | entry.element : mask + } + } + }() +} + +/// Which delivery mechanism can serve a given shortcut. +/// +/// The split exists because the two mechanisms have opposite trade-offs: Carbon +/// hot keys never join the event-delivery path (so they cannot stall input for +/// the system) but cannot express a bare modifier, while an event tap can see +/// bare modifiers but does sit in that path. +enum ShortcutBackend: Equatable { + /// A key plus zero or more modifiers — registrable as a Carbon hot key. + case carbon(keyCode: UInt16, carbonModifiers: UInt32) + + /// A bare modifier press such as Fn/Globe, which only ever surfaces as a + /// `.flagsChanged` event and so needs the modifier tap. + case modifierOnly + + /// Neither mechanism can serve it; the shortcut is dropped with a log line. + case unsupported(UnsupportedReason) + + enum UnsupportedReason: Equatable { + /// Fn combined with a regular key. Carbon cannot match Fn, and + /// registering the chord without it would swallow the bare key + /// system-wide — so a user holding Fn to type Fn+W would stop being + /// able to type W anywhere. + case fnChord + + /// A bare modifier key carrying other modifiers. Not expressible as a + /// hot key, and the modifier tap only tracks Fn on its own. + case modifierChord + } + + static func classify(_ shortcut: CustomShortcut) -> ShortcutBackend { + if shortcut.isFnOnly { return .modifierOnly } + if FnKeyCode.isFnKey(shortcut.keyCode) { return .unsupported(.modifierChord) } + // Deliberately `usesFnAsModifier`, not `fn`: a stored Fn flag on a + // function-group key is an artefact of how those keys report, and + // treating it as a real chord would silently retire arrow and F-key + // shortcuts saved by older builds. + if shortcut.usesFnAsModifier { return .unsupported(.fnChord) } + + return .carbon( + keyCode: shortcut.keyCode, + carbonModifiers: CarbonModifierMask.from( + command: shortcut.command, + option: shortcut.option, + control: shortcut.control, + shift: shortcut.shift + ) + ) + } +} + +extension CustomShortcut { + /// No backend can serve this binding, so it will never fire. Surfaced in the + /// UI: the row still renders a perfectly ordinary-looking shortcut, and + /// without a marker the only clue is that pressing it does nothing. + var needsRerecording: Bool { + if case .unsupported = ShortcutBackend.classify(self) { return true } + return false + } +} + +extension CustomShortcutName { + /// Fires regardless of which modifiers are held. Cancel is the escape hatch + /// from an in-flight recording, and the trigger that started that recording + /// is usually still held down when the user reaches for it — requiring an + /// exact chord would make it unreachable in exactly the case it exists for. + var ignoresModifiers: Bool { self == .cancelRecording } +} + +/// One registrable chord. Several bindings may share a `name` — that is how a +/// modifier-insensitive shortcut is expressed (one registration per modifier +/// combination). +struct HotKeyBinding: Hashable, Sendable { + let name: CustomShortcutName + let keyCode: UInt16 + let carbonModifiers: UInt32 +} + +/// Turns the configured shortcuts into the exact list of chords to register. +/// +/// Two shortcuts can want the same chord — most easily when a modifier- +/// insensitive shortcut expands over every modifier combination and one of +/// those combinations is another shortcut's exact chord. Only one registration +/// can win, and the OS decides by arrival order, so the list has to be built in +/// a fixed order with an explicit rule or which shortcut works varies from run +/// to run. +enum HotKeyBindingPlan { + struct Request: Equatable { + let name: CustomShortcutName + let keyCode: UInt16 + let carbonModifiers: UInt32 + /// Expands to one binding per modifier combination instead of one exact + /// chord. + let ignoresModifiers: Bool + } + + struct Resolution: Equatable { + /// In registration order. Free of internal collisions. + let bindings: [HotKeyBinding] + /// Dropped because a higher-precedence binding claimed the same chord. + let shadowed: [HotKeyBinding] + } + + /// Precedence: a shortcut asking for one exact chord beats a modifier- + /// insensitive expansion that merely happens to cover it — the expansion is + /// a convenience, the exact chord is what the user recorded. Ties between + /// two exact chords go to whichever shortcut is declared first, so the + /// winner never changes between launches. + static func resolve(_ requests: [Request]) -> Resolution { + let declarationOrder = Dictionary( + uniqueKeysWithValues: CustomShortcutName.allCases.enumerated().map { ($1, $0) }) + let ranked = requests.enumerated().sorted { lhs, rhs in + if lhs.element.ignoresModifiers != rhs.element.ignoresModifiers { + return !lhs.element.ignoresModifiers + } + let lhsOrder = declarationOrder[lhs.element.name] ?? .max + let rhsOrder = declarationOrder[rhs.element.name] ?? .max + if lhsOrder != rhsOrder { return lhsOrder < rhsOrder } + return lhs.offset < rhs.offset + } + + var claimed: Set = [] + var bindings: [HotKeyBinding] = [] + var shadowed: [HotKeyBinding] = [] + + for request in ranked.map(\.element) { + let modifierSets = + request.ignoresModifiers + ? CarbonModifierMask.allCombinations : [request.carbonModifiers] + + for modifiers in modifierSets { + let binding = HotKeyBinding( + name: request.name, keyCode: request.keyCode, carbonModifiers: modifiers) + if claimed.insert(Chord(keyCode: request.keyCode, modifiers: modifiers)).inserted { + bindings.append(binding) + } else { + shadowed.append(binding) + } + } + } + + return Resolution(bindings: bindings, shadowed: shadowed) + } + + private struct Chord: Hashable { + let keyCode: UInt16 + let modifiers: UInt32 + } +} + +/// Decides whether an event tap is needed at all, and how invasive it may be. +enum ModifierTapPolicy { + /// Exactly `.flagsChanged`. Widening this mask puts the app back into the + /// delivery path for ordinary typing, which is what this layer exists to + /// avoid — modifier keys are inert on their own, so nothing else is needed + /// to recognise a bare-modifier shortcut. + static let eventMask = CGEventMask(1 << CGEventType.flagsChanged.rawValue) + + /// No bare-modifier shortcut bound means no tap is created at all, and the + /// app stays entirely out of the event chain. + static func needsTap(hasModifierOnlyShortcut: Bool) -> Bool { + hasModifierOnlyShortcut + } + + /// A listen-only tap is never waited on by the window server, so it cannot + /// stall input system-wide; a filter tap can. Only ask for the filter when + /// the modifier press must be hidden from everything else. + static func tapOption(suppressesModifierPress: Bool) -> CGEventTapOptions { + suppressesModifierPress ? .defaultTap : .listenOnly + } +} + +/// Press/release bookkeeping shared by both backends. +/// +/// Invariant: a release only reports true if this tracker saw the matching +/// press. Hold-to-talk stop actions run off the release, so without this a +/// stray release (repeat, or a press that was gated out) could stop a recording +/// that was never started, or stop one twice. +struct HotKeyPressTracker { + private var pressed: Set = [] + + /// True when this is a fresh press and the key-down action should run. + mutating func press(_ name: CustomShortcutName) -> Bool { + pressed.insert(name).inserted + } + + /// True when the matching press was seen and the key-up action should run. + mutating func release(_ name: CustomShortcutName) -> Bool { + pressed.remove(name) != nil + } + + func isPressed(_ name: CustomShortcutName) -> Bool { + pressed.contains(name) + } + + /// Drops presses that can no longer be released and reports them, in + /// declaration order. + /// + /// A hot key unregistered while it is held never delivers its release — the + /// OS simply stops matching the chord — so a hold-style action would run + /// until the app quit. Anything dropped here has to be completed by hand. + mutating func drainStranded(keeping live: Set) -> [CustomShortcutName] { + let stranded = CustomShortcutName.allCases.filter { + pressed.contains($0) && !live.contains($0) + } + pressed.subtract(stranded) + return stranded + } + + mutating func reset() { + pressed.removeAll() + } +} diff --git a/Sources/MiniWhisper/Services/Hotkeys/ShortcutMatcher.swift b/Sources/MiniWhisper/Services/Hotkeys/ShortcutMatcher.swift index 781497b..edb7d86 100644 --- a/Sources/MiniWhisper/Services/Hotkeys/ShortcutMatcher.swift +++ b/Sources/MiniWhisper/Services/Hotkeys/ShortcutMatcher.swift @@ -1,6 +1,8 @@ import Foundation -import AppKit +/// Holds the persisted shortcut set and answers the one question the modifier +/// tap still needs at event time. Key chords are matched by the hot-key +/// registration itself, so no per-event comparison happens for them. final class ShortcutMatcher: @unchecked Sendable { struct MatchResult { let name: CustomShortcutName @@ -26,47 +28,17 @@ final class ShortcutMatcher: @unchecked Sendable { return shortcuts } - func findMatch(keyCode: UInt16, modifiers: NSEvent.ModifierFlags, fnPressed: Bool) -> MatchResult? { - lock.lock() - let current = shortcuts - lock.unlock() - - for (name, shortcut) in current { - if shortcut.isFnOnly { continue } - if shortcut.matches(keyCode: keyCode, modifiers: modifiers, fnPressed: fnPressed) { - return MatchResult(name: name) - } - } - return nil - } - - func findByKeyCode(_ keyCode: UInt16) -> MatchResult? { - lock.lock() - let current = shortcuts - lock.unlock() - - for (name, shortcut) in current { - if shortcut.keyCode == keyCode { - return MatchResult(name: name) - } - } - return nil - } - + /// Scanned in declaration order rather than dictionary order: if a user has + /// bound more than one shortcut to the bare modifier, the same one must win + /// every time, or which action fires would vary between presses. func findFnOnlyShortcut() -> MatchResult? { lock.lock() let current = shortcuts lock.unlock() - for (name, shortcut) in current { - if shortcut.isFnOnly { - return MatchResult(name: name) - } + for name in CustomShortcutName.allCases where current[name]?.isFnOnly == true { + return MatchResult(name: name) } return nil } - - func hasFnOnlyShortcut() -> Bool { - findFnOnlyShortcut() != nil - } } diff --git a/Sources/MiniWhisper/Services/Hotkeys/TapHealthPolicy.swift b/Sources/MiniWhisper/Services/Hotkeys/TapHealthPolicy.swift new file mode 100644 index 0000000..5222002 --- /dev/null +++ b/Sources/MiniWhisper/Services/Hotkeys/TapHealthPolicy.swift @@ -0,0 +1,33 @@ +import Foundation + +/// When an event tap that still reports itself as enabled must be rebuilt +/// anyway. +/// +/// `CGEvent.tapIsEnabled` answers "is this tap registered", not "is it being +/// serviced". A tap can starve: still registered, still enabled, but its events +/// pile up in the window server undelivered. Nothing notifies the process — the +/// `tapDisabledBy*` callbacks only arrive while a tap is still being serviced — +/// so the only way out is to notice from the outside and rebuild. +enum TapStarvationPolicy { + /// Silence alone proves nothing: it is indistinguishable from the user not + /// touching the keyboard. It only narrows *when* to bother asking the window + /// server. + static let silenceThreshold: CFTimeInterval = 90 + + /// Healthy taps report µs–ms queue latency, and the window server's own + /// per-event tap timeout is single-digit seconds. Past this, events are + /// rotting in the queue while the tap still claims to be enabled. + static let starvedLatencyUs: Float = 5_000_000 + + /// A rebuild needs both signals: prolonged silence *and* the window server + /// reporting queued-but-unserviced events. Either alone has a benign + /// explanation — an idle keyboard, or a latency sample taken across a + /// sleep/wake. + /// + /// `reportedLatencyUs` is nil when the tap could not be found in the window + /// server's list, which is not evidence of starvation. + static func isStarved(silentFor: CFTimeInterval, reportedLatencyUs: Float?) -> Bool { + guard silentFor > silenceThreshold, let latency = reportedLatencyUs else { return false } + return latency > starvedLatencyUs + } +} diff --git a/Sources/MiniWhisper/Services/PermissionsManager.swift b/Sources/MiniWhisper/Services/PermissionsManager.swift index ca4996e..0ae6191 100644 --- a/Sources/MiniWhisper/Services/PermissionsManager.swift +++ b/Sources/MiniWhisper/Services/PermissionsManager.swift @@ -21,8 +21,11 @@ final class PermissionsManager: Sendable { func refresh() { microphoneGranted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized - // CGEventTap with .defaultTap (active tap) only needs Accessibility, not Input Monitoring. - // Input Monitoring is for passive taps (.listenOnly). + // Keyboard event taps of both flavors (.defaultTap and .listenOnly) work under + // Accessibility trust; Input Monitoring is the alternative grant that lets + // listen-only taps work in apps WITHOUT Accessibility. This app always requires + // Accessibility (the Fn tap needs it), so no separate Input Monitoring grant is + // needed or tracked. accessibilityGranted = AXIsProcessTrusted() if allGranted && !wasAllGranted { diff --git a/Sources/MiniWhisper/Views/MenuBarView.swift b/Sources/MiniWhisper/Views/MenuBarView.swift index c585b88..ddb9c3d 100644 --- a/Sources/MiniWhisper/Views/MenuBarView.swift +++ b/Sources/MiniWhisper/Views/MenuBarView.swift @@ -440,6 +440,14 @@ private struct ShortcutRow: View { Spacer(minLength: 12) if let shortcut = CustomShortcutStorage.get(name) { + if shortcut.needsRerecording { + // Nothing can register this binding, so it renders + // like any other while never firing. + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 11)) + .foregroundColor(.orange) + .help("This shortcut can no longer be registered. Record a new one.") + } Text(shortcut.compactDisplayString) .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundColor(.secondary) diff --git a/Sources/MiniWhisper/Views/SettingsWindowView.swift b/Sources/MiniWhisper/Views/SettingsWindowView.swift index a99e0e2..6fe035c 100644 --- a/Sources/MiniWhisper/Views/SettingsWindowView.swift +++ b/Sources/MiniWhisper/Views/SettingsWindowView.swift @@ -243,6 +243,7 @@ private struct GeneralSettingsPage: View { editModeBehavior = $0 EditModeSettings.behavior = $0 appState.editModeBehavior = $0 + appState.refreshShortcutRegistrations() } ) ) { @@ -415,6 +416,14 @@ private struct SettingsShortcutRow: View { Button("Cancel") { isEditing = false } .buttonStyle(.borderless) } else { + if needsRerecording { + // A stored binding no backend can register looks entirely + // normal in this row, so without a marker the only symptom + // is a shortcut that quietly does nothing. + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .help("This shortcut can no longer be registered. Record a new one.") + } Button(shortcutLabel) { isEditing = true } .font(.system(.body, design: .monospaced)) } @@ -424,6 +433,10 @@ private struct SettingsShortcutRow: View { private var shortcutLabel: String { CustomShortcutStorage.get(name)?.compactDisplayString ?? "Not Set" } + + private var needsRerecording: Bool { + CustomShortcutStorage.get(name)?.needsRerecording ?? false + } } private struct IntegrationSettingsPage: View { diff --git a/Sources/MiniWhisper/Views/ShortcutRecorderView.swift b/Sources/MiniWhisper/Views/ShortcutRecorderView.swift index 9a7c9a2..49268a3 100644 --- a/Sources/MiniWhisper/Views/ShortcutRecorderView.swift +++ b/Sources/MiniWhisper/Views/ShortcutRecorderView.swift @@ -7,8 +7,7 @@ private let log = Logger(subsystem: Logger.subsystem, category: "ShortcutRecorde struct ShortcutRecorderView: View { @Binding var shortcut: CustomShortcut? @State private var isRecording = false - @State private var eventTap: CFMachPort? - @State private var runLoopSource: CFRunLoopSource? + @State private var context: RecorderContext? var body: some View { HStack(spacing: 8) { @@ -47,14 +46,14 @@ struct ShortcutRecorderView: View { // MARK: - CGEventTap Recording // Uses CGEventTap instead of NSEvent monitors because Fn only generates // .flagsChanged events, which NSEvent.addLocalMonitorForEvents(.keyDown) misses entirely. + // + // This tap has to swallow keystrokes — capturing a chord must not also type + // it into whatever is focused — so unlike the shortcut monitor it cannot be + // listen-only. It is scoped as tightly as possible instead: it exists only + // while a recorder row is on screen, waiting for a single chord. + @MainActor private func startRecording() { - isRecording = true - - let eventMask: CGEventMask = - (1 << CGEventType.keyDown.rawValue) | - (1 << CGEventType.flagsChanged.rawValue) - let context = RecorderContext( onKeyDown: { keyCode, modifiers, fn in handleKeyDown(keyCode: keyCode, modifiers: modifiers, fnPressed: fn) @@ -67,103 +66,17 @@ struct ShortcutRecorderView: View { } ) - RecorderContext.current = context - let refcon = Unmanaged.passUnretained(context).toOpaque() - - eventTap = CGEvent.tapCreate( - tap: .cgSessionEventTap, - place: .headInsertEventTap, - options: .defaultTap, - eventsOfInterest: eventMask, - callback: { _, type, event, refcon -> Unmanaged? in - guard let refcon else { - return Unmanaged.passUnretained(event) - } - - let ctx = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - - if type == .tapDisabledByUserInput { - ctx.reenableImmediately() - return Unmanaged.passUnretained(event) - } - - if type == .tapDisabledByTimeout { - ctx.reenableAfterTimeout() - return Unmanaged.passUnretained(event) - } - - ctx.resetTimeoutBackoff() - - let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode)) - let flags = event.flags - - if type == .flagsChanged { - let fnPressed = flags.contains(.maskSecondaryFn) - let wasFnDown = ctx.fnKeyDown - ctx.fnKeyDown = fnPressed - - let isFnKey = FnKeyCode.isFnKey(keyCode) - if isFnKey { - if fnPressed && !wasFnDown { - ctx.fnPressTime = CFAbsoluteTimeGetCurrent() - ctx.otherKeyPressedDuringFn = false - } else if !fnPressed && wasFnDown { - let wasTap: Bool - if let pressTime = ctx.fnPressTime { - wasTap = (CFAbsoluteTimeGetCurrent() - pressTime) < ctx.maxTapDuration - } else { - wasTap = false - } - ctx.fnPressTime = nil - - if wasTap && !ctx.otherKeyPressedDuringFn { - DispatchQueue.main.async { ctx.onFnOnly() } - return nil - } - } - } - - return Unmanaged.passUnretained(event) - } - - if type == .keyDown { - if ctx.fnKeyDown { - ctx.otherKeyPressedDuringFn = true - } - - if keyCode == UInt16(kVK_Escape) { - DispatchQueue.main.async { ctx.onEscape() } - return nil - } - - let modifiers = flags.modifierFlags - let fnPressed = flags.contains(.maskSecondaryFn) || ctx.fnKeyDown - - DispatchQueue.main.async { ctx.onKeyDown(keyCode, modifiers, fnPressed) } - return nil - } - - return Unmanaged.passUnretained(event) - }, - userInfo: refcon - ) - - guard let eventTap else { + guard RecorderContext.begin(context) else { log.error("Failed to create recorder event tap") isRecording = false - RecorderContext.current = nil return } - context.eventTap = eventTap - log.info("Recorder event tap created") - runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) - if let runLoopSource { - CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) - } - CGEvent.tapEnable(tap: eventTap, enable: true) + self.context = context + isRecording = true } + @MainActor private func handleKeyDown(keyCode: UInt16, modifiers: NSEvent.ModifierFlags, fnPressed: Bool) { // Ignore modifier-only keys (Fn handled separately via handleFnOnly) let modifierKeyCodes: Set = [ @@ -175,19 +88,28 @@ struct ShortcutRecorderView: View { ] guard !modifierKeyCodes.contains(keyCode) else { return } + // Fn cannot take part in a chord: the system hot-key API has no Fn + // modifier, and registering the chord without it would swallow the bare + // key everywhere — binding Fn+W would stop W reaching any app. Keep + // listening instead of storing a binding that could never fire. + guard !fnPressed else { + log.info("Ignoring Fn chord: Fn is only bindable on its own") + return + } + let newShortcut = CustomShortcut( keyCode: keyCode, command: modifiers.contains(.command), option: modifiers.contains(.option), control: modifiers.contains(.control), - shift: modifiers.contains(.shift), - fn: fnPressed + shift: modifiers.contains(.shift) ) shortcut = newShortcut stopRecording() } + @MainActor private func handleFnOnly() { let newShortcut = CustomShortcut( keyCode: 63, @@ -202,55 +124,243 @@ struct ShortcutRecorderView: View { stopRecording() } + @MainActor private func stopRecording() { isRecording = false + if let context { + RecorderContext.end(context) + } + context = nil + } +} + +/// Mutable state for the recorder tap's C callback, which reaches it through a +/// refcon that does not retain — hence the process-wide reference below, which +/// is what keeps it alive. +/// +/// Invariant: this type is confined to the main run loop, and that confinement +/// is its only synchronization. The tap's run-loop source is added to the main +/// run loop, so the callback, the re-enable timers and every field access all +/// happen on the main thread. Servicing the tap on another thread would turn +/// each field here into a data race, and the process-wide reference into a +/// use-after-free the moment a row disappears mid-event. +/// +/// The confinement costs nothing that matters: this tap is created by an +/// explicit user action, lives for the seconds it takes to press one chord, and +/// is then gone. +@MainActor +final class RecorderContext { + private static var current: RecorderContext? + + private let onKeyDown: (UInt16, NSEvent.ModifierFlags, Bool) -> Void + private let onFnOnly: () -> Void + private let onEscape: () -> Void + + private var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + private var fnKeyDown = false + private var otherKeyPressedDuringFn = false + private var fnPressTime: CFAbsoluteTime? + /// Longer than this and the Fn press is a hold, not a binding gesture. + private let maxTapDuration: TimeInterval = 0.5 + private let timeoutReenableDelays: [TimeInterval] = [0.25, 0.5, 1.0] + private var timeoutReenableAttempts = 0 + private var timeoutReenableScheduled = false + + init( + onKeyDown: @escaping (UInt16, NSEvent.ModifierFlags, Bool) -> Void, + onFnOnly: @escaping () -> Void, + onEscape: @escaping () -> Void + ) { + self.onKeyDown = onKeyDown + self.onFnOnly = onFnOnly + self.onEscape = onEscape + } + + // MARK: - Lifecycle + + /// Makes `context` the live recorder and starts its tap. + /// + /// At most one recorder may be live: the callback finds its state through a + /// single process-wide reference, so a second row starting would strand the + /// first row's tap with nothing able to reach or disable it — a tap that + /// swallows keystrokes with no way left to stop it. + static func begin(_ context: RecorderContext) -> Bool { + if let previous = current { + previous.teardown() + current = nil + // Let the superseded row leave its recording state; it can no longer + // receive anything. + previous.onEscape() + } + + guard context.createTap() else { return false } + current = context + return true + } + + static func end(_ context: RecorderContext) { + context.teardown() + if current === context { current = nil } + } + + private func createTap() -> Bool { + let eventMask: CGEventMask = + (1 << CGEventType.keyDown.rawValue) | + (1 << CGEventType.flagsChanged.rawValue) + + guard + let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: eventMask, + callback: recorderTapCallback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) + else { + return false + } + + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + + eventTap = tap + runLoopSource = source + log.info("Recorder event tap created") + return true + } + private func teardown() { if let eventTap { CGEvent.tapEnable(tap: eventTap, enable: false) CFMachPortInvalidate(eventTap) } if let runLoopSource { - CFRunLoopRemoveSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) + CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes) } - RecorderContext.current?.eventTap = nil eventTap = nil runLoopSource = nil - RecorderContext.current = nil + timeoutReenableScheduled = false } -} -/// Mutable state for the CGEventTap C callback. Stored in `current` to prevent -/// deallocation since the tap's refcon uses passUnretained. -final class RecorderContext: @unchecked Sendable { - nonisolated(unsafe) static var current: RecorderContext? + // MARK: - Event handling - let onKeyDown: (UInt16, NSEvent.ModifierFlags, Bool) -> Void - let onFnOnly: () -> Void - let onEscape: () -> Void + /// Returns whether the event was consumed and must not reach anything else. + fileprivate func process(_ event: RecorderEvent) -> Bool { + let type = CGEventType(rawValue: event.typeRawValue) - var eventTap: CFMachPort? - var fnKeyDown: Bool = false - var otherKeyPressedDuringFn: Bool = false - var fnPressTime: CFAbsoluteTime? - let maxTapDuration: TimeInterval = 0.5 - private let timeoutReenableDelays: [TimeInterval] = [0.25, 0.5, 1.0] - private var timeoutReenableAttempts = 0 - private var timeoutReenableScheduled = false + if type == .tapDisabledByUserInput { + reenableImmediately() + return false + } + + if type == .tapDisabledByTimeout { + reenableAfterTimeout() + return false + } + + resetTimeoutBackoff() + + let keyCode = event.keyCode + let flags = CGEventFlags(rawValue: event.flagsRawValue) + + switch type { + case .flagsChanged: + // Only the modifier change that completed a capture is swallowed; + // every other one has to pass through, or modifier state elsewhere + // in the system goes stale. + return handleFlagsChanged(keyCode: keyCode, flags: flags) + case .keyDown: + // Always consumed: capturing a chord must not also type it into + // whatever is focused behind the settings UI. + handleKeyDown(keyCode: keyCode, flags: flags) + return true + default: + return false + } + } + + /// Returns whether the event was consumed. + private func handleFlagsChanged(keyCode: UInt16, flags: CGEventFlags) -> Bool { + let fnPressed = flags.contains(.maskSecondaryFn) + let wasFnDown = fnKeyDown + fnKeyDown = fnPressed + + // Other modifiers pass straight through. Swallowing them would leave + // every other app believing a modifier is still held after capture. + guard FnKeyCode.isFnKey(keyCode) else { return false } + + // Both edges of Fn are consumed, not just the one that completes a + // capture. Letting the press through while eating the release is what + // makes binding Fn start a recording it then has no release left to + // stop: the shortcut monitor's own tap sits behind this one and would + // see an Fn press that never ends. + if fnPressed && !wasFnDown { + fnPressTime = CFAbsoluteTimeGetCurrent() + otherKeyPressedDuringFn = false + return true + } + + guard !fnPressed && wasFnDown else { return true } + + let wasTap: Bool + if let fnPressTime { + wasTap = (CFAbsoluteTimeGetCurrent() - fnPressTime) < maxTapDuration + } else { + wasTap = false + } + fnPressTime = nil + + guard wasTap && !otherKeyPressedDuringFn else { return true } + + deliver { $0.onFnOnly() } + return true + } + + private func handleKeyDown(keyCode: UInt16, flags: CGEventFlags) { + if fnKeyDown { + otherKeyPressedDuringFn = true + } + + if keyCode == UInt16(kVK_Escape) { + deliver { $0.onEscape() } + return + } + + let modifiers = flags.modifierFlags + let heldFn = FunctionKeyGroup.indicatesHeldFn( + keyCode: keyCode, + secondaryFnFlagSet: flags.contains(.maskSecondaryFn), + fnKeyIsDown: fnKeyDown + ) + + deliver { $0.onKeyDown(keyCode, modifiers, heldFn) } + } + + /// Runs a capture callback after this event has been returned. + /// + /// Deliberately deferred: every one of them ends the recording, which + /// invalidates the very tap whose callback is running. + private func deliver(_ body: @escaping (RecorderContext) -> Void) { + Task { @MainActor [self] in body(self) } + } - func reenableImmediately() { + // MARK: - Re-enable handling + + private func reenableImmediately() { timeoutReenableAttempts = 0 timeoutReenableScheduled = false guard let eventTap else { return } CGEvent.tapEnable(tap: eventTap, enable: true) } - func reenableAfterTimeout() { + private func reenableAfterTimeout() { guard !timeoutReenableScheduled else { return } guard timeoutReenableAttempts < timeoutReenableDelays.count else { log.error("Recorder event tap repeatedly disabled by timeout; aborting shortcut capture") - DispatchQueue.main.async { [weak self] in - self?.onEscape() - } + deliver { $0.onEscape() } return } @@ -259,25 +369,44 @@ final class RecorderContext: @unchecked Sendable { timeoutReenableScheduled = true log.warning("Recorder event tap disabled by timeout; scheduling re-enable") DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self else { return } - self.timeoutReenableScheduled = false - guard let eventTap = self.eventTap else { return } - CGEvent.tapEnable(tap: eventTap, enable: true) + // Dispatched onto the main queue, which is where this type lives, so + // the isolation being assumed is the one the dispatch guarantees. + MainActor.assumeIsolated { + guard let self else { return } + self.timeoutReenableScheduled = false + guard let eventTap = self.eventTap else { return } + CGEvent.tapEnable(tap: eventTap, enable: true) + } } } - func resetTimeoutBackoff() { + private func resetTimeoutBackoff() { timeoutReenableAttempts = 0 timeoutReenableScheduled = false } +} - init( - onKeyDown: @escaping (UInt16, NSEvent.ModifierFlags, Bool) -> Void, - onFnOnly: @escaping () -> Void, - onEscape: @escaping () -> Void - ) { - self.onKeyDown = onKeyDown - self.onFnOnly = onFnOnly - self.onEscape = onEscape - } +/// The minimum copied out of a `CGEvent`, which cannot itself be handed to the +/// main actor and whose pointer is only valid for the length of the callback. +struct RecorderEvent: Sendable { + let typeRawValue: UInt32 + let keyCode: UInt16 + let flagsRawValue: UInt64 +} + +/// The tap's source lives on the main run loop, so this runs on the main +/// thread — which is what makes `RecorderContext`'s unsynchronized state safe to +/// touch from here. +private let recorderTapCallback: CGEventTapCallBack = { _, type, event, refcon in + guard let refcon else { return Unmanaged.passUnretained(event) } + let context = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + + let facts = RecorderEvent( + typeRawValue: type.rawValue, + keyCode: UInt16(event.getIntegerValueField(.keyboardEventKeycode)), + flagsRawValue: event.flags.rawValue + ) + + let consumed = MainActor.assumeIsolated { context.process(facts) } + return consumed ? nil : Unmanaged.passUnretained(event) } diff --git a/Tests/MiniWhisperTests/CustomShortcutTests.swift b/Tests/MiniWhisperTests/CustomShortcutTests.swift index 272128d..708c298 100644 --- a/Tests/MiniWhisperTests/CustomShortcutTests.swift +++ b/Tests/MiniWhisperTests/CustomShortcutTests.swift @@ -4,35 +4,8 @@ import CoreGraphics import AppKit @testable import MiniWhisper -struct CustomShortcutMatchTests { - // kVK_ANSI_Grave = 50, kVK_ANSI_K = 40 - static let optionGrave = CustomShortcut(keyCode: UInt16(kVK_ANSI_Grave), option: true) - static let fnK = CustomShortcut(keyCode: UInt16(kVK_ANSI_K), fn: true) - - @Test func exactMatchSucceeds() { - #expect(Self.optionGrave.matches(keyCode: UInt16(kVK_ANSI_Grave), modifiers: .option, fnPressed: false)) - } - - @Test func extraFnBreaksMatch() { - #expect(!Self.optionGrave.matches(keyCode: UInt16(kVK_ANSI_Grave), modifiers: .option, fnPressed: true)) - } - - @Test func missingModifierBreaksMatch() { - #expect(!Self.optionGrave.matches(keyCode: UInt16(kVK_ANSI_Grave), modifiers: [], fnPressed: false)) - } - - @Test func wrongKeyCodeBreaksMatch() { - #expect(!Self.optionGrave.matches(keyCode: UInt16(kVK_ANSI_A), modifiers: .option, fnPressed: false)) - } - - @Test func fnModifierMatchSucceeds() { - #expect(Self.fnK.matches(keyCode: UInt16(kVK_ANSI_K), modifiers: [], fnPressed: true)) - } - - @Test func missingFnBreaksMatch() { - #expect(!Self.fnK.matches(keyCode: UInt16(kVK_ANSI_K), modifiers: [], fnPressed: false)) - } -} +// Per-event chord comparison moved to the hot-key registration itself; which +// backend a shortcut resolves to is covered by ShortcutBackendTests. struct CustomShortcutDisplayTests { @Test func optionGraveDisplayString() { diff --git a/Tests/MiniWhisperTests/FnStateMachineTests.swift b/Tests/MiniWhisperTests/FnStateMachineTests.swift index 65d8ef8..9a3cefd 100644 --- a/Tests/MiniWhisperTests/FnStateMachineTests.swift +++ b/Tests/MiniWhisperTests/FnStateMachineTests.swift @@ -99,4 +99,114 @@ struct FnStateMachineTests { // Active shortcut was cleared #expect(sm.clearActiveFnOnlyShortcut() == nil) } + + /// The Fn+← case: an ordinary key going down while Fn is held retires the + /// action the press started, and the release must not then run it a second + /// time or report itself as a plain tap. + @Test func aKeyPressedDuringTheHoldRetiresTheShortcutExactlyOnce() { + let sm = makeSM() + _ = sm.processFnKeyDown(captureTime: 0, hwTimestamp: 0) + sm.setActiveFnOnlyShortcut(.toggleRecording) + + // Observer sees the arrow key. + #expect(sm.markUsedAsModifier() == .toggleRecording) + // A second key during the same hold has nothing left to retire. + #expect(sm.markUsedAsModifier() == nil) + + let release = sm.processFnKeyUp(captureTime: 0, hwTimestamp: 100_000_000) + #expect(release == .usedAsModifier) + #expect(sm.clearActiveFnOnlyShortcut() == nil) + } + + /// After macOS drops an Fn key-up, the press it belonged to is still held. + /// The recovering press has to be able to find and complete it, or the + /// tracker rejects the new press as a repeat and the user's tap does + /// nothing at all. + @Test func stuckDownRecoveryLeavesTheAbandonedPressRecoverable() { + let sm = makeSM() + _ = sm.processFnKeyDown(captureTime: 0, hwTimestamp: 1_000_000_000) + sm.setActiveFnOnlyShortcut(.toggleRecording) + // The matching key-up never arrives. + + #expect(sm.processFnKeyDown(captureTime: 0, hwTimestamp: 7_000_000_000)) + #expect(sm.clearActiveFnOnlyShortcut() == .toggleRecording) + } + + /// Reading which shortcut is in flight must not hand over responsibility for + /// completing it — that belongs to `clearActiveFnOnlyShortcut()`. + @Test func readingTheActiveShortcutDoesNotClearIt() { + let sm = makeSM() + sm.setActiveFnOnlyShortcut(.toggleRecording) + + #expect(sm.activeFnOnlyShortcutName == .toggleRecording) + #expect(sm.activeFnOnlyShortcutName == .toggleRecording) + #expect(sm.clearActiveFnOnlyShortcut() == .toggleRecording) + #expect(sm.activeFnOnlyShortcutName == nil) + } + + /// A hold with no other key is still an ordinary trigger, however long. + @Test func aHoldWithNoOtherKeyStillCompletesTheShortcut() { + let sm = makeSM() + _ = sm.processFnKeyDown(captureTime: 0, hwTimestamp: 0) + sm.setActiveFnOnlyShortcut(.toggleRecording) + + let release = sm.processFnKeyUp(captureTime: 0, hwTimestamp: 2_000_000_000) + + #expect(release == .fnKeyUp) + #expect(sm.clearActiveFnOnlyShortcut() == .toggleRecording) + } +} + +/// macOS fires its own "press 🌐 to…" action on the *release*, so a press hidden +/// from the system followed by a delivered release triggers exactly what hiding +/// the press was meant to prevent. +struct FnPressSwallowSymmetryTests { + @Test func aSwallowedPressMakesItsReleaseSwallowedToo() { + let sm = FnStateMachine() + _ = sm.processFnKeyDown(captureTime: 0, hwTimestamp: 0) + sm.recordPressSwallowed(true) + + _ = sm.processFnKeyUp(captureTime: 0, hwTimestamp: 100_000_000) + #expect(sm.consumePressSwallowed()) + } + + @Test func aPressLeftAloneLeavesItsReleaseAlone() { + let sm = FnStateMachine() + _ = sm.processFnKeyDown(captureTime: 0, hwTimestamp: 0) + sm.recordPressSwallowed(false) + + #expect(!sm.consumePressSwallowed()) + } + + /// Symmetry holds even when the hold turned out to be modifier use: the + /// system never saw the press either way. + @Test func symmetryHoldsWhenFnWasUsedAsAModifier() { + let sm = FnStateMachine() + _ = sm.processFnKeyDown(captureTime: 0, hwTimestamp: 0) + sm.recordPressSwallowed(true) + _ = sm.markUsedAsModifier() + + #expect(sm.processFnKeyUp(captureTime: 0, hwTimestamp: 100_000_000) == .usedAsModifier) + #expect(sm.consumePressSwallowed()) + } + + /// A stray release with no press behind it must not be swallowed on the + /// strength of an older one. + @Test func theSwallowDecisionIsConsumed() { + let sm = FnStateMachine() + sm.recordPressSwallowed(true) + + #expect(sm.consumePressSwallowed()) + #expect(!sm.consumePressSwallowed()) + } + + @Test func resetClearsAPendingSwallowDecision() { + let sm = FnStateMachine() + _ = sm.processFnKeyDown(captureTime: 0, hwTimestamp: 0) + sm.recordPressSwallowed(true) + + sm.reset() + + #expect(!sm.consumePressSwallowed()) + } } diff --git a/Tests/MiniWhisperTests/HotKeyBindingPlanTests.swift b/Tests/MiniWhisperTests/HotKeyBindingPlanTests.swift new file mode 100644 index 0000000..20544a2 --- /dev/null +++ b/Tests/MiniWhisperTests/HotKeyBindingPlanTests.swift @@ -0,0 +1,168 @@ +import Carbon.HIToolbox +import Testing + +@testable import MiniWhisper + +struct HotKeyBindingPlanTests { + private func exact( + _ name: CustomShortcutName, _ keyCode: Int, modifiers: UInt32 = 0 + ) -> HotKeyBindingPlan.Request { + .init( + name: name, keyCode: UInt16(keyCode), carbonModifiers: modifiers, + ignoresModifiers: false) + } + + private func expanded(_ name: CustomShortcutName, _ keyCode: Int) -> HotKeyBindingPlan.Request { + .init(name: name, keyCode: UInt16(keyCode), carbonModifiers: 0, ignoresModifiers: true) + } + + @Test func exactRequestBecomesOneBinding() { + let plan = HotKeyBindingPlan.resolve([ + exact(.toggleRecording, kVK_ANSI_W, modifiers: UInt32(optionKey)) + ]) + + #expect( + plan.bindings == [ + HotKeyBinding( + name: .toggleRecording, keyCode: UInt16(kVK_ANSI_W), + carbonModifiers: UInt32(optionKey)) + ]) + #expect(plan.shadowed.isEmpty) + } + + @Test func modifierInsensitiveRequestExpandsOverEveryCombination() { + let plan = HotKeyBindingPlan.resolve([expanded(.cancelRecording, kVK_Escape)]) + + #expect(plan.bindings.count == 16) + #expect(Set(plan.bindings.map(\.carbonModifiers)).count == 16) + #expect(plan.shadowed.isEmpty) + } + + /// The collision that actually happens: cancel expands over every modifier + /// combination, and one of those combinations is another shortcut's chord. + @Test func exactChordBeatsAnExpansionCombination() { + let plan = HotKeyBindingPlan.resolve([ + expanded(.cancelRecording, kVK_Escape), + exact(.toggleRecording, kVK_Escape, modifiers: UInt32(optionKey)), + ]) + + let contested = plan.bindings.filter { + $0.keyCode == UInt16(kVK_Escape) && $0.carbonModifiers == UInt32(optionKey) + } + #expect(contested.map(\.name) == [.toggleRecording]) + #expect(plan.bindings.count == 16) // cancel keeps the other 15 + #expect( + plan.shadowed == [ + HotKeyBinding( + name: .cancelRecording, keyCode: UInt16(kVK_Escape), + carbonModifiers: UInt32(optionKey)) + ]) + } + + /// Registration order decides who wins a contested chord, so the plan must + /// not depend on the order it happened to be handed. + @Test func precedenceIsIndependentOfRequestOrder() { + let requests = [ + expanded(.cancelRecording, kVK_Escape), + exact(.toggleRecording, kVK_Escape, modifiers: UInt32(optionKey)), + ] + + #expect(HotKeyBindingPlan.resolve(requests) == HotKeyBindingPlan.resolve(requests.reversed())) + } + + /// Two shortcuts on the same chord: whichever is declared first wins, so the + /// same one works on every launch. + @Test func tiesBetweenExactChordsGoToDeclarationOrder() { + let requests = [ + exact(.editSelection, kVK_ANSI_E, modifiers: UInt32(optionKey)), + exact(.toggleRecording, kVK_ANSI_E, modifiers: UInt32(optionKey)), + ] + + let plan = HotKeyBindingPlan.resolve(requests) + + #expect(plan.bindings.map(\.name) == [.toggleRecording]) + #expect(plan.shadowed.map(\.name) == [.editSelection]) + #expect(HotKeyBindingPlan.resolve(requests.reversed()) == plan) + } + + /// Exact chords must be offered to the OS before any expansion, since first + /// asker wins. + @Test func exactChordsAreRegisteredBeforeExpansions() { + let plan = HotKeyBindingPlan.resolve([ + expanded(.cancelRecording, kVK_Escape), + exact(.toggleRecording, kVK_ANSI_W, modifiers: UInt32(optionKey)), + ]) + + #expect(plan.bindings.first?.name == .toggleRecording) + } + + @Test func unrelatedShortcutsAllSurvive() { + let plan = HotKeyBindingPlan.resolve([ + exact(.toggleRecording, kVK_ANSI_W, modifiers: UInt32(optionKey)), + exact(.autoCleanupRecording, kVK_ANSI_R, modifiers: UInt32(optionKey)), + exact(.editSelection, kVK_ANSI_E, modifiers: UInt32(optionKey)), + ]) + + #expect(plan.bindings.count == 3) + #expect(plan.shadowed.isEmpty) + } + + @Test func emptyRequestListPlansNothing() { + let plan = HotKeyBindingPlan.resolve([]) + + #expect(plan.bindings.isEmpty) + #expect(plan.shadowed.isEmpty) + } +} + +struct HotKeyHandlerGateTests { + /// Fail closed: a chord registered with no handler behind it is swallowed + /// system-wide with nothing able to act on it. + @Test func installFailureLeavesTheGateShut() { + var gate = HotKeyHandlerGate() + + #expect(!gate.ensureInstalled { false }) + #expect(!gate.isInstalled) + } + + @Test func successOpensTheGate() { + var gate = HotKeyHandlerGate() + + #expect(gate.ensureInstalled { true }) + #expect(gate.isInstalled) + } + + @Test func handlerIsInstalledOnlyOnce() { + var gate = HotKeyHandlerGate() + var installs = 0 + + for _ in 0..<3 { + _ = gate.ensureInstalled { + installs += 1 + return true + } + } + + #expect(installs == 1) + } + + /// A failed install must not be permanent — the next registration tries + /// again rather than leaving every shortcut dead for the session. + @Test func failureIsRetriedOnTheNextAttempt() { + var gate = HotKeyHandlerGate() + var attempts = 0 + + let first = gate.ensureInstalled { + attempts += 1 + return false + } + let second = gate.ensureInstalled { + attempts += 1 + return true + } + + #expect(!first) + #expect(second) + #expect(attempts == 2) + } +} diff --git a/Tests/MiniWhisperTests/HotKeyPressTrackerTests.swift b/Tests/MiniWhisperTests/HotKeyPressTrackerTests.swift new file mode 100644 index 0000000..76598a7 --- /dev/null +++ b/Tests/MiniWhisperTests/HotKeyPressTrackerTests.swift @@ -0,0 +1,161 @@ +import Testing + +@testable import MiniWhisper + +// Results are bound to locals before `#expect`: the macro rewrites the call +// into a closure taking an immutable copy, which cannot invoke mutating members. +struct HotKeyPressTrackerTests { + @Test func pressThenReleaseCompletesAHoldToTalkCycle() { + var tracker = HotKeyPressTracker() + + let pressed = tracker.press(.toggleRecording) + #expect(pressed) + #expect(tracker.isPressed(.toggleRecording)) + + let released = tracker.release(.toggleRecording) + #expect(released) + #expect(!tracker.isPressed(.toggleRecording)) + } + + /// A repeat press must not restart an in-flight recording. + @Test func repeatPressIsSuppressed() { + var tracker = HotKeyPressTracker() + + let first = tracker.press(.toggleRecording) + let second = tracker.press(.toggleRecording) + + #expect(first) + #expect(!second) + #expect(tracker.isPressed(.toggleRecording)) + } + + /// Guards the invariant that a stop never runs without its matching start. + @Test func releaseWithoutPressIsIgnored() { + var tracker = HotKeyPressTracker() + + let released = tracker.release(.toggleRecording) + #expect(!released) + } + + @Test func secondReleaseIsIgnored() { + var tracker = HotKeyPressTracker() + + _ = tracker.press(.editSelection) + let first = tracker.release(.editSelection) + let second = tracker.release(.editSelection) + + #expect(first) + #expect(!second) + } + + @Test func pressIsAcceptedAgainAfterRelease() { + var tracker = HotKeyPressTracker() + + let first = tracker.press(.toggleRecording) + let released = tracker.release(.toggleRecording) + let second = tracker.press(.toggleRecording) + + #expect(first) + #expect(released) + #expect(second) + } + + @Test func shortcutsAreTrackedIndependently() { + var tracker = HotKeyPressTracker() + + let toggle = tracker.press(.toggleRecording) + let cancel = tracker.press(.cancelRecording) + let releasedToggle = tracker.release(.toggleRecording) + + #expect(toggle) + #expect(cancel) + #expect(releasedToggle) + #expect(tracker.isPressed(.cancelRecording)) + #expect(!tracker.isPressed(.toggleRecording)) + } + + /// A hot key unregistered mid-hold never delivers its release, so the press + /// has to be completed here or a hold-style action runs forever. + @Test func droppedRegistrationStrandsItsPress() { + var tracker = HotKeyPressTracker() + + _ = tracker.press(.toggleRecording) + _ = tracker.press(.editSelection) + + let stranded = tracker.drainStranded(keeping: [.editSelection]) + + #expect(stranded == [.toggleRecording]) + #expect(!tracker.isPressed(.toggleRecording)) + #expect(tracker.isPressed(.editSelection)) + } + + @Test func stillRegisteredPressesAreLeftAlone() { + var tracker = HotKeyPressTracker() + + _ = tracker.press(.toggleRecording) + let stranded = tracker.drainStranded(keeping: [.toggleRecording, .cancelRecording]) + + #expect(stranded.isEmpty) + #expect(tracker.isPressed(.toggleRecording)) + } + + @Test func aShortcutThatWasNotHeldIsNotReportedAsStranded() { + var tracker = HotKeyPressTracker() + + #expect(tracker.drainStranded(keeping: []).isEmpty) + } + + /// Draining is what fires the key-up handlers, so a second refresh must not + /// fire them again. + @Test func strandedPressesAreReportedOnlyOnce() { + var tracker = HotKeyPressTracker() + + _ = tracker.press(.toggleRecording) + + #expect(tracker.drainStranded(keeping: []) == [.toggleRecording]) + #expect(tracker.drainStranded(keeping: []).isEmpty) + } + + /// What `stop()` relies on: with nothing live, every held press comes back + /// to be completed instead of being silently dropped. + @Test func drainingAgainstAnEmptyLiveSetReturnsEveryHeldPress() { + var tracker = HotKeyPressTracker() + + _ = tracker.press(.toggleRecording) + _ = tracker.press(.cancelRecording) + + let stranded = tracker.drainStranded(keeping: []) + + #expect(Set(stranded) == [.toggleRecording, .cancelRecording]) + #expect(!tracker.isPressed(.toggleRecording)) + #expect(!tracker.isPressed(.cancelRecording)) + } + + @Test func strandedPressesAreReportedInDeclarationOrder() { + var tracker = HotKeyPressTracker() + + for name in CustomShortcutName.allCases.reversed() { + _ = tracker.press(name) + } + + #expect(tracker.drainStranded(keeping: []) == CustomShortcutName.allCases) + } + + /// `stop()` resets the tracker; a release arriving afterwards must not fire + /// a handler for a press that belonged to the previous session. + @Test func resetDropsEveryHeldShortcut() { + var tracker = HotKeyPressTracker() + + _ = tracker.press(.toggleRecording) + _ = tracker.press(.editSelection) + + tracker.reset() + + let staleToggle = tracker.release(.toggleRecording) + let staleEdit = tracker.release(.editSelection) + + #expect(!tracker.isPressed(.toggleRecording)) + #expect(!staleToggle) + #expect(!staleEdit) + } +} diff --git a/Tests/MiniWhisperTests/ShortcutBackendTests.swift b/Tests/MiniWhisperTests/ShortcutBackendTests.swift new file mode 100644 index 0000000..a20dd85 --- /dev/null +++ b/Tests/MiniWhisperTests/ShortcutBackendTests.swift @@ -0,0 +1,223 @@ +import Carbon.HIToolbox +import CoreGraphics +import Testing + +@testable import MiniWhisper + +struct ShortcutBackendClassificationTests { + @Test func chordWithModifiersGoesToCarbon() { + let shortcut = CustomShortcut(keyCode: UInt16(kVK_ANSI_W), option: true) + + #expect( + ShortcutBackend.classify(shortcut) + == .carbon(keyCode: UInt16(kVK_ANSI_W), carbonModifiers: UInt32(optionKey))) + } + + @Test func bareKeyGoesToCarbonWithNoModifiers() { + let shortcut = CustomShortcut(keyCode: UInt16(kVK_Escape)) + + #expect( + ShortcutBackend.classify(shortcut) + == .carbon(keyCode: UInt16(kVK_Escape), carbonModifiers: 0)) + } + + @Test(arguments: [UInt16(63), UInt16(179)]) + func bareFnGoesToModifierTap(keyCode: UInt16) { + #expect(ShortcutBackend.classify(CustomShortcut(keyCode: keyCode)) == .modifierOnly) + } + + /// Registering the chord without Fn would swallow the bare key everywhere, + /// so refusing it is the safe outcome. + @Test func fnPlusRegularKeyIsUnsupported() { + let shortcut = CustomShortcut(keyCode: UInt16(kVK_ANSI_W), fn: true) + + #expect(ShortcutBackend.classify(shortcut) == .unsupported(.fnChord)) + } + + @Test func fnKeyCarryingOtherModifiersIsUnsupported() { + let shortcut = CustomShortcut(keyCode: 63, command: true) + + #expect(ShortcutBackend.classify(shortcut) == .unsupported(.modifierChord)) + } + + /// Older builds stored `fn: true` for every function-group key, because the + /// system reports the flag for them whether or not Fn was held. Reading + /// those back as Fn chords would retire working shortcuts on upgrade. + @Test(arguments: [ + UInt16(kVK_UpArrow), UInt16(kVK_DownArrow), UInt16(kVK_LeftArrow), UInt16(kVK_RightArrow), + UInt16(kVK_F1), UInt16(kVK_F5), UInt16(kVK_F12), + UInt16(kVK_Home), UInt16(kVK_End), UInt16(kVK_PageUp), UInt16(kVK_PageDown), + UInt16(kVK_ForwardDelete), + ]) + func storedFnFlagOnFunctionGroupKeyIsNormalisedToAPlainChord(keyCode: UInt16) { + let migrated = CustomShortcut(keyCode: keyCode, fn: true) + + #expect(ShortcutBackend.classify(migrated) == .carbon(keyCode: keyCode, carbonModifiers: 0)) + #expect(!migrated.needsRerecording) + } + + @Test func storedFnFlagOnAFunctionGroupKeyKeepsItsOtherModifiers() { + let migrated = CustomShortcut(keyCode: UInt16(kVK_UpArrow), option: true, fn: true) + + #expect( + ShortcutBackend.classify(migrated) + == .carbon(keyCode: UInt16(kVK_UpArrow), carbonModifiers: UInt32(optionKey))) + } + + /// The one case a stored Fn flag still means something, and the one the UI + /// has to flag: nothing can register it. + @Test func fnChordOnAnOrdinaryKeyStillNeedsRerecording() { + #expect(CustomShortcut(keyCode: UInt16(kVK_ANSI_W), fn: true).needsRerecording) + #expect(CustomShortcut(keyCode: 63, command: true).needsRerecording) + #expect(!CustomShortcut(keyCode: UInt16(kVK_ANSI_W), option: true).needsRerecording) + #expect(!CustomShortcut(keyCode: 63).needsRerecording) + } + + /// A normalised binding must not keep advertising a modifier it ignores. + @Test func migratedFunctionGroupShortcutDropsFnFromItsDisplayString() { + #expect(CustomShortcut(keyCode: UInt16(kVK_UpArrow), fn: true).compactDisplayString == "↑") + #expect(CustomShortcut(keyCode: UInt16(kVK_ANSI_W), fn: true).compactDisplayString == "Fn+W") + } + + /// Every default binding must land on Carbon, so a stock install needs no + /// event tap and no Accessibility grant for its shortcuts. + @Test func defaultShortcutsAllUseCarbon() { + for (_, shortcut) in CustomShortcutStorage.defaultShortcuts() { + guard case .carbon = ShortcutBackend.classify(shortcut) else { + Issue.record("Default shortcut \(shortcut.compactDisplayString) is not a Carbon chord") + continue + } + } + } +} + +struct CarbonModifierMaskTests { + @Test func noModifiersIsZero() { + #expect(CarbonModifierMask.from(command: false, option: false, control: false, shift: false) == 0) + } + + @Test(arguments: [ + (true, false, false, false, UInt32(cmdKey)), + (false, true, false, false, UInt32(optionKey)), + (false, false, true, false, UInt32(controlKey)), + (false, false, false, true, UInt32(shiftKey)), + ]) + func singleModifierMapsToItsCarbonConstant( + command: Bool, option: Bool, control: Bool, shift: Bool, expected: UInt32 + ) { + #expect( + CarbonModifierMask.from( + command: command, option: option, control: control, shift: shift) == expected) + } + + @Test func modifiersCombineAsABitmask() { + let mask = CarbonModifierMask.from( + command: true, option: true, control: false, shift: true) + + #expect(mask == UInt32(cmdKey) | UInt32(optionKey) | UInt32(shiftKey)) + #expect(mask & UInt32(controlKey) == 0) + } + + @Test func allCombinationsCoversEveryDistinctSubset() { + let combinations = CarbonModifierMask.allCombinations + + #expect(combinations.count == 16) + #expect(Set(combinations).count == 16) + #expect(combinations.contains(0)) + #expect( + combinations.contains( + UInt32(cmdKey) | UInt32(optionKey) | UInt32(controlKey) | UInt32(shiftKey))) + } + + /// A modifier-insensitive shortcut is expressed as one registration per + /// combination, so the exact chord must be among them. + @Test func allCombinationsContainsEveryExactChord() { + for command in [false, true] { + for option in [false, true] { + let mask = CarbonModifierMask.from( + command: command, option: option, control: false, shift: false) + #expect(CarbonModifierMask.allCombinations.contains(mask)) + } + } + } +} + +struct FunctionKeyGroupTests { + @Test(arguments: [ + UInt16(kVK_LeftArrow), UInt16(kVK_RightArrow), UInt16(kVK_UpArrow), UInt16(kVK_DownArrow), + UInt16(kVK_F1), UInt16(kVK_F2), UInt16(kVK_F3), UInt16(kVK_F4), UInt16(kVK_F5), + UInt16(kVK_F6), UInt16(kVK_F7), UInt16(kVK_F8), UInt16(kVK_F9), UInt16(kVK_F10), + UInt16(kVK_F11), UInt16(kVK_F12), + UInt16(kVK_Home), UInt16(kVK_End), UInt16(kVK_PageUp), UInt16(kVK_PageDown), + UInt16(kVK_ForwardDelete), + ]) + func groupCoversEveryKeyThatReportsFnOnItsOwn(keyCode: UInt16) { + #expect(FunctionKeyGroup.setsSecondaryFnIntrinsically(keyCode)) + } + + @Test(arguments: [ + UInt16(kVK_ANSI_W), UInt16(kVK_ANSI_A), UInt16(kVK_Space), UInt16(kVK_Escape), + UInt16(kVK_Delete), UInt16(kVK_Return), UInt16(kVK_Tab), UInt16(63), UInt16(179), + ]) + func ordinaryKeysAreNotInTheGroup(keyCode: UInt16) { + #expect(!FunctionKeyGroup.setsSecondaryFnIntrinsically(keyCode)) + } + + /// The recorder's guard: without this the flag arrows carry intrinsically + /// would reject every one of them as an unbindable Fn chord. + @Test func arrowsAndFKeysStayRecordableDespiteTheFnFlag() { + for keyCode in [UInt16(kVK_UpArrow), UInt16(kVK_F5), UInt16(kVK_ForwardDelete)] { + #expect( + !FunctionKeyGroup.indicatesHeldFn( + keyCode: keyCode, secondaryFnFlagSet: true, fnKeyIsDown: false)) + // Even with Fn genuinely held: an Fn chord could not be bound, so + // the plain key is the useful reading. + #expect( + !FunctionKeyGroup.indicatesHeldFn( + keyCode: keyCode, secondaryFnFlagSet: true, fnKeyIsDown: true)) + } + } + + @Test func heldFnIsStillDetectedOnOrdinaryKeys() { + #expect( + FunctionKeyGroup.indicatesHeldFn( + keyCode: UInt16(kVK_ANSI_W), secondaryFnFlagSet: true, fnKeyIsDown: false)) + #expect( + FunctionKeyGroup.indicatesHeldFn( + keyCode: UInt16(kVK_ANSI_W), secondaryFnFlagSet: false, fnKeyIsDown: true)) + #expect( + !FunctionKeyGroup.indicatesHeldFn( + keyCode: UInt16(kVK_ANSI_W), secondaryFnFlagSet: false, fnKeyIsDown: false)) + } +} + +struct ShortcutModifierSensitivityTests { + @Test func onlyCancelIgnoresModifiers() { + #expect(CustomShortcutName.cancelRecording.ignoresModifiers) + + for name in CustomShortcutName.allCases where name != .cancelRecording { + #expect(!name.ignoresModifiers) + } + } +} + +struct ModifierTapPolicyTests { + /// The whole point of the split: the tap may only ever see modifier + /// changes. keyDown/keyUp belong to Carbon. + @Test func maskIsFlagsChangedOnly() { + #expect(ModifierTapPolicy.eventMask == CGEventMask(1 << CGEventType.flagsChanged.rawValue)) + #expect(ModifierTapPolicy.eventMask == 0x1000) + #expect(ModifierTapPolicy.eventMask & CGEventMask(1 << CGEventType.keyDown.rawValue) == 0) + #expect(ModifierTapPolicy.eventMask & CGEventMask(1 << CGEventType.keyUp.rawValue) == 0) + } + + @Test func tapExistsOnlyForModifierOnlyShortcuts() { + #expect(ModifierTapPolicy.needsTap(hasModifierOnlyShortcut: true)) + #expect(!ModifierTapPolicy.needsTap(hasModifierOnlyShortcut: false)) + } + + @Test func filterTapOnlyWhenThePressMustBeSuppressed() { + #expect(ModifierTapPolicy.tapOption(suppressesModifierPress: true) == .defaultTap) + #expect(ModifierTapPolicy.tapOption(suppressesModifierPress: false) == .listenOnly) + } +} diff --git a/Tests/MiniWhisperTests/TapHealthPolicyTests.swift b/Tests/MiniWhisperTests/TapHealthPolicyTests.swift new file mode 100644 index 0000000..cc5f38a --- /dev/null +++ b/Tests/MiniWhisperTests/TapHealthPolicyTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Testing + +@testable import MiniWhisper + +/// The rebuild decision for a tap that still reports itself as enabled. Getting +/// this wrong in either direction is costly: a false positive tears down a +/// healthy tap every watchdog tick, a false negative leaves the shortcut +/// silently dead until the app restarts. +struct TapStarvationPolicyTests { + private let starved = TapStarvationPolicy.starvedLatencyUs + 1 + private let healthy: Float = 250 // µs, a normal serviced tap + + @Test func idleKeyboardAloneIsNotStarvation() { + #expect( + !TapStarvationPolicy.isStarved( + silentFor: TapStarvationPolicy.silenceThreshold + 60, + reportedLatencyUs: healthy)) + } + + @Test func highLatencyOnARecentlyActiveTapIsNotStarvation() { + #expect(!TapStarvationPolicy.isStarved(silentFor: 1, reportedLatencyUs: starved)) + } + + @Test func silenceAndQueuedEventsTogetherMeanStarvation() { + #expect( + TapStarvationPolicy.isStarved( + silentFor: TapStarvationPolicy.silenceThreshold + 1, + reportedLatencyUs: starved)) + } + + /// No entry in the window server's list is missing evidence, not evidence + /// of starvation. + @Test func anUnreportedTapIsNeverJudgedStarved() { + #expect( + !TapStarvationPolicy.isStarved( + silentFor: TapStarvationPolicy.silenceThreshold + 1_000, + reportedLatencyUs: nil)) + } + + @Test func thresholdsAreExclusive() { + #expect( + !TapStarvationPolicy.isStarved( + silentFor: TapStarvationPolicy.silenceThreshold, + reportedLatencyUs: TapStarvationPolicy.starvedLatencyUs + 1)) + #expect( + !TapStarvationPolicy.isStarved( + silentFor: TapStarvationPolicy.silenceThreshold + 1, + reportedLatencyUs: TapStarvationPolicy.starvedLatencyUs)) + } + + /// Guards the two constants themselves: the latency bar has to sit above + /// the window server's own per-event tap timeout, and the silence bar well + /// above a plausible pause in typing. + @Test func thresholdsStayInTheirIntendedRange() { + #expect(TapStarvationPolicy.starvedLatencyUs >= 5_000_000) + #expect(TapStarvationPolicy.silenceThreshold >= 60) + } +}