diff --git a/apps/desktop/README.md b/apps/desktop/README.md index ebb50f9a412..309160f0339 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -194,6 +194,8 @@ Raw local file bytes are never exposed through the preload bridge and cannot be - `electron-updater` reads the deployment's `/api/desktop/update` feed; production resolves stable releases from `simstudioai/sim`, while dev/staging resolve prereleases from `simstudioai/sim-desktop-releases`. Artifact downloads go directly to GitHub and deltas use `.zip.blockmap`. Sim validates every candidate before starting its download. Developer ID builds installed under `/Applications` use a prompt (Restart and update / Later; Later installs on quit); other packaged builds offer a validated installer download — never forced mid-session. A staged or offered update keeps being re-checked on the normal cadence, and a newer release replaces it, so a shell left running across several releases installs the latest build in one restart instead of the stale one followed by another prompt. - Streams: production follows stable `X.Y.Z` releases, dev follows `-dev.N`, and staging follows `-staging.N`. The feed still recognizes legacy `-alpha.N`/`-beta.N` releases during migration. +- Restart becomes available only after Squirrel confirms native staging. Replacing a staged update returns the UI to downloading; a failed transfer can retain the previous staged build, but a failure after the native feed is replaced requires a retry. Diagnostics record `update_downloaded` after native staging, `update_install` when an explicit restart is committed, and `update_install_result` on the next launch with the expected and installed versions. The staging checkpoint also covers updates installed on a normal quit. +- `bun run test:e2e e2e/updater.spec.ts` exercises the real Electron process, MacUpdater, downloads, retries, and installation checkpoints. Native verification and bundle replacement are simulated. Set `DESKTOP_UPDATER_REPORT_PATH` to choose the JSON report path; by default it is included in Playwright's test results and uploaded by CI on failure. - Staged rollout: after publishing, edit `stagingPercentage: 10` into the release's `latest-mac.yml`, then raise as crash metrics stay clean. - Rollback: a pulled release must be superseded by a **higher** version — users on the broken build will not reinstall an equal one. (A blocked-versions kill-switch was removed as unwired dead code; reintroduce it in `updater.ts` if a remote config source ever exists to feed it.) - Ship the DMG and tell users to install to `/Applications` — App Translocation breaks Squirrel.Mac updates from quarantined paths. diff --git a/apps/desktop/e2e/fixtures/updater.ts b/apps/desktop/e2e/fixtures/updater.ts new file mode 100644 index 00000000000..33fea2ba5dd --- /dev/null +++ b/apps/desktop/e2e/fixtures/updater.ts @@ -0,0 +1,83 @@ +import { writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join, relative } from 'node:path' +import type { DesktopUpdateState } from '@sim/desktop-bridge' +import { app, autoUpdater as nativeUpdater, net } from 'electron' +import { MacUpdater } from 'electron-updater' +import { initUpdater } from '@/main/updater' + +declare global { + var desktopUpdaterFixture: { + check(): void + install(): void + finishStaging(): void + failStaging(): void + read(): { + state: DesktopUpdateState + nativeArchive: string | null + installed: string[] + events: { name: string; data: unknown }[] + } + } +} + +const directory = process.env.SIM_UPDATER_FIXTURE_DIR +const origin = process.env.SIM_UPDATER_FIXTURE_ORIGIN +if (!directory || !origin) throw new Error('Updater fixture configuration is missing') +app.setPath('userData', join(directory, 'user-data')) + +void app.whenReady().then(() => { + const configPath = join(directory, 'updater.yml') + const cache = relative(join(homedir(), 'Library', 'Caches'), join(directory, 'cache')) + writeFileSync(configPath, `updaterCacheDirName: ${JSON.stringify(cache)}\n`) + + let feed: Electron.FeedURLOptions | undefined + let nativeArchive: string | null = null + const installed: string[] = [] + const events: { name: string; data: unknown }[] = [] + + /** Native verification and installation are simulated; MacUpdater and both HTTP transfers run. */ + nativeUpdater.setFeedURL = (options) => { + feed = options + nativeArchive = null + } + nativeUpdater.checkForUpdates = () => { + void (async () => { + if (!feed) throw new Error('Native feed was not configured') + const response = await net.fetch(feed.url, { headers: feed.headers }) + const manifest: { url: string } = await response.json() + const archive = await net.fetch(manifest.url) + nativeArchive = await archive.text() + })().catch((error) => nativeUpdater.emit('error', error)) + } + nativeUpdater.quitAndInstall = () => { + if (nativeArchive === null) throw new Error('Cannot install before native staging') + installed.push(nativeArchive) + } + + const updater = new MacUpdater() + updater.forceDevUpdateConfig = true + updater.disableDifferentialDownload = true + updater.updateConfigPath = configPath + updater.setFeedURL({ provider: 'generic', url: origin }) + const handle = initUpdater({ + getWindow: () => null, + events: { filePath: '', record: (name, data) => events.push({ name, data }) }, + appOrigin: () => origin, + loadAutoUpdater: () => updater, + canSelfUpdate: async () => true, + probeOriginFeed: async () => false, + installStatePath: join(directory, 'update-install.json'), + }) + + globalThis.desktopUpdaterFixture = { + check: () => handle.check(), + install: () => handle.install(), + finishStaging: () => { + if (nativeArchive === null) throw new Error('Native transfer has not finished') + nativeUpdater.emit('update-downloaded', {}, '', nativeArchive, new Date(), '') + }, + failStaging: () => nativeUpdater.emit('error', new Error('Native verification failed')), + read: () => ({ state: handle.getState(), nativeArchive, installed, events }), + } +}) diff --git a/apps/desktop/e2e/updater.spec.ts b/apps/desktop/e2e/updater.spec.ts new file mode 100644 index 00000000000..ac0be66e98a --- /dev/null +++ b/apps/desktop/e2e/updater.spec.ts @@ -0,0 +1,209 @@ +import { createHash } from 'node:crypto' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { _electron as electron, expect, test } from '@playwright/test' +import { getErrorMessage } from '@sim/utils/errors' +import { build } from 'esbuild' + +const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) + +test('the real MacUpdater waits for native staging, replaces old builds, and retries failed staging', async () => { + test.skip(process.platform !== 'darwin', 'Squirrel.Mac lifecycle') + const directory = mkdtempSync(join(tmpdir(), 'sim-updater-e2e-')) + const reportPath = + process.env.DESKTOP_UPDATER_REPORT_PATH ?? test.info().outputPath('updater.json') + let app: Awaited> | undefined + let offeredVersion = '2.0.0' + const requests: { path: string; status: number }[] = [] + const checks: { + name: string + status: 'passed' | 'failed' + durationMs: number + error?: string + }[] = [] + const check = async (name: string, run: () => Promise) => { + const started = Date.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: Date.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: Date.now() - started, + error: getErrorMessage(error), + }) + throw error + } + } + let snapshot: unknown + const server = createServer((request, response) => { + const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname + requests.push({ path, status: 200 }) + if (path === '/latest-mac.yml') { + const archive = Buffer.from(offeredVersion) + response.end( + `version: ${offeredVersion}\nfiles:\n - url: Sim-${offeredVersion}-universal.zip\n sha512: ${createHash('sha512').update(archive).digest('base64')}\n size: ${archive.length}\n` + ) + } else { + response.end(/^\/Sim-(.+)-universal.zip$/.exec(path)?.[1] ?? '') + } + }) + + try { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Missing fixture address') + mkdirSync(join(directory, 'user-data')) + writeFileSync( + join(directory, 'package.json'), + JSON.stringify({ name: 'sim-updater-fixture', version: '1.0.0', main: 'main.cjs' }) + ) + await build({ + entryPoints: [join(DESKTOP_DIR, 'e2e/fixtures/updater.ts')], + outfile: join(directory, 'main.cjs'), + bundle: true, + platform: 'node', + format: 'cjs', + external: ['electron'], + tsconfig: join(DESKTOP_DIR, 'tsconfig.json'), + plugins: [ + { + name: 'approve-fixture-restart', + setup(builder) { + builder.onResolve({ filter: /^@\/main\/dialogs$/ }, () => ({ + path: 'dialog', + namespace: 'fixture', + })) + builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ + contents: + 'export async function showShellDialog() { return { response: 1, checkboxChecked: false } }', + })) + }, + }, + ], + }) + app = await electron.launch({ + args: [directory, '--use-mock-keychain'], + env: { + ...process.env, + SIM_UPDATER_FIXTURE_DIR: directory, + SIM_UPDATER_FIXTURE_ORIGIN: `http://127.0.0.1:${address.port}`, + }, + }) + const shell = app + const read = () => shell.evaluate(() => globalThis.desktopUpdaterFixture.read()) + await expect + .poll(() => shell.evaluate(() => Boolean(globalThis.desktopUpdaterFixture))) + .toBe(true) + + await check('first download waits for native staging', async () => { + await shell.evaluate(() => globalThis.desktopUpdaterFixture.check()) + await expect.poll(async () => (await read()).nativeArchive).toBe('2.0.0') + expect((await read()).state).toEqual({ + status: 'downloading', + version: '2.0.0', + percent: 100, + }) + await shell.evaluate(() => globalThis.desktopUpdaterFixture.install()) + expect((await read()).installed).toEqual([]) + await shell.evaluate(() => globalThis.desktopUpdaterFixture.finishStaging()) + expect((await read()).state).toEqual({ status: 'ready', version: '2.0.0' }) + }) + + await check('replacement cannot restart into stale native update', async () => { + offeredVersion = '2.1.0' + await shell.evaluate(() => globalThis.desktopUpdaterFixture.check()) + await expect.poll(async () => (await read()).nativeArchive).toBe('2.1.0') + expect((await read()).state).toEqual({ + status: 'downloading', + version: '2.1.0', + percent: 100, + }) + await shell.evaluate(() => globalThis.desktopUpdaterFixture.install()) + expect((await read()).installed).toEqual([]) + await shell.evaluate(() => globalThis.desktopUpdaterFixture.failStaging()) + expect((await read()).state).toEqual({ status: 'error', version: '2.1.0' }) + }) + + await check('cached retry installs only the verified replacement', async () => { + await shell.evaluate(() => globalThis.desktopUpdaterFixture.check()) + await expect + .poll(async () => (await read()).state) + .toEqual({ status: 'downloading', version: '2.1.0', percent: 100 }) + await expect.poll(async () => (await read()).nativeArchive).toBe('2.1.0') + await shell.evaluate(() => globalThis.desktopUpdaterFixture.finishStaging()) + expect((await read()).state).toEqual({ status: 'ready', version: '2.1.0' }) + await shell.evaluate(() => globalThis.desktopUpdaterFixture.install()) + await expect.poll(async () => (await read()).installed).toEqual(['2.1.0']) + }) + snapshot = await read() + await check( + 'the next process distinguishes an incomplete update from a successful install', + async () => { + const checkpoint = readFileSync(join(directory, 'update-install.json'), 'utf8') + await app?.close() + app = await electron.launch({ + args: [directory, '--use-mock-keychain'], + env: { + ...process.env, + SIM_UPDATER_FIXTURE_DIR: directory, + SIM_UPDATER_FIXTURE_ORIGIN: `http://127.0.0.1:${address.port}`, + }, + }) + await expect + .poll(() => app?.evaluate(() => globalThis.desktopUpdaterFixture?.read().events)) + .toContainEqual({ + name: 'update_install_result', + data: { expected: '2.1.0', installed: '1.0.0', success: false }, + }) + await app.close() + writeFileSync(join(directory, 'update-install.json'), checkpoint) + writeFileSync( + join(directory, 'package.json'), + JSON.stringify({ name: 'sim-updater-fixture', version: '2.1.0', main: 'main.cjs' }) + ) + app = await electron.launch({ + args: [directory, '--use-mock-keychain'], + env: { + ...process.env, + SIM_UPDATER_FIXTURE_DIR: directory, + SIM_UPDATER_FIXTURE_ORIGIN: `http://127.0.0.1:${address.port}`, + }, + }) + await expect + .poll(() => app?.evaluate(() => globalThis.desktopUpdaterFixture?.read().events)) + .toContainEqual({ + name: 'update_install_result', + data: { expected: '2.1.0', installed: '2.1.0', success: true }, + }) + } + ) + } finally { + if (!snapshot && app) + snapshot = await app + .evaluate(() => globalThis.desktopUpdaterFixture?.read()) + .catch(() => undefined) + mkdirSync(dirname(reportPath), { recursive: true }) + writeFileSync( + reportPath, + JSON.stringify( + { + passed: checks.length === 4 && checks.every((check) => check.status === 'passed'), + checks, + requests, + snapshot, + }, + null, + 2 + ) + ) + await app?.close() + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 56888943323..b3a4618d62b 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -866,6 +866,7 @@ function main(): void { updater = initUpdater({ getWindow: getMainWindow, events, + installStatePath: join(userDataPath, 'update-install.json'), appOrigin, autoDownload: () => config.get('autoDownloadUpdates') ?? true, setRelaunchPending: (pending) => { diff --git a/apps/desktop/src/main/observability.ts b/apps/desktop/src/main/observability.ts index 7dec829b156..132eb026bdf 100644 --- a/apps/desktop/src/main/observability.ts +++ b/apps/desktop/src/main/observability.ts @@ -33,6 +33,8 @@ export type DesktopEventName = | 'update_check' | 'update_feed' | 'update_downloaded' + | 'update_install' + | 'update_install_result' | 'update_error' | 'update_blocked_version' | 'update_manual_mode' diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index e744d16523b..5ed6241ccfb 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -88,10 +88,11 @@ describe('initUpdater state machine', () => { } /** Replays a native Squirrel.Mac event, e.g. `update-downloaded` once a bundle is staged. */ - function emitSquirrel(event: string) { + function emitSquirrel(event: string, ...args: unknown[]) { + if (event === 'error') emit(event, ...args) for (const [name, listener] of vi.mocked(squirrelUpdater.on).mock.calls) { if (name === event) { - ;(listener as () => void)() + ;(listener as (...values: unknown[]) => void)(...args) } } } @@ -163,10 +164,16 @@ describe('initUpdater state machine', () => { emit('download-progress', { percent: 41.7 }) emit('update-downloaded', { version: '2.0.0' }) + expect(handle.getState()).toEqual({ status: 'downloading', version: '2.0.0', percent: 100 }) + handle.install() + expect(dialog.showMessageBox).not.toHaveBeenCalled() + emitSquirrel('update-downloaded') + expect(states).toEqual([ { status: 'checking' }, { status: 'downloading', version: '2.0.0' }, { status: 'downloading', version: '2.0.0', percent: 42 }, + { status: 'downloading', version: '2.0.0', percent: 100 }, { status: 'ready', version: '2.0.0' }, ]) expect(dialog.showMessageBox).not.toHaveBeenCalled() @@ -211,6 +218,7 @@ describe('initUpdater state machine', () => { await vi.advanceTimersByTimeAsync(0) emit('update-available', { version: '2.0.0' }) emit('update-downloaded', { version: '2.0.0' }) + emitSquirrel('update-downloaded') vi.mocked(dialog.showMessageBox).mockClear() handle.install() handle.install() @@ -251,6 +259,7 @@ describe('initUpdater state machine', () => { emit('update-available', { version: '2.0.0' }) handle.check() emit('update-downloaded', { version: '2.0.0' }) + emitSquirrel('update-downloaded') vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 1, checkboxChecked: false, @@ -266,7 +275,7 @@ describe('initUpdater state machine', () => { expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) }) - it('does not install when the updater fails during pre-install teardown', async () => { + it('does not install when native staging fails during teardown with a background check pending', async () => { let finishTeardown: (() => void) | undefined const setRelaunchPending = vi.fn() const { handle } = await createUpdater({ @@ -281,6 +290,8 @@ describe('initUpdater state machine', () => { await vi.advanceTimersByTimeAsync(0) emit('update-available', { version: '2.0.0' }) emit('update-downloaded', { version: '2.0.0' }) + emitSquirrel('update-downloaded') + await vi.advanceTimersByTimeAsync(10_000) vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 1, checkboxChecked: false, @@ -288,7 +299,7 @@ describe('initUpdater state machine', () => { handle.install() await vi.advanceTimersByTimeAsync(0) - emit('error', new Error('native staging failed')) + emitSquirrel('error', new Error('native installer failed')) finishTeardown?.() await vi.advanceTimersByTimeAsync(0) @@ -297,6 +308,39 @@ describe('initUpdater state machine', () => { expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() }) + it('finishes a confirmed restart when an earlier background check fails during teardown', async () => { + let finishTeardown: (() => void) | undefined + let failRefresh: ((error: Error) => void) | undefined + const { handle } = await createUpdater({ + beforeInstall: () => + new Promise((resolve) => { + finishTeardown = resolve + }), + }) + await stageUpdate(handle, '2.0.0') + autoUpdaterMock.checkForUpdates.mockImplementationOnce( + () => + new Promise((_, reject) => { + failRefresh = (error) => { + emit('error', error) + reject(error) + } + }) + ) + await vi.advanceTimersByTimeAsync(10_000) + vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 1, checkboxChecked: false }) + handle.install() + await vi.advanceTimersByTimeAsync(0) + + failRefresh?.(new Error('Background feed unavailable')) + await vi.advanceTimersByTimeAsync(0) + expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' }) + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true) + finishTeardown?.() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + it('bypasses renderer unload guards only after teardown succeeds', async () => { const setRelaunchPending = vi.fn() vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ @@ -312,6 +356,7 @@ describe('initUpdater state machine', () => { await vi.advanceTimersByTimeAsync(0) emit('update-available', { version: '2.0.0' }) emit('update-downloaded', { version: '2.0.0' }) + emitSquirrel('update-downloaded') handle.install() expect(setRelaunchPending).not.toHaveBeenCalled() @@ -332,16 +377,19 @@ describe('initUpdater state machine', () => { emit('update-available', { version: '2.1.0' }) emit('download-progress', { percent: 50 }) expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(2) + expect(handle.getState()).toEqual({ status: 'downloading', version: '2.1.0', percent: 50 }) + handle.install() + expect(dialog.showMessageBox).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(30 * 60 * 1000) expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) emit('update-downloaded', { version: '2.1.0' }) - expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' }) + expect(handle.getState()).toEqual({ status: 'downloading', version: '2.1.0', percent: 100 }) expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true) emitSquirrel('update-downloaded') - expect(states).toEqual([{ status: 'ready', version: '2.1.0' }]) + expect(states.at(-1)).toEqual({ status: 'ready', version: '2.1.0' }) expect(events.record).toHaveBeenCalledWith('update_downloaded', { version: '2.1.0' }) }) @@ -360,7 +408,7 @@ describe('initUpdater state machine', () => { expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) }) - it('keeps a staged update when a background re-check finds nothing newer or fails', async () => { + it('keeps a staged update after a failed check or download, but not a failed native handoff', async () => { const { handle, states } = await createUpdater() await stageUpdate(handle, '2.0.0') states.length = 0 @@ -369,24 +417,28 @@ describe('initUpdater state machine', () => { emit('update-available', { version: '2.0.0' }) await vi.advanceTimersByTimeAsync(30 * 60 * 1000 - 10_000) emit('update-not-available') + autoUpdaterMock.checkForUpdates.mockRejectedValueOnce(new Error('net::ERR_NETWORK_CHANGED')) await vi.advanceTimersByTimeAsync(30 * 60 * 1000) - emit('error', new Error('net::ERR_NETWORK_CHANGED')) await vi.advanceTimersByTimeAsync(30 * 60 * 1000) + autoUpdaterMock.downloadUpdate.mockRejectedValueOnce(new Error('download interrupted')) emit('update-available', { version: '2.1.0' }) - emit('error', new Error('download interrupted')) + await vi.advanceTimersByTimeAsync(0) + expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' }) await vi.advanceTimersByTimeAsync(30 * 60 * 1000) emit('update-available', { version: '2.2.0' }) emit('update-downloaded', { version: '2.2.0' }) - emit('error', new Error('Squirrel could not verify the replacement')) + emitSquirrel('error', new Error('Squirrel could not verify the replacement')) expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(6) expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(3) - expect(states).toEqual([]) - expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' }) - expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true) + expect(states.at(-1)).toEqual({ status: 'error', version: '2.2.0' }) + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false) emitSquirrel('update-downloaded') - expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' }) + expect(handle.getState()).toEqual({ status: 'error', version: '2.2.0' }) + + await stageUpdate(handle, '2.2.0') + expect(handle.getState()).toEqual({ status: 'ready', version: '2.2.0' }) }) it('installs a replacement that finished staging while the restart prompt was open', async () => { @@ -397,7 +449,6 @@ describe('initUpdater state machine', () => { const { handle } = await createUpdater() await stageUpdate(handle, '2.0.0') await vi.advanceTimersByTimeAsync(10_000) - emit('update-available', { version: '2.1.0' }) vi.mocked(dialog.showMessageBox).mockImplementationOnce( () => @@ -406,6 +457,7 @@ describe('initUpdater state machine', () => { }) ) handle.install() + emit('update-available', { version: '2.1.0' }) emit('update-downloaded', { version: '2.1.0' }) emitSquirrel('update-downloaded') expect(handle.getState()).toEqual({ status: 'ready', version: '2.1.0' }) @@ -415,6 +467,32 @@ describe('initUpdater state machine', () => { expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) }) + it('does not restart into the old build when a newer check resolves during teardown', async () => { + let finishTeardown: (() => void) | undefined + const { handle } = await createUpdater({ + beforeInstall: () => + new Promise((resolve) => { + finishTeardown = resolve + }), + }) + await stageUpdate(handle, '2.0.0') + await vi.advanceTimersByTimeAsync(10_000) + vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 1, checkboxChecked: false }) + handle.install() + await vi.advanceTimersByTimeAsync(0) + + emit('update-available', { version: '2.1.0' }) + finishTeardown?.() + await vi.advanceTimersByTimeAsync(0) + expect(handle.getState()).toEqual({ status: 'downloading', version: '2.1.0' }) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + + emit('update-downloaded', { version: '2.1.0' }) + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true) + emitSquirrel('update-downloaded') + expect(handle.getState()).toEqual({ status: 'ready', version: '2.1.0' }) + }) + it('withdraws an offered update once a re-check stores a blocked candidate', async () => { const { handle } = await createUpdater({ autoDownload: false }) handle.check() diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 3dc129e3ce4..05e786dd541 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -1,9 +1,13 @@ import { execFile } from 'node:child_process' +import { unlinkSync } from 'node:fs' import type { DesktopUpdateState } from '@sim/desktop-bridge' import { createLogger } from '@sim/logger' +import { toStringOrNull } from '@sim/utils/coerce' import { getErrorMessage } from '@sim/utils/errors' +import { toRecord } from '@sim/utils/object' import type { BrowserWindow } from 'electron' import { app, net, autoUpdater as squirrelUpdater } from 'electron' +import { readFileWithinLimitSync, writeJsonFileAtomicallySync } from '@/main/atomic-json-file' import { showShellDialog } from '@/main/dialogs' import { isSafeExternalUrl, openExternalSafe } from '@/main/navigation' import type { EventRecorder } from '@/main/observability' @@ -249,6 +253,8 @@ export interface UpdaterDeps { beforeInstall?: () => Promise /** Bypasses renderer unload guards only after the user confirms a relaunch. */ setRelaunchPending?: (pending: boolean) => void + /** Persists the natively staged version so the next process can verify installation. */ + installStatePath?: string } export interface UpdaterHandle { @@ -355,6 +361,38 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { } const currentVersion = app.getVersion() + const clearInstallState = () => { + if (!deps.installStatePath) return + try { + unlinkSync(deps.installStatePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn('Could not clear update installation checkpoint', { error }) + } + } + } + if (deps.installStatePath) { + try { + const pending: unknown = JSON.parse( + readFileWithinLimitSync(deps.installStatePath, 1024).toString('utf8') + ) + const expected = toStringOrNull(toRecord(pending).version) + if (expected && parseSemver(expected) && parseSemver(currentVersion)) { + deps.events.record('update_install_result', { + expected, + installed: currentVersion, + success: + resolveUpdateChannel(expected) === resolveUpdateChannel(currentVersion) && + !isNewerVersion(expected, currentVersion), + }) + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn('Could not read update installation checkpoint', { error }) + } + } + clearInstallState() + } let state: DesktopUpdateState = { status: 'idle' } const listeners = new Set<(state: DesktopUpdateState) => void>() const setState = (next: DesktopUpdateState) => { @@ -394,13 +432,8 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { autoUpdater.logger = null let installInFlight = false let installConfirmationInFlight = false - /** - * True once Squirrel.Mac holds a verified bundle it will install on exit. - * It keeps that bundle through any later failed check, download, or - * restage, so only a failure before staging or during relaunch leaves - * nothing installable. - */ - let squirrelStaged = false + /** The version verified by the current native updater, before its feed is replaced. */ + let stagedVersion: string | null = null let relaunchRequested = false /** @@ -414,12 +447,15 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { .then(() => deps.beforeInstall?.()) .then(() => { if (state.status !== 'ready') { - autoUpdater.autoInstallOnAppQuit = false installInFlight = false return } deps.setRelaunchPending?.(true) relaunchRequested = true + deps.events.record('update_install', { + from: currentVersion, + version: state.version ?? '', + }) autoUpdater.quitAndInstall() }) .catch((error) => { @@ -472,13 +508,10 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { let nextUpdaterCheckId = 0 let updaterCheckTimeout: ReturnType | null = null let updaterRequestId: number | null = null - /** - * The validated version whose download is in flight. While `ready`, a - * non-null value means a newer build is replacing the staged one. - */ + /** The validated version offered or currently downloading. */ let acceptedUpdateVersion: string | null = null - /** A downloaded replacement that becomes `ready` once Squirrel stages it. */ - let pendingReplacementVersion: string | null = null + /** A downloaded archive awaiting native verification and staging. */ + let pendingStagingVersion: string | null = null /** * A staged (`ready`) or offered (`available`) update keeps being re-checked @@ -489,12 +522,33 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { const isRefreshingOffer = () => state.status === 'ready' || state.status === 'available' const canRefreshStagedUpdate = () => - squirrelStaged && + stagedVersion !== null && autoDownloadEnabled && !installInFlight && !installConfirmationInFlight && acceptedUpdateVersion === null && - pendingReplacementVersion === null + pendingStagingVersion === null + + const failDownload = (version: string, error: unknown) => { + if (acceptedUpdateVersion !== version && pendingStagingVersion !== version) return + acceptedUpdateVersion = null + pendingStagingVersion = null + const message = getErrorMessage(error, 'unknown') + logger.warn('Update download failed', { message }) + deps.events.record('update_error', { message }) + if (stagedVersion !== null) { + setState({ status: 'ready', version: stagedVersion }) + } else { + autoUpdater.autoInstallOnAppQuit = false + setState({ status: 'error', version }) + } + } + + const download = (version: string) => { + acceptedUpdateVersion = version + setState({ status: 'downloading', version }) + void autoUpdater.downloadUpdate().catch((error) => failDownload(version, error)) + } const finishProbe = (probeId: number) => { if (activeProbeId !== probeId) return @@ -544,26 +598,16 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { if (state.status === 'ready') { // Only a strictly newer validated release replaces the staged one; the // download reuses the update info this check just stored. - const stagedVersion = state.version ?? currentVersion + const previousVersion = state.version ?? currentVersion if ( !validCandidate || !autoDownloadEnabled || - !isNewerVersion(info.version, stagedVersion) + !isNewerVersion(info.version, previousVersion) ) { return } - acceptedUpdateVersion = info.version - deps.events.record('update_check', { available: info.version, replacing: stagedVersion }) - const replacementVersion = info.version - // Cancellation rejects without an `error` event, so the promise owns - // clearing its replacement for every failure mode. - void autoUpdater.downloadUpdate().catch((error) => { - if (acceptedUpdateVersion === replacementVersion) acceptedUpdateVersion = null - if (pendingReplacementVersion === replacementVersion) pendingReplacementVersion = null - logger.warn('Replacement update download failed; keeping the staged update', { - message: getErrorMessage(error, 'unknown'), - }) - }) + deps.events.record('update_check', { available: info.version, replacing: previousVersion }) + download(info.version) return } // An offer mirrors the feed's latest release, even after a rollback: the @@ -586,16 +630,10 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { } acceptedUpdateVersion = info.version deps.events.record('update_check', { available: info.version }) - setState({ - status: autoDownloadEnabled ? 'downloading' : 'available', - version: info.version, - }) if (autoDownloadEnabled) { - void autoUpdater.downloadUpdate().catch((error) => { - logger.warn('Update download failed', { message: getErrorMessage(error, 'unknown') }) - deps.events.record('update_error', { message: getErrorMessage(error, 'unknown') }) - setState({ status: 'error', version: info.version }) - }) + download(info.version) + } else { + setState({ status: 'available', version: info.version }) } }) @@ -609,15 +647,10 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { }) autoUpdater.on('update-downloaded', (info) => { - if (state.status === 'ready') { - if (acceptedUpdateVersion !== info.version) return - acceptedUpdateVersion = null - // electron-updater hands the file to Squirrel after this event; the - // staged update switches over when Squirrel reports it staged. - pendingReplacementVersion = info.version - return - } if (state.status !== 'downloading') return + // MacUpdater has replaced the native feed before emitting this event. + stagedVersion = null + clearInstallState() if ( acceptedUpdateVersion !== info.version || !isValidUpdateCandidate(info.version, currentVersion) @@ -629,49 +662,46 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { return } acceptedUpdateVersion = null + pendingStagingVersion = info.version autoUpdater.autoInstallOnAppQuit = true - deps.events.record('update_downloaded', { version: info.version }) - setState({ status: 'ready', version: info.version }) + setState({ status: 'downloading', version: info.version, percent: 100 }) }) squirrelUpdater.on('update-downloaded', () => { - squirrelStaged = true - const version = pendingReplacementVersion - if (version === null || state.status !== 'ready') return - pendingReplacementVersion = null + const version = pendingStagingVersion + if (version === null || state.status !== 'downloading') return + pendingStagingVersion = null + stagedVersion = version + if (deps.installStatePath) { + try { + writeJsonFileAtomicallySync(deps.installStatePath, { version }) + } catch (error) { + logger.warn('Could not persist update installation checkpoint', { error }) + } + } deps.events.record('update_downloaded', { version }) setState({ status: 'ready', version }) }) - autoUpdater.on('error', (error) => { - const checkId = activeUpdaterCheckId - if (checkId !== null) { - finishUpdaterCheck(checkId) - if (updaterRequestId === checkId) updaterRequestId = null - } - const message = getErrorMessage(error, 'unknown') - if (state.status === 'ready' && squirrelStaged && !relaunchRequested) { - acceptedUpdateVersion = null - pendingReplacementVersion = null - logger.warn('Update refresh failed; keeping the staged update', { message }) - return - } - if (state.status === 'available') { - logger.warn('Update re-check failed; keeping the offered update', { message }) - return - } + /** Network failures belong to their request promises; native errors invalidate staging. */ + squirrelUpdater.on('error', (error) => { if ( - checkId === null && state.status !== 'downloading' && state.status !== 'ready' && - !installInFlight + !installInFlight && + !relaunchRequested ) { return } installInFlight = false relaunchRequested = false + stagedVersion = null + acceptedUpdateVersion = null + pendingStagingVersion = null deps.setRelaunchPending?.(false) autoUpdater.autoInstallOnAppQuit = false + const message = getErrorMessage(error, 'unknown') + logger.warn('Native update failed', { message }) deps.events.record('update_error', { message }) setState({ status: 'error', version: state.version }) }) @@ -758,7 +788,9 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { if (updaterRequestId === checkId) updaterRequestId = null if (activeUpdaterCheckId !== checkId) return finishUpdaterCheck(checkId) - logger.warn('Update check failed', { message: getErrorMessage(error, 'unknown') }) + const message = getErrorMessage(error, 'unknown') + logger.warn('Update check failed', { message }) + deps.events.record('update_error', { message }) if (state.status === 'checking') setState({ status: 'error' }) }) } @@ -773,10 +805,8 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { return } if (isRefreshingOffer()) { - if (interactive) return if (state.status === 'ready' && !canRefreshStagedUpdate()) return - } - if (interactive) { + } else if (interactive) { setState({ status: 'checking' }) } if (originFeedConfigured) { @@ -806,12 +836,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { }) }, advance() { - setState({ status: 'downloading', version: state.version }) - autoUpdater.downloadUpdate().catch((error) => { - logger.warn('Update download failed', { message: getErrorMessage(error, 'unknown') }) - deps.events.record('update_error', { message: getErrorMessage(error, 'unknown') }) - setState({ status: 'error', version: state.version }) - }) + if (acceptedUpdateVersion !== null) download(acceptedUpdateVersion) }, install() { confirmAndInstall()