From e5e25836350a7041e58bb4ecfd55cba893630df4 Mon Sep 17 00:00:00 2001 From: Carl Date: Sun, 21 Jun 2026 16:49:10 -0700 Subject: [PATCH] fix(desktop): relaunch on Linux after in-app update instead of hanging (#45205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/-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. --- apps/desktop/electron/main.cjs | 117 ++++++++ apps/desktop/electron/update-relaunch.cjs | 265 ++++++++++++++++++ .../desktop/electron/update-relaunch.test.cjs | 231 +++++++++++++++ apps/desktop/package.json | 2 +- apps/desktop/src/app/updates-overlay.tsx | 86 +++++- apps/desktop/src/global.d.ts | 38 ++- apps/desktop/src/i18n/en.ts | 7 + apps/desktop/src/i18n/ja.ts | 7 + apps/desktop/src/i18n/types.ts | 4 + apps/desktop/src/i18n/zh-hant.ts | 7 + apps/desktop/src/i18n/zh.ts | 6 + apps/desktop/src/store/updates.test.ts | 126 ++++++++- apps/desktop/src/store/updates.ts | 70 ++++- 13 files changed, 953 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/electron/update-relaunch.cjs create mode 100644 apps/desktop/electron/update-relaunch.test.cjs diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index d263adf4766..5665e1a8266 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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/-unpacked. + // We can only HONESTLY relaunch into the new GUI when the *running* binary IS + // that rebuilt one — i.e. execPath lives under release/-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 can’t 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') diff --git a/apps/desktop/electron/update-relaunch.cjs b/apps/desktop/electron/update-relaunch.cjs new file mode 100644 index 00000000000..62032cde8c9 --- /dev/null +++ b/apps/desktop/electron/update-relaunch.cjs @@ -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/-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/-unpacked` dir. + * + * - Source / unpacked install (execPath under release/-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/-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/-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 `/apps/desktop/release/-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 key→value 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 +} diff --git a/apps/desktop/electron/update-relaunch.test.cjs b/apps/desktop/electron/update-relaunch.test.cjs new file mode 100644 index 00000000000..0cccb1b20eb --- /dev/null +++ b/apps/desktop/electron/update-relaunch.test.cjs @@ -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/-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/-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/) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1172888a431..81e855451f8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx index 4bf47410d86..0c24dbb8978 100644 --- a/apps/desktop/src/app/updates-overlay.tsx +++ b/apps/desktop/src/app/updates-overlay.tsx @@ -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' && } {phase === 'manual' && ( - handleClose(false)} /> + handleClose(false)} /> + )} + + {phase === 'guiSkew' && ( + 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 ( +
+
+ + + {u.manualTitle} + + {message || u.manualPickedUp} + +
+ + +
+ ) + } + return (
@@ -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 ( +
+
+ + + {u.guiSkewTitle} + + {message || u.guiSkewBody} + +
+ + +
+ ) +} + function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend: boolean }) { const { t } = useI18n() const u = t.updates diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 26ab49fea51..c8ccdddcb2b 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 6dcbd7d53d8..f03f4c6e2d7 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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 wasn’t changed. Update or reinstall the Hermes desktop app (your AppImage / .deb / .rpm) to match.', copy: 'Copy', copied: 'Copied', done: 'Done', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 265c7833aa9..33bc7c3dd6e 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -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: '完了', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index d03568d6d35..fe27cd7269a 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index a4adf5cf01a..adb83534992 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -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: '完成', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index cf58eb97715..695f254e78b 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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: '完成', diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts index bb74cd650c1..25ceda7c22f 100644 --- a/apps/desktop/src/store/updates.test.ts +++ b/apps/desktop/src/store/updates.test.ts @@ -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 => ({ @@ -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() diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index f83b27e76e0..6b6aae9bea1 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -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 { 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,