diff --git a/Cargo.lock b/Cargo.lock index 411fca9b63..61a358112b 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 ea47192fa7..d6144e2f97 100644 --- a/apps/desktop/src-tauri/src/exit_shutdown.rs +++ b/apps/desktop/src-tauri/src/exit_shutdown.rs @@ -1,5 +1,35 @@ 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) +} + +// The relaunch command reaches this script as positional arguments ("$@"), +// never interpolated into it, so spaces/quotes/non-UTF8 in paths pass +// verbatim; the delay lets the old instance die before the new one starts. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub(crate) const RELAUNCH_SH: &str = r#"/bin/sleep 0.7; exec "$@""#; + +// The command that respawns Cap, as discrete argv elements for RELAUNCH_SH. +// Bundles go through `open` (LaunchServices keeps the new instance's own TCC +// identity); a bare Mach-O is exec'd directly, since open(1) would route it +// to Terminal and re-attribute TCC there. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub(crate) fn relaunch_argv(current_exe: &std::path::Path) -> 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, @@ -151,3 +181,96 @@ 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", + ), + ( + "/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(), + Some(Path::new(want)), + "exe: {exe}" + ); + } + } + + #[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 [ + "/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 deb087400a..4490b66213 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -435,7 +435,63 @@ 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. 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); + } +} + +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; + }; + // 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}"); + } + } +} + fn force_exit(code: i32) -> ! { + spawn_relauncher_if_requested(); unsafe extern "C" { fn _exit(code: i32) -> !; } @@ -6039,6 +6095,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 +6887,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); + } +}