mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
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:
parent
1f6994d1ee
commit
e5e2583635
13 changed files with 953 additions and 13 deletions
|
|
@ -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 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')
|
||||
|
|
|
|||
265
apps/desktop/electron/update-relaunch.cjs
Normal file
265
apps/desktop/electron/update-relaunch.cjs
Normal 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 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
|
||||
}
|
||||
231
apps/desktop/electron/update-relaunch.test.cjs
Normal file
231
apps/desktop/electron/update-relaunch.test.cjs
Normal 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/)
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
38
apps/desktop/src/global.d.ts
vendored
38
apps/desktop/src/global.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: '完了',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '完成',
|
||||
|
|
|
|||
|
|
@ -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: '完成',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue