From 07cc40f6ba4610d1125350e46e0b481cfb1b292d Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Fri, 24 Apr 2026 09:36:58 -0500 Subject: [PATCH] fix(input-capture): don't drop events after TCC Accessibility revocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user revokes Accessibility in System Settings while Lan Mouse is in a captured session (cursor on a remote client), the session-level event tap receives `TapDisabledByUserInput`. The previous flow was: 1. Callback sends `ProducerEvent::EventTapDisabled` to notify_tx. 2. Callback falls through the `current_pos.is_some()` branch and returns `CallbackResult::Drop` — *this very event*, plus any racing callback still in flight, get `set_type(Null)`'d and consumed. 3. Outer task calls `handle_producer_event(..).unwrap_or_else(|e| log::error!(..))` — the `EventTapDisabled` error is just logged, the loop keeps running, `current_pos` stays `Some`, cursor stays hidden. Net effect for the user: mouse motion keeps working (pass-through when the tap is fully dead), but clicks and keypresses in the brief disable window silently disappear, and the cursor is still hidden where the captured session left it. Input looks broken until the app is force-quit. Fix: - In the callback, when `TapDisabled*` fires, clear `current_pos` and `CGDisplay::show_cursor` synchronously, then return `CallbackResult::Keep` so this event (and any subsequent racing one) can't hit the drop branch. - Mirror the cleanup in `handle_producer_event`'s `EventTapDisabled` arm so even if the outer task only logs the error, state is still released. Co-Authored-By: Claude Opus 4.7 (1M context) --- input-capture/src/macos.rs | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/input-capture/src/macos.rs b/input-capture/src/macos.rs index a99bde6..517127d 100644 --- a/input-capture/src/macos.rs +++ b/input-capture/src/macos.rs @@ -176,7 +176,17 @@ impl InputCaptureState { } self.active_clients.remove(&p); } - ProducerEvent::EventTapDisabled => return Err(CaptureError::EventTapDisabled), + ProducerEvent::EventTapDisabled => { + // Tap death can happen mid-capture (TCC Accessibility + // revoked, tap-timeout, etc). Release state so we + // don't leave the cursor hidden even if the outer + // task only logs this error rather than propagating. + if self.current_pos.is_some() { + self.show_cursor()?; + self.current_pos = None; + } + return Err(CaptureError::EventTapDisabled); + } }; Ok(()) } @@ -413,12 +423,27 @@ fn create_event_tap<'a>( event_type, CGEventType::TapDisabledByTimeout | CGEventType::TapDisabledByUserInput ) { - log::error!("CGEventTap disabled"); + // When the tap is disabled (including the case where TCC + // Accessibility is revoked mid-session), we MUST drop + // captured state synchronously and return Keep on this + // event. Otherwise the `current_pos.is_some()` branch + // below would drop this event (and any racing callback + // still in flight) back into `CallbackResult::Drop`, + // silently eating the user's clicks and keypresses while + // the tap winds down. Clear state + show the cursor + // here, then notify the producer loop so the service + // can tear down cleanly. + log::error!("CGEventTap disabled, releasing capture state"); + if state.current_pos.is_some() { + let _ = CGDisplay::show_cursor(&CGDisplay::main()); + state.current_pos = None; + } notify_tx .blocking_send(ProducerEvent::EventTapDisabled) .unwrap_or_else(|e| { log::error!("Failed to send notification: {e}"); }); + return CallbackResult::Keep; } // Are we in a client?