Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

123 changes: 123 additions & 0 deletions apps/desktop/src-tauri/src/exit_shutdown.rs
Original file line number Diff line number Diff line change
@@ -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/<binary>). 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<std::path::PathBuf> {
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<std::ffi::OsString> {
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<T, FExit, F>(is_exiting: FExit, operation: F) -> Option<T>
where
FExit: Fn() -> bool,
Expand Down Expand Up @@ -151,3 +181,96 @@ pub(crate) fn abort_join_handles<T>(
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}");
}
}
}
80 changes: 80 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i32>) {
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) -> !;
}
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Prevented restart leaves state armed

When relaunch is requested during an active export, note_exit_requested_code records restart intent and marks the crash sentinel clean before handle_exit_requested prevents the exit. The running process therefore retains the restart flag, causing a later ordinary quit to relaunch Cap, while an intervening crash is no longer reported as an unexpected termination.

Knowledge Base Used: Desktop Tauri App (Rust Backend)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/lib.rs
Line: 6108

Comment:
**Prevented restart leaves state armed**

When relaunch is requested during an active export, `note_exit_requested_code` records restart intent and marks the crash sentinel clean before `handle_exit_requested` prevents the exit. The running process therefore retains the restart flag, causing a later ordinary quit to relaunch Cap, while an intervening crash is no longer reported as an unexpected termination.

**Knowledge Base Used:** [Desktop Tauri App (Rust Backend)](https://app.greptile.com/cap/-/custom-context/knowledge-base/capsoftware/cap/-/docs/desktop-tauri-app.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This scenario can't occur on the vendored runtime: in tauri 2.8.5, ExitRequestApi::prevent_exit() is a no-op when the request carries RESTART_EXIT_CODE (tauri-2.8.5/src/app.rspub fn prevent_exit(&self) { if self.code != Some(RESTART_EXIT_CODE) { … } }). So an exit requested with the restart code cannot be prevented — not during an active export, not by any caller. Once note_exit_requested_code observes the restart code, the process is unconditionally on its way to force_exit, which consumes the flag via swap; the intent and the sentinel state armed here are always consumed by the exit they precede, never left behind in a live process.

Arming only after the prevent decision would introduce the opposite bug: a relaunch requested mid-export still exits (tauri ignores the prevention), but would then neither respawn nor mark the sentinel — a lost restart plus a phantom crash report.

I've extended the comment at the arming site to state this invariant explicitly, with the tauri source reference.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's correct — I missed that prevent_exit is a no-op for RESTART_EXIT_CODE in the vendored runtime. Your reasoning holds: once note_exit_requested_code observes the restart code, the process is unconditionally exiting, force_exit consumes the flag via swap, and there's no live-process state left behind. The alternative ordering you describe would create exactly the inverse defect.

The added comment at the arming site spelling out this invariant with the tauri source reference is the right call. Issue 1 is not valid given the vendored runtime behavior.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.


match handle_exit_requested(
_handle
Expand Down Expand Up @@ -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);
}
}