From d1e963e90a94efc2a2873571239b1f1c1017f3a2 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Fri, 24 Apr 2026 09:15:40 -0500 Subject: [PATCH] macos: fold relaunch prompt into the warning row instead of a toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cut-off toast UX ("Accessibility granted. Relaunch Lan Mouse so capture and emulat…") was unreadable in a compact window and split the "grant" and "relaunch" flows into two disconnected surfaces. Fold everything into the existing warning row with state-dependent content: - AX missing: title = "input capture is disabled" subtitle = "grant Accessibility permission to enable" button = "Grant" → opens System Settings → Accessibility - AX granted, daemon still bailed: title = "relaunch required" subtitle = "Accessibility granted — restart to activate capture and emulation" button = "Relaunch" → spawns a fresh bundle via `open` after a 1s delay, then quits. - Both active: row hidden. The emulation_status_row is kept hidden on macOS because capture and emulation share the same TCC gate — a single row is sufficient and two identical-looking warnings were noisy. `handle_emulation` still exists for the non-macOS platforms where the rows are distinct. Side effects: - `relaunch_bundle` moved from lib.rs to macos_privacy so imp.rs can call it from the row button handler. - AX watcher callback shrinks to `window.present()` + `refresh_capture_emulation_status()`; the toast-based dialog is gone along with its helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- lan-mouse-gtk/resources/window.ui | 4 +- lan-mouse-gtk/src/lib.rs | 79 +++--------------------------- lan-mouse-gtk/src/macos_privacy.rs | 27 ++++++++++ lan-mouse-gtk/src/window.rs | 71 +++++++++++++++++++++------ lan-mouse-gtk/src/window/imp.rs | 33 ++++++++----- 5 files changed, 112 insertions(+), 102 deletions(-) diff --git a/lan-mouse-gtk/resources/window.ui b/lan-mouse-gtk/resources/window.ui index caa969f..609ea4e 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 connections — click Reenable to grant permission + required for outgoing and incoming connections dialog-warning-symbolic @@ -76,7 +76,7 @@ input emulation is disabled - required for incoming connections — click Reenable to grant permission + required for incoming connections dialog-warning-symbolic diff --git a/lan-mouse-gtk/src/lib.rs b/lan-mouse-gtk/src/lib.rs index d4d57c6..3c976ef 100644 --- a/lan-mouse-gtk/src/lib.rs +++ b/lan-mouse-gtk/src/lib.rs @@ -201,17 +201,16 @@ fn build_ui(app: &Application) { // 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(); + // 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. 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); + if let Some(window) = window_weak.upgrade() { + window.present(); + window.refresh_capture_emulation_status(); } }); } @@ -267,65 +266,3 @@ fn build_ui(app: &Application) { 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(); - - // Refresh the capture/emulation status rows so the yellow - // "Reenable" warning disappears. It was showing because the daemon - // reports capture/emulation inactive; now that AX is granted the - // Relaunch toast is the right prompt and the warning is redundant. - window.refresh_capture_emulation_status(); - - 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 41aad57..31fedfa 100644 --- a/lan-mouse-gtk/src/macos_privacy.rs +++ b/lan-mouse-gtk/src/macos_privacy.rs @@ -104,6 +104,33 @@ pub fn open_accessibility_settings() { open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"); } +/// Spawn a fresh instance of the current `.app` bundle via Launch Services +/// after a 1-second delay, so the new instance starts *after* the current +/// process has exited — 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 caller is responsible for +/// quitting the current process (e.g. `Application::quit()`) after this. +pub fn relaunch_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; + }; + + // Trailing `&` backgrounds the sleep+open so our shell call returns + // immediately; the spawned shell is adopted by launchd once we exit. + let cmd = format!("(sleep 1 && open {bundle:?}) &"); + let _ = Command::new("sh").arg("-c").arg(cmd).spawn(); +} + /// Make sure the app appears in System Settings → Privacy → Input Monitoring. /// /// `CGRequestListenEventAccess()` is *supposed* to register the app in the diff --git a/lan-mouse-gtk/src/window.rs b/lan-mouse-gtk/src/window.rs index 0396233..f650157 100644 --- a/lan-mouse-gtk/src/window.rs +++ b/lan-mouse-gtk/src/window.rs @@ -22,6 +22,17 @@ use crate::{ use super::{client_object::ClientObject, client_row::ClientRow}; +#[cfg(target_os = "macos")] +fn set_button_content_label(button: >k::Button, label: &str) { + // The Reenable/Grant/Relaunch button wraps its icon+label in an + // AdwButtonContent (see window.ui). Walk into it and swap the label + // rather than GtkButton::set_label, which would replace the content + // widget and drop the icon. + if let Some(content) = button.child().and_downcast::() { + content.set_label(label); + } +} + glib::wrapper! { pub struct Window(ObjectSubclass) @extends adw::ApplicationWindow, gtk::Window, gtk::Widget, @@ -459,24 +470,52 @@ impl Window { let capture = self.imp().capture_active.get(); let emulation = self.imp().emulation_active.get(); - // On macOS the yellow "Reenable" row only makes sense when - // Accessibility is actually missing. When AX is granted but - // capture/emulation are still off, the daemon simply hasn't been - // restarted yet — the Relaunch toast covers that state, and a - // yellow "grant permission" warning on top of it would be - // redundant and confusing. #[cfg(target_os = "macos")] - let show_warning = !crate::macos_privacy::accessibility_granted(); - #[cfg(not(target_os = "macos"))] - let show_warning = true; + { + // On macOS, capture and emulation share the same TCC gate + // (Accessibility). Collapse to a single warning row — + // emulation_status_row stays hidden and capture_status_row + // doubles as the shared status indicator. Its text and + // button mutate based on whether we're waiting for AX or + // waiting for the user to relaunch the app. + let anything_off = !capture || !emulation; + self.imp().emulation_status_row.set_visible(false); + self.imp().capture_status_row.set_visible(anything_off); + self.imp().capture_emulation_group.set_visible(anything_off); - let show_capture_row = !capture && show_warning; - let show_emulation_row = !emulation && show_warning; - self.imp().capture_status_row.set_visible(show_capture_row); - self.imp().emulation_status_row.set_visible(show_emulation_row); - self.imp() - .capture_emulation_group - .set_visible(show_capture_row || show_emulation_row); + if anything_off { + self.update_macos_warning_row_text(); + } + } + + #[cfg(not(target_os = "macos"))] + { + self.imp().capture_status_row.set_visible(!capture); + self.imp().emulation_status_row.set_visible(!emulation); + self.imp() + .capture_emulation_group + .set_visible(!capture || !emulation); + } + } + + #[cfg(target_os = "macos")] + fn update_macos_warning_row_text(&self) { + let row = &self.imp().capture_status_row; + let button = &self.imp().input_capture_button; + + if crate::macos_privacy::accessibility_granted() { + // AX granted but capture/emulation still off → the daemon + // subprocess bailed at startup and needs a fresh process to + // re-initialize with the new grant in place. + row.set_title("relaunch required"); + row.set_subtitle("Accessibility granted — restart to activate capture and emulation"); + set_button_content_label(button, "Relaunch"); + } else { + // AX missing → send the user to System Settings. + row.set_title("input capture is disabled"); + row.set_subtitle("grant Accessibility permission to enable"); + set_button_content_label(button, "Grant"); + } } pub(super) fn set_authorized_keys(&self, fingerprints: HashMap) { diff --git a/lan-mouse-gtk/src/window/imp.rs b/lan-mouse-gtk/src/window/imp.rs index 8d11928..bb7d48c 100644 --- a/lan-mouse-gtk/src/window/imp.rs +++ b/lan-mouse-gtk/src/window/imp.rs @@ -10,16 +10,6 @@ use lan_mouse_ipc::{DEFAULT_PORT, FrontendRequestWriter}; use crate::authorization_window::AuthorizationWindow; -#[cfg(target_os = "macos")] -fn open_accessibility_if_missing(ctx: &str) { - if crate::macos_privacy::accessibility_granted() { - log::info!("{ctx}: Accessibility already granted, retry only"); - } else { - log::info!("{ctx}: opening Accessibility pane"); - crate::macos_privacy::open_accessibility_settings(); - } -} - #[derive(CompositeTemplate, Default)] #[template(resource = "/de/feschber/LanMouse/window.ui")] pub struct Window { @@ -152,15 +142,32 @@ impl Window { #[template_callback] fn handle_emulation(&self) { - #[cfg(target_os = "macos")] - open_accessibility_if_missing("Reenable emulation"); + // On macOS the emulation_status_row is hidden — capture_status_row + // acts as the shared warning (see update_capture_emulation_status). + // This handler still fires for the non-macOS platforms where the + // emulation row is distinct. self.obj().request_emulation(); } #[template_callback] fn handle_capture(&self) { #[cfg(target_os = "macos")] - open_accessibility_if_missing("Reenable capture"); + { + use crate::macos_privacy; + if macos_privacy::accessibility_granted() { + // AX granted but the row is still visible => the daemon + // subprocess bailed before AX was in place and needs a + // fresh process. Quit + relaunch via Launch Services. + log::info!("capture row clicked in relaunch-required state"); + macos_privacy::relaunch_bundle(); + if let Some(app) = self.obj().application() { + app.quit(); + } + return; + } + log::info!("capture row clicked in AX-missing state, opening pane"); + macos_privacy::open_accessibility_settings(); + } self.obj().request_capture(); }