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) + } +}