From 7c6152663e2dc754e5c92a12a9ce0541a226689c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 30 Jun 2026 08:12:15 +0200 Subject: [PATCH] Show found desktop updates after manual checks (#1815) * fix(desktop): report found app updates during manual checks Manual checks could reuse cached update state while the renderer discarded pending update details. Keep found update versions visible while downloads prepare, and reserve install affordances for ready updates. * test(desktop): cover manual update retry after errors * fix(desktop): keep ready updates ready on recheck --- packages/app/e2e/desktop-updates.spec.ts | 18 ++++ packages/app/e2e/helpers/desktop-updates.ts | 27 ++++- .../updates/desktop-app-updater.test.ts | 14 +-- .../desktop/updates/desktop-app-updater.ts | 14 ++- packages/app/src/i18n/resources.test.ts | 6 ++ packages/app/src/i18n/resources/ar.ts | 3 + packages/app/src/i18n/resources/en.ts | 3 + packages/app/src/i18n/resources/es.ts | 3 + packages/app/src/i18n/resources/fr.ts | 3 + packages/app/src/i18n/resources/ja.ts | 3 + packages/app/src/i18n/resources/pt-BR.ts | 3 + packages/app/src/i18n/resources/ru.ts | 3 + packages/app/src/i18n/resources/zh-CN.ts | 3 + packages/app/src/screens/settings-screen.tsx | 11 ++- .../src/features/app-update-service.test.ts | 99 +++++++++++++++++++ .../src/features/app-update-service.ts | 12 ++- 16 files changed, 209 insertions(+), 16 deletions(-) diff --git a/packages/app/e2e/desktop-updates.spec.ts b/packages/app/e2e/desktop-updates.spec.ts index bf475cd8d..ee9277f86 100644 --- a/packages/app/e2e/desktop-updates.spec.ts +++ b/packages/app/e2e/desktop-updates.spec.ts @@ -4,8 +4,11 @@ import { getServerId } from "./helpers/server-id"; import { loadRealDaemonState, injectDesktopBridge, + openDesktopAboutSettings, openDesktopSettings, expectUpdateBanner, + clickCheckForUpdates, + expectPendingUpdateCheckResult, clickInstallUpdate, expectInstallInProgress, interceptDaemonManagementConfirmDialog, @@ -45,6 +48,21 @@ test.describe("Desktop updates", () => { await clickInstallUpdate(page); await expectInstallInProgress(page); }); + + test("manual check reports a found update while it downloads", async ({ page }) => { + await injectDesktopBridge(page, { + serverId: getServerId(), + updateAvailable: true, + latestVersion: "1.2.3", + updateReadyToInstall: false, + }); + await gotoAppShell(page); + await openDesktopAboutSettings(page); + + await clickCheckForUpdates(page); + + await expectPendingUpdateCheckResult(page, "1.2.3"); + }); }); test.describe("Desktop daemon management", () => { diff --git a/packages/app/e2e/helpers/desktop-updates.ts b/packages/app/e2e/helpers/desktop-updates.ts index 91564b197..8548b6ed7 100644 --- a/packages/app/e2e/helpers/desktop-updates.ts +++ b/packages/app/e2e/helpers/desktop-updates.ts @@ -3,7 +3,8 @@ import { appendFile } from "node:fs/promises"; import { expect, type Page } from "@playwright/test"; import { openSettings } from "./app"; import { getE2EDaemonPort } from "./daemon-port"; -import { openSettingsHost, openSettingsHostSection } from "./settings"; +import { escapeRegex } from "./regex"; +import { openSettingsHost, openSettingsHostSection, openSettingsSection } from "./settings"; interface DaemonApiStatus { version: string; @@ -52,6 +53,7 @@ export interface DesktopBridgeConfig { serverId: string; updateAvailable?: boolean; latestVersion?: string; + updateReadyToInstall?: boolean; slowInstall?: boolean; /** Initial PID reported by desktop_daemon_status. Defaults to null. */ daemonPid?: number | null; @@ -169,7 +171,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi return cfg.updateAvailable ? { hasUpdate: true, - readyToInstall: true, + readyToInstall: cfg.updateReadyToInstall ?? true, currentVersion: "1.0.0", latestVersion: cfg.latestVersion ?? "1.2.3", body: null, @@ -276,12 +278,33 @@ export async function openDesktopSettings(page: Page, serverId: string): Promise }); } +export async function openDesktopAboutSettings(page: Page): Promise { + await openSettings(page); + await openSettingsSection(page, "about"); + await expect(page.getByText("App updates", { exact: true })).toBeVisible(); +} + export async function expectUpdateBanner(page: Page, version: string): Promise { const callout = page.getByTestId("update-callout"); await expect(callout).toBeVisible({ timeout: 15_000 }); await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`); } +export async function clickCheckForUpdates(page: Page): Promise { + await page.getByRole("button", { name: "Check" }).click(); +} + +export async function expectPendingUpdateCheckResult(page: Page, version: string): Promise { + const normalizedVersion = `v${version.replace(/^v/i, "")}`; + await expect( + page.getByText( + new RegExp(`Update found: ${escapeRegex(normalizedVersion)}\\. Downloading\\.\\.\\.`), + ), + ).toBeVisible(); + await expect(page.getByText(`Ready to install: ${normalizedVersion}`)).toHaveCount(0); + await expect(page.getByRole("button", { name: "Update" })).toBeDisabled(); +} + export async function clickInstallUpdate(page: Page): Promise { await page.getByRole("button", { name: "Install & restart" }).click(); } diff --git a/packages/app/src/desktop/updates/desktop-app-updater.test.ts b/packages/app/src/desktop/updates/desktop-app-updater.test.ts index e6658048f..e6f4e7608 100644 --- a/packages/app/src/desktop/updates/desktop-app-updater.test.ts +++ b/packages/app/src/desktop/updates/desktop-app-updater.test.ts @@ -114,15 +114,17 @@ describe("desktop app updater — check", () => { }); }); - it("reports 'pending' when the check resolves with an update that is not yet downloaded", async () => { + it("reports the found update while it is still preparing", async () => { const { updater, port } = createUpdater(); - port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: false })); + port.nextCheckResult( + buildFakeCheckResult({ hasUpdate: true, readyToInstall: false, latestVersion: "1.2.3" }), + ); await updater.checkForUpdates({ releaseChannel: "stable" }); expect(updater.getSnapshot()).toMatchObject({ status: "pending", - availableUpdate: null, + availableUpdate: { latestVersion: "1.2.3", readyToInstall: false }, }); }); @@ -408,17 +410,17 @@ describe("formatStatusText", () => { ).toBe("Update ready: v1.2.3"); }); - it("keeps manual check feedback visible while an update is pending", () => { + it("shows the found version while an update is pending", () => { expect( formatStatusText({ status: "pending", - availableUpdate: null, + availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }), installMessage: null, lastCheckedAt: 42, formatVersion, formatLastCheckedAt, }), - ).toBe("We'll let you know when the update is ready. Last checked at time-42."); + ).toBe("Update found: v1.2.3. Downloading... Last checked at time-42."); }); it("keeps manual check feedback visible when an update is available", () => { diff --git a/packages/app/src/desktop/updates/desktop-app-updater.ts b/packages/app/src/desktop/updates/desktop-app-updater.ts index ab513415c..eea1b6eff 100644 --- a/packages/app/src/desktop/updates/desktop-app-updater.ts +++ b/packages/app/src/desktop/updates/desktop-app-updater.ts @@ -137,6 +137,18 @@ export function formatStatusText(input: { } if (status === "pending") { + if (availableUpdate?.latestVersion) { + return i18n.t( + lastCheckedAt != null + ? "desktop.updates.status.pendingWithVersionAndLastChecked" + : "desktop.updates.status.pendingWithVersion", + { + version: formatVersion(availableUpdate.latestVersion), + time: lastCheckedAt != null ? formatLastCheckedAt(lastCheckedAt) : undefined, + }, + ); + } + if (lastCheckedAt != null) { return i18n.t("desktop.updates.status.pendingWithLastChecked", { time: formatLastCheckedAt(lastCheckedAt), @@ -244,7 +256,7 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp nextAvailable = result; } else if (result.hasUpdate) { nextStatus = "pending"; - nextAvailable = null; + nextAvailable = result; } else { nextStatus = "up-to-date"; nextAvailable = null; diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index c95cb1ac5..ebb8d2ff9 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -524,6 +524,12 @@ describe("translation resources", () => { expect(en.desktop.updates.status.pendingWithLastChecked).toBe( "We'll let you know when the update is ready. Last checked at {{time}}.", ); + expect(en.desktop.updates.status.pendingWithVersion).toBe( + "Update found: {{version}}. Downloading...", + ); + expect(en.desktop.updates.status.pendingWithVersionAndLastChecked).toBe( + "Update found: {{version}}. Downloading... Last checked at {{time}}.", + ); expect(en.desktop.updates.status.availableWithVersion).toBe("Update ready: {{version}}"); expect(en.desktop.updates.status.availableWithVersionAndLastChecked).toBe( "Update ready: {{version}}. Last checked at {{time}}.", diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b6d4157c2..039fb9a60 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -939,6 +939,9 @@ export const ar: TranslationResources = { upToDateWithLastChecked: "Up to date. Last checked at {{time}}.", pending: "سنخبرك عندما يصبح التحديث جاهزًا.", pendingWithLastChecked: "سنخبرك عندما يصبح التحديث جاهزًا. آخر فحص في {{time}}.", + pendingWithVersion: "تم العثور على تحديث: {{version}}. جارٍ التنزيل...", + pendingWithVersionAndLastChecked: + "تم العثور على تحديث: {{version}}. جارٍ التنزيل... آخر فحص في {{time}}.", availableWithVersion: "التحديث جاهز:{{version}}", availableWithVersionAndLastChecked: "التحديث جاهز:{{version}}. آخر فحص في {{time}}.", available: "تحديث التطبيق جاهز للتثبيت.", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 7eebe4d06..39a648c5e 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -947,6 +947,9 @@ export const en = { pending: "We'll let you know when the update is ready.", pendingWithLastChecked: "We'll let you know when the update is ready. Last checked at {{time}}.", + pendingWithVersion: "Update found: {{version}}. Downloading...", + pendingWithVersionAndLastChecked: + "Update found: {{version}}. Downloading... Last checked at {{time}}.", availableWithVersion: "Update ready: {{version}}", availableWithVersionAndLastChecked: "Update ready: {{version}}. Last checked at {{time}}.", available: "An app update is ready to install.", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 55ea1132e..acf532a4c 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -967,6 +967,9 @@ export const es: TranslationResources = { pending: "Le avisaremos cuando la actualización esté lista.", pendingWithLastChecked: "Le avisaremos cuando la actualización esté lista. Última comprobación a las {{time}}.", + pendingWithVersion: "Actualización encontrada: {{version}}. Descargando...", + pendingWithVersionAndLastChecked: + "Actualización encontrada: {{version}}. Descargando... Última comprobación a las {{time}}.", availableWithVersion: "Actualización lista:{{version}}", availableWithVersionAndLastChecked: "Actualización lista:{{version}}. Última comprobación a las {{time}}.", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 221adc4f1..4f3e314dc 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -966,6 +966,9 @@ export const fr: TranslationResources = { pending: "Nous vous informerons lorsque la mise à jour sera prête.", pendingWithLastChecked: "Nous vous informerons lorsque la mise à jour sera prête. Dernière vérification à {{time}}.", + pendingWithVersion: "Mise à jour trouvée : {{version}}. Téléchargement...", + pendingWithVersionAndLastChecked: + "Mise à jour trouvée : {{version}}. Téléchargement... Dernière vérification à {{time}}.", availableWithVersion: "Mise à jour prête:{{version}}", availableWithVersionAndLastChecked: "Mise à jour prête:{{version}}. Dernière vérification à {{time}}.", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 9f7e26e43..017313fdc 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -951,6 +951,9 @@ export const ja: TranslationResources = { upToDateWithLastChecked: "最新の状態です。最終確認: {{time}}。", pending: "更新の準備ができたらお知らせします。", pendingWithLastChecked: "更新の準備ができたらお知らせします。最終確認: {{time}}。", + pendingWithVersion: "更新が見つかりました: {{version}}。ダウンロード中...", + pendingWithVersionAndLastChecked: + "更新が見つかりました: {{version}}。ダウンロード中... 最終確認: {{time}}。", availableWithVersion: "更新の準備ができました: {{version}}", availableWithVersionAndLastChecked: "更新の準備ができました: {{version}}。最終確認: {{time}}。", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 11ff61a75..4dd79464c 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -958,6 +958,9 @@ export const ptBR: TranslationResources = { pending: "Avisaremos quando a atualização estiver pronta.", pendingWithLastChecked: "Avisaremos quando a atualização estiver pronta. Última verificação às {{time}}.", + pendingWithVersion: "Atualização encontrada: {{version}}. Baixando...", + pendingWithVersionAndLastChecked: + "Atualização encontrada: {{version}}. Baixando... Última verificação às {{time}}.", availableWithVersion: "Atualização pronta: {{version}}", availableWithVersionAndLastChecked: "Atualização pronta: {{version}}. Última verificação às {{time}}.", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 0aba25352..efea47b14 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -959,6 +959,9 @@ export const ru: TranslationResources = { pending: "Мы сообщим вам, когда обновление будет готово.", pendingWithLastChecked: "Мы сообщим вам, когда обновление будет готово. Последняя проверка в {{time}}.", + pendingWithVersion: "Найдено обновление: {{version}}. Загрузка...", + pendingWithVersionAndLastChecked: + "Найдено обновление: {{version}}. Загрузка... Последняя проверка в {{time}}.", availableWithVersion: "Обновление готово:{{version}}", availableWithVersionAndLastChecked: "Обновление готово:{{version}}. Последняя проверка в {{time}}.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 1154fbd5a..7bbab211d 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -928,6 +928,9 @@ export const zhCN: TranslationResources = { upToDateWithLastChecked: "已是最新版本。上次检查时间:{{time}}。", pending: "更新准备好后会通知你。", pendingWithLastChecked: "更新准备好后会通知你。上次检查时间:{{time}}。", + pendingWithVersion: "发现更新:{{version}}。正在下载...", + pendingWithVersionAndLastChecked: + "发现更新:{{version}}。正在下载... 上次检查时间:{{time}}。", availableWithVersion: "更新已就绪:{{version}}", availableWithVersionAndLastChecked: "更新已就绪:{{version}}。上次检查时间:{{time}}。", available: "有 app 更新可安装。", diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 7b33bf7ef..cfbfaa07d 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -701,6 +701,9 @@ function DesktopAppUpdateRow() { }); }, [installUpdate, isDesktopApp, t]); + const isUpdateReady = availableUpdate?.readyToInstall === true; + const readyUpdateVersion = isUpdateReady ? availableUpdate?.latestVersion : null; + if (!isDesktopApp) { return null; } @@ -725,10 +728,10 @@ function DesktopAppUpdateRow() { {t("settings.about.updates.label")} {statusText} - {availableUpdate?.latestVersion ? ( + {readyUpdateVersion ? ( {t("settings.about.updates.readyToInstall", { - version: formatVersionWithPrefix(availableUpdate.latestVersion), + version: formatVersionWithPrefix(readyUpdateVersion), })} ) : null} @@ -747,9 +750,9 @@ function DesktopAppUpdateRow() { variant="default" size="sm" onPress={handleInstallUpdate} - disabled={isChecking || isInstalling || !availableUpdate} + disabled={isChecking || isInstalling || !isUpdateReady} > - {getUpdateButtonLabel(t, isInstalling, availableUpdate?.latestVersion)} + {getUpdateButtonLabel(t, isInstalling, readyUpdateVersion)} diff --git a/packages/desktop/src/features/app-update-service.test.ts b/packages/desktop/src/features/app-update-service.test.ts index cfd712077..f243a7c28 100644 --- a/packages/desktop/src/features/app-update-service.test.ts +++ b/packages/desktop/src/features/app-update-service.test.ts @@ -58,6 +58,10 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { this.configuration?.onUpdateAvailable(info); } + finishUpdateDownload(info: RuntimeUpdateInfo): void { + this.configuration?.onUpdateDownloaded(info); + } + async checkForUpdates(): Promise<{ isUpdateAvailable: boolean; updateInfo: RuntimeUpdateInfo; @@ -144,6 +148,37 @@ describe("app update service", () => { }); }); + it("performs a fresh manual check when an update is already cached", 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.nextCheck({ + isUpdateAvailable: true, + updateInfo: { ...rolledOutUpdate, version: "1.2.5" }, + }); + const result = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + + 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("trusts the runtime availability decision before comparing versions", async () => { const { runtime, service } = createService({ bucket: async () => 0 }); runtime.nextCheck({ isUpdateAvailable: false, updateInfo: rolledOutUpdate }); @@ -347,6 +382,70 @@ describe("app update service", () => { }); }); + it("performs a fresh manual check after an update preparation error", 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.nextCheck({ + isUpdateAvailable: true, + updateInfo: { ...rolledOutUpdate, version: "1.2.5" }, + }); + const result = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + + 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("keeps a downloaded update ready when a manual check re-announces 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); + + const recheck = runtime.deferNextCheck(); + const pending = service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + runtime.prepareUpdate(rolledOutUpdate); + recheck.resolve({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + const result = await pending; + + expect(result).toEqual({ + hasUpdate: true, + readyToInstall: true, + currentVersion: "1.2.3", + latestVersion: "1.2.4", + body: null, + date: "2026-04-28T00:00:00.000Z", + 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 }); diff --git a/packages/desktop/src/features/app-update-service.ts b/packages/desktop/src/features/app-update-service.ts index b50e49fc5..f62dd8167 100644 --- a/packages/desktop/src/features/app-update-service.ts +++ b/packages/desktop/src/features/app-update-service.ts @@ -163,9 +163,10 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer }); }, onUpdateAvailable(info) { + const alreadyReady = downloadedUpdateVersion === info.version; cachedUpdateInfo = info; - downloadedUpdateVersion = null; - downloading = true; + downloadedUpdateVersion = alreadyReady ? info.version : null; + downloading = !alreadyReady; runtimeErrorMessage = null; }, onUpdateDownloaded(info) { @@ -212,7 +213,12 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer } const cachedVersion = cachedUpdateInfo?.version ?? null; - if (!runtimeErrorResult && cachedVersion && cachedVersion !== currentVersion) { + if ( + !runtimeErrorResult && + intent === "automatic" && + cachedVersion && + cachedVersion !== currentVersion + ) { return buildCheckResult({ currentVersion, hasUpdate: true,