You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
537 lines
21 KiB
537 lines
21 KiB
import Cocoa
|
|
import Carbon
|
|
import ApplicationServices
|
|
|
|
public enum DictationMode: String, CaseIterable {
|
|
case pushToTalk = "pushToTalk" // Hold to record, release to transcribe
|
|
case toggle = "toggle" // Press once to start, press again to stop
|
|
|
|
public var localizedTitle: String {
|
|
switch self {
|
|
case .pushToTalk:
|
|
return "Push to Talk (Hold Key)"
|
|
case .toggle:
|
|
return "Toggle (Press to Start / Stop)"
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum HotkeyPreset: String, CaseIterable {
|
|
case micKey = "Microphone Key (🎙️ / F5)"
|
|
case option = "Option (Hold ⌥)"
|
|
case doubleTapFn = "Double-Tap Fn (Globe 🌐)"
|
|
case holdFn = "Hold Fn (Globe 🌐)"
|
|
case controlSpace = "Control + Space"
|
|
case capsLock = "Caps Lock"
|
|
case optionSpace = "Option + Space"
|
|
case cmdShiftSpace = "Cmd + Shift + Space"
|
|
case f8 = "F8"
|
|
|
|
public var keyCode: UInt32 {
|
|
switch self {
|
|
case .micKey:
|
|
return UInt32(kVK_F5) // 96
|
|
case .controlSpace, .optionSpace, .cmdShiftSpace:
|
|
return UInt32(kVK_Space) // 49
|
|
case .option:
|
|
return UInt32(kVK_Option) // 58
|
|
case .doubleTapFn, .holdFn:
|
|
return UInt32(kVK_Function) // 63
|
|
case .capsLock:
|
|
return UInt32(kVK_CapsLock) // 57
|
|
case .f8:
|
|
return UInt32(kVK_F8)
|
|
}
|
|
}
|
|
|
|
public var carbonModifiers: UInt32 {
|
|
switch self {
|
|
case .controlSpace:
|
|
return UInt32(controlKey)
|
|
case .optionSpace:
|
|
return UInt32(optionKey)
|
|
case .cmdShiftSpace:
|
|
return UInt32(cmdKey | shiftKey)
|
|
case .micKey, .option, .doubleTapFn, .holdFn, .capsLock, .f8:
|
|
return 0
|
|
}
|
|
}
|
|
}
|
|
|
|
public final class HotkeyManager {
|
|
public static let shared = HotkeyManager()
|
|
|
|
public var onHotkeyPressed: (() -> Void)?
|
|
public var onHotkeyReleased: (() -> Void)?
|
|
public var onEscapePressed: (() -> Void)? // Non-exclusive: both Soniox and active app (Telegram, etc.) receive Escape!
|
|
|
|
private let stateLock = NSLock()
|
|
private var hotKeyRef: EventHotKeyRef?
|
|
private var eventHandlerRef: EventHandlerRef?
|
|
private var eventTapPort: CFMachPort?
|
|
private var runLoopSource: CFRunLoopSource?
|
|
private var globalMonitor: Any?
|
|
private var localMonitor: Any?
|
|
private var escapePollTimer: Timer?
|
|
private var wasEscapePhysicallyDown = false
|
|
|
|
private var isOptionPhysicallyDown = false
|
|
private var isCapsLockPhysicallyDown = false
|
|
private var isF5PhysicallyDown = false
|
|
private var isFnPhysicallyDown = false
|
|
|
|
public var isPhysicalKeyDown: Bool {
|
|
stateLock.lock()
|
|
defer { stateLock.unlock() }
|
|
return isOptionPhysicallyDown || isCapsLockPhysicallyDown || isF5PhysicallyDown || isFnPhysicallyDown
|
|
}
|
|
private var lastFnPressTime: TimeInterval = 0
|
|
private var lastTriggerTime: TimeInterval = 0
|
|
private var lastEscapeTime: TimeInterval = 0
|
|
|
|
public var currentPreset: HotkeyPreset {
|
|
get {
|
|
let val = UserDefaults.standard.string(forKey: "SonioxHotkeyPreset") ?? HotkeyPreset.option.rawValue
|
|
return HotkeyPreset(rawValue: val) ?? .option
|
|
}
|
|
set {
|
|
UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxHotkeyPreset")
|
|
registerHotkeys()
|
|
}
|
|
}
|
|
|
|
public var currentMode: DictationMode {
|
|
get {
|
|
let val = UserDefaults.standard.string(forKey: "SonioxDictationMode") ?? DictationMode.pushToTalk.rawValue
|
|
return DictationMode(rawValue: val) ?? .pushToTalk
|
|
}
|
|
set {
|
|
UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxDictationMode")
|
|
registerHotkeys()
|
|
}
|
|
}
|
|
|
|
private init() {
|
|
setupSleepWakeObservers()
|
|
}
|
|
|
|
private func setupSleepWakeObservers() {
|
|
NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: .main) { [weak self] _ in
|
|
guard let self = self else { return }
|
|
print("HotkeyManager: Mac entering sleep. Resetting keys & unregistering taps.")
|
|
self.resetKeyState()
|
|
self.unregisterHotkeys()
|
|
}
|
|
|
|
NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.didWakeNotification, object: nil, queue: .main) { [weak self] _ in
|
|
guard let self = self else { return }
|
|
print("HotkeyManager: Mac woke from sleep. Re-registering hotkeys in 0.5s...")
|
|
self.resetKeyState()
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
self.registerHotkeys()
|
|
}
|
|
}
|
|
|
|
NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.screensDidWakeNotification, object: nil, queue: .main) { [weak self] _ in
|
|
guard let self = self else { return }
|
|
print("HotkeyManager: Screens woke. Verifying hotkey event tap...")
|
|
if let tap = self.eventTapPort {
|
|
CGEvent.tapEnable(tap: tap, enable: true)
|
|
} else {
|
|
self.registerHotkeys()
|
|
}
|
|
}
|
|
}
|
|
|
|
public func resetKeyState() {
|
|
stateLock.lock()
|
|
isOptionPhysicallyDown = false
|
|
isF5PhysicallyDown = false
|
|
isCapsLockPhysicallyDown = false
|
|
isFnPhysicallyDown = false
|
|
wasEscapePhysicallyDown = false
|
|
stateLock.unlock()
|
|
}
|
|
|
|
/// Enables/disables ultra-lightweight 60Hz hardware Escape key state polling while Soniox is active (recording/transcribing/retrying/HUD visible).
|
|
/// Because it reads hardware HID state directly (CGEventSource.keyState), it NEVER conflicts with or blocks Telegram or other apps' Escape hotkeys,
|
|
/// and works 100% reliably regardless of TCC Input Monitoring ad-hoc signature state!
|
|
public func setEscapeMonitoringActive(_ active: Bool) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self = self else { return }
|
|
self.escapePollTimer?.invalidate()
|
|
self.escapePollTimer = nil
|
|
self.wasEscapePhysicallyDown = false
|
|
|
|
if active {
|
|
self.escapePollTimer = Timer.scheduledTimer(withTimeInterval: 0.016, repeats: true) { [weak self] _ in
|
|
guard let self = self else { return }
|
|
let isDown = CGEventSource.keyState(.combinedSessionState, key: 53) || CGEventSource.keyState(.hidSystemState, key: 53)
|
|
if isDown && !self.wasEscapePhysicallyDown {
|
|
self.wasEscapePhysicallyDown = true
|
|
self.triggerEscape()
|
|
} else if !isDown {
|
|
self.wasEscapePhysicallyDown = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func triggerEscape() {
|
|
stateLock.lock()
|
|
let now = Date().timeIntervalSince1970
|
|
guard (now - lastEscapeTime) > 0.15 else {
|
|
stateLock.unlock()
|
|
return
|
|
}
|
|
lastEscapeTime = now
|
|
stateLock.unlock()
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.onEscapePressed?()
|
|
}
|
|
}
|
|
|
|
public func registerHotkeys() {
|
|
unregisterHotkeys()
|
|
resetKeyState()
|
|
|
|
let preset = currentPreset
|
|
print("HotkeyManager: Registering hotkey for '\(preset.rawValue)', mode: '\(currentMode.rawValue)'")
|
|
|
|
// 1. Carbon HotKey for multi-key combos (Control+Space, Option+Space, Cmd+Shift+Space, F8)
|
|
if preset != .option && preset != .capsLock && preset != .micKey && preset != .doubleTapFn && preset != .holdFn {
|
|
var eventTypes = [
|
|
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
|
|
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased))
|
|
]
|
|
|
|
let selfPtr = Unmanaged.passUnretained(self).toOpaque()
|
|
let handlerCallback: EventHandlerUPP = { (_, eventRef, userData) -> OSStatus in
|
|
guard let eventRef = eventRef, let userData = userData else { return noErr }
|
|
let manager = Unmanaged<HotkeyManager>.fromOpaque(userData).takeUnretainedValue()
|
|
|
|
let kind = GetEventKind(eventRef)
|
|
if kind == UInt32(kEventHotKeyPressed) {
|
|
manager.triggerAction()
|
|
} else if kind == UInt32(kEventHotKeyReleased) {
|
|
if manager.currentMode == .pushToTalk {
|
|
DispatchQueue.main.async {
|
|
manager.onHotkeyReleased?()
|
|
}
|
|
}
|
|
}
|
|
return noErr
|
|
}
|
|
|
|
InstallEventHandler(GetApplicationEventTarget(), handlerCallback, 2, &eventTypes, selfPtr, &eventHandlerRef)
|
|
|
|
let hotKeyID = EventHotKeyID(signature: OSType(0x534F4E58), id: 1)
|
|
RegisterEventHotKey(
|
|
preset.keyCode,
|
|
preset.carbonModifiers,
|
|
hotKeyID,
|
|
GetApplicationEventTarget(),
|
|
0,
|
|
&hotKeyRef
|
|
)
|
|
}
|
|
|
|
// 2. Global Event Tap for high-priority interception
|
|
setupEventTap()
|
|
|
|
// 3. Always-active non-exclusive NSEvent monitor for modifier & Escape pass-through
|
|
setupGlobalMonitor()
|
|
}
|
|
|
|
private func triggerAction() {
|
|
stateLock.lock()
|
|
let now = Date().timeIntervalSince1970
|
|
guard (now - lastTriggerTime) > 0.10 else {
|
|
stateLock.unlock()
|
|
return
|
|
}
|
|
lastTriggerTime = now
|
|
stateLock.unlock()
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.onHotkeyPressed?()
|
|
}
|
|
}
|
|
|
|
private func setupEventTap() {
|
|
let mask = (1 << CGEventType.flagsChanged.rawValue) | (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue)
|
|
let selfPtr = Unmanaged.passUnretained(self).toOpaque()
|
|
|
|
let callback: CGEventTapCallBack = { (proxy, type, event, refcon) -> Unmanaged<CGEvent>? in
|
|
guard let refcon = refcon else { return Unmanaged.passRetained(event) }
|
|
let manager = Unmanaged<HotkeyManager>.fromOpaque(refcon).takeUnretainedValue()
|
|
|
|
// Auto-recover if macOS disabled tap on timeout or user input
|
|
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
|
|
print("HotkeyManager: EventTap was disabled by OS (\(type.rawValue)). Re-enabling now...")
|
|
if let tap = manager.eventTapPort {
|
|
CGEvent.tapEnable(tap: tap, enable: true)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
let flags = event.flags.rawValue
|
|
let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
|
|
|
|
// 0. Escape Key (kVK_Escape = 53): Non-blocking pass-through!
|
|
// Both Soniox Voice AND the active app (Telegram, Chrome, etc.) execute their Escape action simultaneously!
|
|
if keyCode == 53 && type == .keyDown {
|
|
manager.triggerEscape()
|
|
return Unmanaged.passRetained(event) // NEVER swallow Escape so Telegram/apps also receive it!
|
|
}
|
|
|
|
// 1. Microphone Key (🎙️ / F5)
|
|
if manager.currentPreset == .micKey {
|
|
if keyCode == 96 { // 96 = kVK_F5
|
|
if type == .keyDown {
|
|
manager.stateLock.lock()
|
|
let wasDown = manager.isF5PhysicallyDown
|
|
manager.isF5PhysicallyDown = true
|
|
manager.stateLock.unlock()
|
|
if !wasDown {
|
|
manager.triggerAction()
|
|
}
|
|
return nil // Swallow F5 so it doesn't leak to apps
|
|
} else if type == .keyUp {
|
|
manager.stateLock.lock()
|
|
manager.isF5PhysicallyDown = false
|
|
manager.stateLock.unlock()
|
|
if manager.currentMode == .pushToTalk {
|
|
DispatchQueue.main.async {
|
|
manager.onHotkeyReleased?()
|
|
}
|
|
}
|
|
return nil // Swallow F5 keyUp
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Option Key
|
|
else if manager.currentPreset == .option {
|
|
if keyCode == 58 || keyCode == 61 { // 58 = Left Option, 61 = Right Option
|
|
let isAlt = (flags & CGEventFlags.maskAlternate.rawValue) != 0
|
|
manager.stateLock.lock()
|
|
let changed = (isAlt != manager.isOptionPhysicallyDown)
|
|
if changed {
|
|
manager.isOptionPhysicallyDown = isAlt
|
|
}
|
|
manager.stateLock.unlock()
|
|
|
|
if changed {
|
|
if isAlt {
|
|
manager.triggerAction()
|
|
} else if manager.currentMode == .pushToTalk {
|
|
DispatchQueue.main.async {
|
|
manager.onHotkeyReleased?()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. CapsLock Key
|
|
else if manager.currentPreset == .capsLock {
|
|
if keyCode == 57 { // 57 = CapsLock
|
|
manager.stateLock.lock()
|
|
let nowDown = !manager.isCapsLockPhysicallyDown
|
|
manager.isCapsLockPhysicallyDown = nowDown
|
|
manager.stateLock.unlock()
|
|
|
|
if nowDown {
|
|
manager.triggerAction()
|
|
} else {
|
|
if manager.currentMode == .pushToTalk {
|
|
DispatchQueue.main.async {
|
|
manager.onHotkeyReleased?()
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// 4. Control + Space
|
|
else if manager.currentPreset == .controlSpace {
|
|
if keyCode == 49 && type == .keyDown { // 49 = Space
|
|
let isCtrl = (flags & CGEventFlags.maskControl.rawValue) != 0
|
|
if isCtrl {
|
|
manager.triggerAction()
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. Fn / Globe Key (Double-Tap or Hold)
|
|
else if manager.currentPreset == .doubleTapFn || manager.currentPreset == .holdFn {
|
|
if keyCode == 63 { // 63 = kVK_Function (Fn / Globe)
|
|
let isFnActive = (flags & CGEventFlags.maskSecondaryFn.rawValue) != 0
|
|
manager.stateLock.lock()
|
|
let changed = (isFnActive != manager.isFnPhysicallyDown)
|
|
if changed {
|
|
manager.isFnPhysicallyDown = isFnActive
|
|
}
|
|
manager.stateLock.unlock()
|
|
|
|
if changed {
|
|
if isFnActive {
|
|
let now = Date().timeIntervalSince1970
|
|
let interval = now - manager.lastFnPressTime
|
|
manager.lastFnPressTime = now
|
|
|
|
if manager.currentPreset == .doubleTapFn {
|
|
if interval > 0.05 && interval < 0.45 {
|
|
manager.lastFnPressTime = 0
|
|
manager.triggerAction()
|
|
return nil
|
|
}
|
|
} else if manager.currentPreset == .holdFn {
|
|
manager.triggerAction()
|
|
return nil
|
|
}
|
|
} else {
|
|
if manager.currentPreset == .holdFn && manager.currentMode == .pushToTalk {
|
|
DispatchQueue.main.async {
|
|
manager.onHotkeyReleased?()
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return Unmanaged.passRetained(event)
|
|
}
|
|
|
|
if let tap = CGEvent.tapCreate(
|
|
tap: .cghidEventTap,
|
|
place: .headInsertEventTap,
|
|
options: .defaultTap,
|
|
eventsOfInterest: CGEventMask(mask),
|
|
callback: callback,
|
|
userInfo: selfPtr
|
|
) {
|
|
self.eventTapPort = tap
|
|
let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
|
|
self.runLoopSource = source
|
|
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
|
|
CGEvent.tapEnable(tap: tap, enable: true)
|
|
print("HotkeyManager: CGEventTap registered successfully.")
|
|
}
|
|
}
|
|
|
|
private func setupGlobalMonitor() {
|
|
if let mon = globalMonitor {
|
|
NSEvent.removeMonitor(mon)
|
|
self.globalMonitor = nil
|
|
}
|
|
if let lmon = localMonitor {
|
|
NSEvent.removeMonitor(lmon)
|
|
self.localMonitor = nil
|
|
}
|
|
|
|
// Local monitor if Soniox menu/HUD is ever key
|
|
localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { [weak self] event in
|
|
if event.keyCode == 53 && event.type == .keyDown {
|
|
self?.triggerEscape()
|
|
}
|
|
return event // Pass through
|
|
}
|
|
|
|
// Global passive monitor: always active alongside EventTap (deduplicated via stateLock)
|
|
globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.flagsChanged, .keyDown, .keyUp]) { [weak self] event in
|
|
guard let self = self else { return }
|
|
|
|
// Wake up eventTap if it was disabled by OS
|
|
if let tap = self.eventTapPort, !CGEvent.tapIsEnabled(tap: tap) {
|
|
CGEvent.tapEnable(tap: tap, enable: true)
|
|
}
|
|
|
|
let flags = event.modifierFlags
|
|
let keyCode = event.keyCode
|
|
|
|
// Non-exclusive Escape pass-through
|
|
if keyCode == 53 && event.type == .keyDown {
|
|
self.triggerEscape()
|
|
return
|
|
}
|
|
|
|
if self.currentPreset == .option {
|
|
if keyCode == 58 || keyCode == 61 {
|
|
let isAlt = flags.contains(.option)
|
|
self.stateLock.lock()
|
|
let changed = (isAlt != self.isOptionPhysicallyDown)
|
|
if changed { self.isOptionPhysicallyDown = isAlt }
|
|
self.stateLock.unlock()
|
|
|
|
if changed {
|
|
if isAlt {
|
|
self.triggerAction()
|
|
} else if self.currentMode == .pushToTalk {
|
|
DispatchQueue.main.async {
|
|
self.onHotkeyReleased?()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if self.currentPreset == .controlSpace {
|
|
if keyCode == 49 && flags.contains(.control) {
|
|
self.triggerAction()
|
|
}
|
|
} else if self.currentPreset == .capsLock {
|
|
if keyCode == 57 {
|
|
let isCaps = flags.contains(.capsLock)
|
|
self.stateLock.lock()
|
|
let changed = (isCaps != self.isCapsLockPhysicallyDown)
|
|
if changed { self.isCapsLockPhysicallyDown = isCaps }
|
|
self.stateLock.unlock()
|
|
|
|
if changed {
|
|
if isCaps {
|
|
self.triggerAction()
|
|
} else if self.currentMode == .pushToTalk {
|
|
DispatchQueue.main.async {
|
|
self.onHotkeyReleased?()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public func unregisterHotkeys() {
|
|
resetKeyState()
|
|
escapePollTimer?.invalidate()
|
|
escapePollTimer = nil
|
|
if let ref = hotKeyRef {
|
|
UnregisterEventHotKey(ref)
|
|
hotKeyRef = nil
|
|
}
|
|
if let handler = eventHandlerRef {
|
|
RemoveEventHandler(handler)
|
|
eventHandlerRef = nil
|
|
}
|
|
if let tap = eventTapPort, let source = runLoopSource {
|
|
CGEvent.tapEnable(tap: tap, enable: false)
|
|
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
|
|
self.eventTapPort = nil
|
|
self.runLoopSource = nil
|
|
}
|
|
if let mon = globalMonitor {
|
|
NSEvent.removeMonitor(mon)
|
|
self.globalMonitor = nil
|
|
}
|
|
if let lmon = localMonitor {
|
|
NSEvent.removeMonitor(lmon)
|
|
self.localMonitor = nil
|
|
}
|
|
}
|
|
}
|