import AppKit import Carbon.HIToolbox import Foundation /// Which modifier key holds the mic open. public enum PushToTalkKey: String, CaseIterable, Sendable { case rightOption case fn case rightCommand var keyCode: Int64 { switch self { case .rightOption: Int64(kVK_RightOption) // 61 case .fn: Int64(kVK_Function) // 63 case .rightCommand: Int64(kVK_RightCommand) // 54 } } /// Device-*dependent* bit for this specific physical key. /// /// `CGEventFlags.maskAlternate` is the union mask — it's set whenever *either* Option /// key is down. Using it means: hold Left ⌥, tap Right ⌥, and the release is invisible /// (the union bit is still set by the left key), so `onRelease` never fires. The mic /// stays open, the HUD stays up, and the next press is swallowed too. /// /// These raw values are the NX_DEVICE* masks from IOKit's event system; they carry the /// left/right distinction that the public `CGEventFlags` constants discard. var flag: CGEventFlags { switch self { case .rightOption: CGEventFlags(rawValue: 0x40) // NX_DEVICERALTKEYMASK case .rightCommand: CGEventFlags(rawValue: 0x10) // NX_DEVICERCMDKEYMASK case .fn: .maskSecondaryFn // no left/right variant exists } } public var displayName: String { switch self { case .rightOption: "Right ⌥" case .fn: "fn" case .rightCommand: "Right ⌘" } } /// Swallowing `fn` would break fn+arrow, fn+delete and the emoji picker, so we let it /// through. Dedicated right-hand modifiers are safe to consume. var shouldConsumeEvent: Bool { self != .fn } } /// Watches for a held modifier key using a `CGEventTap`. /// /// A tap is required rather than `NSEvent.addGlobalMonitor` because `fn` and left/right /// modifier discrimination don't surface through the higher-level APIs. This needs /// Accessibility permission; without it `CGEvent.tapCreate` returns nil. @MainActor public final class HotkeyMonitor { public init() {} private var tap: CFMachPort? private var runLoopSource: CFRunLoopSource? private var isPressed = false public var key: PushToTalkKey = .rightOption public var onPress: (() -> Void)? public var onRelease: (() -> Void)? /// - Returns: `false` if the tap couldn't be created — almost always missing Accessibility permission. @discardableResult public func start() -> Bool { stop() let mask = (1 << CGEventType.flagsChanged.rawValue) let refcon = Unmanaged.passUnretained(self).toOpaque() guard let tap = CGEvent.tapCreate( tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap, eventsOfInterest: CGEventMask(mask), callback: { _, type, event, refcon in guard let refcon else { return Unmanaged.passUnretained(event) } let monitor = Unmanaged.fromOpaque(refcon).takeUnretainedValue() // CGEvent isn't Sendable, so pull out the plain values before crossing into // actor-isolated code. The tap was added to the main run loop, so this // callback genuinely does run on the main thread. let keyCode = event.getIntegerValueField(.keyboardEventKeycode) let flags = event.flags let consume = MainActor.assumeIsolated { monitor.handle(type: type, keyCode: keyCode, flags: flags) } return consume ? nil : Unmanaged.passUnretained(event) }, userInfo: refcon ) else { Log.hotkey.error("tapCreate failed — Accessibility permission missing?") return false } self.tap = tap let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) runLoopSource = source CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes) CGEvent.tapEnable(tap: tap, enable: true) Log.hotkey.info("listening for \(self.key.displayName)") return true } public func stop() { if let tap { CGEvent.tapEnable(tap: tap, enable: false) } if let runLoopSource { CFRunLoopRemoveSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) } tap = nil runLoopSource = nil isPressed = false } // MARK: - Tap callback /// - Returns: `true` if the event should be swallowed rather than passed along. private func handle(type: CGEventType, keyCode: Int64, flags: CGEventFlags) -> Bool { // The system disables a tap that runs too slowly or is interrupted; re-arm it. if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { if let tap { CGEvent.tapEnable(tap: tap, enable: true) } return false } guard type == .flagsChanged, keyCode == key.keyCode else { return false } let nowPressed = flags.contains(key.flag) guard nowPressed != isPressed else { return false } isPressed = nowPressed if nowPressed { onPress?() } else { onRelease?() } return key.shouldConsumeEvent } }