macos: fold relaunch prompt into the warning row instead of a toast

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) <noreply@anthropic.com>
This commit is contained in:
Jon Kinney
2026-04-24 09:15:40 -05:00
committed by Ferdinand Schober
parent 2dc9ebb6cd
commit 5d7d14fbf7
5 changed files with 112 additions and 102 deletions

View File

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