@@ -69,6 +69,7 @@ class EventTapManager {
6969
7070 @MainActor weak var delegate : HotkeyManagerDelegate ?
7171 @MainActor private var hotkeyState : HotkeyState = . idle
72+ @MainActor private var healthCheckTimer : Timer ?
7273
7374 // MARK: - Initialization
7475
@@ -118,35 +119,120 @@ class EventTapManager {
118119
119120 func cleanup( ) {
120121 stopAndWait ( )
122+ Task { @MainActor in
123+ self . stopHealthCheck ( )
124+ }
121125 NSWorkspace . shared. notificationCenter. removeObserver ( self )
122126 NotificationCenter . default. removeObserver ( self )
123127 }
124128
125- // MARK: - Sleep/Wake Notifications
129+ // MARK: - Health Check
130+
131+ @MainActor private func startHealthCheck( ) {
132+ healthCheckTimer? . invalidate ( )
133+ let block : @Sendable ( Timer ) -> Void = { [ weak self] _ in
134+ Task { @MainActor [ weak self] in
135+ self ? . performHealthCheck ( )
136+ }
137+ }
138+ healthCheckTimer = Timer . scheduledTimer ( withTimeInterval: 30.0 , repeats: true , block: block)
139+ }
140+
141+ @MainActor private func stopHealthCheck( ) {
142+ healthCheckTimer? . invalidate ( )
143+ healthCheckTimer = nil
144+ }
145+
146+ @MainActor private func performHealthCheck( ) {
147+ guard sharedState. withLock ( { $0. isRunning } ) else { return }
148+
149+ guard let tap = self . eventTap else {
150+ // Tap is nil but isRunning is true — something went wrong, rebuild
151+ os_log ( . info, " Health check: event tap is nil, rebuilding " )
152+ rebuildEventTap ( )
153+ return
154+ }
155+
156+ if !CGEvent. tapIsEnabled ( tap: tap) {
157+ // Try re-enabling first
158+ CGEvent . tapEnable ( tap: tap, enable: true )
159+
160+ // Check again after re-enable attempt
161+ if !CGEvent. tapIsEnabled ( tap: tap) {
162+ // Re-enable failed — full rebuild needed
163+ os_log ( . info, " Health check: event tap disabled and re-enable failed, rebuilding " )
164+ rebuildEventTap ( )
165+ } else {
166+ os_log ( . info, " Health check: event tap was disabled, re-enabled successfully " )
167+ }
168+ }
169+ }
170+
171+ @MainActor private func rebuildEventTap( ) {
172+ stopAndWait ( )
173+ start ( )
174+ }
175+
176+ // MARK: - Sleep/Wake & Screen/Session Notifications
126177
127178 private func setupSleepWakeNotifications( ) {
128- NSWorkspace . shared. notificationCenter. addObserver (
179+ let wsnc = NSWorkspace . shared. notificationCenter
180+
181+ // System sleep/wake
182+ wsnc. addObserver (
129183 self ,
130184 selector: #selector( handleWillSleep) ,
131185 name: NSWorkspace . willSleepNotification,
132186 object: nil
133187 )
134-
135- NSWorkspace . shared. notificationCenter. addObserver (
188+ wsnc. addObserver (
136189 self ,
137190 selector: #selector( handleDidWake) ,
138191 name: NSWorkspace . didWakeNotification,
139192 object: nil
140193 )
194+
195+ // Screen lock/unlock (different from system sleep!)
196+ wsnc. addObserver (
197+ self ,
198+ selector: #selector( handleWillSleep) ,
199+ name: NSWorkspace . screensDidSleepNotification,
200+ object: nil
201+ )
202+ wsnc. addObserver (
203+ self ,
204+ selector: #selector( handleDidWake) ,
205+ name: NSWorkspace . screensDidWakeNotification,
206+ object: nil
207+ )
208+
209+ // Fast user switching
210+ wsnc. addObserver (
211+ self ,
212+ selector: #selector( handleDidWake) ,
213+ name: NSWorkspace . sessionDidBecomeActiveNotification,
214+ object: nil
215+ )
216+ wsnc. addObserver (
217+ self ,
218+ selector: #selector( handleWillSleep) ,
219+ name: NSWorkspace . sessionDidResignActiveNotification,
220+ object: nil
221+ )
141222 }
142223
143224 @objc private func handleWillSleep( ) {
144- // System is about to sleep: stop event tap
145- stop ( )
225+ // System is about to sleep / screen locked / session resigned:
226+ // stop event tap synchronously.
227+ // Must use stopAndWait() (not stop()) to ensure the old RunLoop and tap
228+ // are fully cleaned up before the system sleeps. Otherwise, on wake the
229+ // old RunLoop's deferred cleanup can race with start() and clobber the
230+ // newly created tap (setting isRunning back to false / invalidating the new tap).
231+ stopAndWait ( )
146232 }
147233
148234 @objc private func handleDidWake( ) {
149- // System woke up: restart event tap
235+ // System woke up / screen unlocked / session became active : restart event tap
150236 // Delay a bit to ensure system is stable
151237 Task { @MainActor in
152238 try ? await Task . sleep ( nanoseconds: 500_000_000 ) // 0.5 seconds
@@ -231,6 +317,11 @@ class EventTapManager {
231317 return hasPermission
232318 }
233319
320+ /// Whether the CGEvent tap is currently running and capturing events.
321+ var isEventTapActive : Bool {
322+ sharedState. withLock { $0. isRunning }
323+ }
324+
234325 func requestPermission( ) async -> Bool {
235326 Logger . main. info ( " Requesting Accessibility permission via system prompt... " )
236327 let granted = AccessibilityHelper . requestPermission ( )
@@ -269,6 +360,7 @@ class EventTapManager {
269360 sharedState. withLock { $0. didConsumeKeyDown = false }
270361 Task { @MainActor in
271362 self . hotkeyState = . idle
363+ self . stopHealthCheck ( )
272364 }
273365
274366 if let runLoop = runLoop {
@@ -288,6 +380,7 @@ class EventTapManager {
288380 sharedState. withLock { $0. didConsumeKeyDown = false }
289381 Task { @MainActor in
290382 self . hotkeyState = . idle
383+ self . stopHealthCheck ( )
291384 }
292385
293386 let semaphore = DispatchSemaphore ( value: 0 )
@@ -386,6 +479,15 @@ class EventTapManager {
386479
387480 CGEvent . tapEnable ( tap: tap, enable: true )
388481
482+ // EventTap is confirmed active — mark permission for this session and notify UI.
483+ // This is more reliable than AXIsProcessTrustedWithOptions, which can return
484+ // false for sandboxed apps even after the user has granted accessibility permission.
485+ DispatchQueue . main. async {
486+ AccessibilityHelper . markPermissionConfirmed ( )
487+ NotificationCenter . default. post ( name: . eventTapDidStart, object: nil )
488+ self . startHealthCheck ( )
489+ }
490+
389491 CFRunLoopRun ( )
390492
391493 // RunLoop exited (either via stop() or unexpectedly).
@@ -402,6 +504,13 @@ class EventTapManager {
402504 if type == . tapDisabledByTimeout {
403505 if let eventTap = self . eventTap {
404506 CGEvent . tapEnable ( tap: eventTap, enable: true )
507+ // If re-enable failed, schedule a full rebuild via health check
508+ if !CGEvent. tapIsEnabled ( tap: eventTap) {
509+ os_log ( . info, " tapDisabledByTimeout: re-enable failed, scheduling rebuild " )
510+ DispatchQueue . main. async { [ weak self] in
511+ self ? . rebuildEventTap ( )
512+ }
513+ }
405514 }
406515 return Unmanaged . passUnretained ( event)
407516 }
0 commit comments