From 623c05aa4d01e824d6381ba5d547a17834fa157f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 16 Jul 2026 21:20:41 +0200 Subject: [PATCH] Revert "Always revalidate desktop updates before install" This reverts commit 7d80fdfd12c35a345c7220f5d1070aab9d1cf3d7. --- docs/release.md | 4 +- .../desktop/src/daemon/quit-lifecycle.test.ts | 155 ++++------- packages/desktop/src/daemon/quit-lifecycle.ts | 35 +-- .../src/features/app-update-service.test.ts | 254 +++++++++--------- .../src/features/app-update-service.ts | 123 +++++---- .../desktop/src/features/auto-updater.test.ts | 10 + packages/desktop/src/features/auto-updater.ts | 33 ++- packages/desktop/src/main.ts | 11 - 8 files changed, 285 insertions(+), 340 deletions(-) diff --git a/docs/release.md b/docs/release.md index ed868179c..90dd263e3 100644 --- a/docs/release.md +++ b/docs/release.md @@ -169,13 +169,13 @@ This does **not** apply to fresh releases cut via `npm run release:patch` — th ### Releasing during an active rollout -If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it. Rollout-aware clients revalidate the manifest before installing a downloaded update on quit. If N+1 has replaced N but the client is not admitted to N+1 yet, it skips the downloaded N and waits rather than installing two updates in succession. +If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it. If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1. -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast. ### Limitations -- **No pause / kill switch.** To stop new admissions, ship a superseding release. Clients revalidate on quit and will not install the superseded download, but a client that already completed installation cannot be recalled; ship a hotfix `+1` patch. +- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch. - **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix. - **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later. - **Up to ~30 min automatic admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. Clicking **Check** is manual and bypasses rollout admission. diff --git a/packages/desktop/src/daemon/quit-lifecycle.test.ts b/packages/desktop/src/daemon/quit-lifecycle.test.ts index 4d921c443..d9af82b65 100644 --- a/packages/desktop/src/daemon/quit-lifecycle.test.ts +++ b/packages/desktop/src/daemon/quit-lifecycle.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings"; import { @@ -23,151 +23,96 @@ describe("quit-lifecycle", () => { }); it("short-circuits without inspecting the daemon when keep-running is on", async () => { - const events: string[] = []; + const isDesktopManagedDaemonRunning = vi.fn(() => true); + const stopDaemon = vi.fn(async () => undefined); + const showShutdownFeedback = vi.fn(); const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({ settingsStore: { get: async () => SETTINGS_KEEP_RUNNING }, - isDesktopManagedDaemonRunning: () => { - events.push("inspect"); - return true; - }, - stopDaemon: async () => { - events.push("stop"); - }, - showShutdownFeedback: () => { - events.push("feedback"); - }, + isDesktopManagedDaemonRunning, + stopDaemon, + showShutdownFeedback, }); expect(stopped).toBe(false); - expect(events).toEqual([]); + expect(isDesktopManagedDaemonRunning).not.toHaveBeenCalled(); + expect(stopDaemon).not.toHaveBeenCalled(); + expect(showShutdownFeedback).not.toHaveBeenCalled(); }); it("does not stop a manually started daemon on quit", async () => { - const events: string[] = []; + const stopDaemon = vi.fn(async () => undefined); + const showShutdownFeedback = vi.fn(); const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({ settingsStore: { get: async () => SETTINGS_STOP_ON_QUIT }, isDesktopManagedDaemonRunning: () => false, - stopDaemon: async () => { - events.push("stop"); - }, - showShutdownFeedback: () => { - events.push("feedback"); - }, + stopDaemon, + showShutdownFeedback, }); expect(stopped).toBe(false); - expect(events).toEqual([]); + expect(stopDaemon).not.toHaveBeenCalled(); + expect(showShutdownFeedback).not.toHaveBeenCalled(); }); it("shows feedback then stops a desktop-managed daemon", async () => { - const events: string[] = []; + const stopDaemon = vi.fn(async () => undefined); + const showShutdownFeedback = vi.fn(); const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({ settingsStore: { get: async () => SETTINGS_STOP_ON_QUIT }, isDesktopManagedDaemonRunning: () => true, - stopDaemon: async () => { - events.push("stop"); - }, - showShutdownFeedback: () => { - events.push("feedback"); - }, + stopDaemon, + showShutdownFeedback, }); expect(stopped).toBe(true); - expect(events).toEqual(["feedback", "stop"]); + expect(showShutdownFeedback).toHaveBeenCalledTimes(1); + expect(stopDaemon).toHaveBeenCalledTimes(1); + expect(showShutdownFeedback.mock.invocationCallOrder[0]).toBeLessThan( + stopDaemon.mock.invocationCallOrder[0], + ); }); - it("revalidates updates after daemon shutdown before exiting", async () => { + it("preventDefaults the first quit, runs the async stop decision, then exits hard", async () => { let resolveStopDecision: (() => void) | null = null; - let resolveUpdateDecision: (() => void) | null = null; - const events: string[] = []; + const app = { exit: vi.fn() }; + const closeTransportSessions = vi.fn(); + const onStopError = vi.fn(); + const preventDefault = vi.fn(); + const secondPreventDefault = vi.fn(); const handleBeforeQuit = createBeforeQuitHandler({ - app: { - exit: (code) => { - events.push(`exit:${code}`); - }, - }, - closeTransportSessions: () => { - events.push("close-transports"); - }, - stopDesktopManagedDaemonIfNeeded: () => - new Promise((resolve) => { - resolveStopDecision = () => { - events.push("daemon-stopped"); - resolve(false); - }; - }), - installAppUpdateOnQuit: () => - new Promise((resolve) => { - resolveUpdateDecision = () => { - events.push("update-checked"); - resolve(false); - }; - }), - onStopError: () => { - events.push("stop-error"); - }, - onUpdateError: () => { - events.push("update-error"); - }, + app, + closeTransportSessions, + stopDesktopManagedDaemonIfNeeded: vi.fn( + () => + new Promise((resolve) => { + resolveStopDecision = () => resolve(false); + }), + ), + onStopError, }); - handleBeforeQuit({ - preventDefault: () => { - events.push("prevent-default"); - }, - }); + handleBeforeQuit({ preventDefault }); - expect(events).toEqual(["close-transports", "prevent-default"]); + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(closeTransportSessions).toHaveBeenCalledTimes(1); + expect(app.exit).not.toHaveBeenCalled(); expect(resolveStopDecision).not.toBeNull(); resolveStopDecision?.(); await Promise.resolve(); await Promise.resolve(); - expect(events).toEqual(["close-transports", "prevent-default", "daemon-stopped"]); - expect(resolveUpdateDecision).not.toBeNull(); + expect(app.exit).toHaveBeenCalledWith(0); + expect(onStopError).not.toHaveBeenCalled(); - resolveUpdateDecision?.(); - await Promise.resolve(); - await Promise.resolve(); + handleBeforeQuit({ preventDefault: secondPreventDefault }); - expect(events).toEqual([ - "close-transports", - "prevent-default", - "daemon-stopped", - "update-checked", - "exit:0", - ]); - - handleBeforeQuit({ - preventDefault: () => { - events.push("second-prevent-default"); - }, - }); - - expect(events.at(-1)).toBe("close-transports"); - expect(events).not.toContain("second-prevent-default"); - }); - - it("lets the updater own process exit when a validated update is installing", async () => { - const exits: number[] = []; - const handleBeforeQuit = createBeforeQuitHandler({ - app: { exit: (code) => exits.push(code) }, - closeTransportSessions: () => {}, - stopDesktopManagedDaemonIfNeeded: async () => false, - installAppUpdateOnQuit: async () => true, - onStopError: () => {}, - onUpdateError: () => {}, - }); - - handleBeforeQuit({ preventDefault: () => {} }); - await Promise.resolve(); - await Promise.resolve(); - - expect(exits).toEqual([]); + expect(secondPreventDefault).not.toHaveBeenCalled(); + expect(closeTransportSessions).toHaveBeenCalledTimes(2); + expect(app.exit).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/desktop/src/daemon/quit-lifecycle.ts b/packages/desktop/src/daemon/quit-lifecycle.ts index 1f4a5f2a2..879f59778 100644 --- a/packages/desktop/src/daemon/quit-lifecycle.ts +++ b/packages/desktop/src/daemon/quit-lifecycle.ts @@ -46,20 +46,18 @@ export function createBeforeQuitHandler({ app, closeTransportSessions, stopDesktopManagedDaemonIfNeeded, - installAppUpdateOnQuit, onStopError, - onUpdateError, }: { app: BeforeQuitApp; closeTransportSessions: () => void; stopDesktopManagedDaemonIfNeeded: () => Promise; - installAppUpdateOnQuit: () => Promise; onStopError: (error: unknown) => void; - onUpdateError: (error: unknown) => void; }): (event: BeforeQuitEvent) => void { - // The first quit waits for daemon shutdown and update revalidation. A validated - // update re-fires app.quit(); otherwise app.exit(0) bypasses Electron's macOS - // window-all-closed handler, which would veto that second quit. + // We always preventDefault on first quit so we can run the async stop + // decision, then call app.exit(0) — which bypasses Electron's + // close → window-all-closed → will-quit chain. The window-all-closed + // listener is a darwin no-op (macOS convention) and would otherwise + // veto a re-fired app.quit(). let quitting = false; return (event) => { @@ -68,23 +66,12 @@ export function createBeforeQuitHandler({ quitting = true; event.preventDefault(); - void (async () => { - try { - await stopDesktopManagedDaemonIfNeeded(); - } catch (error) { + void stopDesktopManagedDaemonIfNeeded() + .catch((error) => { onStopError(error); - } - - try { - const installingUpdate = await installAppUpdateOnQuit(); - if (installingUpdate) { - return; - } - } catch (error) { - onUpdateError(error); - } - - app.exit(0); - })(); + }) + .finally(() => { + app.exit(0); + }); }; } diff --git a/packages/desktop/src/features/app-update-service.test.ts b/packages/desktop/src/features/app-update-service.test.ts index 4000c8316..f243a7c28 100644 --- a/packages/desktop/src/features/app-update-service.test.ts +++ b/packages/desktop/src/features/app-update-service.test.ts @@ -17,10 +17,7 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { > = []; private gate: ((info: RuntimeUpdateInfo) => boolean | Promise) | null = null; private configuration: AppUpdateRuntimeConfiguration | null = null; - private downloadableUpdate: RuntimeUpdateInfo | null = null; - private downloadedUpdate: RuntimeUpdateInfo | null = null; checkCount = 0; - installedVersions: string[] = []; configure(input: AppUpdateRuntimeConfiguration): void { this.configuration = input; @@ -62,7 +59,6 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { } finishUpdateDownload(info: RuntimeUpdateInfo): void { - this.downloadedUpdate = info; this.configuration?.onUpdateDownloaded(info); } @@ -84,22 +80,12 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { } if (!result || !this.gate) return result; const admitted = await this.gate(result.updateInfo); - const isUpdateAvailable = result.isUpdateAvailable && admitted; - this.downloadableUpdate = isUpdateAvailable ? result.updateInfo : null; - return { ...result, isUpdateAvailable }; + return { ...result, isUpdateAvailable: result.isUpdateAvailable && admitted }; } - async downloadUpdate(): Promise { - if (this.downloadableUpdate) { - this.finishUpdateDownload(this.downloadableUpdate); - } - } + async downloadUpdate(): Promise {} - quitAndInstall(): void { - if (this.downloadedUpdate) { - this.installedVersions.push(this.downloadedUpdate.version); - } - } + quitAndInstall(): void {} } function createService(input?: { now?: () => number; bucket?: () => Promise }) { @@ -193,111 +179,6 @@ describe("app update service", () => { }); }); - it("replaces a downloaded update when a newer release is admitted", async () => { - const { runtime, service } = createService({ bucket: async () => 0 }); - runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); - - await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "automatic", - }); - runtime.finishUpdateDownload(rolledOutUpdate); - - const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" }; - runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); - const result = await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "automatic", - }); - - expect(result).toEqual({ - hasUpdate: true, - readyToInstall: false, - currentVersion: "1.2.3", - latestVersion: "1.2.5", - body: null, - date: "2026-04-28T00:00:00.000Z", - errorMessage: null, - }); - }); - - it("installs the newest admitted release when quitting with an older download", async () => { - const { runtime, service } = createService({ bucket: async () => 0 }); - runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); - - await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "automatic", - }); - runtime.finishUpdateDownload(rolledOutUpdate); - - const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" }; - runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); - const installed = await service.installUpdateOnQuit({ - currentVersion: "1.2.3", - releaseChannel: "stable", - }); - - expect(installed).toBe(true); - expect(runtime.installedVersions).toEqual(["1.2.5"]); - }); - - it("does not install an older download while its replacement is still rolling out", async () => { - const now = Date.parse("2026-04-28T12:00:00.000Z"); - const { runtime, service } = createService({ now: () => now, bucket: async () => 0.4 }); - const olderUpdate = { - ...rolledOutUpdate, - releaseDate: "2026-04-27T00:00:00.000Z", - }; - runtime.nextCheck({ isUpdateAvailable: true, updateInfo: olderUpdate }); - - await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "automatic", - }); - runtime.finishUpdateDownload(olderUpdate); - - const newerUpdate = { - ...rolledOutUpdate, - version: "1.2.5", - releaseDate: "2026-04-28T12:00:00.000Z", - }; - runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); - const installed = await service.installUpdateOnQuit({ - currentVersion: "1.2.3", - releaseChannel: "stable", - }); - - expect(installed).toBe(false); - expect(runtime.installedVersions).toEqual([]); - }); - - it("rechecks for the newest release before a manual install", async () => { - const { runtime, service } = createService({ bucket: async () => 0.99 }); - runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); - - await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "manual", - }); - runtime.finishUpdateDownload(rolledOutUpdate); - - const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" }; - runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); - const result = await service.downloadAndInstallUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - }); - - expect(result.installed).toBe(true); - expect(runtime.installedVersions).toEqual(["1.2.5"]); - }); - it("trusts the runtime availability decision before comparing versions", async () => { const { runtime, service } = createService({ bucket: async () => 0 }); runtime.nextCheck({ isUpdateAvailable: false, updateInfo: rolledOutUpdate }); @@ -441,8 +322,40 @@ describe("app update service", () => { }); }); - it("discovers newer releases after an update fails to prepare", async () => { - const { runtime, service } = createService({ bucket: async () => 0 }); + it("keeps preparation errors emitted before the update check rejects", async () => { + const { runtime, service } = createService(); + const deferredCheck = runtime.deferNextCheck(); + const pending = service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + + runtime.prepareUpdate(rolledOutUpdate); + runtime.failRuntime(new Error("sha512 checksum mismatch")); + deferredCheck.reject(new Error("sha512 checksum mismatch")); + const checkResult = await pending; + expect(checkResult.errorMessage).toBe("sha512 checksum mismatch"); + + const automaticResult = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + + expect(automaticResult).toEqual({ + hasUpdate: true, + readyToInstall: false, + currentVersion: "1.2.3", + latestVersion: "1.2.4", + body: null, + date: "2026-04-28T00:00:00.000Z", + errorMessage: "sha512 checksum mismatch", + }); + }); + + it("returns runtime update errors after an update fails to prepare", async () => { + const { runtime, service } = createService(); runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); await service.checkForAppUpdate({ @@ -452,8 +365,6 @@ describe("app update service", () => { }); runtime.failRuntime(new Error("sha512 checksum mismatch")); - const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" }; - runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); const result = await service.checkForAppUpdate({ currentVersion: "1.2.3", releaseChannel: "stable", @@ -464,10 +375,10 @@ describe("app update service", () => { hasUpdate: true, readyToInstall: false, currentVersion: "1.2.3", - latestVersion: "1.2.5", + latestVersion: "1.2.4", body: null, date: "2026-04-28T00:00:00.000Z", - errorMessage: null, + errorMessage: "sha512 checksum mismatch", }); }); @@ -534,4 +445,91 @@ describe("app update service", () => { errorMessage: null, }); }); + + it("returns runtime update errors to multiple automatic checks before a manual retry clears them", async () => { + const { runtime, service } = createService(); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + runtime.failRuntime(new Error("sha512 checksum mismatch")); + + const firstAutomaticResult = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + const secondAutomaticResult = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + + expect(firstAutomaticResult).toEqual({ + hasUpdate: true, + readyToInstall: false, + currentVersion: "1.2.3", + latestVersion: "1.2.4", + body: null, + date: "2026-04-28T00:00:00.000Z", + errorMessage: "sha512 checksum mismatch", + }); + expect(secondAutomaticResult).toEqual(firstAutomaticResult); + + runtime.nextCheck(null); + const retryResult = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + + expect(runtime.checkCount).toBe(2); + expect(retryResult).toEqual({ + hasUpdate: false, + readyToInstall: false, + currentVersion: "1.2.3", + latestVersion: "1.2.3", + body: null, + date: null, + errorMessage: null, + }); + }); + + it("keeps runtime update errors visible after a manual retry fails", async () => { + const { runtime, service } = createService(); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + runtime.failRuntime(new Error("sha512 checksum mismatch")); + + runtime.failNextCheck(new Error("network down")); + const retryResult = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + const automaticResult = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + + expect(retryResult.errorMessage).toBe("network down"); + expect(automaticResult).toEqual({ + hasUpdate: true, + readyToInstall: false, + currentVersion: "1.2.3", + latestVersion: "1.2.4", + body: null, + date: "2026-04-28T00:00:00.000Z", + errorMessage: "sha512 checksum mismatch", + }); + }); }); diff --git a/packages/desktop/src/features/app-update-service.ts b/packages/desktop/src/features/app-update-service.ts index c6ecb9afd..f62dd8167 100644 --- a/packages/desktop/src/features/app-update-service.ts +++ b/packages/desktop/src/features/app-update-service.ts @@ -62,10 +62,6 @@ export interface AppUpdateService { }, onBeforeQuit?: () => Promise, ): Promise; - installUpdateOnQuit(input: { - currentVersion: string; - releaseChannel: AppReleaseChannel; - }): Promise; } export interface AppUpdateServiceDeps { @@ -116,7 +112,10 @@ function getErrorMessage(error: unknown): string { export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateService { let cachedUpdateInfo: RuntimeUpdateInfo | null = null; let downloadedUpdateVersion: string | null = null; + let downloading = false; let configuredReleaseChannel: AppReleaseChannel | null = null; + let runtimeErrorMessage: string | null = null; + let inFlightUpdateCheckCount = 0; function isReadyToInstallVersion(version: string): boolean { return downloadedUpdateVersion === version; @@ -125,6 +124,23 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer function clearUpdateState(): void { cachedUpdateInfo = null; downloadedUpdateVersion = null; + downloading = false; + runtimeErrorMessage = null; + } + + function buildRuntimeErrorResult(currentVersion: string): AppUpdateCheckResult | null { + if (!runtimeErrorMessage) { + return null; + } + + const info = cachedUpdateInfo; + return buildCheckResult({ + currentVersion, + hasUpdate: info?.version !== undefined && info.version !== currentVersion, + readyToInstall: false, + info, + errorMessage: runtimeErrorMessage, + }); } function configureRuntime(releaseChannel: AppReleaseChannel, intent: AppUpdateCheckIntent): void { @@ -150,15 +166,23 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer const alreadyReady = downloadedUpdateVersion === info.version; cachedUpdateInfo = info; downloadedUpdateVersion = alreadyReady ? info.version : null; + downloading = !alreadyReady; + runtimeErrorMessage = null; }, onUpdateDownloaded(info) { cachedUpdateInfo = info; downloadedUpdateVersion = info.version; + downloading = false; + runtimeErrorMessage = null; }, onUpdateNotAvailable() { clearUpdateState(); }, onError(error) { + downloading = false; + if (inFlightUpdateCheckCount === 0 || cachedUpdateInfo) { + runtimeErrorMessage = getErrorMessage(error); + } deps.reportRuntimeError?.(error); }, }); @@ -183,7 +207,28 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer configureRuntime(releaseChannel, intent); + const runtimeErrorResult = buildRuntimeErrorResult(currentVersion); + if (runtimeErrorResult && intent === "automatic") { + return runtimeErrorResult; + } + + const cachedVersion = cachedUpdateInfo?.version ?? null; + if ( + !runtimeErrorResult && + intent === "automatic" && + cachedVersion && + cachedVersion !== currentVersion + ) { + return buildCheckResult({ + currentVersion, + hasUpdate: true, + readyToInstall: isReadyToInstallVersion(cachedVersion), + info: cachedUpdateInfo, + }); + } + try { + inFlightUpdateCheckCount += 1; const result = await deps.runtime.checkForUpdates(); if (!result || !result.updateInfo || !result.isUpdateAvailable) { clearUpdateState(); @@ -200,6 +245,8 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer if (hasUpdate) { cachedUpdateInfo = info; + downloading = !isReadyToInstallVersion(latestVersion); + runtimeErrorMessage = null; return buildCheckResult({ currentVersion, hasUpdate: true, @@ -222,6 +269,8 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer readyToInstall: false, errorMessage: getErrorMessage(error), }); + } finally { + inFlightUpdateCheckCount -= 1; } } @@ -243,26 +292,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer }; } - const check = await checkForAppUpdate({ - currentVersion, - releaseChannel, - intent: "manual", - }); - if (!check.hasUpdate) { - return { - installed: false, - version: currentVersion, - message: check.errorMessage ?? "No update available.", - }; - } - - return installCachedUpdate(currentVersion, onBeforeQuit); - } - - async function installCachedUpdate( - currentVersion: string, - onBeforeQuit?: () => Promise, - ): Promise { if (!cachedUpdateInfo) { return { installed: false, @@ -271,6 +300,8 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer }; } + configureRuntime(releaseChannel, "manual"); + const readyVersion = cachedUpdateInfo.version; if (isReadyToInstallVersion(readyVersion)) { await performQuitAndInstall(deps.runtime, onBeforeQuit); @@ -281,16 +312,20 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer }; } + if (downloading) { + return { + installed: false, + version: currentVersion, + message: "Update is still being prepared. Try again in a moment.", + }; + } + + downloading = true; + try { await deps.runtime.downloadUpdate(); - if (cachedUpdateInfo?.version !== readyVersion) { - return { - installed: false, - version: currentVersion, - message: "A newer update was found and will be installed later.", - }; - } downloadedUpdateVersion = readyVersion; + downloading = false; await performQuitAndInstall(deps.runtime, onBeforeQuit); return { @@ -299,6 +334,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer message: "Update downloaded. The app will restart shortly.", }; } catch (error) { + downloading = false; const message = error instanceof Error ? error.message : String(error); deps.reportInstallError?.(message); return { @@ -309,33 +345,8 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer } } - async function installUpdateOnQuit({ - currentVersion, - releaseChannel, - }: { - currentVersion: string; - releaseChannel: AppReleaseChannel; - }): Promise { - if (!deps.isPackaged() || !downloadedUpdateVersion) { - return false; - } - - const check = await checkForAppUpdate({ - currentVersion, - releaseChannel, - intent: "automatic", - }); - if (!check.hasUpdate) { - return false; - } - - const result = await installCachedUpdate(currentVersion); - return result.installed; - } - return { checkForAppUpdate, downloadAndInstallUpdate, - installUpdateOnQuit, }; } diff --git a/packages/desktop/src/features/auto-updater.test.ts b/packages/desktop/src/features/auto-updater.test.ts index f810b8da3..4de12d87d 100644 --- a/packages/desktop/src/features/auto-updater.test.ts +++ b/packages/desktop/src/features/auto-updater.test.ts @@ -19,8 +19,18 @@ import { resolveStagingUserId, rolloutManifestSchema, shouldAdmitToRollout, + shouldAutoInstallOnQuit, } from "./auto-updater"; +describe("shouldAutoInstallOnQuit", () => { + it("auto-installs on quit everywhere except Linux AppImage", () => { + expect(shouldAutoInstallOnQuit({ platform: "linux", isAppImage: true })).toBe(false); + expect(shouldAutoInstallOnQuit({ platform: "linux", isAppImage: false })).toBe(true); + expect(shouldAutoInstallOnQuit({ platform: "darwin", isAppImage: false })).toBe(true); + expect(shouldAutoInstallOnQuit({ platform: "win32", isAppImage: false })).toBe(true); + }); +}); + describe("shouldAdmitToRollout", () => { it("admits beta, missing rollout hours, zero-hour rollout, and missing release date", () => { expect( diff --git a/packages/desktop/src/features/auto-updater.ts b/packages/desktop/src/features/auto-updater.ts index 39697bef8..876114585 100644 --- a/packages/desktop/src/features/auto-updater.ts +++ b/packages/desktop/src/features/auto-updater.ts @@ -76,16 +76,31 @@ export function getStagingUserId(): Promise { return cachedStagingUserIdPromise; } +// AppImages have no install step. electron-updater "installs" by unlinking the +// running file and mv-ing the downloaded one into place; on app quit it does this +// via a *blocking* execFileSync(newAppImage, { APPIMAGE_EXIT_AFTER_INSTALL: "true" }). +// That env var is only honored by AppImageLauncher, so without it the freshly +// launched process boots the full app and never exits — the quit hangs forever, +// with the old binary already deleted. We therefore install AppImages only on +// explicit quitAndInstall (the "Update now" button), which takes the non-blocking +// spawn path. Every other target keeps auto-install-on-quit, which works there. +export function shouldAutoInstallOnQuit(input: { + platform: NodeJS.Platform; + isAppImage: boolean; +}): boolean { + return !(input.platform === "linux" && input.isAppImage); +} + class ElectronAppUpdateRuntime implements AppUpdateRuntime { private configured = false; configure(input: AppUpdateRuntimeConfiguration): void { autoUpdater.autoDownload = true; autoUpdater.autoRunAppAfterInstall = true; - // Paseo revalidates the current manifest before explicitly installing on quit. - // Electron's built-in handler would install an older download without checking - // whether a newer release has superseded it. - autoUpdater.autoInstallOnAppQuit = false; + autoUpdater.autoInstallOnAppQuit = shouldAutoInstallOnQuit({ + platform: process.platform, + isAppImage: Boolean(process.env.APPIMAGE), + }); autoUpdater.allowPrerelease = input.releaseChannel === "beta"; autoUpdater.channel = input.releaseChannel === "beta" ? "beta" : "latest"; autoUpdater.allowDowngrade = false; @@ -179,13 +194,3 @@ export async function downloadAndInstallUpdate( onBeforeQuit, ); } - -export async function installAppUpdateOnQuit({ - currentVersion, - releaseChannel, -}: { - currentVersion: string; - releaseChannel: AppReleaseChannel; -}): Promise { - return appUpdateService.installUpdateOnQuit({ currentVersion, releaseChannel }); -} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 5f040c53b..494a78496 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -85,7 +85,6 @@ import { import { runDesktopStartup } from "./desktop-startup.js"; import { autoUpdateInstalledSkills } from "./integrations/skills/index.js"; import { registerBrowserAutomationIpc } from "./features/browser-automation/ipc.js"; -import { installAppUpdateOnQuit } from "./features/auto-updater.js"; const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081"; const APP_SCHEME = "paseo"; @@ -999,19 +998,9 @@ app.on( stopDaemon: () => stopDesktopDaemonViaCli("quit"), showShutdownFeedback: showDaemonShutdownDialog, }), - installAppUpdateOnQuit: async () => { - const settings = await getDesktopSettingsStore().get(); - return installAppUpdateOnQuit({ - currentVersion: app.getVersion(), - releaseChannel: settings.releaseChannel, - }); - }, onStopError: (error) => { log.error("[desktop daemon] failed to stop managed daemon on quit", error); }, - onUpdateError: (error) => { - log.error("[auto-updater] failed to validate downloaded update on quit", error); - }, }), );