Files
EdisonVoice/Sources/EdisonCore/Capture/HotkeyMonitor.swift
T

142 lines
5.3 KiB
Swift

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<HotkeyMonitor>.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
}
}