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:
parent
d649973751
commit
0ee9723b52
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue