diff --git a/lan-mouse-gtk/Cargo.toml b/lan-mouse-gtk/Cargo.toml index 4a1a202..71c4083 100644 --- a/lan-mouse-gtk/Cargo.toml +++ b/lan-mouse-gtk/Cargo.toml @@ -8,7 +8,7 @@ repository = "https://github.com/feschber/lan-mouse" [dependencies] gtk = { package = "gtk4", version = "0.9.0", features = ["v4_2"] } -adw = { package = "libadwaita", version = "0.7.0", features = ["v1_1"] } +adw = { package = "libadwaita", version = "0.7.0", features = ["v1_2"] } async-channel = { version = "2.1.1" } hostname = "0.4.0" log = "0.4.20" diff --git a/lan-mouse-gtk/resources/window.ui b/lan-mouse-gtk/resources/window.ui index 609ea4e..caa969f 100644 --- a/lan-mouse-gtk/resources/window.ui +++ b/lan-mouse-gtk/resources/window.ui @@ -50,7 +50,7 @@ input capture is disabled - required for outgoing and incoming connections + required for outgoing connections — click Reenable to grant permission dialog-warning-symbolic @@ -76,7 +76,7 @@ input emulation is disabled - required for incoming connections + required for incoming connections — click Reenable to grant permission dialog-warning-symbolic diff --git a/lan-mouse-gtk/src/lib.rs b/lan-mouse-gtk/src/lib.rs index 6204879..f4001d8 100644 --- a/lan-mouse-gtk/src/lib.rs +++ b/lan-mouse-gtk/src/lib.rs @@ -200,6 +200,20 @@ 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 prompt the user to relaunch 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. + let app_weak = app.downgrade(); + let window_weak = window.downgrade(); + macos_privacy::watch_for_accessibility_grant(move || { + if let (Some(app), Some(window)) = (app_weak.upgrade(), window_weak.upgrade()) + { + show_macos_relaunch_dialog(&app, &window); + } + }); } glib::spawn_future_local(clone!( @@ -252,3 +266,60 @@ fn build_ui(app: &Application) { #[cfg(not(target_os = "macos"))] window.present(); } + +#[cfg(target_os = "macos")] +fn show_macos_relaunch_dialog(app: &Application, window: &Window) { + // Present the window so the toast is visible — on macOS the main + // window starts hidden (LSUIElement accessory app), so a toast + // otherwise fires into a surface the user can't see. + window.present(); + + let toast = adw::Toast::builder() + .title( + "Accessibility granted. Relaunch Lan Mouse so capture and \ + emulation can initialize.", + ) + .button_label("Relaunch") + .priority(adw::ToastPriority::High) + // 0 => never auto-dismiss. Relaunch is mandatory for things to + // work, so don't let the user miss the action. + .timeout(0) + .build(); + + let app = app.clone(); + toast.connect_button_clicked(move |_| { + relaunch_macos_bundle(); + app.quit(); + }); + + window.add_toast(toast); +} + +#[cfg(target_os = "macos")] +fn relaunch_macos_bundle() { + // Resolve the .app bundle path from the current executable: it lives + // at /Contents/MacOS/lan-mouse, so three parents up is the + // bundle root we hand to `open`. + let Ok(exe) = std::env::current_exe() else { + return; + }; + let Some(bundle) = exe + .parent() + .and_then(std::path::Path::parent) + .and_then(std::path::Path::parent) + else { + return; + }; + + // Fire `sleep 1 && open ` in a detached shell so the new + // instance starts *after* we've quit — otherwise Launch Services + // reactivates the existing process instead of launching a fresh one, + // and the stale IPC socket would block the new daemon subprocess. + // The trailing `&` backgrounds the command, and we don't wait on the + // spawn, so the shell is adopted by launchd after we exit. + let cmd = format!("(sleep 1 && open {bundle:?}) &", bundle = bundle); + let _ = std::process::Command::new("sh") + .arg("-c") + .arg(cmd) + .spawn(); +} diff --git a/lan-mouse-gtk/src/macos_privacy.rs b/lan-mouse-gtk/src/macos_privacy.rs index 22583b0..5f13e1c 100644 --- a/lan-mouse-gtk/src/macos_privacy.rs +++ b/lan-mouse-gtk/src/macos_privacy.rs @@ -11,6 +11,8 @@ use std::ffi::{c_uchar, c_void}; use std::process::Command; use std::sync::Once; +use gtk::glib; + // 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 @@ -83,6 +85,13 @@ pub fn post_event_granted() -> bool { raw != 0 } +// Variants `InputMonitoring` and `PostEvent` are currently never returned +// by `missing_capture_pane` / `missing_emulation_pane` — on macOS 13+ those +// categories auto-grant via Accessibility and the bundle typically isn't +// listed in those separate panes, so routing users there is a dead end. +// Kept in the enum so older-macOS behavior can be restored without a +// structural change. +#[allow(dead_code)] pub enum CapturePane { Accessibility, InputMonitoring, @@ -90,6 +99,7 @@ pub enum CapturePane { None, } +#[allow(dead_code)] pub enum EmulationPane { Accessibility, PostEvent, @@ -100,7 +110,13 @@ pub fn missing_capture_pane() -> CapturePane { if !accessibility_granted() { CapturePane::Accessibility } else if !input_monitoring_granted() { - CapturePane::InputMonitoring + // On macOS 13+, Accessibility trust confers the listen-only + // event-tap privilege that Input Monitoring gates, and on Sequoia + // the bundle typically isn't listed in the Input Monitoring pane + // at all. The actionable fix when IM preflight is still 0 is to + // re-toggle Accessibility, so send the user there rather than to + // an empty IM list. + CapturePane::Accessibility } else { CapturePane::None } @@ -110,12 +126,41 @@ pub fn missing_emulation_pane() -> EmulationPane { if !accessibility_granted() { EmulationPane::Accessibility } else if !post_event_granted() { - EmulationPane::PostEvent + // Post Event is nested under Accessibility on modern macOS and + // auto-grants alongside it. Point the user back to Accessibility + // for the same reason as the capture case above. + EmulationPane::Accessibility } else { EmulationPane::None } } +/// 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. +/// +/// 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) +where + F: FnMut() + 'static, +{ + if accessibility_granted() { + return; + } + log::info!("watching for Accessibility grant"); + 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 + } + }); +} + pub fn open_accessibility_settings() { open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"); } diff --git a/lan-mouse-gtk/src/window.rs b/lan-mouse-gtk/src/window.rs index 47db926..3b87730 100644 --- a/lan-mouse-gtk/src/window.rs +++ b/lan-mouse-gtk/src/window.rs @@ -432,6 +432,10 @@ impl Window { pub(super) fn show_toast(&self, msg: &str) { let toast = adw::Toast::new(msg); + self.add_toast(toast); + } + + pub(super) fn add_toast(&self, toast: adw::Toast) { let toast_overlay = &self.imp().toast_overlay; toast_overlay.add_toast(toast); }