142 lines
5.3 KiB
Swift
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
|
|
}
|
|
}
|