From 5e79743bd05a4bcfa48f9e9262896985e767a91b Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Fri, 24 Apr 2026 02:09:32 -0500 Subject: [PATCH] macos: per-pane TCC navigation and Sequoia-tolerant permission flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS the three TCC grants (Accessibility, Input Monitoring, Post Event) live in separate Privacy panes. Before this change the "Reenable" row sent the user to Accessibility regardless of which grant was actually missing, and the daemon's own permission checks re-fired the Accessibility prompt on every retry. - lan-mouse-gtk/src/macos_privacy.rs: new module that exposes silent preflight checks (AXIsProcessTrusted, CGPreflightListenEventAccess, CGPreflightPostEventAccess), per-pane URL-scheme navigation, and a Once-guarded fire_initial_prompts() called from build_ui. The initial-prompt path only fires the Accessibility prompt if AX is missing and then returns; secondary registrations run only after AX is granted, which prevents a double Accessibility alert on Sequoia where Post Event is nested under Accessibility. - Input Monitoring registration attempts CGEventTapCreate at kCGSessionEventTap (not kCGHIDEventTap) so a failure surfaces as an Input Monitoring signal rather than triggering an Accessibility prompt as a side effect. - lan-mouse-gtk/src/window/imp.rs: handle_capture / handle_emulation switch on the missing-pane enum and navigate to the specific pane via x-apple.systempreferences:... URLs before re-requesting. - lan-mouse-gtk/resources/window.ui: pill class on the Reenable buttons so the hover padding matches the rest of libadwaita. - input-capture/src/macos.rs, input-emulation/src/macos.rs: make request_*_permission() a silent preflight (AXIsProcessTrusted / CGPreflightListenEventAccess / CGPreflightPostEventAccess), so the daemon no longer fires TCC prompts on retry — all prompting is owned by the GUI. - input-capture/src/error.rs, input-emulation/src/error.rs: new error variants so the GUI can distinguish missing-AX from missing-IM / missing-PostEvent for pane routing. Verified on macOS 15.5: first launch fires a single AX prompt; second launch (AX granted) registers under Input Monitoring via the session-tap attempt and requests Post Event. Sequoia auto-grants the listen-only path via AX so the IM list may stay empty, which is the intended OS behavior and no longer blocks capture. Co-Authored-By: Claude Opus 4.7 (1M context) --- input-capture/src/error.rs | 4 + input-capture/src/macos.rs | 40 +++++ input-emulation/Cargo.toml | 2 + input-emulation/src/error.rs | 4 + input-emulation/src/macos.rs | 38 +++++ lan-mouse-gtk/resources/window.ui | 4 +- lan-mouse-gtk/src/macos_privacy.rs | 245 +++++++++++++++++++++++++++++ lan-mouse-gtk/src/window/imp.rs | 34 ++++ 8 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 lan-mouse-gtk/src/macos_privacy.rs diff --git a/input-capture/src/error.rs b/input-capture/src/error.rs index 469234a..7324156 100644 --- a/input-capture/src/error.rs +++ b/input-capture/src/error.rs @@ -149,6 +149,10 @@ pub enum MacosCaptureCreationError { #[cfg(target_os = "macos")] #[error("event tap creation failed")] EventTapCreation, + #[error("accessibility permission is required")] + AccessibilityPermission, + #[error("input monitoring permission is required")] + InputMonitoringPermission, #[error("failed to set CG Cursor property")] CGCursorProperty, #[cfg(target_os = "macos")] diff --git a/input-capture/src/macos.rs b/input-capture/src/macos.rs index 2abb15e..a99bde6 100644 --- a/input-capture/src/macos.rs +++ b/input-capture/src/macos.rs @@ -527,6 +527,8 @@ pub struct MacOSInputCapture { impl MacOSInputCapture { pub async fn new() -> Result { + request_macos_capture_permissions()?; + let state = Arc::new(Mutex::new(InputCaptureState::new()?)); let (event_tx, event_rx) = mpsc::channel(32); let (notify_tx, mut notify_rx) = mpsc::channel(32); @@ -580,6 +582,38 @@ impl MacOSInputCapture { } } +fn request_macos_capture_permissions() -> Result<(), MacosCaptureCreationError> { + // Call both request functions unconditionally so macOS surfaces both + // TCC prompts on the very first launch. TCC always returns `false` the + // first time a permission is requested (the grant only becomes visible + // on the next process launch), so returning early on the first failure + // would skip the second prompt and force the user through an extra + // relaunch just to see it. + let accessibility = request_accessibility_permission(); + let input_monitoring = request_input_monitoring_permission(); + + if !accessibility { + return Err(MacosCaptureCreationError::AccessibilityPermission); + } + if !input_monitoring { + return Err(MacosCaptureCreationError::InputMonitoringPermission); + } + Ok(()) +} + +fn request_accessibility_permission() -> bool { + // Silent check. The GUI owns the one-time user-visible prompt at + // startup (see lan_mouse_gtk::macos_privacy) so retries triggered by + // clicking the "Reenable" button don't pop a fresh Accessibility + // alert every time. + unsafe { AXIsProcessTrusted() } +} + +fn request_input_monitoring_permission() -> bool { + // Silent check, same reasoning as above. + unsafe { CGPreflightListenEventAccess() } +} + impl Drop for MacOSInputCapture { fn drop(&mut self) { self.run_loop.stop(); @@ -651,6 +685,12 @@ extern "C" { event_source: CGEventSource, seconds: CFTimeInterval, ); + fn CGPreflightListenEventAccess() -> bool; +} + +#[link(name = "ApplicationServices", kind = "framework")] +extern "C" { + fn AXIsProcessTrusted() -> bool; } unsafe fn configure_cf_settings() -> Result<(), MacosCaptureCreationError> { diff --git a/input-emulation/Cargo.toml b/input-emulation/Cargo.toml index 28c5ecd..3839122 100644 --- a/input-emulation/Cargo.toml +++ b/input-emulation/Cargo.toml @@ -49,6 +49,8 @@ reis = { version = "0.5.0", features = ["tokio"], optional = true } [target.'cfg(target_os="macos")'.dependencies] bitflags = "2.6.0" +core-foundation = "0.10.0" +core-foundation-sys = "0.8.6" core-graphics = { version = "0.25.0", features = ["highsierra"] } keycode = "1.0.0" diff --git a/input-emulation/src/error.rs b/input-emulation/src/error.rs index 078e851..9ba9f0e 100644 --- a/input-emulation/src/error.rs +++ b/input-emulation/src/error.rs @@ -154,6 +154,10 @@ pub enum X11EmulationCreationError { pub enum MacOSEmulationCreationError { #[error("could not create event source")] EventSourceCreation, + #[error("accessibility permission is required")] + AccessibilityPermission, + #[error("input control permission is required")] + InputControlPermission, } #[cfg(windows)] diff --git a/input-emulation/src/macos.rs b/input-emulation/src/macos.rs index ae5f307..881fc22 100644 --- a/input-emulation/src/macos.rs +++ b/input-emulation/src/macos.rs @@ -61,6 +61,8 @@ unsafe impl Send for MacOSEmulation {} impl MacOSEmulation { pub(crate) fn new() -> Result { + request_macos_emulation_permissions()?; + let event_source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState) .map_err(|_| MacOSEmulationCreationError::EventSourceCreation)?; Ok(Self { @@ -119,6 +121,42 @@ impl MacOSEmulation { } } +fn request_macos_emulation_permissions() -> Result<(), MacOSEmulationCreationError> { + // Request both permissions up front so the user sees both TCC prompts + // on the first launch. See the matching comment in input-capture/src/ + // macos.rs::request_macos_capture_permissions for the rationale. + let accessibility = request_accessibility_permission(); + let input_control = request_input_control_permission(); + + if !accessibility { + return Err(MacOSEmulationCreationError::AccessibilityPermission); + } + if !input_control { + return Err(MacOSEmulationCreationError::InputControlPermission); + } + Ok(()) +} + +fn request_accessibility_permission() -> bool { + // Silent check. The GUI owns the one-time user-visible prompt at + // startup (see lan_mouse_gtk::macos_privacy). + unsafe { AXIsProcessTrusted() } +} + +fn request_input_control_permission() -> bool { + unsafe { CGPreflightPostEventAccess() } +} + +#[link(name = "CoreGraphics", kind = "framework")] +extern "C" { + fn CGPreflightPostEventAccess() -> bool; +} + +#[link(name = "ApplicationServices", kind = "framework")] +extern "C" { + fn AXIsProcessTrusted() -> bool; +} + fn key_event(event_source: CGEventSource, key: u16, state: u8, modifiers: XMods) { let event = match CGEvent::new_keyboard_event(event_source, key, state != 0) { Ok(e) => e, diff --git a/lan-mouse-gtk/resources/window.ui b/lan-mouse-gtk/resources/window.ui index a3d1c89..609ea4e 100644 --- a/lan-mouse-gtk/resources/window.ui +++ b/lan-mouse-gtk/resources/window.ui @@ -63,7 +63,7 @@ center @@ -89,7 +89,7 @@ center diff --git a/lan-mouse-gtk/src/macos_privacy.rs b/lan-mouse-gtk/src/macos_privacy.rs new file mode 100644 index 0000000..22583b0 --- /dev/null +++ b/lan-mouse-gtk/src/macos_privacy.rs @@ -0,0 +1,245 @@ +#![cfg(target_os = "macos")] + +//! Tiny macOS Privacy-pane helpers used by the GUI. +//! +//! Clicking "Reenable" on the capture/emulation warning row should take the +//! user to whichever Privacy pane is actually missing a grant — opening the +//! Accessibility pane when the user has already granted Accessibility (and +//! only needs Input Monitoring) is confusing and hides the real request. + +use std::ffi::{c_uchar, c_void}; +use std::process::Command; +use std::sync::Once; + +// Apple declares `AXIsProcessTrusted` as returning `Boolean` (`unsigned char`), +// NOT C's `bool`. Rust's `bool` has a strict bit pattern (0 or 1) so binding +// a `Boolean`-returning function as `-> bool` is technically UB if Apple ever +// returns a non-canonical true value. Keep these as `c_uchar` and normalize. +#[link(name = "ApplicationServices", kind = "framework")] +extern "C" { + fn AXIsProcessTrusted() -> c_uchar; + fn AXIsProcessTrustedWithOptions(options: *const c_void) -> c_uchar; +} + +#[link(name = "CoreFoundation", kind = "framework")] +extern "C" { + static kCFAllocatorDefault: *const c_void; + static kCFTypeDictionaryKeyCallBacks: *const c_void; + static kCFTypeDictionaryValueCallBacks: *const c_void; + static kCFBooleanTrue: *const c_void; + fn CFDictionaryCreate( + allocator: *const c_void, + keys: *const *const c_void, + values: *const *const c_void, + num: isize, + key_callbacks: *const c_void, + value_callbacks: *const c_void, + ) -> *const c_void; + fn CFRelease(cf: *const c_void); +} + +// kAXTrustedCheckOptionPrompt is a CFStringRef exported from ApplicationServices. +#[link(name = "ApplicationServices", kind = "framework")] +extern "C" { + static kAXTrustedCheckOptionPrompt: *const c_void; +} + +#[link(name = "CoreGraphics", kind = "framework")] +extern "C" { + fn CGPreflightListenEventAccess() -> c_uchar; + fn CGRequestListenEventAccess() -> c_uchar; + fn CGPreflightPostEventAccess() -> c_uchar; + fn CGRequestPostEventAccess() -> c_uchar; + + // CFMachPortRef CGEventTapCreate( + // CGEventTapLocation tap, CGEventTapPlacement place, + // CGEventTapOptions options, CGEventMask eventsOfInterest, + // CGEventTapCallBack callback, void *userInfo); + fn CGEventTapCreate( + tap: u32, + place: u32, + options: u32, + events_of_interest: u64, + callback: *const c_void, + user_info: *const c_void, + ) -> *const c_void; +} + +pub fn accessibility_granted() -> bool { + let raw = unsafe { AXIsProcessTrusted() }; + log::debug!("AXIsProcessTrusted() = {raw}"); + raw != 0 +} + +pub fn input_monitoring_granted() -> bool { + let raw = unsafe { CGPreflightListenEventAccess() }; + log::debug!("CGPreflightListenEventAccess() = {raw}"); + raw != 0 +} + +pub fn post_event_granted() -> bool { + let raw = unsafe { CGPreflightPostEventAccess() }; + log::debug!("CGPreflightPostEventAccess() = {raw}"); + raw != 0 +} + +pub enum CapturePane { + Accessibility, + InputMonitoring, + /// Everything is already granted; the caller should just retry. + None, +} + +pub enum EmulationPane { + Accessibility, + PostEvent, + None, +} + +pub fn missing_capture_pane() -> CapturePane { + if !accessibility_granted() { + CapturePane::Accessibility + } else if !input_monitoring_granted() { + CapturePane::InputMonitoring + } else { + CapturePane::None + } +} + +pub fn missing_emulation_pane() -> EmulationPane { + if !accessibility_granted() { + EmulationPane::Accessibility + } else if !post_event_granted() { + EmulationPane::PostEvent + } else { + EmulationPane::None + } +} + +pub fn open_accessibility_settings() { + open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"); +} + +pub fn open_input_monitoring_settings() { + unsafe { + ensure_listed_in_input_monitoring(); + } + open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"); +} + +pub fn open_post_event_settings() { + unsafe { + CGRequestPostEventAccess(); + } + open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_PostEvent"); +} + +/// Make sure the app appears in System Settings → Privacy → Input Monitoring. +/// +/// `CGRequestListenEventAccess()` is *supposed* to register the app in the +/// list (and prompt) on first call, but in practice — particularly after a +/// `tccutil reset ListenEvent ` — it often silently no-ops and the +/// app never gets added. The reliable way to force registration is to +/// attempt a protected action: create a `CGEventTap`. If permission is +/// missing the call returns null, but the attempt itself causes TCC to add +/// the bundle to the Input Monitoring pane so the user can toggle it on. +/// If permission already exists the tap is created successfully, and we +/// tear it down immediately so it doesn't intercept events. +unsafe fn ensure_listed_in_input_monitoring() { + let req = CGRequestListenEventAccess(); + log::debug!("CGRequestListenEventAccess() = {req}"); + let cb = input_monitoring_noop_tap_callback as *const c_void; + // Use kCGSessionEventTap (1), NOT kCGHIDEventTap (0). The HID tap sits + // below window-server input and requires Accessibility in addition to + // Input Monitoring, so attempting it when Accessibility isn't granted + // surfaces an Accessibility prompt as a side effect — which is confusing + // on top of the real Accessibility prompt we already fire explicitly. + // The session tap requires only Input Monitoring, so its failure is a + // clean "Input Monitoring missing" signal that TCC uses to list the + // bundle under the Input Monitoring pane. + // kCGHeadInsertEventTap = 0, kCGEventTapOptionListenOnly = 1, + // mask kCGEventKeyDown = 1 << 10. + let tap = CGEventTapCreate(1, 0, 1, 1 << 10, cb, std::ptr::null()); + log::debug!("CGEventTapCreate(kCGSessionEventTap) -> {tap:?}"); + if !tap.is_null() { + CFRelease(tap); + } +} + +extern "C" fn input_monitoring_noop_tap_callback( + _proxy: *const c_void, + _ty: u32, + event: *const c_void, + _refcon: *const c_void, +) -> *const c_void { + // Pass through unchanged. This tap is never added to a run loop, so + // in practice the callback never fires — it exists only so the tap + // can be created (and the attempt is what forces TCC registration). + event +} + +fn open_url(url: &str) { + if let Err(e) = Command::new("open").arg(url).spawn() { + log::warn!("failed to open {url}: {e}"); + } +} + +/// One-shot, at GUI startup: if a permission is missing, fire the system +/// prompt. This is where the familiar first-launch "Lan Mouse.app would +/// like to control this computer" alert comes from. Subsequent clicks on +/// the Reenable button use URL-scheme navigation instead, so we never +/// double up alerts on retries. +/// +/// Guarded with a `Once` because GApplication::activate can fire more +/// than once in a process (reactivation, window presentation) and we +/// must not re-pop the TCC alert on each activation — that looks like a +/// bug to the user. +pub fn fire_initial_prompts() { + static FIRED: Once = Once::new(); + FIRED.call_once(fire_initial_prompts_inner); +} + +fn fire_initial_prompts_inner() { + if !accessibility_granted() { + // When Accessibility isn't granted yet, ONLY fire the Accessibility + // prompt. Do NOT also try to register Input Monitoring or Post Event + // — those paths have been observed to surface a second Accessibility + // dialog on top of the one we fire explicitly (Post Event is part of + // the Accessibility category on modern macOS, and CGEventTap attempts + // can bail on Accessibility before they reach the Input Monitoring + // check). Once the user grants Accessibility and relaunches, this + // branch is skipped and we register the other grants cleanly below. + log::info!("firing first-launch Accessibility prompt"); + unsafe { + let key = kAXTrustedCheckOptionPrompt; + let value = kCFBooleanTrue; + let options = CFDictionaryCreate( + kCFAllocatorDefault, + &key as *const _, + &value as *const _, + 1, + kCFTypeDictionaryKeyCallBacks, + kCFTypeDictionaryValueCallBacks, + ); + AXIsProcessTrustedWithOptions(options); + CFRelease(options); + } + return; + } + // Accessibility is granted. Attempt Input Monitoring registration + // unconditionally — even if preflight returns true — so the bundle gets + // listed in System Settings under its own identity (otherwise launches + // from a parent process that already has Input Monitoring, e.g. Terminal, + // inherit the grant but the bundle is never listed for the user to + // toggle persistently). + log::info!("ensuring Lan Mouse is listed under Input Monitoring"); + unsafe { + ensure_listed_in_input_monitoring(); + } + // Same for Post Event: now that Accessibility is present, this call is + // safe — it won't surface the generic Accessibility prompt. + log::info!("ensuring Lan Mouse is listed under Accessibility > Post Event"); + unsafe { + CGRequestPostEventAccess(); + } +} diff --git a/lan-mouse-gtk/src/window/imp.rs b/lan-mouse-gtk/src/window/imp.rs index f37647e..25c753c 100644 --- a/lan-mouse-gtk/src/window/imp.rs +++ b/lan-mouse-gtk/src/window/imp.rs @@ -142,11 +142,45 @@ impl Window { #[template_callback] fn handle_emulation(&self) { + #[cfg(target_os = "macos")] + { + use crate::macos_privacy::{self, EmulationPane}; + match macos_privacy::missing_emulation_pane() { + EmulationPane::Accessibility => { + log::info!("Reenable emulation: opening Accessibility pane"); + macos_privacy::open_accessibility_settings(); + } + EmulationPane::PostEvent => { + log::info!("Reenable emulation: opening Post Event pane"); + macos_privacy::open_post_event_settings(); + } + EmulationPane::None => { + log::info!("Reenable emulation: both grants present, retry only"); + } + } + } self.obj().request_emulation(); } #[template_callback] fn handle_capture(&self) { + #[cfg(target_os = "macos")] + { + use crate::macos_privacy::{self, CapturePane}; + match macos_privacy::missing_capture_pane() { + CapturePane::Accessibility => { + log::info!("Reenable capture: opening Accessibility pane"); + macos_privacy::open_accessibility_settings(); + } + CapturePane::InputMonitoring => { + log::info!("Reenable capture: opening Input Monitoring pane"); + macos_privacy::open_input_monitoring_settings(); + } + CapturePane::None => { + log::info!("Reenable capture: both grants present, retry only"); + } + } + } self.obj().request_capture(); }