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 e4167df17..e6658048f 100644 --- a/packages/app/src/desktop/updates/desktop-app-updater.test.ts +++ b/packages/app/src/desktop/updates/desktop-app-updater.test.ts @@ -57,6 +57,18 @@ describe("desktop app updater — check", () => { expect(port.recordedChecks).toEqual([{ releaseChannel: "stable", intent: "automatic" }]); }); + it("does not add manual last-checked feedback for automatic checks", async () => { + const { updater, port } = createUpdater({ now: () => 42 }); + port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: false })); + + await updater.checkForUpdates({ releaseChannel: "stable", intent: "automatic", silent: true }); + + expect(updater.getSnapshot()).toMatchObject({ + status: "pending", + lastCheckedAt: null, + }); + }); + it("moves to 'checking' during a non-silent check", async () => { const { updater, port } = createUpdater(); const deferred = port.deferNextCheck(); @@ -135,6 +147,134 @@ describe("desktop app updater — check", () => { }); }); + it("reports service-returned check errors", async () => { + const { updater, port } = createUpdater({ now: () => 42 }); + port.nextCheckResult(buildFakeCheckResult({ errorMessage: "sha512 checksum mismatch" })); + + await updater.checkForUpdates({ releaseChannel: "stable" }); + + expect(updater.getSnapshot()).toMatchObject({ + status: "error", + errorMessage: "sha512 checksum mismatch", + lastCheckedAt: 42, + }); + }); + + it("keeps no-update silent check errors quiet", async () => { + const { updater, port } = createUpdater({ now: () => 42 }); + port.nextCheckResult( + buildFakeCheckResult({ hasUpdate: true, readyToInstall: true, latestVersion: "1.2.3" }), + ); + await updater.checkForUpdates({ releaseChannel: "stable" }); + + port.nextCheckResult(buildFakeCheckResult({ errorMessage: "network down" })); + await updater.checkForUpdates({ releaseChannel: "stable", intent: "automatic", silent: true }); + + expect(updater.getSnapshot()).toMatchObject({ + status: "available", + errorMessage: null, + lastCheckedAt: 42, + }); + }); + + it("shows silent update preparation errors when an update is involved", async () => { + const { updater, port } = createUpdater(); + port.nextCheckResult( + buildFakeCheckResult({ + hasUpdate: true, + readyToInstall: false, + latestVersion: "1.2.3", + errorMessage: "sha512 checksum mismatch", + }), + ); + + await updater.checkForUpdates({ releaseChannel: "stable", intent: "automatic", silent: true }); + + expect(updater.getSnapshot()).toMatchObject({ + status: "error", + errorMessage: "sha512 checksum mismatch", + lastCheckedAt: null, + }); + }); + + it("does not let a silent check supersede an in-flight manual check", async () => { + const { updater, port } = createUpdater(); + const deferred = port.deferNextCheck(); + + const manualCheck = updater.checkForUpdates({ releaseChannel: "stable" }); + port.nextCheckResult(buildFakeCheckResult({ errorMessage: "network down" })); + await updater.checkForUpdates({ releaseChannel: "stable", intent: "automatic", silent: true }); + + deferred.resolve(buildFakeCheckResult({ hasUpdate: false, readyToInstall: false })); + await manualCheck; + + expect(port.recordedChecks).toEqual([{ releaseChannel: "stable", intent: "manual" }]); + expect(updater.getSnapshot().status).toBe("up-to-date"); + }); + + it("does not let an older silent check supersede a newer silent check", async () => { + const { updater, port } = createUpdater(); + const olderCheck = port.deferNextCheck(); + const olderPending = updater.checkForUpdates({ + releaseChannel: "stable", + intent: "automatic", + silent: true, + }); + const newerCheck = port.deferNextCheck(); + const newerPending = updater.checkForUpdates({ + releaseChannel: "stable", + intent: "automatic", + silent: true, + }); + + newerCheck.resolve( + buildFakeCheckResult({ hasUpdate: true, readyToInstall: true, latestVersion: "2.0.0" }), + ); + await newerPending; + expect(updater.getSnapshot()).toMatchObject({ + status: "available", + availableUpdate: { latestVersion: "2.0.0" }, + }); + + olderCheck.resolve(buildFakeCheckResult({ hasUpdate: false, readyToInstall: false })); + await olderPending; + + expect(updater.getSnapshot()).toMatchObject({ + status: "available", + availableUpdate: { latestVersion: "2.0.0" }, + }); + }); + + it("lets a newer silent check win after an older silent check resolves first", async () => { + const { updater, port } = createUpdater(); + const olderCheck = port.deferNextCheck(); + const olderPending = updater.checkForUpdates({ + releaseChannel: "stable", + intent: "automatic", + silent: true, + }); + const newerCheck = port.deferNextCheck(); + const newerPending = updater.checkForUpdates({ + releaseChannel: "stable", + intent: "automatic", + silent: true, + }); + + olderCheck.resolve(buildFakeCheckResult({ hasUpdate: false, readyToInstall: false })); + await olderPending; + expect(updater.getSnapshot().status).toBe("idle"); + + newerCheck.resolve( + buildFakeCheckResult({ hasUpdate: true, readyToInstall: true, latestVersion: "2.0.0" }), + ); + await newerPending; + + expect(updater.getSnapshot()).toMatchObject({ + status: "available", + availableUpdate: { latestVersion: "2.0.0" }, + }); + }); + it("does not move to 'error' when a silent check throws", async () => { const { updater, port } = createUpdater(); port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true })); @@ -268,6 +408,32 @@ describe("formatStatusText", () => { ).toBe("Update ready: v1.2.3"); }); + it("keeps manual check feedback visible while an update is pending", () => { + expect( + formatStatusText({ + status: "pending", + availableUpdate: null, + installMessage: null, + lastCheckedAt: 42, + formatVersion, + formatLastCheckedAt, + }), + ).toBe("We'll let you know when the update is ready. Last checked at time-42."); + }); + + it("keeps manual check feedback visible when an update is available", () => { + expect( + formatStatusText({ + status: "available", + availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }), + installMessage: null, + lastCheckedAt: 42, + formatVersion, + formatLastCheckedAt, + }), + ).toBe("Update ready: v1.2.3. Last checked at time-42."); + }); + it("falls back to a generic 'available' message when no version is reported", () => { expect( formatStatusText({ diff --git a/packages/app/src/desktop/updates/desktop-app-updater.ts b/packages/app/src/desktop/updates/desktop-app-updater.ts index e15e9783d..ab513415c 100644 --- a/packages/app/src/desktop/updates/desktop-app-updater.ts +++ b/packages/app/src/desktop/updates/desktop-app-updater.ts @@ -137,13 +137,30 @@ export function formatStatusText(input: { } if (status === "pending") { + if (lastCheckedAt != null) { + return i18n.t("desktop.updates.status.pendingWithLastChecked", { + time: formatLastCheckedAt(lastCheckedAt), + }); + } return i18n.t("desktop.updates.status.pending"); } if (status === "available") { if (availableUpdate?.latestVersion) { - return i18n.t("desktop.updates.status.availableWithVersion", { - version: formatVersion(availableUpdate.latestVersion), + return i18n.t( + lastCheckedAt != null + ? "desktop.updates.status.availableWithVersionAndLastChecked" + : "desktop.updates.status.availableWithVersion", + { + version: formatVersion(availableUpdate.latestVersion), + time: lastCheckedAt != null ? formatLastCheckedAt(lastCheckedAt) : undefined, + }, + ); + } + + if (lastCheckedAt != null) { + return i18n.t("desktop.updates.status.availableWithLastChecked", { + time: formatLastCheckedAt(lastCheckedAt), }); } return i18n.t("desktop.updates.status.available"); @@ -182,13 +199,17 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp return null; } const { releaseChannel, intent = "manual", silent = false } = options; + if (silent && state.status === "checking") { + return null; + } + const requestVersion = state.requestVersion + 1; commit({ ...state, requestVersion, status: silent ? state.status : "checking", - errorMessage: null, + errorMessage: silent ? state.errorMessage : null, }); try { @@ -197,7 +218,24 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp return result; } - const nextLastCheckedAt = deps.now(); + const nextLastCheckedAt = intent === "manual" ? deps.now() : state.lastCheckedAt; + if (result.errorMessage) { + if (silent && !result.hasUpdate) { + console.warn("[DesktopUpdater] Silent update check failed", result.errorMessage); + return result; + } + + commit({ + ...state, + status: "error", + availableUpdate: null, + errorMessage: result.errorMessage, + installMessage: null, + lastCheckedAt: nextLastCheckedAt, + }); + return result; + } + let nextStatus: DesktopAppUpdateStatus; let nextAvailable: DesktopAppUpdateCheckResult | null; @@ -216,6 +254,7 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp ...state, status: nextStatus, availableUpdate: nextAvailable, + errorMessage: null, installMessage: null, lastCheckedAt: nextLastCheckedAt, }); @@ -229,12 +268,12 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp const message = getErrorMessage(error); if (silent) { console.warn("[DesktopUpdater] Silent update check failed", message); - commit({ ...state }); } else { commit({ ...state, status: "error", errorMessage: message, + lastCheckedAt: intent === "manual" ? deps.now() : state.lastCheckedAt, }); } return null; diff --git a/packages/app/src/desktop/updates/desktop-updates.ts b/packages/app/src/desktop/updates/desktop-updates.ts index 4e2c2adba..1246a6b86 100644 --- a/packages/app/src/desktop/updates/desktop-updates.ts +++ b/packages/app/src/desktop/updates/desktop-updates.ts @@ -10,6 +10,7 @@ export interface DesktopAppUpdateCheckResult { latestVersion: string | null; body: string | null; date: string | null; + errorMessage: string | null; } export interface DesktopAppUpdateInstallResult { @@ -121,6 +122,7 @@ export async function checkDesktopAppUpdate({ latestVersion: toStringOrNull(result.latestVersion), body: toStringOrNull(result.body), date: toStringOrNull(result.date), + errorMessage: toStringOrNull(result.errorMessage), }; } diff --git a/packages/app/src/desktop/updates/test-utils/fake-desktop-app-updater-port.ts b/packages/app/src/desktop/updates/test-utils/fake-desktop-app-updater-port.ts index 420ddfbde..471bb8e01 100644 --- a/packages/app/src/desktop/updates/test-utils/fake-desktop-app-updater-port.ts +++ b/packages/app/src/desktop/updates/test-utils/fake-desktop-app-updater-port.ts @@ -41,6 +41,7 @@ function buildCheckResult( latestVersion: null, body: null, date: null, + errorMessage: null, ...overrides, }; } diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 7d4b1b0dc..c95cb1ac5 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -521,8 +521,17 @@ describe("translation resources", () => { expect(en.desktop.updates.status.installing).toBe("Installing app update..."); expect(en.desktop.updates.status.upToDate).toBe("App is up to date."); expect(en.desktop.updates.status.pending).toBe("We'll let you know when the update is ready."); + 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.availableWithVersion).toBe("Update ready: {{version}}"); + expect(en.desktop.updates.status.availableWithVersionAndLastChecked).toBe( + "Update ready: {{version}}. Last checked at {{time}}.", + ); expect(en.desktop.updates.status.available).toBe("An app update is ready to install."); + expect(en.desktop.updates.status.availableWithLastChecked).toBe( + "An app update is ready to install. Last checked at {{time}}.", + ); expect(en.desktop.updates.status.installed).toBe("App update installed. Restart required."); expect(en.desktop.updates.status.failed).toBe("Failed to update app."); expect(en.desktop.updates.status.idle).toBe("Update status has not been checked yet."); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 860175303..b6d4157c2 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -938,8 +938,11 @@ export const ar: TranslationResources = { upToDate: "التطبيق محدث.", upToDateWithLastChecked: "Up to date. Last checked at {{time}}.", pending: "سنخبرك عندما يصبح التحديث جاهزًا.", + pendingWithLastChecked: "سنخبرك عندما يصبح التحديث جاهزًا. آخر فحص في {{time}}.", availableWithVersion: "التحديث جاهز:{{version}}", + availableWithVersionAndLastChecked: "التحديث جاهز:{{version}}. آخر فحص في {{time}}.", available: "تحديث التطبيق جاهز للتثبيت.", + availableWithLastChecked: "تحديث التطبيق جاهز للتثبيت. آخر فحص في {{time}}.", installed: "تم تثبيت تحديث التطبيق. إعادة التشغيل مطلوبة.", failed: "فشل في تحديث التطبيق.", idle: "لم يتم التحقق من حالة التحديث بعد.", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index e473546e1..65058edb5 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -945,8 +945,12 @@ export const en = { upToDate: "App is up to date.", upToDateWithLastChecked: "Up to date. Last checked at {{time}}.", 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}}.", availableWithVersion: "Update ready: {{version}}", + availableWithVersionAndLastChecked: "Update ready: {{version}}. Last checked at {{time}}.", available: "An app update is ready to install.", + availableWithLastChecked: "An app update is ready to install. Last checked at {{time}}.", installed: "App update installed. Restart required.", failed: "Failed to update app.", idle: "Update status has not been checked yet.", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 95a1ef551..55ea1132e 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -965,8 +965,14 @@ export const es: TranslationResources = { upToDate: "La aplicación está actualizada.", upToDateWithLastChecked: "Up to date. Last checked at {{time}}.", pending: "Le avisaremos cuando la actualización esté lista.", + pendingWithLastChecked: + "Le avisaremos cuando la actualización esté lista. Última comprobación a las {{time}}.", availableWithVersion: "Actualización lista:{{version}}", + availableWithVersionAndLastChecked: + "Actualización lista:{{version}}. Última comprobación a las {{time}}.", available: "Una actualización de la aplicación está lista para instalarse.", + availableWithLastChecked: + "Una actualización de la aplicación está lista para instalarse. Última comprobación a las {{time}}.", installed: "Actualización de la aplicación instalada. Se requiere reinicio.", failed: "No se pudo actualizar la aplicación.", idle: "El estado de la actualización aún no se ha comprobado.", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 0a8ed5bbb..221adc4f1 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -964,8 +964,14 @@ export const fr: TranslationResources = { upToDate: "L'application est à jour.", upToDateWithLastChecked: "Up to date. Last checked at {{time}}.", 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}}.", availableWithVersion: "Mise à jour prête:{{version}}", + availableWithVersionAndLastChecked: + "Mise à jour prête:{{version}}. Dernière vérification à {{time}}.", available: "Une mise à jour de l'application est prête à être installée.", + availableWithLastChecked: + "Une mise à jour de l'application est prête à être installée. Dernière vérification à {{time}}.", installed: "Mise à jour de l'application installée. Redémarrage requis.", failed: "Échec de la mise à jour de l'application.", idle: "L'état de la mise à jour n'a pas encore été vérifié.", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 6deb07889..9f7e26e43 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -950,8 +950,12 @@ export const ja: TranslationResources = { upToDate: "アプリは最新です。", upToDateWithLastChecked: "最新の状態です。最終確認: {{time}}。", pending: "更新の準備ができたらお知らせします。", + pendingWithLastChecked: "更新の準備ができたらお知らせします。最終確認: {{time}}。", availableWithVersion: "更新の準備ができました: {{version}}", + availableWithVersionAndLastChecked: + "更新の準備ができました: {{version}}。最終確認: {{time}}。", available: "アプリの更新をインストールできます。", + availableWithLastChecked: "アプリの更新をインストールできます。最終確認: {{time}}。", installed: "アプリの更新がインストールされました。再起動が必要です。", failed: "アプリの更新に失敗しました。", idle: "更新ステータスはまだ確認されていません。", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index a38038048..11ff61a75 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -956,8 +956,14 @@ export const ptBR: TranslationResources = { upToDate: "O app está atualizado.", upToDateWithLastChecked: "Atualizado. Última verificação às {{time}}.", pending: "Avisaremos quando a atualização estiver pronta.", + pendingWithLastChecked: + "Avisaremos quando a atualização estiver pronta. Última verificação às {{time}}.", availableWithVersion: "Atualização pronta: {{version}}", + availableWithVersionAndLastChecked: + "Atualização pronta: {{version}}. Última verificação às {{time}}.", available: "Há uma atualização do app pronta para instalar.", + availableWithLastChecked: + "Há uma atualização do app pronta para instalar. Última verificação às {{time}}.", installed: "Atualização do app instalada. Reinicialização obrigatória.", failed: "Falha ao atualizar o app.", idle: "O status de atualização ainda não foi verificado.", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 39a42b5a6..0aba25352 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -957,8 +957,14 @@ export const ru: TranslationResources = { upToDate: "Приложение актуально.", upToDateWithLastChecked: "Up to date. Last checked at {{time}}.", pending: "Мы сообщим вам, когда обновление будет готово.", + pendingWithLastChecked: + "Мы сообщим вам, когда обновление будет готово. Последняя проверка в {{time}}.", availableWithVersion: "Обновление готово:{{version}}", + availableWithVersionAndLastChecked: + "Обновление готово:{{version}}. Последняя проверка в {{time}}.", available: "Обновление приложения готово к установке.", + availableWithLastChecked: + "Обновление приложения готово к установке. Последняя проверка в {{time}}.", installed: "Обновление приложения установлено. Требуется перезагрузка.", failed: "Не удалось обновить приложение.", idle: "Статус обновления еще не проверен.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 6cf1982bc..1154fbd5a 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -927,8 +927,11 @@ export const zhCN: TranslationResources = { upToDate: "App 已是最新版本。", upToDateWithLastChecked: "已是最新版本。上次检查时间:{{time}}。", pending: "更新准备好后会通知你。", + pendingWithLastChecked: "更新准备好后会通知你。上次检查时间:{{time}}。", availableWithVersion: "更新已就绪:{{version}}", + availableWithVersionAndLastChecked: "更新已就绪:{{version}}。上次检查时间:{{time}}。", available: "有 app 更新可安装。", + availableWithLastChecked: "有 app 更新可安装。上次检查时间:{{time}}。", installed: "App 更新已安装。需要重启。", failed: "App 更新失败。", idle: "尚未检查更新状态。", diff --git a/packages/desktop/src/features/app-update-service.test.ts b/packages/desktop/src/features/app-update-service.test.ts index d5b2c3705..cfd712077 100644 --- a/packages/desktop/src/features/app-update-service.test.ts +++ b/packages/desktop/src/features/app-update-service.test.ts @@ -8,10 +8,19 @@ import { } from "./app-update-service"; class FakeAppUpdateRuntime implements AppUpdateRuntime { - private checks: Array<{ isUpdateAvailable: boolean; updateInfo: RuntimeUpdateInfo } | null> = []; + private checks: Array< + | { isUpdateAvailable: boolean; updateInfo: RuntimeUpdateInfo } + | null + | Error + | { kind: "check-error"; error: Error; emitRuntimeError: boolean } + | { kind: "deferred"; promise: Promise } + > = []; private gate: ((info: RuntimeUpdateInfo) => boolean | Promise) | null = null; + private configuration: AppUpdateRuntimeConfiguration | null = null; + checkCount = 0; configure(input: AppUpdateRuntimeConfiguration): void { + this.configuration = input; this.gate = input.shouldAdmitUpdate; } @@ -19,11 +28,52 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { this.checks.push(result); } + failNextCheck(error: Error): void { + this.checks.push(error); + } + + failNextCheckAndEmitRuntimeError(error: Error): void { + this.checks.push({ kind: "check-error", error, emitRuntimeError: true }); + } + + deferNextCheck(): { + resolve(result: RuntimeUpdateCheckResult | null): void; + reject(error: Error): void; + } { + let resolve!: (result: RuntimeUpdateCheckResult | null) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + this.checks.push({ kind: "deferred", promise }); + return { resolve, reject }; + } + + failRuntime(error: Error): void { + this.configuration?.onError(error); + } + + prepareUpdate(info: RuntimeUpdateInfo): void { + this.configuration?.onUpdateAvailable(info); + } + async checkForUpdates(): Promise<{ isUpdateAvailable: boolean; updateInfo: RuntimeUpdateInfo; } | null> { + this.checkCount += 1; const result = this.checks.shift() ?? null; + if (result instanceof Error) throw result; + if (result?.kind === "check-error") { + if (result.emitRuntimeError) { + this.configuration?.onError(result.error); + } + throw result.error; + } + if (result?.kind === "deferred") { + return result.promise; + } if (!result || !this.gate) return result; const admitted = await this.gate(result.updateInfo); return { ...result, isUpdateAvailable: result.isUpdateAvailable && admitted }; @@ -69,6 +119,7 @@ describe("app update service", () => { latestVersion: "1.2.3", body: null, date: null, + errorMessage: null, }); }); @@ -89,6 +140,7 @@ describe("app update service", () => { latestVersion: "1.2.4", body: null, date: "2026-04-28T00:00:00.000Z", + errorMessage: null, }); }); @@ -109,6 +161,276 @@ describe("app update service", () => { latestVersion: "1.2.3", body: null, date: null, + errorMessage: null, + }); + }); + + it("returns check errors so the renderer can show feedback", async () => { + const { runtime, service } = createService(); + runtime.failNextCheck(new Error("network down")); + + const result = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + + expect(result).toEqual({ + hasUpdate: false, + readyToInstall: false, + currentVersion: "1.2.3", + latestVersion: "1.2.3", + body: null, + date: null, + errorMessage: "network down", + }); + }); + + it("performs a fresh retry after a failed check emits a runtime error", async () => { + const { runtime, service } = createService(); + runtime.failNextCheckAndEmitRuntimeError(new Error("network down")); + + const firstResult = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + expect(firstResult.errorMessage).toBe("network down"); + + 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("does not replay runtime errors emitted by the active check to automatic consumers", async () => { + const { runtime, service } = createService(); + runtime.failNextCheckAndEmitRuntimeError(new Error("network down")); + + const checkResult = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + expect(checkResult.errorMessage).toBe("network down"); + + const automaticResult = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + + expect(runtime.checkCount).toBe(2); + expect(automaticResult).toEqual({ + hasUpdate: false, + readyToInstall: false, + currentVersion: "1.2.3", + latestVersion: "1.2.3", + body: null, + date: null, + errorMessage: null, + }); + }); + + it("does not cache runtime errors from overlapping active checks", async () => { + const { runtime, service } = createService(); + const firstCheck = runtime.deferNextCheck(); + const firstPending = service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + const secondCheck = runtime.deferNextCheck(); + const secondPending = service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + + firstCheck.resolve(null); + await firstPending; + + runtime.failRuntime(new Error("network down")); + secondCheck.reject(new Error("network down")); + const secondResult = await secondPending; + expect(secondResult.errorMessage).toBe("network down"); + + runtime.nextCheck(null); + const automaticResult = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + + expect(runtime.checkCount).toBe(3); + expect(automaticResult).toEqual({ + hasUpdate: false, + readyToInstall: false, + currentVersion: "1.2.3", + latestVersion: "1.2.3", + body: null, + date: null, + errorMessage: null, + }); + }); + + 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({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + runtime.failRuntime(new Error("sha512 checksum mismatch")); + + 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.4", + body: null, + date: "2026-04-28T00:00:00.000Z", + errorMessage: "sha512 checksum mismatch", + }); + }); + + 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 555e23e96..b50e49fc5 100644 --- a/packages/desktop/src/features/app-update-service.ts +++ b/packages/desktop/src/features/app-update-service.ts @@ -12,6 +12,7 @@ export interface AppUpdateCheckResult { latestVersion: string; body: string | null; date: string | null; + errorMessage: string | null; } export interface AppUpdateInstallResult { @@ -78,8 +79,9 @@ function buildCheckResult(input: { hasUpdate: boolean; readyToInstall: boolean; info?: RuntimeUpdateInfo | null; + errorMessage?: string | null; }): AppUpdateCheckResult { - const { currentVersion, hasUpdate, readyToInstall, info } = input; + const { currentVersion, hasUpdate, readyToInstall, info, errorMessage = null } = input; return { hasUpdate, @@ -88,6 +90,7 @@ function buildCheckResult(input: { latestVersion: info?.version ?? currentVersion, body: typeof info?.releaseNotes === "string" ? info.releaseNotes : null, date: typeof info?.releaseDate === "string" ? info.releaseDate : null, + errorMessage, }; } @@ -99,11 +102,20 @@ async function performQuitAndInstall( runtime.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true); } +function getErrorMessage(error: unknown): string { + if (error instanceof Error && typeof error.message === "string") { + return error.message; + } + return String(error); +} + 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; @@ -113,6 +125,22 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer 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 { @@ -138,17 +166,22 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer cachedUpdateInfo = info; downloadedUpdateVersion = null; downloading = true; + 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); }, }); @@ -173,8 +206,13 @@ 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 (cachedVersion && cachedVersion !== currentVersion) { + if (!runtimeErrorResult && cachedVersion && cachedVersion !== currentVersion) { return buildCheckResult({ currentVersion, hasUpdate: true, @@ -184,6 +222,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer } try { + inFlightUpdateCheckCount += 1; const result = await deps.runtime.checkForUpdates(); if (!result || !result.updateInfo || !result.isUpdateAvailable) { clearUpdateState(); @@ -201,6 +240,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer if (hasUpdate) { cachedUpdateInfo = info; downloading = !isReadyToInstallVersion(latestVersion); + runtimeErrorMessage = null; return buildCheckResult({ currentVersion, hasUpdate: true, @@ -221,7 +261,10 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer currentVersion, hasUpdate: false, readyToInstall: false, + errorMessage: getErrorMessage(error), }); + } finally { + inFlightUpdateCheckCount -= 1; } }