11import AppKit
2+ import CoreGraphics
23import 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
1213final 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