fix(desktop): make the non-Windows updater bypass an explicit policy

resolveUpdaterBinary() picked up a staged hermes-setup on every platform, so a
macOS binary predating the update hand-off protocol took over the update, held
the marker, and had its `hermes update` child refuse its own parent. The in-app
Update button then failed for good, with no route -- update, re-download or
reinstall -- back to a capable binary (#74836).

Move the decision into a pure resolveStagedUpdaterBinary() helper in
updater-process.ts and return null off Windows. The installer self-copies into
HERMES_HOME on every platform (paths::installer_dest,
bootstrap::copy_self_to_hermes_home), so finding that binary on macOS or Linux
is expected rather than leftover junk: declining to hand it an update is a
policy decision, and the comments now say so instead of describing the binary
as Windows-specific.

Cover the resolver in updater-process.test.ts: Windows accepts a staged
hermes-setup.exe, macOS/Linux return null even when hermes-setup exists, and
Windows returns null when nothing is staged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zakhounet 2026-07-31 08:17:55 +00:00 committed by Teknium
parent d649973751
commit 0ee9723b52
3 changed files with 117 additions and 27 deletions

View File

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

View File

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

View File

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