macos: quit immediately when Accessibility is revoked mid-session

Even with the earlier event-tap-callback cleanup fix, revoking
Accessibility in System Settings while Lan Mouse was running with
an active capture tap could still leave system input wedged — clicks
and keypresses silently dropped until the app was force-quit.

The reliable fix is to not rely on in-process tap teardown at all.
When AX is revoked:

- The kernel guarantees an active CGEventTap is dismantled when the
  owning process exits.
- SIGINT+wait on the daemon child (main.rs already does this on
  GUI exit) drops the daemon's tap and restores the cursor.

So: continuously poll AX state (1-second GLib timer, replacing the
one-shot grant watcher), and on a revoke transition call
`app.quit()`. Input is restored within ~1-2 seconds regardless of
capture state — no force-quit required, no stuck cursor, no silently
consumed events beyond the brief window until the poller fires.

The grant-transition case is preserved: on a 0→1 flip the warning
row swaps to its "relaunch required" state, same as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jon Kinney
2026-04-24 09:58:23 -05:00
committed by Ferdinand Schober
parent 07cc40f6ba
commit 94e9301e9c
2 changed files with 58 additions and 27 deletions

View File

@@ -200,18 +200,32 @@ fn build_ui(app: &Application) {
macos_status_item::setup(app, &window); macos_status_item::setup(app, &window);
// First-launch TCC prompts. No-op when already granted. // First-launch TCC prompts. No-op when already granted.
macos_privacy::fire_initial_prompts(); macos_privacy::fire_initial_prompts();
// If Accessibility wasn't granted at startup, watch for the grant // Watch the Accessibility grant continuously for the lifetime
// and switch the status row into its "relaunch required" state // of the process. On a grant, swap the warning row into its
// when it lands. The daemon subprocess initialized without AX // "relaunch required" state (the daemon subprocess already
// (bailed with "accessibility permission is required") and can't // bailed and can't recover without a restart). On a REVOKE,
// recover without a restart, so a live AX toggle without a // quit immediately — an active CGEventTap at
// relaunch leaves the app in a broken state otherwise. // HeadInsertEventTap can wedge system input if the process
// lingers after losing AX, and forcing the process to exit is
// the only bulletproof way to guarantee the kernel tears the
// tap down.
let window_weak = window.downgrade(); let window_weak = window.downgrade();
macos_privacy::watch_for_accessibility_grant(move || { let app_weak = app.downgrade();
macos_privacy::watch_accessibility_state(move |change| match change {
macos_privacy::AccessibilityChange::Granted => {
if let Some(window) = window_weak.upgrade() { if let Some(window) = window_weak.upgrade() {
window.present(); window.present();
window.refresh_capture_emulation_status(); window.refresh_capture_emulation_status();
} }
}
macos_privacy::AccessibilityChange::Revoked => {
log::warn!(
"Accessibility revoked — quitting to avoid wedging system input"
);
if let Some(app) = app_weak.upgrade() {
app.quit();
}
}
}); });
} }

View File

@@ -74,29 +74,46 @@ pub fn accessibility_granted() -> bool {
} }
/// Poll for an Accessibility grant transition. Starts a 1-second GLib pub enum AccessibilityChange {
/// timer that fires `on_granted` once, the first time /// AX was missing at startup and the user has now granted it.
/// `AXIsProcessTrusted()` returns true. A no-op if AX is already granted. /// Capture/emulation still need a relaunch to take effect, since
/// the daemon subprocess already bailed.
Granted,
/// AX was granted and the user has now revoked it. Quit immediately
/// — leaving the process alive with an active CGEventTap at
/// HeadInsertEventTap can wedge system input (clicks/keys silently
/// consumed) until the process dies. See
/// macos-cgeventtap-drop-fallthrough-tcc-revoke skill for the
/// underlying event-tap-disable footgun.
Revoked,
}
/// Poll for Accessibility grant/revoke transitions. Starts a 1-second
/// GLib timer that fires `on_change` every time `AXIsProcessTrusted()`
/// flips, and keeps running for the lifetime of the process.
/// ///
/// We rely on polling rather than AXObserver because the AX notification /// We rely on polling rather than AXObserver because the AX notification
/// API requires a trusted process to subscribe — the precondition we're /// API requires a trusted process to subscribe — the precondition we
/// waiting for. This runs on the GTK main thread (via timeout_add_seconds_local). /// can't assume. This runs on the GTK main thread (via
pub fn watch_for_accessibility_grant<F>(mut on_granted: F) /// `timeout_add_seconds_local`).
pub fn watch_accessibility_state<F>(mut on_change: F)
where where
F: FnMut() + 'static, F: FnMut(AccessibilityChange) + 'static,
{ {
if accessibility_granted() { let mut last = accessibility_granted();
return; log::info!("watching Accessibility state (initial = {last})");
}
log::info!("watching for Accessibility grant");
glib::timeout_add_seconds_local(1, move || { glib::timeout_add_seconds_local(1, move || {
if accessibility_granted() { let current = accessibility_granted();
log::info!("Accessibility granted; firing relaunch prompt"); if current != last {
on_granted(); log::info!("Accessibility state flip: {last} -> {current}");
glib::ControlFlow::Break on_change(if current {
AccessibilityChange::Granted
} else { } else {
glib::ControlFlow::Continue AccessibilityChange::Revoked
});
last = current;
} }
glib::ControlFlow::Continue
}); });
} }