fix(input-capture): don't drop events after TCC Accessibility revocation

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) <noreply@anthropic.com>
This commit is contained in:
Jon Kinney
2026-04-24 09:36:58 -05:00
committed by Ferdinand Schober
parent 5d7d14fbf7
commit 07cc40f6ba

View File

@@ -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?