fix(desktop): keep manually admitted updates available (#3865)

Automatic readiness polls reapplied the stable rollout gate and cleared the same update that a manual check had already admitted. Keep same-version admission monotonic while still clearing withdrawn releases and rejecting newer releases that have not reached the user.
This commit is contained in:
Mohamed Boudra
2026-08-26 09:51:20 +02:00
committed by GitHub
parent 5a38a33345
commit d46267aed6
5 changed files with 159 additions and 25 deletions
+38 -17
View File
@@ -58,6 +58,7 @@ export interface DesktopRuntimeConfig {
updateAvailable?: boolean;
latestVersion?: string;
updateReadyToInstall?: boolean;
manualUpdateBypassesRollout?: boolean;
slowInstall?: boolean;
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
daemonPid?: number | null;
@@ -139,6 +140,7 @@ export async function installDesktopRuntime(
let daemonRunning = true;
let currentPid: number | null = cfg.daemonPid ?? null;
let startCount = 0;
let manualUpdateAdmitted = false;
window.__desktopDaemonStartRequested = false;
function buildDaemonStatus() {
@@ -175,6 +177,33 @@ export async function installDesktopRuntime(
}
}
function buildAppUpdateCheckResult(hasUpdate: boolean, readyToInstall: boolean) {
return {
hasUpdate,
readyToInstall,
currentVersion: "1.0.0",
latestVersion: hasUpdate ? (cfg.latestVersion ?? "1.2.3") : null,
body: null,
date: null,
};
}
function checkAppUpdate(intent: unknown) {
if (!cfg.manualUpdateBypassesRollout) {
return buildAppUpdateCheckResult(
cfg.updateAvailable === true,
cfg.updateAvailable === true && (cfg.updateReadyToInstall ?? true),
);
}
if (intent === "manual") {
manualUpdateAdmitted = true;
return buildAppUpdateCheckResult(true, false);
}
return buildAppUpdateCheckResult(manualUpdateAdmitted, manualUpdateAdmitted);
}
const desktopBridge: {
platform: string;
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
@@ -192,23 +221,7 @@ export async function installDesktopRuntime(
platform: "darwin",
invoke: async (command: string, args?: Record<string, unknown>) => {
if (command === "check_app_update") {
return cfg.updateAvailable
? {
hasUpdate: true,
readyToInstall: cfg.updateReadyToInstall ?? true,
currentVersion: "1.0.0",
latestVersion: cfg.latestVersion ?? "1.2.3",
body: null,
date: null,
}
: {
hasUpdate: false,
readyToInstall: false,
currentVersion: "1.0.0",
latestVersion: null,
body: null,
date: null,
};
return checkAppUpdate(args?.intent);
}
if (command === "install_app_update") {
@@ -353,6 +366,14 @@ export async function expectPendingUpdateCheckResult(page: Page, version: string
await expect(page.getByRole("button", { name: "Update" })).toBeDisabled();
}
export async function expectReadyUpdateCheckResult(page: Page, version: string): Promise<void> {
const normalizedVersion = `v${version.replace(/^v/i, "")}`;
await expect(page.getByText(`Ready to install: ${normalizedVersion}`)).toBeVisible({
timeout: 15_000,
});
await expect(page.getByRole("button", { name: `Update to ${normalizedVersion}` })).toBeEnabled();
}
export async function clickInstallUpdate(page: Page): Promise<void> {
await page.getByRole("button", { name: "Install & restart" }).click();
}
+15
View File
@@ -14,6 +14,7 @@ import {
expectUpdateBanner,
clickCheckForUpdates,
expectPendingUpdateCheckResult,
expectReadyUpdateCheckResult,
clickInstallUpdate,
expectInstallInProgress,
interceptDaemonManagementConfirmDialog,
@@ -86,6 +87,20 @@ test.describe("Desktop updates", () => {
await expectPendingUpdateCheckResult(page, "1.2.3");
});
test("manual update remains available after the automatic rollout recheck", async ({ page }) => {
await installDesktopRuntime(page, {
serverId: getServerId(),
latestVersion: "1.2.3",
manualUpdateBypassesRollout: true,
});
await gotoAppShell(page);
await openDesktopAboutSettings(page);
await clickCheckForUpdates(page);
await expectPendingUpdateCheckResult(page, "1.2.3");
await expectReadyUpdateCheckResult(page, "1.2.3");
});
});
test.describe("Desktop daemon management", () => {
@@ -208,6 +208,76 @@ describe("app update service", () => {
});
});
it("keeps a manually admitted update after a rollout-gated automatic recheck", async () => {
const { runtime, service } = createService();
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
runtime.finishUpdateDownload(rolledOutUpdate);
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
const result = await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "automatic",
});
expect(result).toMatchObject({
hasUpdate: true,
readyToInstall: true,
latestVersion: "1.2.4",
});
});
it("keeps preparing a manually admitted update after a rollout-gated automatic recheck", async () => {
const { runtime, service } = createService();
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
const result = await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "automatic",
});
expect(result).toMatchObject({
hasUpdate: true,
readyToInstall: false,
latestVersion: "1.2.4",
});
});
it("clears a cached update when the manifest no longer contains it", async () => {
const { runtime, service } = createService();
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
runtime.finishUpdateDownload(rolledOutUpdate);
runtime.nextCheck(null);
const result = await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "automatic",
});
expect(result.hasUpdate).toBe(false);
});
it("waits for an automatic poll before starting a manual rollout-bypassing check", async () => {
const { runtime, service } = createService();
const automaticCheck = runtime.deferNextCheck();
@@ -38,7 +38,6 @@ export interface AppUpdateRuntimeConfiguration {
shouldAdmitUpdate(info: RuntimeUpdateInfo): boolean | Promise<boolean>;
onUpdateAvailable(info: RuntimeUpdateInfo): void;
onUpdateDownloaded(info: RuntimeUpdateInfo): void;
onUpdateNotAvailable(): void;
onError(error: unknown): void;
}
@@ -147,6 +146,24 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
preparingUpdateVersion = null;
}
function buildPreviouslyAdmittedUpdateResult(
currentVersion: string,
checkedInfo: RuntimeUpdateInfo,
): AppUpdateCheckResult | null {
const info = cachedUpdateInfo;
if (!info || info.version === currentVersion || info.version !== checkedInfo.version) {
return null;
}
return buildCheckResult({
currentVersion,
hasUpdate: true,
readyToInstall: isReadyToInstallVersion(info.version),
info,
errorMessage: preparationError?.version === info.version ? preparationError.message : null,
});
}
function configureRuntime(releaseChannel: AppReleaseChannel, intent: AppUpdateCheckIntent): void {
if (configuredReleaseChannel !== releaseChannel) {
clearUpdateState();
@@ -186,9 +203,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
preparationError = null;
}
},
onUpdateNotAvailable() {
clearUpdateState();
},
onError(error) {
if (preparingUpdateVersion) {
preparationError = {
@@ -233,7 +247,24 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
try {
const result = await deps.runtime.checkForUpdates();
if (!result || !result.updateInfo || !result.isUpdateAvailable) {
if (!result || !result.updateInfo) {
clearUpdateState();
return buildCheckResult({
currentVersion,
hasUpdate: false,
readyToInstall: false,
});
}
if (!result.isUpdateAvailable) {
const admittedUpdate = buildPreviouslyAdmittedUpdateResult(
currentVersion,
result.updateInfo,
);
if (admittedUpdate) {
return admittedUpdate;
}
clearUpdateState();
return buildCheckResult({
currentVersion,
@@ -138,9 +138,6 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime {
autoUpdater.on("update-downloaded", (info) => {
input.onUpdateDownloaded(info as RuntimeUpdateInfo);
});
autoUpdater.on("update-not-available", () => {
input.onUpdateNotAvailable();
});
autoUpdater.on("error", (error) => {
if (isUpdateChannelNotPublished(error)) return;
input.onError(error);