From 94e9301e9c71d382c89e9dd80e6eeeb39f666a72 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Fri, 24 Apr 2026 09:58:23 -0500 Subject: [PATCH] macos: quit immediately when Accessibility is revoked mid-session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lan-mouse-gtk/src/lib.rs | 34 ++++++++++++++------ lan-mouse-gtk/src/macos_privacy.rs | 51 ++++++++++++++++++++---------- 2 files changed, 58 insertions(+), 27 deletions(-) diff --git a/lan-mouse-gtk/src/lib.rs b/lan-mouse-gtk/src/lib.rs index 3c976ef..112aae6 100644 --- a/lan-mouse-gtk/src/lib.rs +++ b/lan-mouse-gtk/src/lib.rs @@ -200,17 +200,31 @@ fn build_ui(app: &Application) { macos_status_item::setup(app, &window); // First-launch TCC prompts. No-op when already granted. macos_privacy::fire_initial_prompts(); - // If Accessibility wasn't granted at startup, watch for the grant - // and switch the status row into its "relaunch required" state - // when it lands. The daemon subprocess initialized without AX - // (bailed with "accessibility permission is required") and can't - // recover without a restart, so a live AX toggle without a - // relaunch leaves the app in a broken state otherwise. + // Watch the Accessibility grant continuously for the lifetime + // of the process. On a grant, swap the warning row into its + // "relaunch required" state (the daemon subprocess already + // bailed and can't recover without a restart). On a REVOKE, + // quit immediately — an active CGEventTap at + // 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(); - macos_privacy::watch_for_accessibility_grant(move || { - if let Some(window) = window_weak.upgrade() { - window.present(); - window.refresh_capture_emulation_status(); + 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() { + window.present(); + 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(); + } } }); } diff --git a/lan-mouse-gtk/src/macos_privacy.rs b/lan-mouse-gtk/src/macos_privacy.rs index 31fedfa..9183a96 100644 --- a/lan-mouse-gtk/src/macos_privacy.rs +++ b/lan-mouse-gtk/src/macos_privacy.rs @@ -74,29 +74,46 @@ pub fn accessibility_granted() -> bool { } -/// Poll for an Accessibility grant transition. Starts a 1-second GLib -/// timer that fires `on_granted` once, the first time -/// `AXIsProcessTrusted()` returns true. A no-op if AX is already granted. +pub enum AccessibilityChange { + /// AX was missing at startup and the user has now granted it. + /// 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 -/// API requires a trusted process to subscribe — the precondition we're -/// waiting for. This runs on the GTK main thread (via timeout_add_seconds_local). -pub fn watch_for_accessibility_grant(mut on_granted: F) +/// API requires a trusted process to subscribe — the precondition we +/// can't assume. This runs on the GTK main thread (via +/// `timeout_add_seconds_local`). +pub fn watch_accessibility_state(mut on_change: F) where - F: FnMut() + 'static, + F: FnMut(AccessibilityChange) + 'static, { - if accessibility_granted() { - return; - } - log::info!("watching for Accessibility grant"); + let mut last = accessibility_granted(); + log::info!("watching Accessibility state (initial = {last})"); glib::timeout_add_seconds_local(1, move || { - if accessibility_granted() { - log::info!("Accessibility granted; firing relaunch prompt"); - on_granted(); - glib::ControlFlow::Break - } else { - glib::ControlFlow::Continue + let current = accessibility_granted(); + if current != last { + log::info!("Accessibility state flip: {last} -> {current}"); + on_change(if current { + AccessibilityChange::Granted + } else { + AccessibilityChange::Revoked + }); + last = current; } + glib::ControlFlow::Continue }); }