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
@@ -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({
@@ -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;
@@ -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),
};
}
@@ -41,6 +41,7 @@ function buildCheckResult(
latestVersion: null,
body: null,
date: null,
errorMessage: null,
...overrides,
};
}