fix(desktop): relaunch on Linux after in-app update instead of hanging (#45205)

On a Linux source install the in-app updater ran the full backend update +
desktop rebuild successfully but never restarted the app — it hung forever on
the applying overlay with no close button. Two causes:

- applyUpdatesPosixInApp() only handled the macOS .app bundle swap;
  runningAppBundle() is null off macOS, so Linux fell through to
  { ok: true, backendUpdated: true } without ever relaunching.
- The renderer store had no terminal state for that result shape, so
  $updateApply stayed { applying: true } and the overlay's close button
  (hidden while applying) never appeared.

Fix (new electron/update-relaunch.cjs, pure + unit-tested):
- Decide the Linux outcome from whether the *running* binary is the one we
  just rebuilt (execPath under release/<plat>-unpacked, path-segment-aware so
  linux-unpacked-evil can't masquerade) and whether its chrome-sandbox helper
  is launchable (root:root + setuid, or an --no-sandbox / ELECTRON_DISABLE_SANDBOX
  opt-out):
    relaunch — detached watcher waits for this PID to exit (graceful, then
      SIGKILL), self-deletes, and re-execs the rebuilt binary with the original
      launch context (filtered args + HERMES_*/sandbox env + cwd) restored.
    guiSkew  — AppImage/.deb/.rpm/dev: backend updated but this GUI package was
      NOT changed; surface an honest closeable 'reinstall the desktop app'
      terminal state instead of lying that it loads next launch (#37541 skew).
    manual   — rebuilt binary but sandbox helper not launchable: keep the
      working window, don't quit into a dead app.
- store/updates.ts lands a terminal, closeable state for EVERY resolved apply
  outcome (handedOff / guiSkew / manualRestart / updated-not-relaunched / error)
  so the hang is impossible regardless of platform or result.
- New DesktopUpdateStage values (update/rebuild/done/guiSkew) + GuiSkewView so
  progress reads correctly and the skew state is closeable. i18n in all four
  locales (en/ja/zh/zh-hant) in parity.
- electron/update-relaunch.test.cjs (16 tests) + store outcome tests.

Salvaged from #45205 onto current main. Linux quit dwell uses the shared
UPDATE_HANDOFF_DWELL_MS (2.5s) from #50448 for consistency. Four-locale i18n
parity, AUTHOR_MAP entry, and the test wiring added on top.

Closes #45205.
This commit is contained in:
Carl 2026-06-21 16:49:10 -07:00 committed by Teknium
parent 1f6994d1ee
commit e5e2583635
13 changed files with 953 additions and 13 deletions

View file

@ -44,6 +44,15 @@ const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-e
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
const { readLiveUpdateMarker } = require('./update-marker.cjs')
const {
resolveUnpackedRelease,
decideRelaunchOutcome,
sandboxPreflight,
sandboxFallbackFromEnv,
collectRelaunchArgs,
collectRelaunchEnv,
buildRelaunchScript
} = require('./update-relaunch.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const { worktreesForIpc } = require('./git-worktrees.cjs')
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
@ -2110,6 +2119,114 @@ async function applyUpdatesPosixInApp() {
return { ok: false, backendUpdated: true, error: 'desktop rebuild failed' }
}
// Linux in-app update terminal state (#45205). `hermes desktop --build-only`
// rebuilds the unpacked app in place under apps/desktop/release/<plat>-unpacked.
// We can only HONESTLY relaunch into the new GUI when the *running* binary IS
// that rebuilt one — i.e. execPath lives under release/<plat>-unpacked. The
// outcome is decided by three signals (see update-relaunch.cjs):
//
// underUnpacked + sandboxOk → 'relaunch': detached watcher re-execs us in
// place (mirrors the macOS handoff). Without it the update succeeds but
// the app never restarts and the overlay hangs on "applying" forever.
// !underUnpacked → 'guiSkew': the running shell is an AppImage/
// .deb/.rpm/dev/unresolved binary we did NOT replace. Claiming "loads
// next launch" is a lie (GUI/backend skew, #37541) — surface an
// explicit closeable terminal state telling the user the GUI package
// was NOT changed and must be updated/reinstalled.
// underUnpacked + !sandboxOk → 'manual': we'd be relaunching the rebuilt
// binary, but a fresh rebuild can leave chrome-sandbox without
// root:root + setuid (mode 4755) and Electron then refuses to launch
// ("quit and never came back"). DO NOT quit into a dead app — keep the
// working window and surface the closeable manual-restart state.
if (!IS_MAC) {
const unpackedDir = resolveUnpackedRelease(process.execPath, updateRoot, process.platform)
const underUnpacked = unpackedDir !== null
const preflight = underUnpacked
? sandboxPreflight(unpackedDir, p => fs.statSync(p))
: { ok: false, reason: 'not-under-unpacked', path: null }
const sandboxFallback = sandboxFallbackFromEnv(process.env, process.argv.slice(1))
const sandboxOk = preflight.ok || sandboxFallback
if (underUnpacked && !preflight.ok) {
rememberLog(
`[updates] sandbox preflight: not launchable (${preflight.reason}) at ${preflight.path}; ` +
`fallback=${sandboxFallback ? 'env/--no-sandbox' : 'none'}`
)
}
const outcome = decideRelaunchOutcome({ underUnpacked, sandboxOk })
if (outcome === 'relaunch') {
emitUpdateProgress({ stage: 'restart', message: 'Restarting Hermes…', percent: 100 })
// Preserve launch context across the re-exec: replay the original args
// (filtered of Electron internals) and the env/cwd that define which
// backend/profile/root this instance talks to. Without this the
// relaunched instance comes up with default context instead of the user's.
const relaunchArgs = collectRelaunchArgs(process.argv.slice(1))
const relaunchEnv = collectRelaunchEnv(process.env)
const relaunchScript = buildRelaunchScript({
pid: process.pid,
execPath: process.execPath,
args: relaunchArgs,
env: relaunchEnv,
cwd: process.cwd()
})
const scriptPath = path.join(app.getPath('temp'), `hermes-desktop-update-${Date.now()}.sh`)
try {
fs.writeFileSync(scriptPath, relaunchScript, { mode: 0o755 })
const child = spawn('/bin/bash', [scriptPath], { detached: true, stdio: 'ignore' })
child.unref()
rememberLog(
`[updates] launched linux relaunch: ${scriptPath} -> ${process.execPath} ` +
`(args=${relaunchArgs.length}, env=${Object.keys(relaunchEnv).length})`
)
setTimeout(() => app.quit(), UPDATE_HANDOFF_DWELL_MS)
return { ok: true, handedOff: true }
} catch (err) {
rememberLog(`[updates] linux relaunch failed: ${err.message}; falling back to manual restart`)
return {
ok: true,
backendUpdated: true,
guiUpdated: false,
manualRestart: true,
message: 'Backend updated. Quit and reopen Hermes to load the new version.'
}
}
}
if (outcome === 'guiSkew') {
emitUpdateProgress({
stage: 'guiSkew',
message:
'Backend updated, but the desktop app package was not changed. ' +
'Update or reinstall the Hermes desktop app to match.',
percent: 100
})
rememberLog(
`[updates] gui/backend skew: execPath ${process.execPath} not under release/*-unpacked; ` +
'backend updated, GUI package unchanged (AppImage/.deb/.rpm/dev/unresolved)'
)
return { ok: true, backendUpdated: true, guiUpdated: false, guiSkew: true }
}
// outcome === 'manual': we're the rebuilt binary, but its sandbox helper is
// not launchable and no fallback applies. Keep this working window alive.
rememberLog(
`[updates] sandbox not launchable (${preflight.reason}); skipping auto-relaunch, ` +
'returning manual-restart so the user keeps a working window'
)
return {
ok: true,
backendUpdated: true,
guiUpdated: false,
manualRestart: true,
sandboxBlocked: true,
message:
'Backend updated. The rebuilt app cant relaunch automatically ' +
'(sandbox helper needs root). Quit and reopen Hermes to finish.'
}
}
const rebuiltApp = [
path.join(updateRoot, 'apps', 'desktop', 'release', 'mac-arm64', 'Hermes.app'),
path.join(updateRoot, 'apps', 'desktop', 'release', 'mac', 'Hermes.app')

View file

@ -0,0 +1,265 @@
'use strict'
/**
* update-relaunch.cjs pure decision + script-generation helpers for the
* Linux in-app update relaunch (#45205).
*
* Extracted from main.cjs's `applyUpdatesPosixInApp` so the security- and
* correctness-critical "do we relaunch, or land on a manual terminal state?"
* decision is unit-testable without booting Electron (main.cjs
* `require('electron')` at load).
*
* Background
* ----------
* After `hermes update` + `hermes desktop --build-only`, the freshly-rebuilt
* GUI lives under `apps/desktop/release/<plat>-unpacked`. We can only honestly
* relaunch into the new GUI when the *running* binary is that rebuilt one
* i.e. its execPath is under the rebuilt `release/<plat>-unpacked` dir.
*
* - Source / unpacked install (execPath under release/<plat>-unpacked):
* the running binary IS the thing we just rebuilt relaunch it in place.
* - AppImage / .deb / .rpm / dev / unresolved (execPath elsewhere):
* the backend was updated but THIS GUI shell was NOT replaced. Claiming
* "the new version loads next launch" is a lie that produces GUI/backend
* skew (#37541): the user keeps running the old GUI against new backend
* code with no path to fix it from inside the app. Surface an explicit
* terminal state telling them the GUI package must be reinstalled.
*
* Sandbox preflight (#3 in the review)
* ------------------------------------
* A fresh `release/<plat>-unpacked` rebuild can leave `chrome-sandbox` without
* the required `root:root` + setuid (mode 4755). Electron then refuses to
* launch with "The SUID sandbox helper binary was found, but is not configured
* correctly" and the relaunch yields "quit and never came back" a dead app.
* Before we quit+hand off we preflight the rebuilt sandbox helper; if it is NOT
* launchable (and no working non-interactive fallback applies see
* sandboxFallbackFromEnv) we DO NOT quit. We keep the working window and return
* the closeable manual-restart terminal state instead.
*/
const path = require('node:path')
// Map process.platform → electron-builder's `release/<dir>-unpacked` name.
function unpackedDirName(platform) {
if (platform === 'darwin') return 'mac-unpacked' // not used (mac swaps bundles)
if (platform === 'win32') return 'win-unpacked'
return 'linux-unpacked'
}
/**
* If `execPath` lives under `<updateRoot>/apps/desktop/release/<plat>-unpacked`,
* return that unpacked dir; otherwise null. A null result means the running
* binary is NOT the thing we just rebuilt (AppImage/.deb/.rpm/dev), so we must
* not claim a GUI relaunch.
*
* Match is a path-segment-aware prefix check (not a bare string startsWith) so
* `.../release/linux-unpacked-evil` can't masquerade as `.../release/linux-unpacked`.
*/
function resolveUnpackedRelease(execPath, updateRoot, platform) {
if (!execPath || !updateRoot) return null
const releaseDir = path.join(updateRoot, 'apps', 'desktop', 'release')
const unpacked = path.join(releaseDir, unpackedDirName(platform))
const normalizedExec = path.resolve(String(execPath))
// execPath must be the unpacked dir itself or a descendant of it.
const withSep = unpacked.endsWith(path.sep) ? unpacked : unpacked + path.sep
if (normalizedExec === unpacked || normalizedExec.startsWith(withSep)) {
return unpacked
}
return null
}
/**
* Pure decision: given whether the running binary is under the rebuilt
* unpacked release AND whether its sandbox helper is launchable, choose the
* terminal outcome.
*
* 'relaunch' quit + detached watcher re-execs the rebuilt binary in place.
* 'guiSkew' backend updated, GUI package NOT changed; user must reinstall
* the GUI. Closeable terminal state; does NOT claim a GUI update.
* 'manual' running the rebuilt binary, but its sandbox helper is not
* launchable and no fallback applies; do NOT quit into a dead
* app. Closeable manual-restart terminal state.
*/
function decideRelaunchOutcome({ underUnpacked, sandboxOk }) {
if (!underUnpacked) return 'guiSkew'
if (!sandboxOk) return 'manual'
return 'relaunch'
}
/**
* Preflight the rebuilt sandbox helper. Returns
* { ok: boolean, reason: string, path: string }
*
* `ok` is true when chrome-sandbox is owned by uid 0 AND has the setuid bit
* (mode & 0o4000) i.e. Electron can launch it. If chrome-sandbox does not
* exist at all we treat it as ok: this Electron build does not use the SUID
* sandbox helper (e.g. it ships the namespace sandbox), so the relaunch is not
* blocked on it.
*
* `statSync` is injectable so this is testable without a real setuid file.
*/
function sandboxPreflight(unpackedDir, statSync) {
if (!unpackedDir) return { ok: false, reason: 'no-unpacked-dir', path: null }
const sandboxPath = path.join(unpackedDir, 'chrome-sandbox')
let st
try {
st = statSync(sandboxPath)
} catch {
// No chrome-sandbox helper present → this build doesn't rely on the SUID
// sandbox; nothing to block the relaunch.
return { ok: true, reason: 'no-sandbox-helper', path: sandboxPath }
}
const ownedByRoot = st.uid === 0
const hasSetuid = (st.mode & 0o4000) !== 0
if (ownedByRoot && hasSetuid) {
return { ok: true, reason: 'launchable', path: sandboxPath }
}
if (!ownedByRoot && !hasSetuid) {
return { ok: false, reason: 'not-root-not-setuid', path: sandboxPath }
}
if (!ownedByRoot) return { ok: false, reason: 'not-root', path: sandboxPath }
return { ok: false, reason: 'not-setuid', path: sandboxPath }
}
/**
* Detect a non-interactive sandbox fallback the user has opted into via the
* environment. The reviewer asked us to integrate with any existing
* `--no-sandbox` / chrome-sandbox handling. A repo grep found NO existing
* non-interactive sandbox fallback in the desktop app (the only chrome-sandbox
* reference is documentation in scripts/before-pack.cjs). The one signal that
* DOES exist is the standard Electron escape hatch: ELECTRON_DISABLE_SANDBOX=1
* (and the equivalent `--no-sandbox` already present in the launch args). If
* the user has set that, the rebuilt binary will start even with a broken
* chrome-sandbox, so the relaunch is safe.
*
* Returns true when a fallback makes the relaunch safe despite a failed
* sandbox preflight.
*/
function sandboxFallbackFromEnv(env, launchArgs) {
const disable = String((env && env.ELECTRON_DISABLE_SANDBOX) || '').trim()
if (disable === '1' || disable.toLowerCase() === 'true') return true
if (Array.isArray(launchArgs) && launchArgs.some(a => a === '--no-sandbox')) return true
return false
}
// POSIX single-quote a value for safe inclusion in the generated bash script.
function shellQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`
}
// Electron / Chromium internal switches that must NOT be replayed on re-exec:
// they are runtime artifacts of THIS launch, not user intent, and re-passing
// them can change sandbox/zygote behavior or point at stale fds/dirs.
const INTERNAL_ARG_PREFIXES = [
'--type=', // renderer/gpu/zygote child markers
'--user-data-dir=',
'--enable-features=',
'--disable-features=',
'--field-trial-handle=',
'--enable-logging',
'--log-file=',
// NB: --no-sandbox is deliberately NOT stripped — it reflects the user's /
// environment's SUID-sandbox opt-out (some hardened kernels/containers require
// it) and is the signal sandboxFallbackFromEnv() uses to allow a relaunch when
// chrome-sandbox isn't setuid. Dropping it would make exactly that relaunch
// fail ("quit and never came back").
'--disable-gpu-sandbox',
'--lang=',
'--inspect',
'--remote-debugging-port='
]
/**
* Filter Electron internals out of the original launch args so we replay only
* meaningful user/launcher intent (deep-link URLs, app-specific flags).
* `argv` is expected to be process.argv.slice(1) for a PACKAGED app (argv[0] is
* the exec path itself; there is no entry-script arg as in a dev run).
*/
function collectRelaunchArgs(argv) {
if (!Array.isArray(argv)) return []
return argv.filter(arg => {
if (typeof arg !== 'string' || arg.length === 0) return false
return !INTERNAL_ARG_PREFIXES.some(prefix =>
prefix.endsWith('=') ? arg.startsWith(prefix) : arg === prefix || arg.startsWith(prefix + '=')
)
})
}
// Env keys whose values define the relaunched instance's context (which
// backend/profile/root it talks to). Anything HERMES_DESKTOP_* is preserved
// plus HERMES_HOME. We snapshot the values, not the live env, so the new
// instance comes up pointed at the same place this one was.
// ELECTRON_DISABLE_SANDBOX is preserved for the same reason --no-sandbox is kept
// in the replayed args: if a relaunch is only safe because the user opted out of
// the SUID sandbox, the relaunched instance must inherit that opt-out too.
const PRESERVED_ENV_KEYS = ['HERMES_HOME', 'ELECTRON_DISABLE_SANDBOX']
const PRESERVED_ENV_PREFIXES = ['HERMES_DESKTOP_']
function collectRelaunchEnv(env) {
const out = {}
if (!env || typeof env !== 'object') return out
for (const [key, value] of Object.entries(env)) {
if (value == null) continue
if (PRESERVED_ENV_KEYS.includes(key) || PRESERVED_ENV_PREFIXES.some(p => key.startsWith(p))) {
out[key] = String(value)
}
}
return out
}
/**
* Build the detached bash watcher that waits for the parent to exit (graceful
* window then SIGKILL), self-deletes, and re-execs the rebuilt binary WITH the
* original launch context (cwd, env, args) restored.
*
* @param {object} o
* @param {number} o.pid parent (this) process pid to wait on
* @param {string} o.execPath binary to re-exec
* @param {string[]} o.args filtered launch args to replay
* @param {object} o.env env keyvalue to export before exec
* @param {string} o.cwd working directory to restore
*/
function buildRelaunchScript({ pid, execPath, args, env, cwd }) {
const exports = Object.entries(env || {})
.map(([k, v]) => `export ${k}=${shellQuote(v)}`)
.join('\n')
const quotedArgs = (args || []).map(shellQuote).join(' ')
const cwdLine = cwd ? `cd ${shellQuote(cwd)} 2>/dev/null || true` : ''
// NOTE: `exec` replaces the watcher process with the relaunched app, so the
// re-exec inherits exactly the env/cwd we set above.
return `#!/bin/bash
set -u
APP_PID=${Number(pid)}
# Wait up to ~30s for a graceful exit, then SIGKILL: a hung/zombie parent must
# be gone before we relaunch, or the new instance bails on the single-instance
# lock. (#45205)
for _ in $(seq 1 60); do
kill -0 "$APP_PID" 2>/dev/null || break
sleep 0.5
done
if kill -0 "$APP_PID" 2>/dev/null; then
kill -9 "$APP_PID" 2>/dev/null || true
sleep 0.5
fi
# Self-delete so temp watchers don't accumulate across updates.
rm -f -- "$0" 2>/dev/null || true
${cwdLine}
${exports}
exec ${shellQuote(execPath)}${quotedArgs ? ' ' + quotedArgs : ''}
`
}
module.exports = {
unpackedDirName,
resolveUnpackedRelease,
decideRelaunchOutcome,
sandboxPreflight,
sandboxFallbackFromEnv,
collectRelaunchArgs,
collectRelaunchEnv,
buildRelaunchScript,
shellQuote,
INTERNAL_ARG_PREFIXES,
PRESERVED_ENV_KEYS,
PRESERVED_ENV_PREFIXES
}

View file

@ -0,0 +1,231 @@
/**
* Tests for electron/update-relaunch.cjs the pure decision + script helpers
* behind the Linux in-app update relaunch (#45205).
*
* Run with: node --test electron/update-relaunch.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* What this locks (review acceptance criteria for PR #45205):
* 1. The execPath split: only a binary under release/<plat>-unpacked may
* relaunch/claim a GUI update; AppImage/.deb/.rpm/dev/unresolved paths land
* on the guiSkew terminal state and do NOT claim the GUI was updated.
* 2. Launch context is replayed on re-exec (args filtered of Electron
* internals; HERMES_HOME / HERMES_DESKTOP_* env + cwd preserved) and is
* safely shell-quoted.
* 3. The sandbox preflight: chrome-sandbox must be root-owned + setuid to be
* launchable; otherwise the decision degrades to a manual terminal state
* (keep a working window) unless a non-interactive fallback applies.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { execFileSync } = require('node:child_process')
const {
unpackedDirName,
resolveUnpackedRelease,
decideRelaunchOutcome,
sandboxPreflight,
sandboxFallbackFromEnv,
collectRelaunchArgs,
collectRelaunchEnv,
buildRelaunchScript,
shellQuote
} = require('./update-relaunch.cjs')
const ROOT = '/home/u/.hermes/hermes-agent'
const UNPACKED = path.join(ROOT, 'apps', 'desktop', 'release', 'linux-unpacked')
// ---------------------------------------------------------------------------
// 1) The execPath split — the heart of the GUI/backend skew guard.
// ---------------------------------------------------------------------------
test('unpackedDirName maps platform to the electron-builder dir', () => {
assert.equal(unpackedDirName('linux'), 'linux-unpacked')
assert.equal(unpackedDirName('win32'), 'win-unpacked')
})
test('resolveUnpackedRelease returns the dir for a binary UNDER release/<plat>-unpacked', () => {
const exec = path.join(UNPACKED, 'hermes')
assert.equal(resolveUnpackedRelease(exec, ROOT, 'linux'), UNPACKED)
// The unpacked dir itself also counts.
assert.equal(resolveUnpackedRelease(UNPACKED, ROOT, 'linux'), UNPACKED)
})
test('resolveUnpackedRelease is null for AppImage / .deb / .rpm / dev / unresolved paths', () => {
// AppImage mount
assert.equal(resolveUnpackedRelease('/tmp/.mount_Hermes12345/AppRun', ROOT, 'linux'), null)
// .deb / .rpm system install
assert.equal(resolveUnpackedRelease('/usr/lib/hermes/hermes', ROOT, 'linux'), null)
assert.equal(resolveUnpackedRelease('/opt/Hermes/hermes', ROOT, 'linux'), null)
// dev electron
assert.equal(resolveUnpackedRelease('/home/u/.hermes/hermes-agent/node_modules/electron/dist/electron', ROOT, 'linux'), null)
// empty / missing
assert.equal(resolveUnpackedRelease('', ROOT, 'linux'), null)
assert.equal(resolveUnpackedRelease(path.join(UNPACKED, 'hermes'), '', 'linux'), null)
})
test('resolveUnpackedRelease is not fooled by a sibling prefix dir', () => {
// `.../release/linux-unpacked-evil` must NOT match `.../release/linux-unpacked`.
const sneaky = path.join(ROOT, 'apps', 'desktop', 'release', 'linux-unpacked-evil', 'hermes')
assert.equal(resolveUnpackedRelease(sneaky, ROOT, 'linux'), null)
})
test('decideRelaunchOutcome: only under-unpacked + sandbox-ok relaunches', () => {
assert.equal(decideRelaunchOutcome({ underUnpacked: true, sandboxOk: true }), 'relaunch')
// Under unpacked but sandbox not launchable → manual (keep a working window).
assert.equal(decideRelaunchOutcome({ underUnpacked: true, sandboxOk: false }), 'manual')
// Not under unpacked → guiSkew regardless of sandbox flag.
assert.equal(decideRelaunchOutcome({ underUnpacked: false, sandboxOk: true }), 'guiSkew')
assert.equal(decideRelaunchOutcome({ underUnpacked: false, sandboxOk: false }), 'guiSkew')
})
// ---------------------------------------------------------------------------
// 3) Sandbox preflight
// ---------------------------------------------------------------------------
const fakeStat = (uid, mode) => () => ({ uid, mode })
const throwStat = () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
test('sandboxPreflight: root-owned + setuid is launchable', () => {
const r = sandboxPreflight(UNPACKED, fakeStat(0, 0o4755))
assert.equal(r.ok, true)
assert.equal(r.reason, 'launchable')
})
test('sandboxPreflight: not root → not launchable', () => {
const r = sandboxPreflight(UNPACKED, fakeStat(1000, 0o4755))
assert.equal(r.ok, false)
assert.equal(r.reason, 'not-root')
})
test('sandboxPreflight: missing setuid bit → not launchable', () => {
const r = sandboxPreflight(UNPACKED, fakeStat(0, 0o755))
assert.equal(r.ok, false)
assert.equal(r.reason, 'not-setuid')
})
test('sandboxPreflight: neither root nor setuid (the fresh-rebuild trap)', () => {
const r = sandboxPreflight(UNPACKED, fakeStat(1000, 0o755))
assert.equal(r.ok, false)
assert.equal(r.reason, 'not-root-not-setuid')
})
test('sandboxPreflight: no chrome-sandbox helper present → ok (build does not use SUID sandbox)', () => {
const r = sandboxPreflight(UNPACKED, throwStat)
assert.equal(r.ok, true)
assert.equal(r.reason, 'no-sandbox-helper')
})
test('sandboxFallbackFromEnv: ELECTRON_DISABLE_SANDBOX / --no-sandbox make a broken sandbox safe', () => {
assert.equal(sandboxFallbackFromEnv({ ELECTRON_DISABLE_SANDBOX: '1' }, []), true)
assert.equal(sandboxFallbackFromEnv({ ELECTRON_DISABLE_SANDBOX: 'true' }, []), true)
assert.equal(sandboxFallbackFromEnv({}, ['--no-sandbox']), true)
assert.equal(sandboxFallbackFromEnv({}, ['--foo']), false)
assert.equal(sandboxFallbackFromEnv({}, []), false)
assert.equal(sandboxFallbackFromEnv(null, null), false)
})
// ---------------------------------------------------------------------------
// 2) Launch-context preservation
// ---------------------------------------------------------------------------
test('collectRelaunchArgs drops Electron internals, keeps user/launcher args', () => {
const argv = [
'--type=renderer',
'--user-data-dir=/tmp/x',
'--enable-features=Foo',
'--field-trial-handle=123',
'--no-sandbox', // sandbox opt-out — KEEP (user/env intent + relaunch fallback)
'--lang=en-US',
'hermes://open/agent/42', // deep link — keep
'--profile=work', // app flag — keep
'--remote-debugging-port=9222' // internal — drop
]
assert.deepEqual(collectRelaunchArgs(argv), ['--no-sandbox', 'hermes://open/agent/42', '--profile=work'])
assert.deepEqual(collectRelaunchArgs(undefined), [])
})
test('collectRelaunchEnv preserves HERMES_HOME + HERMES_DESKTOP_* + sandbox opt-out only', () => {
const env = {
HERMES_HOME: '/home/u/.hermes',
HERMES_DESKTOP_REMOTE_URL: 'http://box:9119',
HERMES_DESKTOP_REMOTE_TOKEN: 'secret',
HERMES_DESKTOP_HERMES_ROOT: '/home/u/dev/hermes',
ELECTRON_DISABLE_SANDBOX: '1', // sandbox opt-out — preserved
PATH: '/usr/bin', // not preserved
HOME: '/home/u', // not preserved
UNRELATED: 'x'
}
assert.deepEqual(collectRelaunchEnv(env), {
HERMES_HOME: '/home/u/.hermes',
HERMES_DESKTOP_REMOTE_URL: 'http://box:9119',
HERMES_DESKTOP_REMOTE_TOKEN: 'secret',
HERMES_DESKTOP_HERMES_ROOT: '/home/u/dev/hermes',
ELECTRON_DISABLE_SANDBOX: '1'
})
assert.deepEqual(collectRelaunchEnv(null), {})
})
// ---------------------------------------------------------------------------
// Generated watcher script: safe quoting + valid bash syntax.
// ---------------------------------------------------------------------------
test('shellQuote neutralizes single quotes and metacharacters', () => {
assert.equal(shellQuote(`a'b`), `'a'\\''b'`)
assert.equal(shellQuote('$(rm -rf /)'), `'$(rm -rf /)'`)
})
test('buildRelaunchScript embeds pid/exec/args/env/cwd and is valid bash', () => {
const script = buildRelaunchScript({
pid: 4242,
execPath: '/home/u/.hermes/hermes-agent/apps/desktop/release/linux-unpacked/Hermes',
args: ['hermes://open/agent/42', "--note=it's fine"],
env: { HERMES_HOME: '/home/u/.hermes', HERMES_DESKTOP_REMOTE_URL: 'http://box:9119' },
cwd: '/home/u/work dir'
})
// Structural assertions.
assert.match(script, /^#!\/bin\/bash/)
assert.match(script, /APP_PID=4242/)
assert.match(script, /kill -9 "\$APP_PID"/)
assert.match(script, /rm -f -- "\$0"/)
// env exports + cwd restore + args replay are present and quoted.
assert.match(script, /export HERMES_HOME='\/home\/u\/\.hermes'/)
assert.match(script, /export HERMES_DESKTOP_REMOTE_URL='http:\/\/box:9119'/)
assert.match(script, /cd '\/home\/u\/work dir'/)
assert.match(script, /exec '.*\/linux-unpacked\/Hermes' 'hermes:\/\/open\/agent\/42' '--note=it'\\''s fine'/)
// It must be syntactically valid bash (`bash -n`). Write to a temp file and lint.
const tmp = path.join(os.tmpdir(), `hermes-relaunch-test-${Date.now()}.sh`)
fs.writeFileSync(tmp, script)
try {
execFileSync('bash', ['-n', tmp], { stdio: 'pipe' })
} finally {
fs.rmSync(tmp, { force: true })
}
})
test('buildRelaunchScript with no args/env still lints clean', () => {
const script = buildRelaunchScript({
pid: 1,
execPath: '/opt/Hermes/Hermes',
args: [],
env: {},
cwd: ''
})
const tmp = path.join(os.tmpdir(), `hermes-relaunch-test2-${Date.now()}.sh`)
fs.writeFileSync(tmp, script)
try {
execFileSync('bash', ['-n', tmp], { stdio: 'pipe' })
} finally {
fs.rmSync(tmp, { force: true })
}
// exec line has no trailing args.
assert.match(script, /exec '\/opt\/Hermes\/Hermes'\n/)
})

View file

@ -37,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/windows-user-env.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",

View file

@ -61,14 +61,16 @@ export function UpdatesOverlay() {
const behind = status?.behind ?? 0
const phase: 'idle' | 'applying' | 'manual' | 'error' =
const phase: 'idle' | 'applying' | 'manual' | 'guiSkew' | 'error' =
apply.stage === 'manual'
? 'manual'
: apply.applying || apply.stage === 'restart'
? 'applying'
: apply.stage === 'error'
? 'error'
: 'idle'
: apply.stage === 'guiSkew'
? 'guiSkew'
: apply.applying || apply.stage === 'restart'
? 'applying'
: apply.stage === 'error'
? 'error'
: 'idle'
const handleClose = (next: boolean) => {
if (phase === 'applying') {
@ -77,7 +79,13 @@ export function UpdatesOverlay() {
setUpdateOverlayOpen(next)
if (!next && (apply.stage === 'error' || apply.stage === 'restart' || apply.stage === 'manual')) {
if (
!next &&
(apply.stage === 'error' ||
apply.stage === 'restart' ||
apply.stage === 'manual' ||
apply.stage === 'guiSkew')
) {
resetUpdateApplyState()
}
}
@ -95,7 +103,11 @@ export function UpdatesOverlay() {
{phase === 'applying' && <ApplyingView apply={apply} isBackend={isBackend} />}
{phase === 'manual' && (
<ManualView command={apply.command ?? 'hermes update'} onDone={() => handleClose(false)} />
<ManualView command={apply.command ?? null} message={apply.message} onDone={() => handleClose(false)} />
)}
{phase === 'guiSkew' && (
<GuiSkewView message={apply.message} onDone={() => handleClose(false)} />
)}
{phase === 'error' && (
@ -251,18 +263,48 @@ function IdleView({
)
}
function ManualView({ command, onDone }: { command: string; onDone: () => void }) {
function ManualView({
command,
message,
onDone
}: {
command: string | null
message?: string
onDone: () => void
}) {
const { t } = useI18n()
const u = t.updates
const [copied, setCopied] = useState(false)
const handleCopy = () => {
if (!command) return
void writeClipboardText(command).then(() => {
setCopied(true)
window.setTimeout(() => setCopied(false), 1800)
})
}
// No command (e.g. the Linux sandbox-blocked relaunch): render the explanatory
// message + a Done button, not a copy-a-command box.
if (!command) {
return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<Terminal className="size-8 text-primary" />
<DialogTitle className="text-center text-xl">{u.manualTitle}</DialogTitle>
<DialogDescription className="text-center text-sm">
{message || u.manualPickedUp}
</DialogDescription>
</div>
<Button className="font-semibold" onClick={onDone} size="lg" variant="secondary">
{u.done}
</Button>
</div>
)
}
return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
@ -309,6 +351,32 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
)
}
// Linux GUI/backend skew (#45205): backend updated, but the running desktop app
// package (AppImage/.deb/.rpm) was NOT changed. Closeable terminal state that
// tells the user to update/reinstall the desktop app — never claims the GUI was
// updated.
function GuiSkewView({ message, onDone }: { message?: string; onDone: () => void }) {
const { t } = useI18n()
const u = t.updates
return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<AlertCircle className="size-8 text-amber-500" />
<DialogTitle className="text-center text-xl">{u.guiSkewTitle}</DialogTitle>
<DialogDescription className="max-w-prose text-center text-sm leading-5 text-muted-foreground">
{message || u.guiSkewBody}
</DialogDescription>
</div>
<Button className="font-semibold" onClick={onDone} size="lg" variant="secondary">
{u.done}
</Button>
</div>
)
}
function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend: boolean }) {
const { t } = useI18n()
const u = t.updates

View file

@ -229,9 +229,45 @@ export interface DesktopUpdateApplyResult {
manual?: boolean
command?: string
hermesRoot?: string
/** True when the backend was updated but the GUI couldn't be relaunched in
* place (AppImage / dev run): the new version loads on next launch. */
backendUpdated?: boolean
/** False when the running GUI package was NOT replaced by this update
* (Linux GUI/backend skew, or a sandbox-blocked relaunch). Distinguishes
* "backend only" outcomes from a real in-place GUI relaunch. (#45205) */
guiUpdated?: boolean
/** True for the Linux GUI/backend-skew terminal state: backend updated but
* the running AppImage/.deb/.rpm shell is unchanged and must be
* reinstalled. Renders a closeable "update the desktop app" message. */
guiSkew?: boolean
/** True when the update finished but the app must be quit + reopened by hand
* (e.g. the rebuilt sandbox helper isn't launchable): keep a working
* window, don't auto-quit into a dead app. (#45205) */
manualRestart?: boolean
/** True when the auto-relaunch was skipped specifically because the rebuilt
* chrome-sandbox helper is not launchable (not root:root + setuid). */
sandboxBlocked?: boolean
/** True when a detached relauncher took over (macOS bundle swap / Linux
* re-exec): the app is about to quit and reopen itself. */
handedOff?: boolean
}
export type DesktopUpdateStage = 'idle' | 'prepare' | 'fetch' | 'pull' | 'pydeps' | 'restart' | 'manual' | 'error'
export type DesktopUpdateStage =
| 'idle'
| 'prepare'
| 'fetch'
| 'pull'
| 'pydeps'
| 'update'
| 'rebuild'
| 'restart'
| 'done'
| 'manual'
/** Backend updated but the running GUI package (AppImage/.deb/.rpm) was NOT
* changed the user must update/reinstall the desktop app. Terminal,
* closeable; never claims the GUI was updated. (#45205) */
| 'guiSkew'
| 'error'
export interface DesktopUpdateProgress {
stage: DesktopUpdateStage

View file

@ -1355,8 +1355,12 @@ export const en: Translations = {
fetch: 'Downloading…',
pull: 'Almost there…',
pydeps: 'Finishing up…',
update: 'Updating Hermes…',
rebuild: 'Rebuilding the desktop app…',
restart: 'Restarting Hermes…',
done: 'Update complete',
manual: 'Update from your terminal',
guiSkew: 'Update the desktop app',
error: 'Update paused'
},
checking: 'Looking for updates…',
@ -1379,6 +1383,9 @@ export const en: Translations = {
manualTitle: 'Update from your terminal',
manualBody: 'You installed Hermes from the command line, so updates run there too. Paste this into your terminal:',
manualPickedUp: 'Hermes will pick up the new version next time you launch it.',
guiSkewTitle: 'Update the desktop app',
guiSkewBody:
'The backend was updated, but this desktop app package wasnt changed. Update or reinstall the Hermes desktop app (your AppImage / .deb / .rpm) to match.',
copy: 'Copy',
copied: 'Copied',
done: 'Done',

View file

@ -1483,8 +1483,12 @@ export const ja = defineLocale({
fetch: 'ダウンロード中…',
pull: 'もうすぐ完了…',
pydeps: '仕上げ中…',
update: 'Hermes を更新中…',
rebuild: 'デスクトップアプリを再ビルド中…',
restart: 'Hermes を再起動中…',
done: '更新が完了しました',
manual: 'ターミナルから更新',
guiSkew: 'デスクトップアプリを更新してください',
error: '更新が一時停止中'
},
checking: '更新を確認中…',
@ -1509,6 +1513,9 @@ export const ja = defineLocale({
manualBody:
'Hermes をコマンドラインからインストールしたため、更新もそこで実行されます。これをターミナルに貼り付けてください:',
manualPickedUp: 'Hermes は次回起動時に新しいバージョンを読み込みます。',
guiSkewTitle: 'デスクトップアプリを更新してください',
guiSkewBody:
'バックエンドは更新されましたが、このデスクトップアプリのパッケージは変更されていません。一致させるために Hermes デスクトップアプリAppImage / .deb / .rpmを更新または再インストールしてください。',
copy: 'コピー',
copied: 'コピーしました',
done: '完了',

View file

@ -1049,6 +1049,10 @@ export interface Translations {
manualTitle: string
manualBody: string
manualPickedUp: string
/** GUI/backend skew (#45205): backend updated but the running desktop app
* package (AppImage/.deb/.rpm) was not changed and must be reinstalled. */
guiSkewTitle: string
guiSkewBody: string
copy: string
copied: string
done: string

View file

@ -1436,8 +1436,12 @@ export const zhHant = defineLocale({
fetch: '下載中…',
pull: '快完成了…',
pydeps: '收尾中…',
update: '正在更新 Hermes…',
rebuild: '正在重新建置桌面應用程式…',
restart: '正在重新啟動 Hermes…',
done: '更新完成',
manual: '從終端機更新',
guiSkew: '請更新桌面應用程式',
error: '更新已暫停'
},
checking: '正在檢查更新…',
@ -1460,6 +1464,9 @@ export const zhHant = defineLocale({
manualTitle: '從終端機更新',
manualBody: '您是從命令列安裝的 Hermes因此更新也需要在那裡執行。請將此指令貼到終端機',
manualPickedUp: '下次啟動 Hermes 時會使用新版本。',
guiSkewTitle: '請更新桌面應用程式',
guiSkewBody:
'後端已更新,但此桌面應用程式套件未變更。請更新或重新安裝 Hermes 桌面應用程式(你的 AppImage / .deb / .rpm以保持一致。',
copy: '複製',
copied: '已複製',
done: '完成',

View file

@ -1541,8 +1541,12 @@ export const zh: Translations = {
fetch: '下载中…',
pull: '马上完成…',
pydeps: '收尾中…',
update: '正在更新 Hermes…',
rebuild: '正在重新构建桌面应用…',
restart: '正在重启 Hermes…',
done: '更新完成',
manual: '从终端更新',
guiSkew: '请更新桌面应用',
error: '更新已暂停'
},
checking: '正在检查更新…',
@ -1565,6 +1569,8 @@ export const zh: Translations = {
manualTitle: '从终端更新',
manualBody: '你是从命令行安装的 Hermes因此更新也需要在那里运行。请将此命令粘贴到终端',
manualPickedUp: '下次启动 Hermes 时会使用新版本。',
guiSkewTitle: '请更新桌面应用',
guiSkewBody: '后端已更新,但此桌面应用包未更改。请更新或重新安装 Hermes 桌面应用(你的 AppImage / .deb / .rpm以保持一致。',
copy: '复制',
copied: '已复制',
done: '完成',

View file

@ -41,7 +41,18 @@ vi.mock('@/hermes', () => ({
getActionStatus: (...args: unknown[]) => getActionStatusSpy(...args)
}))
const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus, applyBackendUpdate, $backendUpdateApply, reportBackendContract } = await import('./updates')
const {
maybeNotifyUpdateAvailable,
checkBackendUpdates,
$backendUpdateStatus,
applyBackendUpdate,
$backendUpdateApply,
reportBackendContract,
applyUpdates,
$updateApply,
$updateOverlayOpen,
resetUpdateApplyState
} = await import('./updates')
const { setConnection } = await import('./session')
const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
@ -218,6 +229,119 @@ describe('checkBackendUpdates', () => {
})
})
describe('applyUpdates terminal state', () => {
const applyMock = vi.fn()
beforeEach(() => {
storage.clear()
notifySpy.mockClear()
dismissSpy.mockClear()
applyMock.mockReset()
resetUpdateApplyState()
$updateOverlayOpen.set(true)
;(globalThis as unknown as { window: unknown }).window = {
hermesDesktop: { updates: { apply: applyMock } }
}
vi.useRealTimers()
})
afterEach(() => {
delete (globalThis as unknown as { window?: unknown }).window
})
it('holds the restart view when a relauncher hands off (no close, no toast)', async () => {
applyMock.mockResolvedValue({ ok: true, handedOff: true })
const result = await applyUpdates()
expect(result.handedOff).toBe(true)
// The detached relauncher will quit + reopen us; keep "applying" until then.
expect($updateApply.get().applying).toBe(true)
expect($updateOverlayOpen.get()).toBe(true)
expect(notifySpy).not.toHaveBeenCalled()
})
it('closes the overlay + toasts when updated but not relaunched in place', async () => {
// The Linux AppImage / dev-run path: backend + GUI updated, no in-place
// relaunch. Must not strand the overlay on a closeless spinner.
applyMock.mockResolvedValue({ ok: true, backendUpdated: true })
await applyUpdates()
expect($updateOverlayOpen.get()).toBe(false)
expect($updateApply.get().applying).toBe(false)
expect($updateApply.get().stage).toBe('idle')
expect(notifySpy).toHaveBeenCalledTimes(1)
expect(notifySpy.mock.calls[0]?.[0]).toMatchObject({ kind: 'success' })
})
it('lands on a closeable error state when the apply resolves not-ok', async () => {
applyMock.mockResolvedValue({ ok: false, error: 'rebuild-failed', message: 'rebuild failed' })
await applyUpdates()
expect($updateApply.get().applying).toBe(false)
expect($updateApply.get().stage).toBe('error')
expect($updateApply.get().error).toBe('rebuild-failed')
})
it('keeps the manual command state for CLI installs with no staged updater', async () => {
applyMock.mockResolvedValue({ ok: true, manual: true, command: 'hermes update' })
await applyUpdates()
expect($updateApply.get().stage).toBe('manual')
expect($updateApply.get().command).toBe('hermes update')
expect($updateOverlayOpen.get()).toBe(true)
expect(notifySpy).not.toHaveBeenCalled()
})
it('lands on the guiSkew terminal state for a GUI/backend skew (AppImage/.deb/.rpm), without claiming a GUI update', async () => {
// Linux: backend updated, but the running desktop package was NOT replaced.
// Must NOT toast "loads next launch" — that's the dishonest message #45205
// guards against. Lands on a closeable guiSkew view instead.
applyMock.mockResolvedValue({
ok: true,
backendUpdated: true,
guiUpdated: false,
guiSkew: true,
message: 'Backend updated, but the desktop app package was not changed.'
})
const result = await applyUpdates()
expect(result.guiUpdated).toBe(false)
expect($updateApply.get().stage).toBe('guiSkew')
expect($updateApply.get().applying).toBe(false)
expect($updateApply.get().message).toMatch(/desktop app package was not changed/)
// Overlay stays open on a closeable terminal view; no "all set" toast.
expect($updateOverlayOpen.get()).toBe(true)
expect(notifySpy).not.toHaveBeenCalled()
})
it('lands on a closeable manual-restart state when the rebuilt sandbox blocks auto-relaunch', async () => {
// Under release/*-unpacked but chrome-sandbox isn't launchable: don't quit
// into a dead app — keep a working window on a closeable manual state.
applyMock.mockResolvedValue({
ok: true,
backendUpdated: true,
guiUpdated: false,
manualRestart: true,
sandboxBlocked: true,
message: 'Backend updated. Quit and reopen Hermes to finish.'
})
const result = await applyUpdates()
expect(result.manualRestart).toBe(true)
expect($updateApply.get().stage).toBe('manual')
expect($updateApply.get().command).toBeNull()
expect($updateApply.get().message).toMatch(/Quit and reopen/)
expect($updateOverlayOpen.get()).toBe(true)
expect(notifySpy).not.toHaveBeenCalled()
})
})
describe('applyBackendUpdate recovery', () => {
beforeEach(() => {
storage.clear()

View file

@ -342,6 +342,70 @@ export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promis
message: result.command ?? 'hermes update',
command: result.command ?? 'hermes update'
})
return result
}
// A detached relauncher took over (macOS bundle swap / Linux re-exec): the
// app is about to quit and reopen, so hold the "Restarting…" view until it
// does. Every other resolved outcome MUST land on a terminal, closeable
// state: the apply IPC resolves here, but the progress stream may have left
// us on a non-terminal stage (e.g. 'done'/'rebuild'), which renders as a
// spinner with no close button — the exact hang this guards against.
// Linux GUI/backend skew (#45205): the backend was updated but the running
// desktop app PACKAGE was not changed (AppImage/.deb/.rpm). We must NOT tell
// the user "the new version loads next launch" — that's false; this packaged
// shell keeps running old GUI code against the new backend. Land on the
// dedicated, closeable guiSkew terminal state telling them to update/reinstall
// the desktop app.
if (result?.guiSkew) {
$updateApply.set({
...IDLE,
applying: false,
stage: 'guiSkew',
message: result.message ?? translateNow('updates.guiSkewBody')
})
return result
}
// Backend updated but the app couldn't auto-relaunch (e.g. the rebuilt
// sandbox helper isn't launchable): keep a closeable manual-restart state so
// the user keeps a working window instead of a dead app or a stuck spinner.
if (result?.ok && result?.manualRestart) {
$updateApply.set({
...IDLE,
applying: false,
stage: 'manual',
message: result.message ?? translateNow('updates.manualPickedUp')
})
return result
}
if (!result?.handedOff) {
if (result?.ok) {
// Updated, but couldn't relaunch in place (AppImage / dev run). Dismiss
// the overlay and let the user know the new version loads next launch
// rather than stranding them on an un-closeable spinner.
setUpdateOverlayOpen(false)
resetUpdateApplyState()
notify({
durationMs: 8000,
id: UPDATE_TOAST_ID,
kind: 'success',
message: translateNow('updates.manualPickedUp'),
title: translateNow('updates.allSetTitle')
})
} else {
$updateApply.set({
...$updateApply.get(),
applying: false,
stage: 'error',
error: result?.error ?? 'apply-failed',
message: result?.message ?? translateNow('updates.errorBody')
})
}
}
return result
@ -457,7 +521,11 @@ export async function applyBackendUpdate(): Promise<DesktopUpdateApplyResult> {
function ingestProgress(payload: DesktopUpdateProgress): void {
const current = $updateApply.get()
const log = [...current.log, { stage: payload.stage, message: payload.message, at: payload.at }].slice(-50)
const terminal = payload.stage === 'error' || payload.stage === 'restart' || payload.stage === 'manual'
const terminal =
payload.stage === 'error' ||
payload.stage === 'restart' ||
payload.stage === 'manual' ||
payload.stage === 'guiSkew'
$updateApply.set({
applying: !terminal,