From cd2f4b32f6a46af7410dd3882c568630a2f9601e Mon Sep 17 00:00:00 2001 From: Adrian Date: Tue, 4 Aug 2026 16:11:45 -0700 Subject: [PATCH 1/2] fix(desktop): honor tauri relaunch() on macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tauri restarts by exiting with RESTART_EXIT_CODE and respawning once the event loop unwinds. On macOS every exit path funnels into force_exit()'s hard _exit(), so the loop never unwinds and the respawn never runs: the onboarding "Restart Required" prompt and the updater's restart both quit without coming back (observed as exit code 2147483647 with no subsequent launch). Record the intent when ExitRequested carries RESTART_EXIT_CODE and honor it at the force_exit choke point, which also covers the exit watchdog. The respawn uses a detached `open` on the .app bundle so LaunchServices gives the new instance its own TCC identity by code signature — important here, since the onboarding restart exists to re-evaluate screen-recording permission. Outside a bundle (dev runs) the executable is spawned directly, because open(1) hands a bare Mach-O to Terminal and would re-attribute TCC to it. Also marks the crash sentinel clean on this path: tauri exempts restart requests from prevent_exit, so the runtime exits before the async cleanup that normally disarms the sentinel can finish, and every relaunch was reported as an unexpected termination on the next launch. Co-Authored-By: Claude --- apps/desktop/src-tauri/src/exit_shutdown.rs | 54 +++++++++++++ apps/desktop/src-tauri/src/lib.rs | 90 +++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/apps/desktop/src-tauri/src/exit_shutdown.rs b/apps/desktop/src-tauri/src/exit_shutdown.rs index ea47192fa7f..1ab188ec950 100644 --- a/apps/desktop/src-tauri/src/exit_shutdown.rs +++ b/apps/desktop/src-tauri/src/exit_shutdown.rs @@ -1,5 +1,17 @@ use tokio::task::JoinHandle; +// The .app bundle for a relaunch via LaunchServices, derived from the running +// executable (…/Cap.app/Contents/MacOS/). None outside a bundle (dev +// runs) — callers must NOT hand a bare Mach-O to open(1), which would route it +// to Terminal and re-attribute TCC to Terminal. +pub(crate) fn relaunch_target(current_exe: &std::path::Path) -> Option { + current_exe + .ancestors() + .nth(3) + .filter(|p| p.extension().is_some_and(|e| e == "app")) + .map(std::path::Path::to_path_buf) +} + pub(crate) fn run_while_active(is_exiting: FExit, operation: F) -> Option where FExit: Fn() -> bool, @@ -151,3 +163,45 @@ pub(crate) fn abort_join_handles( task.abort(); } } + +#[cfg(test)] +mod relaunch_target_tests { + use super::relaunch_target; + use std::path::Path; + + #[test] + fn bundle_layouts_resolve_to_the_app() { + for (exe, want) in [ + ( + "/Applications/Cap.app/Contents/MacOS/Cap", + "/Applications/Cap.app", + ), + ( + "/Applications/Cap.app/Contents/MacOS/Cap - Development", + "/Applications/Cap.app", + ), + ( + "/Volumes/Cap 0.5.7/Cap.app/Contents/MacOS/Cap", + "/Volumes/Cap 0.5.7/Cap.app", + ), + ] { + assert_eq!( + relaunch_target(Path::new(exe)).as_deref(), + Some(Path::new(want)), + "exe: {exe}" + ); + } + } + + #[test] + fn non_bundle_layouts_are_refused() { + for exe in [ + "/repo/src-tauri/target/debug/cap-desktop", + "/usr/local/bin/cap", + "/a/b", + "/", + ] { + assert_eq!(relaunch_target(Path::new(exe)), None, "exe: {exe}"); + } + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index deb087400a4..8ba427cac62 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -435,7 +435,73 @@ fn spawn_process_memory_sampler(app: AppHandle) { }); } +static RESTART_REQUESTED_ON_EXIT: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +// tauri's relaunch() contract is "exit with RESTART_EXIT_CODE, respawn after +// the event loop unwinds" — but every macOS exit here funnels into +// force_exit's hard _exit(), so the loop never unwinds and tauri's respawn +// never runs: the onboarding "Restart Required" prompt quit without +// restarting (observed on the official 0.5.7 build, exit code 2147483647 with +// no relaunch). The intent is recorded at ExitRequested and honored at the +// force_exit choke point, which also covers the exit watchdog's hard exit. +pub(crate) fn note_exit_requested_code(code: Option) { + if code == Some(tauri::RESTART_EXIT_CODE) { + // Logged here, not in force_exit: the non-blocking appender drops + // records emitted microseconds before _exit(). + info!("Relaunch requested; will respawn after exit"); + // A deliberate relaunch is a clean shutdown. tauri exempts restart + // requests from prevent_exit, so the runtime exits before the async + // cleanup (which normally disarms the crash sentinel) can finish — + // without this, every relaunch reports a phantom crash on next boot. + crash_sentinel::mark_clean_exit(); + RESTART_REQUESTED_ON_EXIT.store(true, std::sync::atomic::Ordering::Release); + } +} + +fn spawn_relauncher_if_requested() { + #[cfg(target_os = "macos")] + { + // swap: exactly one relauncher even if the exit watchdog and the main + // exit path race into force_exit together. + if !RESTART_REQUESTED_ON_EXIT.swap(false, std::sync::atomic::Ordering::AcqRel) { + return; + } + // eprintln below, not tracing: the non-blocking appender drops records + // this close to _exit(), stderr writes are synchronous. + let Ok(exe) = std::env::current_exe() else { + eprintln!("cap relaunch: current_exe() failed; not respawning"); + return; + }; + match exit_shutdown::relaunch_target(&exe) { + Some(bundle) => { + let path = bundle.display().to_string(); + if path.contains('\'') { + eprintln!("cap relaunch: bundle path contains a quote; not respawning: {path}"); + return; + } + // A detached shell survives this process (reparented to + // launchd). The delay lets the old instance die completely + // first, so the single-instance plugin in the fresh one never + // meets a live listener; `open` goes through LaunchServices so + // the new instance keeps normal app context (TCC, dock). + let _ = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(format!("sleep 0.7; /usr/bin/open '{path}'")) + .spawn(); + } + None => { + // Dev / non-bundle run: open(1) would route a bare Mach-O to + // Terminal and re-attribute TCC to it — spawn the executable + // directly instead, like tauri's own process::restart does. + let _ = std::process::Command::new(&exe).spawn(); + } + } + } +} + fn force_exit(code: i32) -> ! { + spawn_relauncher_if_requested(); unsafe extern "C" { fn _exit(code: i32) -> !; } @@ -6039,6 +6105,7 @@ fn handle_run_event(_handle: &AppHandle, event: tauri::RunEvent) { } tauri::RunEvent::ExitRequested { code, api, .. } => { info!(?code, "App exit requested"); + note_exit_requested_code(code); match handle_exit_requested( _handle @@ -6830,3 +6897,26 @@ mod screenshot_share_cache_tests { assert!(link.is_none()); } } + +#[cfg(test)] +mod relaunch_intent_tests { + use super::*; + + #[test] + fn restart_exit_code_sets_relaunch_intent() { + RESTART_REQUESTED_ON_EXIT.store(false, std::sync::atomic::Ordering::Release); + note_exit_requested_code(None); + note_exit_requested_code(Some(0)); + note_exit_requested_code(Some(1)); + assert!( + !RESTART_REQUESTED_ON_EXIT.load(std::sync::atomic::Ordering::Acquire), + "ordinary exits must not schedule a relaunch" + ); + note_exit_requested_code(Some(tauri::RESTART_EXIT_CODE)); + assert!( + RESTART_REQUESTED_ON_EXIT.load(std::sync::atomic::Ordering::Acquire), + "tauri relaunch() exits with RESTART_EXIT_CODE and must respawn" + ); + RESTART_REQUESTED_ON_EXIT.store(false, std::sync::atomic::Ordering::Release); + } +} From 6ee0bec45d8cd9fee5a4496866e68ed7ef6d23f2 Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 6 Aug 2026 12:55:20 -0700 Subject: [PATCH 2/2] fix(desktop): harden the macOS relauncher per review - Pass the relaunch command to /bin/sh as positional arguments ("$@") instead of interpolating the bundle path into the script: paths with apostrophes, spaces, or non-UTF8 bytes now relaunch instead of being abandoned, and the quote bail-out is gone. - Give the dev/non-bundle path the same delayed detached spawn as the bundle path, so the replacement never races the old instance's live single-instance listener. - Extract relaunch_argv() and the RELAUNCH_SH script into exit_shutdown and pin both argv shapes plus the no-interpolation property with a behavioral test that runs the real /bin/sh (the doubled space in the hostile path is what makes an unquoted $@ observable). - /bin/sleep by absolute path; report relauncher spawn failure on stderr; allow(dead_code) off-macOS for the clippy -D warnings matrix. - Cargo.lock: cap-desktop 0.5.7 -> 0.5.8, aligning with Cargo.toml at the PR base (upstream main's lock already has 0.5.8); required for the --locked CI jobs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LGzjw5CMyzkgwdARCQnaP8 --- Cargo.lock | 2 +- apps/desktop/src-tauri/src/exit_shutdown.rs | 69 +++++++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 44 +++++-------- 3 files changed, 87 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 411fca9b632..61a358112b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1434,7 +1434,7 @@ dependencies = [ [[package]] name = "cap-desktop" -version = "0.5.7" +version = "0.5.8" dependencies = [ "aho-corasick", "anyhow", diff --git a/apps/desktop/src-tauri/src/exit_shutdown.rs b/apps/desktop/src-tauri/src/exit_shutdown.rs index 1ab188ec950..d6144e2f974 100644 --- a/apps/desktop/src-tauri/src/exit_shutdown.rs +++ b/apps/desktop/src-tauri/src/exit_shutdown.rs @@ -12,6 +12,24 @@ pub(crate) fn relaunch_target(current_exe: &std::path::Path) -> Option Vec { + match relaunch_target(current_exe) { + Some(bundle) => vec!["/usr/bin/open".into(), bundle.into_os_string()], + None => vec![current_exe.as_os_str().to_os_string()], + } +} + pub(crate) fn run_while_active(is_exiting: FExit, operation: F) -> Option where FExit: Fn() -> bool, @@ -184,6 +202,10 @@ mod relaunch_target_tests { "/Volumes/Cap 0.5.7/Cap.app/Contents/MacOS/Cap", "/Volumes/Cap 0.5.7/Cap.app", ), + ( + "/Users/alice/Alice's Apps/Cap.app/Contents/MacOS/Cap", + "/Users/alice/Alice's Apps/Cap.app", + ), ] { assert_eq!( relaunch_target(Path::new(exe)).as_deref(), @@ -193,6 +215,53 @@ mod relaunch_target_tests { } } + #[test] + fn bundle_argv_is_open_plus_bundle_as_discrete_elements() { + use std::ffi::OsString; + + assert_eq!( + super::relaunch_argv(Path::new( + "/Users/alice/Alice's Apps/Cap.app/Contents/MacOS/Cap" + )), + vec![ + OsString::from("/usr/bin/open"), + OsString::from("/Users/alice/Alice's Apps/Cap.app"), + ], + "apostrophes and spaces must survive as a single argv element" + ); + } + + #[test] + fn non_bundle_argv_is_the_executable_itself() { + use std::ffi::OsString; + + assert_eq!( + super::relaunch_argv(Path::new("/repo/src-tauri/target/debug/cap-desktop")), + vec![OsString::from("/repo/src-tauri/target/debug/cap-desktop")], + ); + } + + #[test] + #[cfg(target_os = "macos")] + fn relaunch_sh_delivers_hostile_paths_as_one_argument() { + // The doubled space is load-bearing: an unquoted $@ would field-split + // and /bin/echo would rejoin with single spaces, changing the output. + let hostile = "/tmp/Alice's \"quoted\" $HOME `Apps`/Cap.app"; + let out = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(super::RELAUNCH_SH) + .arg("cap-relaunch") + .args(["/bin/echo", hostile]) + .output() + .expect("spawn /bin/sh"); + assert!(out.status.success()); + assert_eq!( + String::from_utf8_lossy(&out.stdout), + format!("{hostile}\n"), + "the script must pass \"$@\" through unsplit and uninterpolated" + ); + } + #[test] fn non_bundle_layouts_are_refused() { for exe in [ diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 8ba427cac62..4490b66213d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -450,10 +450,12 @@ pub(crate) fn note_exit_requested_code(code: Option) { // Logged here, not in force_exit: the non-blocking appender drops // records emitted microseconds before _exit(). info!("Relaunch requested; will respawn after exit"); - // A deliberate relaunch is a clean shutdown. tauri exempts restart - // requests from prevent_exit, so the runtime exits before the async - // cleanup (which normally disarms the crash sentinel) can finish — - // without this, every relaunch reports a phantom crash on next boot. + // A deliberate relaunch is a clean shutdown. In tauri 2.8.5, + // prevent_exit() is a no-op when code == RESTART_EXIT_CODE (app.rs), + // so this exit can no longer be prevented and the state armed here is + // always consumed by the force_exit it precedes — and the runtime + // exits before the async cleanup that normally disarms the crash + // sentinel, so without this every relaunch reports a phantom crash. crash_sentinel::mark_clean_exit(); RESTART_REQUESTED_ON_EXIT.store(true, std::sync::atomic::Ordering::Release); } @@ -473,29 +475,17 @@ fn spawn_relauncher_if_requested() { eprintln!("cap relaunch: current_exe() failed; not respawning"); return; }; - match exit_shutdown::relaunch_target(&exe) { - Some(bundle) => { - let path = bundle.display().to_string(); - if path.contains('\'') { - eprintln!("cap relaunch: bundle path contains a quote; not respawning: {path}"); - return; - } - // A detached shell survives this process (reparented to - // launchd). The delay lets the old instance die completely - // first, so the single-instance plugin in the fresh one never - // meets a live listener; `open` goes through LaunchServices so - // the new instance keeps normal app context (TCC, dock). - let _ = std::process::Command::new("/bin/sh") - .arg("-c") - .arg(format!("sleep 0.7; /usr/bin/open '{path}'")) - .spawn(); - } - None => { - // Dev / non-bundle run: open(1) would route a bare Mach-O to - // Terminal and re-attribute TCC to it — spawn the executable - // directly instead, like tauri's own process::restart does. - let _ = std::process::Command::new(&exe).spawn(); - } + // A detached shell survives this process (reparented to launchd); the + // delay lets the old instance die first so the fresh single-instance + // plugin never meets a live listener. + if let Err(err) = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(exit_shutdown::RELAUNCH_SH) + .arg("cap-relaunch") + .args(exit_shutdown::relaunch_argv(&exe)) + .spawn() + { + eprintln!("cap relaunch: failed to spawn relauncher: {err}"); } } }