macos: prompt to relaunch after live Accessibility grant

The daemon subprocess initializes at startup and bails immediately if
Accessibility is missing ("accessibility permission is required"). If
the user then grants AX mid-session via the system prompt, the daemon
has no way to retry from its bailed state — capture and emulation
stay dead until the next restart. Make the GUI watch for the AX
transition and surface a toast with a "Relaunch" button that quits
the app and spawns a fresh instance via Launch Services.

While here:
- Route capture/emulation "missing pane" fallback to the Accessibility
  pane instead of the Input Monitoring / Post Event panes when AX is
  already granted. On macOS 13+ those separate grants auto-confer via
  Accessibility and the bundle typically isn't listed in the IM pane
  at all, so the old navigation was a dead end.
- Reword the status-row subtitles so the action is clearer: the user
  now sees "click Reenable to grant permission" instead of a generic
  "required for outgoing connections".
- Bump libadwaita feature flag to v1_2 for AdwToast button signals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jon Kinney
2026-04-24 08:27:34 -05:00
parent d3d0804fee
commit a45e00e03a
5 changed files with 125 additions and 5 deletions

View File

@@ -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 <bundle>/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 <bundle>` 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();
}