Initial commit of Edison Voice menu-bar dictation app
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user