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,