|
|
@ -63,7 +63,7 @@ public final class HotkeyManager { |
|
|
|
|
|
|
|
|
public var onHotkeyPressed: (() -> Void)? |
|
|
public var onHotkeyPressed: (() -> Void)? |
|
|
public var onHotkeyReleased: (() -> Void)? |
|
|
public var onHotkeyReleased: (() -> Void)? |
|
|
public var onEscapePressed: (() -> Bool)? // Returns true if Soniox was active and consumed Escape |
|
|
|
|
|
|
|
|
public var onEscapePressed: (() -> Void)? // Non-exclusive: both Soniox and active app (Telegram, etc.) receive Escape! |
|
|
|
|
|
|
|
|
private let stateLock = NSLock() |
|
|
private let stateLock = NSLock() |
|
|
private var hotKeyRef: EventHotKeyRef? |
|
|
private var hotKeyRef: EventHotKeyRef? |
|
|
@ -72,6 +72,8 @@ public final class HotkeyManager { |
|
|
private var runLoopSource: CFRunLoopSource? |
|
|
private var runLoopSource: CFRunLoopSource? |
|
|
private var globalMonitor: Any? |
|
|
private var globalMonitor: Any? |
|
|
private var localMonitor: Any? |
|
|
private var localMonitor: Any? |
|
|
|
|
|
private var escapePollTimer: Timer? |
|
|
|
|
|
private var wasEscapePhysicallyDown = false |
|
|
|
|
|
|
|
|
private var isOptionPhysicallyDown = false |
|
|
private var isOptionPhysicallyDown = false |
|
|
private var isCapsLockPhysicallyDown = false |
|
|
private var isCapsLockPhysicallyDown = false |
|
|
@ -85,6 +87,7 @@ public final class HotkeyManager { |
|
|
} |
|
|
} |
|
|
private var lastFnPressTime: TimeInterval = 0 |
|
|
private var lastFnPressTime: TimeInterval = 0 |
|
|
private var lastTriggerTime: TimeInterval = 0 |
|
|
private var lastTriggerTime: TimeInterval = 0 |
|
|
|
|
|
private var lastEscapeTime: TimeInterval = 0 |
|
|
|
|
|
|
|
|
public var currentPreset: HotkeyPreset { |
|
|
public var currentPreset: HotkeyPreset { |
|
|
get { |
|
|
get { |
|
|
@ -146,9 +149,50 @@ public final class HotkeyManager { |
|
|
isF5PhysicallyDown = false |
|
|
isF5PhysicallyDown = false |
|
|
isCapsLockPhysicallyDown = false |
|
|
isCapsLockPhysicallyDown = false |
|
|
isFnPhysicallyDown = false |
|
|
isFnPhysicallyDown = false |
|
|
|
|
|
wasEscapePhysicallyDown = false |
|
|
stateLock.unlock() |
|
|
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() { |
|
|
public func registerHotkeys() { |
|
|
unregisterHotkeys() |
|
|
unregisterHotkeys() |
|
|
resetKeyState() |
|
|
resetKeyState() |
|
|
@ -194,10 +238,10 @@ public final class HotkeyManager { |
|
|
) |
|
|
) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// 2. Global Event Tap for high-priority interception (Microphone Key, Option, CapsLock, Fn/Globe, Escape) |
|
|
|
|
|
|
|
|
// 2. Global Event Tap for high-priority interception |
|
|
setupEventTap() |
|
|
setupEventTap() |
|
|
|
|
|
|
|
|
// 3. Fallback NSEvent monitor (active ONLY if CGEventTap is disabled/unavailable) |
|
|
|
|
|
|
|
|
// 3. Always-active non-exclusive NSEvent monitor for modifier & Escape pass-through |
|
|
setupGlobalMonitor() |
|
|
setupGlobalMonitor() |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@ -236,11 +280,11 @@ public final class HotkeyManager { |
|
|
let flags = event.flags.rawValue |
|
|
let flags = event.flags.rawValue |
|
|
let keyCode = event.getIntegerValueField(.keyboardEventKeycode) |
|
|
let keyCode = event.getIntegerValueField(.keyboardEventKeycode) |
|
|
|
|
|
|
|
|
// 0. Escape Key (kVK_Escape = 53): Instant Cancel of active recording / transcription / recovery / HUD |
|
|
|
|
|
|
|
|
// 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 { |
|
|
if keyCode == 53 && type == .keyDown { |
|
|
if let consumed = manager.onEscapePressed?(), consumed { |
|
|
|
|
|
return nil // Swallow Escape so it only cancels Soniox and doesn't close user's dialog/page |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
manager.triggerEscape() |
|
|
|
|
|
return Unmanaged.passRetained(event) // NEVER swallow Escape so Telegram/apps also receive it! |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// 1. Microphone Key (🎙️ / F5) |
|
|
// 1. Microphone Key (🎙️ / F5) |
|
|
@ -388,25 +432,34 @@ public final class HotkeyManager { |
|
|
NSEvent.removeMonitor(mon) |
|
|
NSEvent.removeMonitor(mon) |
|
|
self.globalMonitor = nil |
|
|
self.globalMonitor = nil |
|
|
} |
|
|
} |
|
|
|
|
|
if let lmon = localMonitor { |
|
|
|
|
|
NSEvent.removeMonitor(lmon) |
|
|
|
|
|
self.localMonitor = nil |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
// Active backup monitor: ONLY acts if CGEventTap is nil or disabled by OS, eliminating dual-trigger race conditions! |
|
|
|
|
|
|
|
|
// 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 |
|
|
globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.flagsChanged, .keyDown, .keyUp]) { [weak self] event in |
|
|
guard let self = self else { return } |
|
|
guard let self = self else { return } |
|
|
|
|
|
|
|
|
if let tap = self.eventTapPort, CGEvent.tapIsEnabled(tap: tap) { |
|
|
|
|
|
return // Primary CGEventTap is healthy and handling events; do not double-process |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Wake up eventTap if it was disabled |
|
|
|
|
|
if let tap = self.eventTapPort { |
|
|
|
|
|
|
|
|
// Wake up eventTap if it was disabled by OS |
|
|
|
|
|
if let tap = self.eventTapPort, !CGEvent.tapIsEnabled(tap: tap) { |
|
|
CGEvent.tapEnable(tap: tap, enable: true) |
|
|
CGEvent.tapEnable(tap: tap, enable: true) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
let flags = event.modifierFlags |
|
|
let flags = event.modifierFlags |
|
|
let keyCode = event.keyCode |
|
|
let keyCode = event.keyCode |
|
|
|
|
|
|
|
|
|
|
|
// Non-exclusive Escape pass-through |
|
|
if keyCode == 53 && event.type == .keyDown { |
|
|
if keyCode == 53 && event.type == .keyDown { |
|
|
_ = self.onEscapePressed?() |
|
|
|
|
|
|
|
|
self.triggerEscape() |
|
|
return |
|
|
return |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@ -456,6 +509,8 @@ public final class HotkeyManager { |
|
|
|
|
|
|
|
|
public func unregisterHotkeys() { |
|
|
public func unregisterHotkeys() { |
|
|
resetKeyState() |
|
|
resetKeyState() |
|
|
|
|
|
escapePollTimer?.invalidate() |
|
|
|
|
|
escapePollTimer = nil |
|
|
if let ref = hotKeyRef { |
|
|
if let ref = hotKeyRef { |
|
|
UnregisterEventHotKey(ref) |
|
|
UnregisterEventHotKey(ref) |
|
|
hotKeyRef = nil |
|
|
hotKeyRef = nil |
|
|
@ -474,5 +529,9 @@ public final class HotkeyManager { |
|
|
NSEvent.removeMonitor(mon) |
|
|
NSEvent.removeMonitor(mon) |
|
|
self.globalMonitor = nil |
|
|
self.globalMonitor = nil |
|
|
} |
|
|
} |
|
|
|
|
|
if let lmon = localMonitor { |
|
|
|
|
|
NSEvent.removeMonitor(lmon) |
|
|
|
|
|
self.localMonitor = nil |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |