From e42f602b59c580e0a963d16247edcea5dfaf9bec Mon Sep 17 00:00:00 2001 From: Ali Alavi Date: Fri, 23 Oct 2026 10:42:45 +0330 Subject: [PATCH] fix(hotkey): non-exclusive hardware Escape detection so both Soniox and active apps execute Escape (v2.09) --- Info.plist | 2 +- src/AppDelegate.swift | 11 ++--- src/HUDOverlay.swift | 6 +++ src/HotkeyManager.swift | 89 ++++++++++++++++++++++++++++++++++------- 4 files changed, 85 insertions(+), 23 deletions(-) diff --git a/Info.plist b/Info.plist index d378e2c..ea104d3 100644 --- a/Info.plist +++ b/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.08 + 2.09 CFBundleVersion 1 LSMinimumSystemVersion diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index 96b5cbb..4779c24 100644 --- a/src/AppDelegate.swift +++ b/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 - 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 - 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() diff --git a/src/HUDOverlay.swift b/src/HUDOverlay.swift index 08e006f..5e19945 100644 --- a/src/HUDOverlay.swift +++ b/src/HUDOverlay.swift @@ -88,6 +88,7 @@ public final class HUDOverlayController { window.orderFrontRegardless() window.alphaValue = 1.0 self.isVisible = true + HotkeyManager.shared.setEscapeMonitoringActive(true) } orb.updateAudioLevel(level, isLagging: isLagging, pingQuality: pingQuality) @@ -96,6 +97,7 @@ public final class HUDOverlayController { window.orderFrontRegardless() window.alphaValue = 1.0 self.isVisible = true + HotkeyManager.shared.setEscapeMonitoringActive(true) } orb.showRetryState(reason: reason) @@ -104,6 +106,7 @@ public final class HUDOverlayController { window.orderFrontRegardless() window.alphaValue = 1.0 self.isVisible = true + HotkeyManager.shared.setEscapeMonitoringActive(true) } orb.showTranscribingState() @@ -120,6 +123,7 @@ public final class HUDOverlayController { window.orderFrontRegardless() window.alphaValue = 1.0 self.isVisible = true + HotkeyManager.shared.setEscapeMonitoringActive(true) } orb.showErrorState(message: message) let work = DispatchWorkItem { [weak self] in @@ -138,6 +142,7 @@ public final class HUDOverlayController { self.autoHideWorkItem?.cancel() self.autoHideWorkItem = nil self.isVisible = false + HotkeyManager.shared.setEscapeMonitoringActive(false) if let window = self.window { window.alphaValue = 0.0 window.orderOut(nil) @@ -152,6 +157,7 @@ public final class HUDOverlayController { self.autoHideWorkItem?.cancel() self.autoHideWorkItem = nil self.isVisible = false + HotkeyManager.shared.setEscapeMonitoringActive(false) NSAnimationContext.runAnimationGroup({ context in context.duration = 0.15 window.animator().alphaValue = 0.0 diff --git a/src/HotkeyManager.swift b/src/HotkeyManager.swift index 6fbbb6a..341cbc0 100644 --- a/src/HotkeyManager.swift +++ b/src/HotkeyManager.swift @@ -63,7 +63,7 @@ public final class HotkeyManager { public var onHotkeyPressed: (() -> 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 var hotKeyRef: EventHotKeyRef? @@ -72,6 +72,8 @@ public final class HotkeyManager { 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 @@ -85,6 +87,7 @@ public final class HotkeyManager { } private var lastFnPressTime: TimeInterval = 0 private var lastTriggerTime: TimeInterval = 0 + private var lastEscapeTime: TimeInterval = 0 public var currentPreset: HotkeyPreset { get { @@ -146,9 +149,50 @@ public final class HotkeyManager { 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() @@ -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() - // 3. Fallback NSEvent monitor (active ONLY if CGEventTap is disabled/unavailable) + // 3. Always-active non-exclusive NSEvent monitor for modifier & Escape pass-through setupGlobalMonitor() } @@ -236,11 +280,11 @@ public final class HotkeyManager { let flags = event.flags.rawValue 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 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) @@ -388,25 +432,34 @@ public final class HotkeyManager { NSEvent.removeMonitor(mon) 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 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) } let flags = event.modifierFlags let keyCode = event.keyCode + // Non-exclusive Escape pass-through if keyCode == 53 && event.type == .keyDown { - _ = self.onEscapePressed?() + self.triggerEscape() return } @@ -456,6 +509,8 @@ public final class HotkeyManager { public func unregisterHotkeys() { resetKeyState() + escapePollTimer?.invalidate() + escapePollTimer = nil if let ref = hotKeyRef { UnregisterEventHotKey(ref) hotKeyRef = nil @@ -474,5 +529,9 @@ public final class HotkeyManager { NSEvent.removeMonitor(mon) self.globalMonitor = nil } + if let lmon = localMonitor { + NSEvent.removeMonitor(lmon) + self.localMonitor = nil + } } }