Files
gart/code/Hex-main/Hex/Clients/KeyEventMonitorClient.swift
T

612 lines
21 KiB
Swift
Executable File

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<KeyEvent, Error> = {
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<Void>()
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<Void, Never>?
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<Value>(_ 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<KeyEvent, Error> {
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<KeyEventMonitorClientLive>
.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<T>(
_ 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.
}