From 376d021feef9cb27e6f5a750d6a423221e49f6c8 Mon Sep 17 00:00:00 2001 From: Brad Hallett Date: Sun, 28 Jun 2026 04:22:11 -0700 Subject: [PATCH] fix(desktop): force app exit after update/uninstall handoff on macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS app.quit() closes windows but window-all-closed deliberately keeps the process alive (Dock convention). Every detached hand-off (update swap, relaunch, Windows bootstrap recovery, uninstall cleanup) waits for the desktop PID to exit before replacing/removing the bundle — so the process never dying means the script spins its full PID-wait and the user sees a blank app, or an uninstall that appears to do nothing. Add a module-level isQuittingForHandoff flag, set before every hand-off app.quit(); window-all-closed then quits on all platforms when it's set. Covers all five hand-off sites including the Linux relaunch path. --- apps/desktop/electron/main.cjs | 23 ++++++++++++++++++++++- scripts/release.py | 1 + 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 7c45dea4e40..8fc05254ca5 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -1971,6 +1971,16 @@ async function readCommitLog(cwd, branch) { let updateInFlight = false +// Set to true when the desktop is about to quit so a detached swap/install/ +// uninstall script can take over. On macOS, app.quit() closes windows but +// window-all-closed deliberately keeps the process alive (standard Electron +// macOS convention). Without this flag the process never exits — the detached +// hand-off script spins its PID-wait for the full timeout, and the user sees a +// blank app with no window (and an uninstall that appears to do nothing). When +// set, window-all-closed calls app.quit() on every platform so the process +// actually dies and the hand-off script can proceed immediately. +let isQuittingForHandoff = false + // Resolve the staged updater binary. The Tauri installer copies itself to // HERMES_HOME/hermes-setup.exe on a successful install (see // apps/bootstrap-installer paths::copy_self_to_hermes_home). That binary owns @@ -2226,6 +2236,7 @@ async function applyUpdates(opts = {}) { // appears), THEN quit to release the venv shim. The updater rebuilds and // relaunches us when it's done. (#50419 — a 600ms quit looked like a crash // and lured users into the #50238 relaunch loop.) + isQuittingForHandoff = true setTimeout(() => { app.quit() }, UPDATE_HANDOFF_DWELL_MS) @@ -2283,6 +2294,7 @@ async function handOffWindowsBootstrapRecovery(reason) { // Same dwell as the in-app update hand-off (#50419): give the updater's // window time to appear before we vanish, so the recovery doesn't look like // a crash and provoke a mid-recovery relaunch. + isQuittingForHandoff = true setTimeout(() => { app.quit() }, UPDATE_HANDOFF_DWELL_MS) @@ -2490,6 +2502,7 @@ async function applyUpdatesPosixInApp() { `[updates] launched linux relaunch: ${scriptPath} -> ${process.execPath} ` + `(args=${relaunchArgs.length}, env=${Object.keys(relaunchEnv).length})` ) + isQuittingForHandoff = true setTimeout(() => app.quit(), UPDATE_HANDOFF_DWELL_MS) return { ok: true, handedOff: true } } catch (err) { @@ -2595,6 +2608,7 @@ fi child.unref() rememberLog(`[updates] launched mac swap+relaunch: ${scriptPath} (${rebuiltApp} -> ${targetApp})`) + isQuittingForHandoff = true setTimeout(() => app.quit(), 600) return { ok: true, handedOff: true, rebuiltApp, targetApp } } @@ -7359,6 +7373,7 @@ async function runDesktopUninstall(mode) { // Give the renderer a beat to show its "uninstalling…" state, then quit so // the venv python shim + app bundle unlock and the cleanup script can run. + isQuittingForHandoff = true setTimeout(() => app.quit(), 800) return { ok: true, mode, willRemoveAppBundle: Boolean(removeBundle), scriptPath } } @@ -7564,5 +7579,11 @@ app.on('before-quit', () => { }) app.on('window-all-closed', () => { - if (process.platform !== 'darwin') app.quit() + // macOS convention: keep the process alive in the Dock when the user closes + // the last window. But when we're handing off to a detached updater / swap / + // uninstall script, the process MUST exit so the script can replace or remove + // the bundle and relaunch — without this the script's PID-wait spins to its + // full timeout and the user is left with an invisible app (or an uninstall + // that appears to do nothing). + if (process.platform !== 'darwin' || isQuittingForHandoff) app.quit() }) diff --git a/scripts/release.py b/scripts/release.py index 231d697d287..2cebd963db3 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -48,6 +48,7 @@ AUTHOR_MAP = { "prathamesh290504@gmail.com": "PRATHAMESH75", # PR #37550 salvage (ExecStopPost cgroup-orphan reaper to unblock systemd restart; #37454) "der@konsi.org": "konsisumer", # PR #19608 salvage (read-modify-write merge in write_credential_pool to preserve concurrently-added credentials; #19566) "linyubin@users.noreply.github.com": "linyubin", # PR #50228 salvage (eager fallback on persistent transport timeout/overloaded; #22277) + "bradhallett@users.noreply.github.com": "bradhallett", # PR #46948 salvage (force app exit after update/uninstall handoff on macOS; #46948) "65363919+coygeek@users.noreply.github.com": "coygeek", # PR #37951 salvage (fail closed when provider env blocklist import fails; #37950) "5261694+djstunami@users.noreply.github.com": "djstunami", # PR #5316 salvage / co-author (suppress transient check_fn flakes so subagents keep file/terminal tools; #21658 / #5304) "jmmaloney4@gmail.com": "jmmaloney4", # PR #25206 salvage (re-select credential pool on primary runtime restore; #25205)