Always revalidate desktop updates before install

This commit is contained in:
Mohamed Boudra
2026-07-16 20:17:58 +02:00
parent 04c71c5890
commit 7d80fdfd12
8 changed files with 340 additions and 285 deletions
+2 -2
View File
@@ -169,13 +169,13 @@ This does **not** apply to fresh releases cut via `npm run release:patch` — th
### Releasing during an active rollout ### Releasing during an active rollout
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it. If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it. Rollout-aware clients revalidate the manifest before installing a downloaded update on quit. If N+1 has replaced N but the client is not admitted to N+1 yet, it skips the downloaded N and waits rather than installing two updates in succession.
If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+1> -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast. If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+1> -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast.
### Limitations ### Limitations
- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch. - **No pause / kill switch.** To stop new admissions, ship a superseding release. Clients revalidate on quit and will not install the superseded download, but a client that already completed installation cannot be recalled; ship a hotfix `+1` patch.
- **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix. - **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix.
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later. - **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
- **Up to ~30 min automatic admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. Clicking **Check** is manual and bypasses rollout admission. - **Up to ~30 min automatic admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. Clicking **Check** is manual and bypasses rollout admission.
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it } from "vitest";
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings"; import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
import { import {
@@ -23,96 +23,151 @@ describe("quit-lifecycle", () => {
}); });
it("short-circuits without inspecting the daemon when keep-running is on", async () => { it("short-circuits without inspecting the daemon when keep-running is on", async () => {
const isDesktopManagedDaemonRunning = vi.fn(() => true); const events: string[] = [];
const stopDaemon = vi.fn(async () => undefined);
const showShutdownFeedback = vi.fn();
const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({ const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({
settingsStore: { get: async () => SETTINGS_KEEP_RUNNING }, settingsStore: { get: async () => SETTINGS_KEEP_RUNNING },
isDesktopManagedDaemonRunning, isDesktopManagedDaemonRunning: () => {
stopDaemon, events.push("inspect");
showShutdownFeedback, return true;
},
stopDaemon: async () => {
events.push("stop");
},
showShutdownFeedback: () => {
events.push("feedback");
},
}); });
expect(stopped).toBe(false); expect(stopped).toBe(false);
expect(isDesktopManagedDaemonRunning).not.toHaveBeenCalled(); expect(events).toEqual([]);
expect(stopDaemon).not.toHaveBeenCalled();
expect(showShutdownFeedback).not.toHaveBeenCalled();
}); });
it("does not stop a manually started daemon on quit", async () => { it("does not stop a manually started daemon on quit", async () => {
const stopDaemon = vi.fn(async () => undefined); const events: string[] = [];
const showShutdownFeedback = vi.fn();
const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({ const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({
settingsStore: { get: async () => SETTINGS_STOP_ON_QUIT }, settingsStore: { get: async () => SETTINGS_STOP_ON_QUIT },
isDesktopManagedDaemonRunning: () => false, isDesktopManagedDaemonRunning: () => false,
stopDaemon, stopDaemon: async () => {
showShutdownFeedback, events.push("stop");
},
showShutdownFeedback: () => {
events.push("feedback");
},
}); });
expect(stopped).toBe(false); expect(stopped).toBe(false);
expect(stopDaemon).not.toHaveBeenCalled(); expect(events).toEqual([]);
expect(showShutdownFeedback).not.toHaveBeenCalled();
}); });
it("shows feedback then stops a desktop-managed daemon", async () => { it("shows feedback then stops a desktop-managed daemon", async () => {
const stopDaemon = vi.fn(async () => undefined); const events: string[] = [];
const showShutdownFeedback = vi.fn();
const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({ const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({
settingsStore: { get: async () => SETTINGS_STOP_ON_QUIT }, settingsStore: { get: async () => SETTINGS_STOP_ON_QUIT },
isDesktopManagedDaemonRunning: () => true, isDesktopManagedDaemonRunning: () => true,
stopDaemon, stopDaemon: async () => {
showShutdownFeedback, events.push("stop");
},
showShutdownFeedback: () => {
events.push("feedback");
},
}); });
expect(stopped).toBe(true); expect(stopped).toBe(true);
expect(showShutdownFeedback).toHaveBeenCalledTimes(1); expect(events).toEqual(["feedback", "stop"]);
expect(stopDaemon).toHaveBeenCalledTimes(1);
expect(showShutdownFeedback.mock.invocationCallOrder[0]).toBeLessThan(
stopDaemon.mock.invocationCallOrder[0],
);
}); });
it("preventDefaults the first quit, runs the async stop decision, then exits hard", async () => { it("revalidates updates after daemon shutdown before exiting", async () => {
let resolveStopDecision: (() => void) | null = null; let resolveStopDecision: (() => void) | null = null;
const app = { exit: vi.fn() }; let resolveUpdateDecision: (() => void) | null = null;
const closeTransportSessions = vi.fn(); const events: string[] = [];
const onStopError = vi.fn();
const preventDefault = vi.fn();
const secondPreventDefault = vi.fn();
const handleBeforeQuit = createBeforeQuitHandler({ const handleBeforeQuit = createBeforeQuitHandler({
app, app: {
closeTransportSessions, exit: (code) => {
stopDesktopManagedDaemonIfNeeded: vi.fn( events.push(`exit:${code}`);
() => },
new Promise<boolean>((resolve) => { },
resolveStopDecision = () => resolve(false); closeTransportSessions: () => {
}), events.push("close-transports");
), },
onStopError, stopDesktopManagedDaemonIfNeeded: () =>
new Promise<boolean>((resolve) => {
resolveStopDecision = () => {
events.push("daemon-stopped");
resolve(false);
};
}),
installAppUpdateOnQuit: () =>
new Promise<boolean>((resolve) => {
resolveUpdateDecision = () => {
events.push("update-checked");
resolve(false);
};
}),
onStopError: () => {
events.push("stop-error");
},
onUpdateError: () => {
events.push("update-error");
},
}); });
handleBeforeQuit({ preventDefault }); handleBeforeQuit({
preventDefault: () => {
events.push("prevent-default");
},
});
expect(preventDefault).toHaveBeenCalledTimes(1); expect(events).toEqual(["close-transports", "prevent-default"]);
expect(closeTransportSessions).toHaveBeenCalledTimes(1);
expect(app.exit).not.toHaveBeenCalled();
expect(resolveStopDecision).not.toBeNull(); expect(resolveStopDecision).not.toBeNull();
resolveStopDecision?.(); resolveStopDecision?.();
await Promise.resolve(); await Promise.resolve();
await Promise.resolve(); await Promise.resolve();
expect(app.exit).toHaveBeenCalledWith(0); expect(events).toEqual(["close-transports", "prevent-default", "daemon-stopped"]);
expect(onStopError).not.toHaveBeenCalled(); expect(resolveUpdateDecision).not.toBeNull();
handleBeforeQuit({ preventDefault: secondPreventDefault }); resolveUpdateDecision?.();
await Promise.resolve();
await Promise.resolve();
expect(secondPreventDefault).not.toHaveBeenCalled(); expect(events).toEqual([
expect(closeTransportSessions).toHaveBeenCalledTimes(2); "close-transports",
expect(app.exit).toHaveBeenCalledTimes(1); "prevent-default",
"daemon-stopped",
"update-checked",
"exit:0",
]);
handleBeforeQuit({
preventDefault: () => {
events.push("second-prevent-default");
},
});
expect(events.at(-1)).toBe("close-transports");
expect(events).not.toContain("second-prevent-default");
});
it("lets the updater own process exit when a validated update is installing", async () => {
const exits: number[] = [];
const handleBeforeQuit = createBeforeQuitHandler({
app: { exit: (code) => exits.push(code) },
closeTransportSessions: () => {},
stopDesktopManagedDaemonIfNeeded: async () => false,
installAppUpdateOnQuit: async () => true,
onStopError: () => {},
onUpdateError: () => {},
});
handleBeforeQuit({ preventDefault: () => {} });
await Promise.resolve();
await Promise.resolve();
expect(exits).toEqual([]);
}); });
}); });
+24 -11
View File
@@ -46,18 +46,20 @@ export function createBeforeQuitHandler({
app, app,
closeTransportSessions, closeTransportSessions,
stopDesktopManagedDaemonIfNeeded, stopDesktopManagedDaemonIfNeeded,
installAppUpdateOnQuit,
onStopError, onStopError,
onUpdateError,
}: { }: {
app: BeforeQuitApp; app: BeforeQuitApp;
closeTransportSessions: () => void; closeTransportSessions: () => void;
stopDesktopManagedDaemonIfNeeded: () => Promise<boolean>; stopDesktopManagedDaemonIfNeeded: () => Promise<boolean>;
installAppUpdateOnQuit: () => Promise<boolean>;
onStopError: (error: unknown) => void; onStopError: (error: unknown) => void;
onUpdateError: (error: unknown) => void;
}): (event: BeforeQuitEvent) => void { }): (event: BeforeQuitEvent) => void {
// We always preventDefault on first quit so we can run the async stop // The first quit waits for daemon shutdown and update revalidation. A validated
// decision, then call app.exit(0) — which bypasses Electron's // update re-fires app.quit(); otherwise app.exit(0) bypasses Electron's macOS
// close → window-all-closed → will-quit chain. The window-all-closed // window-all-closed handler, which would veto that second quit.
// listener is a darwin no-op (macOS convention) and would otherwise
// veto a re-fired app.quit().
let quitting = false; let quitting = false;
return (event) => { return (event) => {
@@ -66,12 +68,23 @@ export function createBeforeQuitHandler({
quitting = true; quitting = true;
event.preventDefault(); event.preventDefault();
void stopDesktopManagedDaemonIfNeeded() void (async () => {
.catch((error) => { try {
await stopDesktopManagedDaemonIfNeeded();
} catch (error) {
onStopError(error); onStopError(error);
}) }
.finally(() => {
app.exit(0); try {
}); const installingUpdate = await installAppUpdateOnQuit();
if (installingUpdate) {
return;
}
} catch (error) {
onUpdateError(error);
}
app.exit(0);
})();
}; };
} }
@@ -17,7 +17,10 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime {
> = []; > = [];
private gate: ((info: RuntimeUpdateInfo) => boolean | Promise<boolean>) | null = null; private gate: ((info: RuntimeUpdateInfo) => boolean | Promise<boolean>) | null = null;
private configuration: AppUpdateRuntimeConfiguration | null = null; private configuration: AppUpdateRuntimeConfiguration | null = null;
private downloadableUpdate: RuntimeUpdateInfo | null = null;
private downloadedUpdate: RuntimeUpdateInfo | null = null;
checkCount = 0; checkCount = 0;
installedVersions: string[] = [];
configure(input: AppUpdateRuntimeConfiguration): void { configure(input: AppUpdateRuntimeConfiguration): void {
this.configuration = input; this.configuration = input;
@@ -59,6 +62,7 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime {
} }
finishUpdateDownload(info: RuntimeUpdateInfo): void { finishUpdateDownload(info: RuntimeUpdateInfo): void {
this.downloadedUpdate = info;
this.configuration?.onUpdateDownloaded(info); this.configuration?.onUpdateDownloaded(info);
} }
@@ -80,12 +84,22 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime {
} }
if (!result || !this.gate) return result; if (!result || !this.gate) return result;
const admitted = await this.gate(result.updateInfo); const admitted = await this.gate(result.updateInfo);
return { ...result, isUpdateAvailable: result.isUpdateAvailable && admitted }; const isUpdateAvailable = result.isUpdateAvailable && admitted;
this.downloadableUpdate = isUpdateAvailable ? result.updateInfo : null;
return { ...result, isUpdateAvailable };
} }
async downloadUpdate(): Promise<void> {} async downloadUpdate(): Promise<void> {
if (this.downloadableUpdate) {
this.finishUpdateDownload(this.downloadableUpdate);
}
}
quitAndInstall(): void {} quitAndInstall(): void {
if (this.downloadedUpdate) {
this.installedVersions.push(this.downloadedUpdate.version);
}
}
} }
function createService(input?: { now?: () => number; bucket?: () => Promise<number> }) { function createService(input?: { now?: () => number; bucket?: () => Promise<number> }) {
@@ -179,6 +193,111 @@ describe("app update service", () => {
}); });
}); });
it("replaces a downloaded update when a newer release is admitted", async () => {
const { runtime, service } = createService({ bucket: async () => 0 });
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "automatic",
});
runtime.finishUpdateDownload(rolledOutUpdate);
const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" };
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
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.5",
body: null,
date: "2026-04-28T00:00:00.000Z",
errorMessage: null,
});
});
it("installs the newest admitted release when quitting with an older download", async () => {
const { runtime, service } = createService({ bucket: async () => 0 });
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "automatic",
});
runtime.finishUpdateDownload(rolledOutUpdate);
const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" };
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
const installed = await service.installUpdateOnQuit({
currentVersion: "1.2.3",
releaseChannel: "stable",
});
expect(installed).toBe(true);
expect(runtime.installedVersions).toEqual(["1.2.5"]);
});
it("does not install an older download while its replacement is still rolling out", async () => {
const now = Date.parse("2026-04-28T12:00:00.000Z");
const { runtime, service } = createService({ now: () => now, bucket: async () => 0.4 });
const olderUpdate = {
...rolledOutUpdate,
releaseDate: "2026-04-27T00:00:00.000Z",
};
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: olderUpdate });
await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "automatic",
});
runtime.finishUpdateDownload(olderUpdate);
const newerUpdate = {
...rolledOutUpdate,
version: "1.2.5",
releaseDate: "2026-04-28T12:00:00.000Z",
};
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
const installed = await service.installUpdateOnQuit({
currentVersion: "1.2.3",
releaseChannel: "stable",
});
expect(installed).toBe(false);
expect(runtime.installedVersions).toEqual([]);
});
it("rechecks for the newest release before a manual install", async () => {
const { runtime, service } = createService({ bucket: async () => 0.99 });
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
runtime.finishUpdateDownload(rolledOutUpdate);
const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" };
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
const result = await service.downloadAndInstallUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
});
expect(result.installed).toBe(true);
expect(runtime.installedVersions).toEqual(["1.2.5"]);
});
it("trusts the runtime availability decision before comparing versions", async () => { it("trusts the runtime availability decision before comparing versions", async () => {
const { runtime, service } = createService({ bucket: async () => 0 }); const { runtime, service } = createService({ bucket: async () => 0 });
runtime.nextCheck({ isUpdateAvailable: false, updateInfo: rolledOutUpdate }); runtime.nextCheck({ isUpdateAvailable: false, updateInfo: rolledOutUpdate });
@@ -322,40 +441,8 @@ describe("app update service", () => {
}); });
}); });
it("keeps preparation errors emitted before the update check rejects", async () => { it("discovers newer releases after an update fails to prepare", async () => {
const { runtime, service } = createService(); const { runtime, service } = createService({ bucket: async () => 0 });
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 }); runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
await service.checkForAppUpdate({ await service.checkForAppUpdate({
@@ -365,6 +452,8 @@ describe("app update service", () => {
}); });
runtime.failRuntime(new Error("sha512 checksum mismatch")); runtime.failRuntime(new Error("sha512 checksum mismatch"));
const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" };
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
const result = await service.checkForAppUpdate({ const result = await service.checkForAppUpdate({
currentVersion: "1.2.3", currentVersion: "1.2.3",
releaseChannel: "stable", releaseChannel: "stable",
@@ -375,10 +464,10 @@ describe("app update service", () => {
hasUpdate: true, hasUpdate: true,
readyToInstall: false, readyToInstall: false,
currentVersion: "1.2.3", currentVersion: "1.2.3",
latestVersion: "1.2.4", latestVersion: "1.2.5",
body: null, body: null,
date: "2026-04-28T00:00:00.000Z", date: "2026-04-28T00:00:00.000Z",
errorMessage: "sha512 checksum mismatch", errorMessage: null,
}); });
}); });
@@ -445,91 +534,4 @@ describe("app update service", () => {
errorMessage: null, errorMessage: null,
}); });
}); });
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",
});
});
}); });
@@ -62,6 +62,10 @@ export interface AppUpdateService {
}, },
onBeforeQuit?: () => Promise<void>, onBeforeQuit?: () => Promise<void>,
): Promise<AppUpdateInstallResult>; ): Promise<AppUpdateInstallResult>;
installUpdateOnQuit(input: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
}): Promise<boolean>;
} }
export interface AppUpdateServiceDeps { export interface AppUpdateServiceDeps {
@@ -112,10 +116,7 @@ function getErrorMessage(error: unknown): string {
export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateService { export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateService {
let cachedUpdateInfo: RuntimeUpdateInfo | null = null; let cachedUpdateInfo: RuntimeUpdateInfo | null = null;
let downloadedUpdateVersion: string | null = null; let downloadedUpdateVersion: string | null = null;
let downloading = false;
let configuredReleaseChannel: AppReleaseChannel | null = null; let configuredReleaseChannel: AppReleaseChannel | null = null;
let runtimeErrorMessage: string | null = null;
let inFlightUpdateCheckCount = 0;
function isReadyToInstallVersion(version: string): boolean { function isReadyToInstallVersion(version: string): boolean {
return downloadedUpdateVersion === version; return downloadedUpdateVersion === version;
@@ -124,23 +125,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
function clearUpdateState(): void { function clearUpdateState(): void {
cachedUpdateInfo = null; cachedUpdateInfo = null;
downloadedUpdateVersion = 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 { function configureRuntime(releaseChannel: AppReleaseChannel, intent: AppUpdateCheckIntent): void {
@@ -166,23 +150,15 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
const alreadyReady = downloadedUpdateVersion === info.version; const alreadyReady = downloadedUpdateVersion === info.version;
cachedUpdateInfo = info; cachedUpdateInfo = info;
downloadedUpdateVersion = alreadyReady ? info.version : null; downloadedUpdateVersion = alreadyReady ? info.version : null;
downloading = !alreadyReady;
runtimeErrorMessage = null;
}, },
onUpdateDownloaded(info) { onUpdateDownloaded(info) {
cachedUpdateInfo = info; cachedUpdateInfo = info;
downloadedUpdateVersion = info.version; downloadedUpdateVersion = info.version;
downloading = false;
runtimeErrorMessage = null;
}, },
onUpdateNotAvailable() { onUpdateNotAvailable() {
clearUpdateState(); clearUpdateState();
}, },
onError(error) { onError(error) {
downloading = false;
if (inFlightUpdateCheckCount === 0 || cachedUpdateInfo) {
runtimeErrorMessage = getErrorMessage(error);
}
deps.reportRuntimeError?.(error); deps.reportRuntimeError?.(error);
}, },
}); });
@@ -207,28 +183,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
configureRuntime(releaseChannel, intent); configureRuntime(releaseChannel, intent);
const runtimeErrorResult = buildRuntimeErrorResult(currentVersion);
if (runtimeErrorResult && intent === "automatic") {
return runtimeErrorResult;
}
const cachedVersion = cachedUpdateInfo?.version ?? null;
if (
!runtimeErrorResult &&
intent === "automatic" &&
cachedVersion &&
cachedVersion !== currentVersion
) {
return buildCheckResult({
currentVersion,
hasUpdate: true,
readyToInstall: isReadyToInstallVersion(cachedVersion),
info: cachedUpdateInfo,
});
}
try { try {
inFlightUpdateCheckCount += 1;
const result = await deps.runtime.checkForUpdates(); const result = await deps.runtime.checkForUpdates();
if (!result || !result.updateInfo || !result.isUpdateAvailable) { if (!result || !result.updateInfo || !result.isUpdateAvailable) {
clearUpdateState(); clearUpdateState();
@@ -245,8 +200,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
if (hasUpdate) { if (hasUpdate) {
cachedUpdateInfo = info; cachedUpdateInfo = info;
downloading = !isReadyToInstallVersion(latestVersion);
runtimeErrorMessage = null;
return buildCheckResult({ return buildCheckResult({
currentVersion, currentVersion,
hasUpdate: true, hasUpdate: true,
@@ -269,8 +222,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
readyToInstall: false, readyToInstall: false,
errorMessage: getErrorMessage(error), errorMessage: getErrorMessage(error),
}); });
} finally {
inFlightUpdateCheckCount -= 1;
} }
} }
@@ -292,6 +243,26 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
}; };
} }
const check = await checkForAppUpdate({
currentVersion,
releaseChannel,
intent: "manual",
});
if (!check.hasUpdate) {
return {
installed: false,
version: currentVersion,
message: check.errorMessage ?? "No update available.",
};
}
return installCachedUpdate(currentVersion, onBeforeQuit);
}
async function installCachedUpdate(
currentVersion: string,
onBeforeQuit?: () => Promise<void>,
): Promise<AppUpdateInstallResult> {
if (!cachedUpdateInfo) { if (!cachedUpdateInfo) {
return { return {
installed: false, installed: false,
@@ -300,8 +271,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
}; };
} }
configureRuntime(releaseChannel, "manual");
const readyVersion = cachedUpdateInfo.version; const readyVersion = cachedUpdateInfo.version;
if (isReadyToInstallVersion(readyVersion)) { if (isReadyToInstallVersion(readyVersion)) {
await performQuitAndInstall(deps.runtime, onBeforeQuit); await performQuitAndInstall(deps.runtime, onBeforeQuit);
@@ -312,20 +281,16 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
}; };
} }
if (downloading) {
return {
installed: false,
version: currentVersion,
message: "Update is still being prepared. Try again in a moment.",
};
}
downloading = true;
try { try {
await deps.runtime.downloadUpdate(); await deps.runtime.downloadUpdate();
if (cachedUpdateInfo?.version !== readyVersion) {
return {
installed: false,
version: currentVersion,
message: "A newer update was found and will be installed later.",
};
}
downloadedUpdateVersion = readyVersion; downloadedUpdateVersion = readyVersion;
downloading = false;
await performQuitAndInstall(deps.runtime, onBeforeQuit); await performQuitAndInstall(deps.runtime, onBeforeQuit);
return { return {
@@ -334,7 +299,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
message: "Update downloaded. The app will restart shortly.", message: "Update downloaded. The app will restart shortly.",
}; };
} catch (error) { } catch (error) {
downloading = false;
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
deps.reportInstallError?.(message); deps.reportInstallError?.(message);
return { return {
@@ -345,8 +309,33 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
} }
} }
async function installUpdateOnQuit({
currentVersion,
releaseChannel,
}: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
}): Promise<boolean> {
if (!deps.isPackaged() || !downloadedUpdateVersion) {
return false;
}
const check = await checkForAppUpdate({
currentVersion,
releaseChannel,
intent: "automatic",
});
if (!check.hasUpdate) {
return false;
}
const result = await installCachedUpdate(currentVersion);
return result.installed;
}
return { return {
checkForAppUpdate, checkForAppUpdate,
downloadAndInstallUpdate, downloadAndInstallUpdate,
installUpdateOnQuit,
}; };
} }
@@ -19,18 +19,8 @@ import {
resolveStagingUserId, resolveStagingUserId,
rolloutManifestSchema, rolloutManifestSchema,
shouldAdmitToRollout, shouldAdmitToRollout,
shouldAutoInstallOnQuit,
} from "./auto-updater"; } from "./auto-updater";
describe("shouldAutoInstallOnQuit", () => {
it("auto-installs on quit everywhere except Linux AppImage", () => {
expect(shouldAutoInstallOnQuit({ platform: "linux", isAppImage: true })).toBe(false);
expect(shouldAutoInstallOnQuit({ platform: "linux", isAppImage: false })).toBe(true);
expect(shouldAutoInstallOnQuit({ platform: "darwin", isAppImage: false })).toBe(true);
expect(shouldAutoInstallOnQuit({ platform: "win32", isAppImage: false })).toBe(true);
});
});
describe("shouldAdmitToRollout", () => { describe("shouldAdmitToRollout", () => {
it("admits beta, missing rollout hours, zero-hour rollout, and missing release date", () => { it("admits beta, missing rollout hours, zero-hour rollout, and missing release date", () => {
expect( expect(
+14 -19
View File
@@ -76,31 +76,16 @@ export function getStagingUserId(): Promise<string> {
return cachedStagingUserIdPromise; return cachedStagingUserIdPromise;
} }
// AppImages have no install step. electron-updater "installs" by unlinking the
// running file and mv-ing the downloaded one into place; on app quit it does this
// via a *blocking* execFileSync(newAppImage, { APPIMAGE_EXIT_AFTER_INSTALL: "true" }).
// That env var is only honored by AppImageLauncher, so without it the freshly
// launched process boots the full app and never exits — the quit hangs forever,
// with the old binary already deleted. We therefore install AppImages only on
// explicit quitAndInstall (the "Update now" button), which takes the non-blocking
// spawn path. Every other target keeps auto-install-on-quit, which works there.
export function shouldAutoInstallOnQuit(input: {
platform: NodeJS.Platform;
isAppImage: boolean;
}): boolean {
return !(input.platform === "linux" && input.isAppImage);
}
class ElectronAppUpdateRuntime implements AppUpdateRuntime { class ElectronAppUpdateRuntime implements AppUpdateRuntime {
private configured = false; private configured = false;
configure(input: AppUpdateRuntimeConfiguration): void { configure(input: AppUpdateRuntimeConfiguration): void {
autoUpdater.autoDownload = true; autoUpdater.autoDownload = true;
autoUpdater.autoRunAppAfterInstall = true; autoUpdater.autoRunAppAfterInstall = true;
autoUpdater.autoInstallOnAppQuit = shouldAutoInstallOnQuit({ // Paseo revalidates the current manifest before explicitly installing on quit.
platform: process.platform, // Electron's built-in handler would install an older download without checking
isAppImage: Boolean(process.env.APPIMAGE), // whether a newer release has superseded it.
}); autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.allowPrerelease = input.releaseChannel === "beta"; autoUpdater.allowPrerelease = input.releaseChannel === "beta";
autoUpdater.channel = input.releaseChannel === "beta" ? "beta" : "latest"; autoUpdater.channel = input.releaseChannel === "beta" ? "beta" : "latest";
autoUpdater.allowDowngrade = false; autoUpdater.allowDowngrade = false;
@@ -194,3 +179,13 @@ export async function downloadAndInstallUpdate(
onBeforeQuit, onBeforeQuit,
); );
} }
export async function installAppUpdateOnQuit({
currentVersion,
releaseChannel,
}: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
}): Promise<boolean> {
return appUpdateService.installUpdateOnQuit({ currentVersion, releaseChannel });
}
+11
View File
@@ -85,6 +85,7 @@ import {
import { runDesktopStartup } from "./desktop-startup.js"; import { runDesktopStartup } from "./desktop-startup.js";
import { autoUpdateInstalledSkills } from "./integrations/skills/index.js"; import { autoUpdateInstalledSkills } from "./integrations/skills/index.js";
import { registerBrowserAutomationIpc } from "./features/browser-automation/ipc.js"; import { registerBrowserAutomationIpc } from "./features/browser-automation/ipc.js";
import { installAppUpdateOnQuit } from "./features/auto-updater.js";
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081"; const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
const APP_SCHEME = "paseo"; const APP_SCHEME = "paseo";
@@ -998,9 +999,19 @@ app.on(
stopDaemon: () => stopDesktopDaemonViaCli("quit"), stopDaemon: () => stopDesktopDaemonViaCli("quit"),
showShutdownFeedback: showDaemonShutdownDialog, showShutdownFeedback: showDaemonShutdownDialog,
}), }),
installAppUpdateOnQuit: async () => {
const settings = await getDesktopSettingsStore().get();
return installAppUpdateOnQuit({
currentVersion: app.getVersion(),
releaseChannel: settings.releaseChannel,
});
},
onStopError: (error) => { onStopError: (error) => {
log.error("[desktop daemon] failed to stop managed daemon on quit", error); log.error("[desktop daemon] failed to stop managed daemon on quit", error);
}, },
onUpdateError: (error) => {
log.error("[auto-updater] failed to validate downloaded update on quit", error);
},
}), }),
); );