From 111fdb81fd94587d31e4f6ff83d0c37faf409e77 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 29 Jun 2026 21:00:42 +0200 Subject: [PATCH] Show desktop update check feedback (#1808) * fix(desktop): show app update check feedback Manual desktop app update checks now leave visible status feedback even when the shared update state is pending or available. Updater check and preparation errors are carried through the existing result path so the settings row and callout can show the failure instead of only logging it. * fix(desktop): make update retries perform fresh checks Manual retries now clear runtime errors emitted by a failed check so the next click calls the updater again. Background checks also skip while a visible manual check is active, and last-checked update copy uses complete localized strings. * fix(desktop): punctuate update check timestamp copy * fix(desktop): share runtime update errors * fix(desktop): preserve update preparation errors * fix(desktop): settle update check review races * fix(desktop): settle quiet update check errors * fix(desktop): handle overlapping update checks * fix(desktop): preserve preparation errors during checks --- .../updates/desktop-app-updater.test.ts | 166 +++++++++ .../desktop/updates/desktop-app-updater.ts | 49 ++- .../src/desktop/updates/desktop-updates.ts | 2 + .../fake-desktop-app-updater-port.ts | 1 + packages/app/src/i18n/resources.test.ts | 9 + packages/app/src/i18n/resources/ar.ts | 3 + packages/app/src/i18n/resources/en.ts | 4 + packages/app/src/i18n/resources/es.ts | 6 + packages/app/src/i18n/resources/fr.ts | 6 + packages/app/src/i18n/resources/ja.ts | 4 + packages/app/src/i18n/resources/pt-BR.ts | 6 + packages/app/src/i18n/resources/ru.ts | 6 + packages/app/src/i18n/resources/zh-CN.ts | 3 + .../src/features/app-update-service.test.ts | 324 +++++++++++++++++- .../src/features/app-update-service.ts | 47 ++- 15 files changed, 628 insertions(+), 8 deletions(-) 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; } }