Skip to content

Commit 5bdfada

Browse files
committed
fix(input): use CGEvent tap to consume Escape key events
- Replace NSEvent monitors with CGEvent tap to enable global event consumption - Prevent Escape key presses from leaking to the focused application - Check state machine state instead of window visibility for more robust handling
1 parent e49c228 commit 5bdfada

File tree

2 files changed

+72
-36
lines changed

2 files changed

+72
-36
lines changed

OpenDictation/App/AppDelegate.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
186186
self?.stateMachine?.send(.escapePressed)
187187
}
188188

189-
// Only handle escape when panel is visible
189+
// Only handle escape when dictation is active (state != .idle).
190+
// Using state machine as source of truth is more robust than window visibility
191+
// which can desync after window corruption.
190192
monitor.shouldHandleEscape = { [weak self] in
191-
return self?.notchPanel?.isVisible == true
193+
return self?.stateMachine?.state != .idle
192194
}
193195

194196
// Start monitoring (lives for app lifetime)
Lines changed: 68 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import AppKit
2+
import CoreGraphics
23
import os.log
34

4-
/// Singleton service that monitors for Escape key presses globally.
5+
/// Singleton service that monitors for Escape key presses globally using a CGEvent tap.
56
///
67
/// This service lives for the entire app lifetime and is NOT tied to the
7-
/// NotchOverlayPanel lifecycle. This follows NotchDrop's pattern of keeping
8-
/// event monitors separate from UI components to survive screen changes.
8+
/// NotchOverlayPanel lifecycle. This uses a low-level CGEvent tap which allows
9+
/// consuming events globally, preventing them from leaking to other applications.
910
///
10-
/// Pattern: Singleton event monitor (NotchDrop's EventMonitors class)
11+
/// Pattern: CGEvent tap (ghostty, Rectangle, alt-tab-macos pattern)
1112
@MainActor
1213
final class EscapeKeyMonitor {
1314

@@ -17,8 +18,8 @@ final class EscapeKeyMonitor {
1718

1819
// MARK: - Properties
1920

20-
private var globalMonitor: Any?
21-
private var localMonitor: Any?
21+
private var eventTap: CFMachPort?
22+
private var runLoopSource: CFRunLoopSource?
2223
private let logger = Logger.app(category: "EscapeKeyMonitor")
2324

2425
/// Whether monitoring is active
@@ -36,59 +37,92 @@ final class EscapeKeyMonitor {
3637

3738
// MARK: - Public Methods
3839

39-
/// Starts monitoring for Escape key presses.
40-
/// Safe to call multiple times (will not create duplicate monitors).
40+
/// Starts monitoring for Escape key presses using a CGEvent tap.
41+
/// Safe to call multiple times.
4142
func start() {
4243
guard !isMonitoring else { return }
4344

44-
// Global monitor for when app is not focused
45-
globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
46-
self?.handleKeyEvent(event)
45+
let eventMask = CGEventMask(1 << CGEventType.keyDown.rawValue)
46+
47+
// Create event tap - requires Accessibility permissions.
48+
// We use .cgSessionEventTap to intercept events before other apps receive them.
49+
// We use .defaultTap to enable event consumption.
50+
guard let tap = CGEvent.tapCreate(
51+
tap: .cgSessionEventTap,
52+
place: .headInsertEventTap,
53+
options: .defaultTap,
54+
eventsOfInterest: eventMask,
55+
callback: Self.eventTapCallback,
56+
userInfo: UnsafeMutableRawPointer(Unmanaged.passRetained(self).toOpaque())
57+
) else {
58+
logger.error("Failed to create CGEvent tap - missing Accessibility permissions?")
59+
return
4760
}
4861

49-
// Local monitor for when app is focused (and to consume the event)
50-
localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
51-
if self?.handleKeyEvent(event) == true {
52-
return nil // Consume the event
53-
}
54-
return event
62+
eventTap = tap
63+
runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
64+
65+
if let source = runLoopSource {
66+
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
5567
}
5668

69+
// Enable the tap
70+
CGEvent.tapEnable(tap: tap, enable: true)
71+
5772
isMonitoring = true
58-
logger.debug("Escape key monitoring started")
73+
logger.debug("Escape key monitoring started (CGEvent tap)")
5974
}
6075

6176
/// Stops monitoring for Escape key presses.
6277
func stop() {
63-
if let monitor = globalMonitor {
64-
NSEvent.removeMonitor(monitor)
65-
globalMonitor = nil
78+
if let source = runLoopSource {
79+
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
80+
runLoopSource = nil
6681
}
67-
if let monitor = localMonitor {
68-
NSEvent.removeMonitor(monitor)
69-
localMonitor = nil
82+
if let tap = eventTap {
83+
CFMachPortInvalidate(tap)
84+
eventTap = nil
7085
}
7186
isMonitoring = false
7287
logger.debug("Escape key monitoring stopped")
7388
}
7489

7590
// MARK: - Private Methods
7691

77-
/// Handles a key event. Returns true if the event was consumed.
78-
@discardableResult
79-
private func handleKeyEvent(_ event: NSEvent) -> Bool {
92+
/// C-style static callback required by CGEvent.tapCreate.
93+
private static let eventTapCallback: CGEventTapCallBack = { proxy, type, cgEvent, userInfo in
94+
guard let userInfo = userInfo else {
95+
return Unmanaged.passUnretained(cgEvent)
96+
}
97+
98+
// Get our monitor instance back from the opaque pointer
99+
let monitor = Unmanaged<EscapeKeyMonitor>.fromOpaque(userInfo).takeUnretainedValue()
100+
101+
// Only handle keyDown events
102+
guard type == .keyDown else {
103+
return Unmanaged.passUnretained(cgEvent)
104+
}
105+
80106
// Check for Escape key (keyCode 53)
81-
guard event.keyCode == 53 else { return false }
107+
let keyCode = cgEvent.getIntegerValueField(.keyboardEventKeycode)
108+
guard keyCode == 53 else {
109+
return Unmanaged.passUnretained(cgEvent)
110+
}
82111

83-
// Check if we should handle this escape
84-
guard shouldHandleEscape?() == true else { return false }
112+
// Check if we should handle this escape (delegated to AppDelegate)
113+
guard monitor.shouldHandleEscape?() == true else {
114+
return Unmanaged.passUnretained(cgEvent)
115+
}
85116

86-
logger.debug("Escape key pressed")
117+
monitor.logger.debug("Escape key pressed - consuming event")
87118

88-
DispatchQueue.main.async { [weak self] in
89-
self?.onEscapePressed?()
119+
// Notify of the escape press - must be done on main thread for Swift concurrency safety
120+
DispatchQueue.main.async {
121+
monitor.onEscapePressed?()
90122
}
91123

92-
return true
124+
// Return nil to consume the event, preventing it from bleeding through to other apps
125+
return nil
93126
}
94127
}
128+

0 commit comments

Comments
 (0)