import AppKit import ApplicationServices import Carbon import ComposableArchitecture import CoreGraphics import Dependencies import DependenciesMacros import Foundation import HexCore import IOKit import IOKit.hidsystem import Sauce private let logger = HexLog.keyEvent struct KeyEventMonitorToken: Sendable { private let cancelHandler: @Sendable () -> Void init(cancel: @escaping @Sendable () -> Void) { self.cancelHandler = cancel } func cancel() { cancelHandler() } static let noop = KeyEventMonitorToken(cancel: {}) } public extension KeyEvent { init(cgEvent: CGEvent, type: CGEventType, isFnPressed: Bool) { let keyCode = Int(cgEvent.getIntegerValueField(.keyboardEventKeycode)) // Accessing keyboard layout / input source via Sauce must be on main thread. let key: Key? if cgEvent.type == .keyDown { if Thread.isMainThread { key = Sauce.shared.key(for: keyCode) } else { key = DispatchQueue.main.sync { Sauce.shared.key(for: keyCode) } } } else { key = nil } var modifiers = Modifiers.from(carbonFlags: cgEvent.flags) if !isFnPressed { modifiers = modifiers.removing(kind: .fn) } self.init(key: key, modifiers: modifiers) } } @DependencyClient struct KeyEventMonitorClient { var listenForKeyPress: @Sendable () async -> AsyncThrowingStream = { AsyncThrowingStream { _ in } } var handleKeyEvent: @Sendable (@Sendable @escaping (KeyEvent) -> Bool) -> KeyEventMonitorToken = { _ in .noop } var handleInputEvent: @Sendable (@Sendable @escaping (InputEvent) -> Bool) -> KeyEventMonitorToken = { _ in .noop } var startMonitoring: @Sendable () async -> Void = {} var stopMonitoring: @Sendable () -> Void = {} } extension KeyEventMonitorClient: DependencyKey { static var liveValue: KeyEventMonitorClient { let live = KeyEventMonitorClientLive() return KeyEventMonitorClient( listenForKeyPress: { live.listenForKeyPress() }, handleKeyEvent: { handler in live.handleKeyEvent(handler) }, handleInputEvent: { handler in live.handleInputEvent(handler) }, startMonitoring: { live.startMonitoring() }, stopMonitoring: { live.stopMonitoring() } ) } } extension DependencyValues { var keyEventMonitor: KeyEventMonitorClient { get { self[KeyEventMonitorClient.self] } set { self[KeyEventMonitorClient.self] = newValue } } } class KeyEventMonitorClientLive { private var eventTapPort: CFMachPort? private var runLoopSource: CFRunLoopSource? private var continuations: [UUID: @Sendable (KeyEvent) -> Bool] = [:] private var inputContinuations: [UUID: @Sendable (InputEvent) -> Bool] = [:] private let queue = DispatchQueue(label: "com.kitlangton.Hex.KeyEventMonitor", attributes: .concurrent) private let queueSpecificKey = DispatchSpecificKey() private var isMonitoring = false private var wantsMonitoring = false private var accessibilityTrusted = false private var inputMonitoringTrusted = false /// Set when key events are observed arriving at the tap. Key events only flow when Input /// Monitoring is genuinely granted, so this overrides stale `IOHIDCheckAccess` denials (#250). private var inputMonitoringProvenByEvents = false private var trustMonitorTask: Task? private var systemEventObservers: [NSObjectProtocol] = [] private var isFnPressed = false private var hasPromptedForAccessibilityTrust = false private let accessibilityTrustProvider: @Sendable () -> Bool private let accessibilityTrustPrompt: @Sendable () -> Bool private let inputMonitoringTrustProvider: @Sendable () -> Bool @Shared(.hotkeyPermissionState) private var hotkeyPermissionState: HotkeyPermissionState private let trustCheckIntervalNanoseconds: UInt64 = 100_000_000 // 100ms init( accessibilityTrustProvider: @escaping @Sendable () -> Bool = { let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String return AXIsProcessTrustedWithOptions([promptKey: false] as CFDictionary) }, accessibilityTrustPrompt: @escaping @Sendable () -> Bool = { let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String return AXIsProcessTrustedWithOptions([promptKey: true] as CFDictionary) }, inputMonitoringTrustProvider: @escaping @Sendable () -> Bool = { IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeGranted } ) { self.accessibilityTrustProvider = accessibilityTrustProvider self.accessibilityTrustPrompt = accessibilityTrustPrompt self.inputMonitoringTrustProvider = inputMonitoringTrustProvider queue.setSpecific(key: queueSpecificKey, value: ()) logger.info("Initializing HotKeyClient with CGEvent tap.") registerSystemEventObservers() } deinit { let center = NSWorkspace.shared.notificationCenter for observer in systemEventObservers { center.removeObserver(observer) } self.stopMonitoring() } private var hasHandlers: Bool { readState { !(continuations.isEmpty && inputContinuations.isEmpty) } } func readState(_ operation: () -> Value) -> Value { // Handler registration performs permission checks from a barrier block on this queue. if DispatchQueue.getSpecific(key: queueSpecificKey) != nil { return operation() } return queue.sync(execute: operation) } private func setMonitoringIntent(_ value: Bool) { queue.async(flags: .barrier) { [weak self] in self?.wantsMonitoring = value } } private func desiredMonitoringState() -> Bool { // Intentionally not gated on `inputMonitoringTrusted`: `IOHIDCheckAccess` is notorious for // returning stale denials (after sleep, MDM re-logins, or OS updates) while events still // flow, and tearing the tap down on that signal killed working hotkeys (#250). The tap only // needs Accessibility to exist; without Input Monitoring, macOS simply withholds key events // (modifiers still arrive), and creating the tap is what triggers the permission prompt. readState { wantsMonitoring && accessibilityTrusted && !(continuations.isEmpty && inputContinuations.isEmpty) } } /// Provide a stream of key events. func listenForKeyPress() -> AsyncThrowingStream { AsyncThrowingStream { continuation in let uuid = UUID() queue.async(flags: .barrier) { [weak self] in guard let self = self else { return } self.continuations[uuid] = { event in continuation.yield(event) return false } let shouldStart = self.continuations.count == 1 && self.inputContinuations.isEmpty // Start monitoring if this is the first subscription if shouldStart { self.startMonitoring() } } // Cleanup on cancellation continuation.onTermination = { [weak self] _ in self?.removeHandlerContinuation(uuid: uuid) } } } private func removeHandlerContinuation(uuid: UUID) { queue.async(flags: .barrier) { [weak self] in guard let self = self else { return } self.continuations[uuid] = nil if self.continuations.isEmpty && self.inputContinuations.isEmpty { self.stopMonitoring() } } } private func removeInputContinuation(uuid: UUID) { queue.async(flags: .barrier) { [weak self] in guard let self = self else { return } self.inputContinuations[uuid] = nil if self.continuations.isEmpty && self.inputContinuations.isEmpty { self.stopMonitoring() } } } func startMonitoring() { setMonitoringIntent(true) startTrustMonitorIfNeeded() refreshTrustedFlag(promptIfUntrusted: true) Task { [weak self] in await self?.refreshMonitoringState(reason: "startMonitoring") } } // TODO: Handle removing the handler from the continuations on deinit/cancellation func handleKeyEvent(_ handler: @Sendable @escaping (KeyEvent) -> Bool) -> KeyEventMonitorToken { let uuid = UUID() queue.async(flags: .barrier) { [weak self] in guard let self = self else { return } self.continuations[uuid] = handler let shouldStart = self.continuations.count == 1 && self.inputContinuations.isEmpty if shouldStart { self.startMonitoring() } } return KeyEventMonitorToken { [weak self] in self?.removeHandlerContinuation(uuid: uuid) } } func handleInputEvent(_ handler: @Sendable @escaping (InputEvent) -> Bool) -> KeyEventMonitorToken { let uuid = UUID() queue.async(flags: .barrier) { [weak self] in guard let self = self else { return } self.inputContinuations[uuid] = handler let shouldStart = self.inputContinuations.count == 1 && self.continuations.isEmpty if shouldStart { self.startMonitoring() } } return KeyEventMonitorToken { [weak self] in self?.removeInputContinuation(uuid: uuid) } } func stopMonitoring() { setMonitoringIntent(false) Task { [weak self] in await self?.refreshMonitoringState(reason: "stopMonitoring") } cancelTrustMonitorIfNeeded() } private func startTrustMonitorIfNeeded() { queue.async(flags: .barrier) { [weak self] in guard let self else { return } guard self.trustMonitorTask == nil else { return } self.trustMonitorTask = Task { [weak self] in await self?.watchPermissions() } } } private func cancelTrustMonitorIfNeeded() { queue.async(flags: .barrier) { [weak self] in guard let self else { return } guard !self.wantsMonitoring else { return } self.trustMonitorTask?.cancel() self.trustMonitorTask = nil } } // no separate helper; handled inline above private func watchPermissions() async { var last = ( accessibility: currentAccessibilityTrust(), input: currentInputMonitoringTrust() ) await handlePermissionChange(accessibility: last.accessibility, input: last.input, reason: "initial") while !Task.isCancelled { try? await Task.sleep(nanoseconds: trustCheckIntervalNanoseconds) let current = ( accessibility: currentAccessibilityTrust(), input: currentInputMonitoringTrust() ) if current.accessibility != last.accessibility || current.input != last.input { let combinedBefore = last.accessibility && last.input let combinedAfter = current.accessibility && current.input let reason: String if combinedAfter && !combinedBefore { reason = "regained" } else if !combinedAfter && combinedBefore { reason = "revoked" } else { reason = "updated" } await handlePermissionChange(accessibility: current.accessibility, input: current.input, reason: reason) last = current } else if current.accessibility { await ensureTapIsRunning() } } } private func handlePermissionChange(accessibility: Bool, input: Bool, reason: String) async { setPermissionFlags(accessibility: accessibility, input: input) logger.notice("Permission update: accessibility=\(accessibility), inputMonitoring=\(input), reason=\(reason)") if accessibility && input { logger.notice("Keyboard monitoring permissions granted (\(reason)).") } else { if !accessibility { logger.error("Accessibility permission missing (\(reason)); suspending tap.") } if !input { logger.error("Input Monitoring permission missing (\(reason)); keyed hotkeys may not fire until it is granted. Tap stays alive in case the check is stale.") } } await refreshMonitoringState(reason: "trust_\(reason)") } private func ensureTapIsRunning() async { guard desiredMonitoringState() else { return } await activateTapOnMain(reason: "watchdog_keepalive") } private func refreshMonitoringState(reason: String) async { let shouldMonitor = desiredMonitoringState() if shouldMonitor { await activateTapOnMain(reason: reason) } else { await deactivateTapOnMain(reason: reason) } } private func setPermissionFlags(accessibility: Bool, input: Bool) { queue.async(flags: .barrier) { [weak self] in self?.accessibilityTrusted = accessibility self?.inputMonitoringTrusted = input } recordSharedPermissionState(accessibility: accessibility, input: input) } private func recordSharedPermissionState(accessibility: Bool, input: Bool) { $hotkeyPermissionState.withLock { $0.accessibility = accessibility ? .granted : .denied $0.inputMonitoring = input ? .granted : .denied $0.lastUpdated = Date() } } private func activateTapOnMain(reason: String) async { await MainActor.run { self.activateTapIfNeeded(reason: reason) } } private func deactivateTapOnMain(reason: String) async { await MainActor.run { self.deactivateTap(reason: reason) } } @MainActor private func activateTapIfNeeded(reason: String) { if isMonitoring { // The 100ms permission watchdog lands here while healthy; use it to revive taps that // macOS disabled without sending a tapDisabled event (observed after sleep, #250). if let eventTapPort, !CGEvent.tapIsEnabled(tap: eventTapPort) { CGEvent.tapEnable(tap: eventTapPort, enable: true) logger.notice("Re-enabled event tap that was silently disabled (reason: \(reason)).") } return } guard hasHandlers else { return } let accessibilityTrusted = currentAccessibilityTrust() let inputMonitoringTrusted = currentInputMonitoringTrust() setPermissionFlags(accessibility: accessibilityTrusted, input: inputMonitoringTrusted) guard accessibilityTrusted else { logger.error("Cannot start key event monitoring (reason: \(reason)); accessibility permission is not granted.") return } if !inputMonitoringTrusted { logger.notice("Input Monitoring not yet granted; creating event tap will trigger permission prompt (reason: \(reason)).") } let eventMask = ((1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) | (1 << CGEventType.flagsChanged.rawValue) | (1 << CGEventType.leftMouseDown.rawValue) | (1 << CGEventType.rightMouseDown.rawValue) | (1 << CGEventType.otherMouseDown.rawValue)) guard let eventTap = CGEvent.tapCreate( tap: .cghidEventTap, place: .headInsertEventTap, options: .defaultTap, eventsOfInterest: CGEventMask(eventMask), callback: { _, type, cgEvent, userInfo in guard let hotKeyClientLive = Unmanaged .fromOpaque(userInfo!) .takeUnretainedValue() as KeyEventMonitorClientLive? else { return Unmanaged.passUnretained(cgEvent) } if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { hotKeyClientLive.handleTapDisabledEvent(type) return Unmanaged.passUnretained(cgEvent) } // An event arriving at the tap is authoritative proof the underlying permission is // granted. Never drop delivered events because a cached permission check went // stale (#250) — that turned recoverable TCC hiccups into dead hotkeys. hotKeyClientLive.noteEventDelivered(type: type) if type == .leftMouseDown || type == .rightMouseDown || type == .otherMouseDown { _ = hotKeyClientLive.processInputEvent(.mouseClick) return Unmanaged.passUnretained(cgEvent) } hotKeyClientLive.updateFnStateIfNeeded(type: type, cgEvent: cgEvent) let keyEvent = KeyEvent(cgEvent: cgEvent, type: type, isFnPressed: hotKeyClientLive.isFnPressed) let handledByKeyHandler = hotKeyClientLive.processKeyEvent(keyEvent) let handledByInputHandler = hotKeyClientLive.processInputEvent(.keyboard(keyEvent)) return (handledByKeyHandler || handledByInputHandler) ? nil : Unmanaged.passUnretained(cgEvent) }, userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) ) else { logger.error("Failed to create event tap (reason: \(reason)).") return } eventTapPort = eventTap let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) self.runLoopSource = runLoopSource CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .commonModes) CGEvent.tapEnable(tap: eventTap, enable: true) isMonitoring = true logger.info("Started monitoring key events via CGEvent tap (reason: \(reason)).") } @MainActor private func deactivateTap(reason: String) { guard isMonitoring || eventTapPort != nil else { return } if let runLoopSource = runLoopSource { CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes) self.runLoopSource = nil } if let eventTapPort = eventTapPort { CGEvent.tapEnable(tap: eventTapPort, enable: false) self.eventTapPort = nil } isMonitoring = false clearInputMonitoringProof() logger.info("Suspended key event monitoring (reason: \(reason)).") } private func clearInputMonitoringProof() { queue.async(flags: .barrier) { [weak self] in self?.inputMonitoringProvenByEvents = false } } private func handleTapDisabledEvent(_ type: CGEventType) { let reason = type == .tapDisabledByTimeout ? "timeout" : "userInput" logger.error("Event tap disabled by \(reason); scheduling restart.") Task { [weak self] in guard let self else { return } await self.refreshMonitoringState(reason: "tap_disabled_\(reason)") } } private func processEvent( _ event: T, handlers: () -> [UUID: @Sendable (T) -> Bool] ) -> Bool { let handlerList = readState { Array(handlers().values) } return handlerList.reduce(false) { handled, handler in handler(event) || handled } } private func processKeyEvent(_ keyEvent: KeyEvent) -> Bool { processEvent(keyEvent, handlers: { continuations }) } private func processInputEvent(_ inputEvent: InputEvent) -> Bool { processEvent(inputEvent, handlers: { inputContinuations }) } /// Records that an event was delivered to the tap. Key events are proof that Input /// Monitoring is genuinely granted even when `IOHIDCheckAccess` reports otherwise (#250). fileprivate func noteEventDelivered(type: CGEventType) { guard type == .keyDown || type == .keyUp else { return } let alreadyProven = readState { inputMonitoringProvenByEvents } guard !alreadyProven else { return } queue.async(flags: .barrier) { [weak self] in self?.inputMonitoringProvenByEvents = true self?.inputMonitoringTrusted = true } if IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) != kIOHIDAccessTypeGranted { logger.notice("Key events are flowing while IOHIDCheckAccess reports denied; treating Input Monitoring as granted (stale TCC cache, #250).") } } /// Recreate the event tap after system transitions that are known to leave taps in a dead or /// stale state (wake from sleep, session reactivation after fast user switching / MDM logout). private func registerSystemEventObservers() { let center = NSWorkspace.shared.notificationCenter let events: [(Notification.Name, String)] = [ (NSWorkspace.didWakeNotification, "system_wake"), (NSWorkspace.sessionDidBecomeActiveNotification, "session_active"), ] for (name, reason) in events { let observer = center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in self?.restartTapAfterSystemEvent(reason: reason) } systemEventObservers.append(observer) } } private func restartTapAfterSystemEvent(reason: String) { logger.notice("System transition (\(reason)); recreating event tap to recover from any stale state.") Task { [weak self] in guard let self else { return } await self.deactivateTapOnMain(reason: reason) await self.refreshMonitoringState(reason: reason) } } } extension KeyEventMonitorClientLive { private func updateFnStateIfNeeded(type: CGEventType, cgEvent: CGEvent) { guard type == .flagsChanged else { return } let keyCode = Int(cgEvent.getIntegerValueField(.keyboardEventKeycode)) guard keyCode == kVK_Function else { return } isFnPressed = cgEvent.flags.contains(.maskSecondaryFn) } private func refreshTrustedFlag(promptIfUntrusted: Bool) { var accessibilityTrusted = currentAccessibilityTrust() if !accessibilityTrusted && promptIfUntrusted && !hasPromptedForAccessibilityTrust { accessibilityTrusted = requestAccessibilityTrustPrompt() hasPromptedForAccessibilityTrust = true logger.notice("Prompted for accessibility trust") } let inputMonitoringTrusted = currentInputMonitoringTrust() setPermissionFlags(accessibility: accessibilityTrusted, input: inputMonitoringTrusted) } private func currentAccessibilityTrust() -> Bool { accessibilityTrustProvider() } private func requestAccessibilityTrustPrompt() -> Bool { accessibilityTrustPrompt() } private func currentInputMonitoringTrust() -> Bool { if inputMonitoringTrustProvider() { return true } // A stale TCC cache can report denied while key events demonstrably flow (#250); // trust the events over the check so the watchdog and settings UI stay honest. return readState { inputMonitoringProvenByEvents } } // Intentionally no request helper: creating the event tap prompts macOS 15+ for Input Monitoring // the same way older versions did, while we still track status for UI. }