Browse Source

fix(hotkey): non-exclusive hardware Escape detection so both Soniox and active apps execute Escape (v2.09)

main
Ali Alavi 1 week ago
parent
commit
e42f602b59
  1. 2
      Info.plist
  2. 11
      src/AppDelegate.swift
  3. 6
      src/HUDOverlay.swift
  4. 89
      src/HotkeyManager.swift

2
Info.plist

@ -19,7 +19,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2.08</string>
<string>2.09</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>1</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>

11
src/AppDelegate.swift

@ -106,16 +106,13 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
// Escape Key Handler: Immediately aborts active recording, transcription, recovery, and hides the green orb!
// Escape Key Handler: Non-exclusive! Both Soniox Voice AND the active app (Telegram, etc.) execute Escape!
HotkeyManager.shared.onEscapePressed = { [weak self] in HotkeyManager.shared.onEscapePressed = { [weak self] in
guard let self = self else { return false }
guard let self = self else { return }
let isActive = self.audioRecorder.isRecording || self.isBusyFinalizing || (self.activeSession != nil) || HUDOverlayController.shared.isVisible let isActive = self.audioRecorder.isRecording || self.isBusyFinalizing || (self.activeSession != nil) || HUDOverlayController.shared.isVisible
guard isActive else { return false }
guard isActive else { return }
DispatchQueue.main.async {
self.cancelEverythingImmediately()
}
return true // Consume Escape so it only cancels Soniox Voice
self.cancelEverythingImmediately()
} }
HotkeyManager.shared.registerHotkeys() HotkeyManager.shared.registerHotkeys()

6
src/HUDOverlay.swift

@ -88,6 +88,7 @@ public final class HUDOverlayController {
window.orderFrontRegardless() window.orderFrontRegardless()
window.alphaValue = 1.0 window.alphaValue = 1.0
self.isVisible = true self.isVisible = true
HotkeyManager.shared.setEscapeMonitoringActive(true)
} }
orb.updateAudioLevel(level, isLagging: isLagging, pingQuality: pingQuality) orb.updateAudioLevel(level, isLagging: isLagging, pingQuality: pingQuality)
@ -96,6 +97,7 @@ public final class HUDOverlayController {
window.orderFrontRegardless() window.orderFrontRegardless()
window.alphaValue = 1.0 window.alphaValue = 1.0
self.isVisible = true self.isVisible = true
HotkeyManager.shared.setEscapeMonitoringActive(true)
} }
orb.showRetryState(reason: reason) orb.showRetryState(reason: reason)
@ -104,6 +106,7 @@ public final class HUDOverlayController {
window.orderFrontRegardless() window.orderFrontRegardless()
window.alphaValue = 1.0 window.alphaValue = 1.0
self.isVisible = true self.isVisible = true
HotkeyManager.shared.setEscapeMonitoringActive(true)
} }
orb.showTranscribingState() orb.showTranscribingState()
@ -120,6 +123,7 @@ public final class HUDOverlayController {
window.orderFrontRegardless() window.orderFrontRegardless()
window.alphaValue = 1.0 window.alphaValue = 1.0
self.isVisible = true self.isVisible = true
HotkeyManager.shared.setEscapeMonitoringActive(true)
} }
orb.showErrorState(message: message) orb.showErrorState(message: message)
let work = DispatchWorkItem { [weak self] in let work = DispatchWorkItem { [weak self] in
@ -138,6 +142,7 @@ public final class HUDOverlayController {
self.autoHideWorkItem?.cancel() self.autoHideWorkItem?.cancel()
self.autoHideWorkItem = nil self.autoHideWorkItem = nil
self.isVisible = false self.isVisible = false
HotkeyManager.shared.setEscapeMonitoringActive(false)
if let window = self.window { if let window = self.window {
window.alphaValue = 0.0 window.alphaValue = 0.0
window.orderOut(nil) window.orderOut(nil)
@ -152,6 +157,7 @@ public final class HUDOverlayController {
self.autoHideWorkItem?.cancel() self.autoHideWorkItem?.cancel()
self.autoHideWorkItem = nil self.autoHideWorkItem = nil
self.isVisible = false self.isVisible = false
HotkeyManager.shared.setEscapeMonitoringActive(false)
NSAnimationContext.runAnimationGroup({ context in NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.15 context.duration = 0.15
window.animator().alphaValue = 0.0 window.animator().alphaValue = 0.0

89
src/HotkeyManager.swift

@ -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
}
} }
} }
Loading…
Cancel
Save