fix(desktop): force app exit after update/uninstall handoff on macOS
Some checks are pending
CI / Detect affected areas (push) Waiting to run
CI / Python tests (push) Blocked by required conditions
CI / Python lints (push) Blocked by required conditions
CI / TypeScript (push) Blocked by required conditions
CI / Docs Site (push) Blocked by required conditions
CI / Deny unrelated histories (push) Blocked by required conditions
CI / Check contributors (push) Blocked by required conditions
CI / Check uv.lock (push) Blocked by required conditions
CI / Lint Docker scripts (push) Blocked by required conditions
CI / Build&Test Docker image (push) Blocked by required conditions
CI / Supply-chain scan (push) Blocked by required conditions
CI / OSV scan (push) Waiting to run
CI / All required checks pass (push) Blocked by required conditions
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run

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.
This commit is contained in:
Brad Hallett 2026-06-28 04:22:11 -07:00 committed by Teknium
parent e54bedd8ea
commit 376d021fee
2 changed files with 23 additions and 1 deletions

View file

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

View file

@ -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)