From d46267aed6d062f1a0b14602dc9c310a04093a54 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 26 Aug 2026 09:51:20 +0200 Subject: [PATCH] fix(desktop): keep manually admitted updates available (#3865) Automatic readiness polls reapplied the stable rollout gate and cleared the same update that a manual check had already admitted. Keep same-version admission monotonic while still clearing withdrawn releases and rejecting newer releases that have not reached the user. --- packages/desktop/e2e/support/runtime.ts | 55 ++++++++++----- packages/desktop/e2e/updates.spec.ts | 15 ++++ .../src/features/app-update-service.test.ts | 70 +++++++++++++++++++ .../src/features/app-update-service.ts | 41 +++++++++-- packages/desktop/src/features/auto-updater.ts | 3 - 5 files changed, 159 insertions(+), 25 deletions(-) diff --git a/packages/desktop/e2e/support/runtime.ts b/packages/desktop/e2e/support/runtime.ts index fdffe1f31c..14b770b2fd 100644 --- a/packages/desktop/e2e/support/runtime.ts +++ b/packages/desktop/e2e/support/runtime.ts @@ -58,6 +58,7 @@ export interface DesktopRuntimeConfig { updateAvailable?: boolean; latestVersion?: string; updateReadyToInstall?: boolean; + manualUpdateBypassesRollout?: boolean; slowInstall?: boolean; /** Initial PID reported by desktop_daemon_status. Defaults to null. */ daemonPid?: number | null; @@ -139,6 +140,7 @@ export async function installDesktopRuntime( let daemonRunning = true; let currentPid: number | null = cfg.daemonPid ?? null; let startCount = 0; + let manualUpdateAdmitted = false; window.__desktopDaemonStartRequested = false; function buildDaemonStatus() { @@ -175,6 +177,33 @@ export async function installDesktopRuntime( } } + function buildAppUpdateCheckResult(hasUpdate: boolean, readyToInstall: boolean) { + return { + hasUpdate, + readyToInstall, + currentVersion: "1.0.0", + latestVersion: hasUpdate ? (cfg.latestVersion ?? "1.2.3") : null, + body: null, + date: null, + }; + } + + function checkAppUpdate(intent: unknown) { + if (!cfg.manualUpdateBypassesRollout) { + return buildAppUpdateCheckResult( + cfg.updateAvailable === true, + cfg.updateAvailable === true && (cfg.updateReadyToInstall ?? true), + ); + } + + if (intent === "manual") { + manualUpdateAdmitted = true; + return buildAppUpdateCheckResult(true, false); + } + + return buildAppUpdateCheckResult(manualUpdateAdmitted, manualUpdateAdmitted); + } + const desktopBridge: { platform: string; invoke: (command: string, args?: Record) => Promise; @@ -192,23 +221,7 @@ export async function installDesktopRuntime( platform: "darwin", invoke: async (command: string, args?: Record) => { if (command === "check_app_update") { - return cfg.updateAvailable - ? { - hasUpdate: true, - readyToInstall: cfg.updateReadyToInstall ?? true, - currentVersion: "1.0.0", - latestVersion: cfg.latestVersion ?? "1.2.3", - body: null, - date: null, - } - : { - hasUpdate: false, - readyToInstall: false, - currentVersion: "1.0.0", - latestVersion: null, - body: null, - date: null, - }; + return checkAppUpdate(args?.intent); } if (command === "install_app_update") { @@ -353,6 +366,14 @@ export async function expectPendingUpdateCheckResult(page: Page, version: string await expect(page.getByRole("button", { name: "Update" })).toBeDisabled(); } +export async function expectReadyUpdateCheckResult(page: Page, version: string): Promise { + const normalizedVersion = `v${version.replace(/^v/i, "")}`; + await expect(page.getByText(`Ready to install: ${normalizedVersion}`)).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByRole("button", { name: `Update to ${normalizedVersion}` })).toBeEnabled(); +} + export async function clickInstallUpdate(page: Page): Promise { await page.getByRole("button", { name: "Install & restart" }).click(); } diff --git a/packages/desktop/e2e/updates.spec.ts b/packages/desktop/e2e/updates.spec.ts index 6cef40e508..9a2153163e 100644 --- a/packages/desktop/e2e/updates.spec.ts +++ b/packages/desktop/e2e/updates.spec.ts @@ -14,6 +14,7 @@ import { expectUpdateBanner, clickCheckForUpdates, expectPendingUpdateCheckResult, + expectReadyUpdateCheckResult, clickInstallUpdate, expectInstallInProgress, interceptDaemonManagementConfirmDialog, @@ -86,6 +87,20 @@ test.describe("Desktop updates", () => { await expectPendingUpdateCheckResult(page, "1.2.3"); }); + + test("manual update remains available after the automatic rollout recheck", async ({ page }) => { + await installDesktopRuntime(page, { + serverId: getServerId(), + latestVersion: "1.2.3", + manualUpdateBypassesRollout: true, + }); + await gotoAppShell(page); + await openDesktopAboutSettings(page); + + await clickCheckForUpdates(page); + await expectPendingUpdateCheckResult(page, "1.2.3"); + await expectReadyUpdateCheckResult(page, "1.2.3"); + }); }); test.describe("Desktop daemon management", () => { diff --git a/packages/desktop/src/features/app-update-service.test.ts b/packages/desktop/src/features/app-update-service.test.ts index 1e8154602b..e3be85bca0 100644 --- a/packages/desktop/src/features/app-update-service.test.ts +++ b/packages/desktop/src/features/app-update-service.test.ts @@ -208,6 +208,76 @@ describe("app update service", () => { }); }); + it("keeps a manually admitted update after a rollout-gated automatic recheck", async () => { + const { runtime, service } = createService(); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + runtime.finishUpdateDownload(rolledOutUpdate); + + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + const result = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + + expect(result).toMatchObject({ + hasUpdate: true, + readyToInstall: true, + latestVersion: "1.2.4", + }); + }); + + it("keeps preparing a manually admitted update after a rollout-gated automatic recheck", async () => { + const { runtime, service } = createService(); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + const result = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + + expect(result).toMatchObject({ + hasUpdate: true, + readyToInstall: false, + latestVersion: "1.2.4", + }); + }); + + it("clears a cached update when the manifest no longer contains it", async () => { + const { runtime, service } = createService(); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + runtime.finishUpdateDownload(rolledOutUpdate); + + runtime.nextCheck(null); + const result = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + + expect(result.hasUpdate).toBe(false); + }); + it("waits for an automatic poll before starting a manual rollout-bypassing check", async () => { const { runtime, service } = createService(); const automaticCheck = runtime.deferNextCheck(); diff --git a/packages/desktop/src/features/app-update-service.ts b/packages/desktop/src/features/app-update-service.ts index d5dc513eff..876a5a5553 100644 --- a/packages/desktop/src/features/app-update-service.ts +++ b/packages/desktop/src/features/app-update-service.ts @@ -38,7 +38,6 @@ export interface AppUpdateRuntimeConfiguration { shouldAdmitUpdate(info: RuntimeUpdateInfo): boolean | Promise; onUpdateAvailable(info: RuntimeUpdateInfo): void; onUpdateDownloaded(info: RuntimeUpdateInfo): void; - onUpdateNotAvailable(): void; onError(error: unknown): void; } @@ -147,6 +146,24 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer preparingUpdateVersion = null; } + function buildPreviouslyAdmittedUpdateResult( + currentVersion: string, + checkedInfo: RuntimeUpdateInfo, + ): AppUpdateCheckResult | null { + const info = cachedUpdateInfo; + if (!info || info.version === currentVersion || info.version !== checkedInfo.version) { + return null; + } + + return buildCheckResult({ + currentVersion, + hasUpdate: true, + readyToInstall: isReadyToInstallVersion(info.version), + info, + errorMessage: preparationError?.version === info.version ? preparationError.message : null, + }); + } + function configureRuntime(releaseChannel: AppReleaseChannel, intent: AppUpdateCheckIntent): void { if (configuredReleaseChannel !== releaseChannel) { clearUpdateState(); @@ -186,9 +203,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer preparationError = null; } }, - onUpdateNotAvailable() { - clearUpdateState(); - }, onError(error) { if (preparingUpdateVersion) { preparationError = { @@ -233,7 +247,24 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer try { const result = await deps.runtime.checkForUpdates(); - if (!result || !result.updateInfo || !result.isUpdateAvailable) { + if (!result || !result.updateInfo) { + clearUpdateState(); + return buildCheckResult({ + currentVersion, + hasUpdate: false, + readyToInstall: false, + }); + } + + if (!result.isUpdateAvailable) { + const admittedUpdate = buildPreviouslyAdmittedUpdateResult( + currentVersion, + result.updateInfo, + ); + if (admittedUpdate) { + return admittedUpdate; + } + clearUpdateState(); return buildCheckResult({ currentVersion, diff --git a/packages/desktop/src/features/auto-updater.ts b/packages/desktop/src/features/auto-updater.ts index d0e5f432db..46656a3917 100644 --- a/packages/desktop/src/features/auto-updater.ts +++ b/packages/desktop/src/features/auto-updater.ts @@ -138,9 +138,6 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime { autoUpdater.on("update-downloaded", (info) => { input.onUpdateDownloaded(info as RuntimeUpdateInfo); }); - autoUpdater.on("update-not-available", () => { - input.onUpdateNotAvailable(); - }); autoUpdater.on("error", (error) => { if (isUpdateChannelNotPublished(error)) return; input.onError(error);