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
This commit is contained in:
Mohamed Boudra
2026-06-29 21:00:42 +02:00
committed by GitHub
parent efd7ab3420
commit 111fdb81fd
15 changed files with 628 additions and 8 deletions

View File

@@ -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<RuntimeUpdateCheckResult | null> }
> = [];
private gate: ((info: RuntimeUpdateInfo) => boolean | Promise<boolean>) | 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<RuntimeUpdateCheckResult | null>((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",
});
});
});

View File

@@ -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;
}
}