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(); }