diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 83bd6b25a0e06..87ebe3a8c6f38 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -200,7 +200,7 @@ import { sandboxPreflight } from './update-relaunch' import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote' -import { spawnUpdaterProcess } from './updater-process' +import { resolveStagedUpdaterBinary, spawnUpdaterProcess } from './updater-process' import { formatBlockerMessage, formatProbeFailedMessage, scanVenvBlockers } from './venv-blocker-scan' import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace' import { @@ -2602,26 +2602,14 @@ let isQuittingForHandoff = false let quitPromptOpen = false let quitConfirmedWithActiveWork = false -// Resolve the staged updater binary. The Tauri installer copies itself to -// HERMES_HOME/hermes-setup.exe on a successful install (see -// apps/bootstrap-installer paths::copy_self_to_hermes_home). That binary owns -// ALL repo mutation — running `hermes update` + rebuilding the desktop — so -// the desktop never touches its own bits while running. Returns null when the -// updater isn't staged (e.g. a dev/source run that never went through the -// installer); callers degrade gracefully. -// -// hermes-setup is the Tauri Windows-installer self-copy path. On macOS/Linux -// the drag-and-drop .app uses applyUpdatesPosixInApp instead, so a stale -// hermes-setup (e.g. from an old macOS install, #74836) must not route the -// update into the Windows-style handoff. +// Resolve the staged updater binary the desktop may hand an update to. On +// Windows that binary owns ALL repo mutation — running `hermes update` + +// rebuilding the desktop — so the desktop never touches its own bits while +// running. macOS/Linux stage the same binary but deliberately do not use it; +// see resolveStagedUpdaterBinary for the policy and for #74836. Returns null +// whenever no hand-off applies; callers degrade gracefully. function resolveUpdaterBinary() { - if (!IS_WINDOWS) { - return null - } - - const candidate = path.join(HERMES_HOME, 'hermes-setup.exe') - - return fileExists(candidate) ? candidate : null + return resolveStagedUpdaterBinary(HERMES_HOME, { fileExists, isWindows: IS_WINDOWS }) } function repairMacUpdaterHelper(updater) { @@ -2849,12 +2837,13 @@ async function applyUpdates(opts = {}) { const updater = resolveUpdaterBinary() if (!updater && !IS_WINDOWS) { - // macOS/Linux drag-install: no staged Tauri hermes-setup. Unlike Windows - // (where a venv-shim file lock forces the quit→hand-off→rebuild dance), - // there's no mandatory file locking here, so the desktop can drive the - // whole update itself: `hermes update` (backend) + `hermes desktop - // --build-only` (OS-aware GUI rebuild), then swap the running .app bundle - // with the freshly built one and relaunch. + // macOS/Linux: never hand off, staged hermes-setup or not — the resolver + // returns null there by policy. Unlike Windows (where a venv-shim file + // lock forces the quit→hand-off→rebuild dance), there's no mandatory file + // locking here, so the desktop can drive the whole update itself: + // `hermes update` (backend) + `hermes desktop --build-only` (OS-aware GUI + // rebuild), then swap the running .app bundle with the freshly built one + // and relaunch. return await applyUpdatesPosixInApp(opts) } diff --git a/apps/desktop/electron/updater-process.test.ts b/apps/desktop/electron/updater-process.test.ts index ce61855dff52a..70174b26f1f6f 100644 --- a/apps/desktop/electron/updater-process.test.ts +++ b/apps/desktop/electron/updater-process.test.ts @@ -1,9 +1,10 @@ import assert from 'node:assert/strict' import type { SpawnOptions } from 'node:child_process' +import path from 'node:path' import { test } from 'vitest' -import { spawnUpdaterProcess } from './updater-process' +import { resolveStagedUpdaterBinary, spawnUpdaterProcess } from './updater-process' test('spawnUpdaterProcess hides the updater console and detaches the child on Windows', () => { const calls: Array<{ args: string[]; command: string; options: SpawnOptions }> = [] @@ -60,3 +61,49 @@ test('spawnUpdaterProcess preserves updater options off Windows', () => { assert.deepEqual(capturedOptions, { detached: true, stdio: 'ignore' }) }) + +test('resolveStagedUpdaterBinary hands Windows the staged installer it finds', () => { + const home = 'C:\\Users\\hermes\\AppData\\Local\\hermes' + const staged = path.join(home, 'hermes-setup.exe') + const probed: string[] = [] + + const resolved = resolveStagedUpdaterBinary(home, { + fileExists: (candidate) => { + probed.push(candidate) + + return candidate === staged + }, + isWindows: true + }) + + assert.equal(resolved, staged) + assert.deepEqual(probed, [staged]) +}) + +test('resolveStagedUpdaterBinary returns null off Windows even when hermes-setup is staged (#74836)', () => { + const home = '/Users/hermes/.hermes' + let probes = 0 + + const resolved = resolveStagedUpdaterBinary(home, { + // The installer stages hermes-setup on macOS/Linux too, so "it exists" is + // the normal case — and precisely the one that must not win. + fileExists: () => { + probes += 1 + + return true + }, + isWindows: false + }) + + assert.equal(resolved, null) + assert.equal(probes, 0) +}) + +test('resolveStagedUpdaterBinary returns null on Windows when nothing is staged', () => { + const resolved = resolveStagedUpdaterBinary('C:\\Users\\hermes\\AppData\\Local\\hermes', { + fileExists: () => false, + isWindows: true + }) + + assert.equal(resolved, null) +}) diff --git a/apps/desktop/electron/updater-process.ts b/apps/desktop/electron/updater-process.ts index d0706a6e51402..56352da3e8534 100644 --- a/apps/desktop/electron/updater-process.ts +++ b/apps/desktop/electron/updater-process.ts @@ -1,4 +1,6 @@ import { spawn, type SpawnOptions } from 'node:child_process' +import { statSync } from 'node:fs' +import path from 'node:path' import { hiddenWindowsChildOptions } from './windows-child-options' @@ -7,6 +9,58 @@ export interface UpdaterChild { unref: () => void } +export interface ResolveStagedUpdaterBinaryDeps { + isWindows?: boolean + fileExists?: (candidate: string) => boolean +} + +function stagedFileExists(candidate: string): boolean { + try { + return statSync(candidate).isFile() + } catch { + return false + } +} + +/** + * Decide which staged installer binary — if any — may be handed an update. + * + * The Tauri installer self-copies into HERMES_HOME on *every* platform + * (`hermes-setup.exe` on Windows, `hermes-setup` elsewhere — see + * apps/bootstrap-installer `paths::installer_dest` and + * `bootstrap::copy_self_to_hermes_home`), so finding that binary on macOS or + * Linux is expected, not leftover junk. + * + * Handing an update to it is nonetheless a Windows-only policy. Windows needs + * the quit -> hand-off -> rebuild dance because a venv shim file lock keeps the + * running desktop from rewriting its own bits; macOS and Linux have no such + * lock and update in place through applyUpdatesPosixInApp(). Off Windows the + * hand-off therefore buys nothing and costs a great deal: a staged binary older + * than the hand-off protocol holds the update marker, spawns `hermes update`, + * and that child refuses its own parent — wedging the in-app Update button for + * good, with no route (update, re-download, reinstall) to a newer binary + * (#74836). Returning null off Windows is what routes those platforms to the + * in-app updater. + * + * Null on Windows too when nothing is staged (a dev/source run, or a CLI + * install that never went through the installer); callers degrade gracefully. + */ +export function resolveStagedUpdaterBinary( + hermesHome: string, + deps: ResolveStagedUpdaterBinaryDeps = {} +): string | null { + const isWindows = deps.isWindows ?? process.platform === 'win32' + + if (!isWindows) { + return null + } + + const fileExists = deps.fileExists ?? stagedFileExists + const candidate = path.join(hermesHome, 'hermes-setup.exe') + + return fileExists(candidate) ? candidate : null +} + export interface SpawnUpdaterProcessDeps { isWindows?: boolean spawnProcess?: (command: string, args: string[], options: SpawnOptions) => UpdaterChild