Always install the newest eligible desktop update (#2149)
* Reapply "Always revalidate desktop updates before install"
This reverts commit 623c05aa4d.
* fix(desktop): make update revalidation safe on quit
Updater-triggered quits now bypass normal quit handling, and manifest revalidation gives up after five seconds without allowing a late install.
* fix(desktop): preserve fail-closed update installs
Keep cached updates deferred when quit-time validation is offline, and report validation timeouts separately from superseded releases.
* fix(desktop): preserve updater quit behavior
Automatic installs remain silent without relaunching, updater handoff is bounded, and background preparation failures remain visible without pinning manifest checks.
* fix(desktop): keep AppImage updates manual
Preserve the AppImage safety exemption by skipping ordinary quit-time installation while retaining the explicit Update now path.
* fix(desktop): serialize update preparation
* fix(desktop): recognize macOS updater quit handoff
This commit is contained in:
+2
-2
@@ -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 for up to five seconds 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 revalidation times out, the app exits without installing the cached update.
|
||||||
|
|
||||||
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,8 +1,8 @@
|
|||||||
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 {
|
||||||
createBeforeQuitHandler,
|
createQuitLifecycle,
|
||||||
shouldStopDesktopManagedDaemonOnQuit,
|
shouldStopDesktopManagedDaemonOnQuit,
|
||||||
stopDesktopManagedDaemonOnQuitIfNeeded,
|
stopDesktopManagedDaemonOnQuitIfNeeded,
|
||||||
} from "./quit-lifecycle";
|
} from "./quit-lifecycle";
|
||||||
@@ -16,6 +16,18 @@ const SETTINGS_STOP_ON_QUIT = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void } {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForQuitLifecycle(): Promise<void> {
|
||||||
|
return new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
describe("quit-lifecycle", () => {
|
describe("quit-lifecycle", () => {
|
||||||
it("only stops when keepRunningAfterQuit is explicitly disabled", () => {
|
it("only stops when keepRunningAfterQuit is explicitly disabled", () => {
|
||||||
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_STOP_ON_QUIT)).toBe(true);
|
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_STOP_ON_QUIT)).toBe(true);
|
||||||
@@ -23,96 +35,238 @@ 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;
|
const stopDecision = deferred<boolean>();
|
||||||
const app = { exit: vi.fn() };
|
const updateDecision = deferred<boolean>();
|
||||||
const closeTransportSessions = vi.fn();
|
const events: string[] = [];
|
||||||
const onStopError = vi.fn();
|
|
||||||
const preventDefault = vi.fn();
|
|
||||||
const secondPreventDefault = vi.fn();
|
|
||||||
|
|
||||||
const handleBeforeQuit = createBeforeQuitHandler({
|
const quitLifecycle = createQuitLifecycle({
|
||||||
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: () => stopDecision.promise,
|
||||||
|
installAppUpdateOnQuit: () => updateDecision.promise,
|
||||||
|
createUpdateDeadlineSignal: () => new AbortController().signal,
|
||||||
|
onStopError: () => {
|
||||||
|
events.push("stop-error");
|
||||||
|
},
|
||||||
|
onUpdateError: () => {
|
||||||
|
events.push("update-error");
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
handleBeforeQuit({ preventDefault });
|
quitLifecycle.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();
|
|
||||||
|
|
||||||
resolveStopDecision?.();
|
events.push("daemon-stopped");
|
||||||
await Promise.resolve();
|
stopDecision.resolve(false);
|
||||||
await Promise.resolve();
|
await waitForQuitLifecycle();
|
||||||
|
|
||||||
expect(app.exit).toHaveBeenCalledWith(0);
|
expect(events).toEqual(["close-transports", "prevent-default", "daemon-stopped"]);
|
||||||
expect(onStopError).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
handleBeforeQuit({ preventDefault: secondPreventDefault });
|
events.push("update-checked");
|
||||||
|
updateDecision.resolve(false);
|
||||||
|
await waitForQuitLifecycle();
|
||||||
|
|
||||||
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",
|
||||||
|
]);
|
||||||
|
|
||||||
|
quitLifecycle.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 quitLifecycle = createQuitLifecycle({
|
||||||
|
app: { exit: (code) => exits.push(code) },
|
||||||
|
closeTransportSessions: () => {},
|
||||||
|
stopDesktopManagedDaemonIfNeeded: async () => false,
|
||||||
|
installAppUpdateOnQuit: async () => true,
|
||||||
|
createUpdateDeadlineSignal: () => new AbortController().signal,
|
||||||
|
onStopError: () => {},
|
||||||
|
onUpdateError: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
quitLifecycle.handleBeforeQuit({ preventDefault: () => {} });
|
||||||
|
await waitForQuitLifecycle();
|
||||||
|
quitLifecycle.handleBeforeQuitForUpdate();
|
||||||
|
await waitForQuitLifecycle();
|
||||||
|
|
||||||
|
expect(exits).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes a repeated quit as updater handoff", async () => {
|
||||||
|
const exits: number[] = [];
|
||||||
|
let preventedQuitCount = 0;
|
||||||
|
const quitLifecycle = createQuitLifecycle({
|
||||||
|
app: { exit: (code) => exits.push(code) },
|
||||||
|
closeTransportSessions: () => {},
|
||||||
|
stopDesktopManagedDaemonIfNeeded: async () => false,
|
||||||
|
installAppUpdateOnQuit: async () => true,
|
||||||
|
createUpdateDeadlineSignal: () => new AbortController().signal,
|
||||||
|
onStopError: () => {},
|
||||||
|
onUpdateError: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
quitLifecycle.handleBeforeQuit({ preventDefault: () => preventedQuitCount++ });
|
||||||
|
await waitForQuitLifecycle();
|
||||||
|
quitLifecycle.handleBeforeQuit({ preventDefault: () => preventedQuitCount++ });
|
||||||
|
await waitForQuitLifecycle();
|
||||||
|
|
||||||
|
expect(preventedQuitCount).toBe(1);
|
||||||
|
expect(exits).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits when the updater does not take ownership before its deadline", async () => {
|
||||||
|
const revalidationDeadline = new AbortController();
|
||||||
|
const handoffDeadline = new AbortController();
|
||||||
|
let deadlineCount = 0;
|
||||||
|
const exits: number[] = [];
|
||||||
|
const quitLifecycle = createQuitLifecycle({
|
||||||
|
app: { exit: (code) => exits.push(code) },
|
||||||
|
closeTransportSessions: () => {},
|
||||||
|
stopDesktopManagedDaemonIfNeeded: async () => false,
|
||||||
|
installAppUpdateOnQuit: async () => true,
|
||||||
|
createUpdateDeadlineSignal: () =>
|
||||||
|
deadlineCount++ === 0 ? revalidationDeadline.signal : handoffDeadline.signal,
|
||||||
|
onStopError: () => {},
|
||||||
|
onUpdateError: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
quitLifecycle.handleBeforeQuit({ preventDefault: () => {} });
|
||||||
|
await waitForQuitLifecycle();
|
||||||
|
handoffDeadline.abort();
|
||||||
|
await waitForQuitLifecycle();
|
||||||
|
|
||||||
|
expect(exits).toEqual([0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not intercept a quit started by a manual update", () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const quitLifecycle = createQuitLifecycle({
|
||||||
|
app: { exit: (code) => events.push(`exit:${code}`) },
|
||||||
|
closeTransportSessions: () => events.push("close-transports"),
|
||||||
|
stopDesktopManagedDaemonIfNeeded: async () => {
|
||||||
|
events.push("stop-daemon");
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
installAppUpdateOnQuit: async () => {
|
||||||
|
events.push("revalidate-update");
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
createUpdateDeadlineSignal: () => new AbortController().signal,
|
||||||
|
onStopError: () => events.push("stop-error"),
|
||||||
|
onUpdateError: () => events.push("update-error"),
|
||||||
|
});
|
||||||
|
|
||||||
|
quitLifecycle.handleBeforeQuitForUpdate();
|
||||||
|
quitLifecycle.handleBeforeQuit({
|
||||||
|
preventDefault: () => events.push("prevent-default"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toEqual(["close-transports"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits when update revalidation reaches its deadline", async () => {
|
||||||
|
const deadline = new AbortController();
|
||||||
|
const updateDecision = deferred<boolean>();
|
||||||
|
const exits: number[] = [];
|
||||||
|
const quitLifecycle = createQuitLifecycle({
|
||||||
|
app: { exit: (code) => exits.push(code) },
|
||||||
|
closeTransportSessions: () => {},
|
||||||
|
stopDesktopManagedDaemonIfNeeded: async () => false,
|
||||||
|
installAppUpdateOnQuit: () => updateDecision.promise,
|
||||||
|
createUpdateDeadlineSignal: () => deadline.signal,
|
||||||
|
onStopError: () => {},
|
||||||
|
onUpdateError: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
quitLifecycle.handleBeforeQuit({ preventDefault: () => {} });
|
||||||
|
await waitForQuitLifecycle();
|
||||||
|
deadline.abort();
|
||||||
|
await waitForQuitLifecycle();
|
||||||
|
|
||||||
|
expect(exits).toEqual([0]);
|
||||||
|
|
||||||
|
updateDecision.resolve(true);
|
||||||
|
await waitForQuitLifecycle();
|
||||||
|
expect(exits).toEqual([0]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ interface BeforeQuitApp {
|
|||||||
exit(code: number): void;
|
exit(code: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QuitLifecycle {
|
||||||
|
handleBeforeQuit(event: BeforeQuitEvent): void;
|
||||||
|
handleBeforeQuitForUpdate(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeferredUpdateQuit {
|
||||||
|
promise: Promise<boolean>;
|
||||||
|
resolve(): void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StopOnQuitDeps {
|
export interface StopOnQuitDeps {
|
||||||
settingsStore: Pick<DesktopSettingsStore, "get">;
|
settingsStore: Pick<DesktopSettingsStore, "get">;
|
||||||
isDesktopManagedDaemonRunning: () => boolean;
|
isDesktopManagedDaemonRunning: () => boolean;
|
||||||
@@ -42,36 +52,95 @@ export async function stopDesktopManagedDaemonOnQuitIfNeeded(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBeforeQuitHandler({
|
function waitForUpdateDeadline(signal: AbortSignal): Promise<boolean> {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
signal.addEventListener("abort", () => resolve(false), { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeferredUpdateQuit(): DeferredUpdateQuit {
|
||||||
|
let resolvePromise!: (started: boolean) => void;
|
||||||
|
const promise = new Promise<boolean>((resolve) => {
|
||||||
|
resolvePromise = resolve;
|
||||||
|
});
|
||||||
|
return { promise, resolve: () => resolvePromise(true) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createQuitLifecycle({
|
||||||
app,
|
app,
|
||||||
closeTransportSessions,
|
closeTransportSessions,
|
||||||
stopDesktopManagedDaemonIfNeeded,
|
stopDesktopManagedDaemonIfNeeded,
|
||||||
|
installAppUpdateOnQuit,
|
||||||
|
createUpdateDeadlineSignal,
|
||||||
onStopError,
|
onStopError,
|
||||||
|
onUpdateError,
|
||||||
}: {
|
}: {
|
||||||
app: BeforeQuitApp;
|
app: BeforeQuitApp;
|
||||||
closeTransportSessions: () => void;
|
closeTransportSessions: () => void;
|
||||||
stopDesktopManagedDaemonIfNeeded: () => Promise<boolean>;
|
stopDesktopManagedDaemonIfNeeded: () => Promise<boolean>;
|
||||||
|
installAppUpdateOnQuit: (signal: AbortSignal) => Promise<boolean>;
|
||||||
|
createUpdateDeadlineSignal: () => AbortSignal;
|
||||||
onStopError: (error: unknown) => void;
|
onStopError: (error: unknown) => void;
|
||||||
}): (event: BeforeQuitEvent) => void {
|
onUpdateError: (error: unknown) => void;
|
||||||
// We always preventDefault on first quit so we can run the async stop
|
}): QuitLifecycle {
|
||||||
// decision, then call app.exit(0) — which bypasses Electron's
|
// The first quit waits for daemon shutdown and update revalidation. A validated
|
||||||
// close → window-all-closed → will-quit chain. The window-all-closed
|
// update re-fires app.quit(); otherwise app.exit(0) bypasses Electron's macOS
|
||||||
// listener is a darwin no-op (macOS convention) and would otherwise
|
// window-all-closed handler, which would veto that second quit.
|
||||||
// veto a re-fired app.quit().
|
|
||||||
let quitting = false;
|
let quitting = false;
|
||||||
|
let quittingForUpdate = false;
|
||||||
|
const updateQuit = createDeferredUpdateQuit();
|
||||||
|
|
||||||
return (event) => {
|
function handleBeforeQuit(event: BeforeQuitEvent): void {
|
||||||
closeTransportSessions();
|
closeTransportSessions();
|
||||||
if (quitting) return;
|
if (quittingForUpdate) return;
|
||||||
|
if (quitting) {
|
||||||
|
// MacUpdater's no-relaunch path calls app.quit() without emitting
|
||||||
|
// before-quit-for-update. A second quit is equivalent handoff evidence.
|
||||||
|
updateQuit.resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
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);
|
const signal = createUpdateDeadlineSignal();
|
||||||
|
const updateInstallation = installAppUpdateOnQuit(signal).catch((error) => {
|
||||||
|
onUpdateError(error);
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
|
const installingUpdate = await Promise.race([
|
||||||
|
updateInstallation,
|
||||||
|
waitForUpdateDeadline(signal),
|
||||||
|
]);
|
||||||
|
if (installingUpdate) {
|
||||||
|
const handoffStarted = await Promise.race([
|
||||||
|
updateQuit.promise,
|
||||||
|
waitForUpdateDeadline(createUpdateDeadlineSignal()),
|
||||||
|
]);
|
||||||
|
if (handoffStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.exit(0);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleBeforeQuit,
|
||||||
|
handleBeforeQuitForUpdate() {
|
||||||
|
quittingForUpdate = true;
|
||||||
|
updateQuit.resolve();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,19 @@ 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;
|
||||||
|
private activeDownload: {
|
||||||
|
info: RuntimeUpdateInfo;
|
||||||
|
promise: Promise<void>;
|
||||||
|
resolve(): void;
|
||||||
|
reject(error: Error): void;
|
||||||
|
} | null = null;
|
||||||
checkCount = 0;
|
checkCount = 0;
|
||||||
|
downloadCallCount = 0;
|
||||||
|
downloadedVersions: string[] = [];
|
||||||
|
installedVersions: string[] = [];
|
||||||
|
installModes: Array<{ isSilent: boolean; isForceRunAfter: boolean }> = [];
|
||||||
|
|
||||||
configure(input: AppUpdateRuntimeConfiguration): void {
|
configure(input: AppUpdateRuntimeConfiguration): void {
|
||||||
this.configuration = input;
|
this.configuration = input;
|
||||||
@@ -59,9 +71,42 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
finishUpdateDownload(info: RuntimeUpdateInfo): void {
|
finishUpdateDownload(info: RuntimeUpdateInfo): void {
|
||||||
|
this.downloadedUpdate = info;
|
||||||
|
this.downloadedVersions.push(info.version);
|
||||||
this.configuration?.onUpdateDownloaded(info);
|
this.configuration?.onUpdateDownloaded(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beginUpdateDownload(info: RuntimeUpdateInfo): {
|
||||||
|
resolve(): void;
|
||||||
|
reject(error: Error): void;
|
||||||
|
} {
|
||||||
|
this.downloadableUpdate = info;
|
||||||
|
this.prepareUpdate(info);
|
||||||
|
let resolvePromise!: () => void;
|
||||||
|
let rejectPromise!: (error: Error) => void;
|
||||||
|
const promise = new Promise<void>((resolve, reject) => {
|
||||||
|
resolvePromise = resolve;
|
||||||
|
rejectPromise = reject;
|
||||||
|
});
|
||||||
|
void promise.catch(() => undefined);
|
||||||
|
const activeDownload = {
|
||||||
|
info,
|
||||||
|
promise,
|
||||||
|
resolve: () => {
|
||||||
|
this.finishUpdateDownload(info);
|
||||||
|
this.activeDownload = null;
|
||||||
|
resolvePromise();
|
||||||
|
},
|
||||||
|
reject: (error: Error) => {
|
||||||
|
this.configuration?.onError(error);
|
||||||
|
this.activeDownload = null;
|
||||||
|
rejectPromise(error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.activeDownload = activeDownload;
|
||||||
|
return { resolve: activeDownload.resolve, reject: activeDownload.reject };
|
||||||
|
}
|
||||||
|
|
||||||
async checkForUpdates(): Promise<{
|
async checkForUpdates(): Promise<{
|
||||||
isUpdateAvailable: boolean;
|
isUpdateAvailable: boolean;
|
||||||
updateInfo: RuntimeUpdateInfo;
|
updateInfo: RuntimeUpdateInfo;
|
||||||
@@ -80,12 +125,27 @@ 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> {
|
||||||
|
this.downloadCallCount += 1;
|
||||||
|
if (this.activeDownload) {
|
||||||
|
return this.activeDownload.promise;
|
||||||
|
}
|
||||||
|
if (this.downloadableUpdate) {
|
||||||
|
this.finishUpdateDownload(this.downloadableUpdate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
quitAndInstall(): void {}
|
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void {
|
||||||
|
if (this.downloadedUpdate) {
|
||||||
|
this.installedVersions.push(this.downloadedUpdate.version);
|
||||||
|
this.installModes.push({ isSilent, isForceRunAfter });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createService(input?: { now?: () => number; bucket?: () => Promise<number> }) {
|
function createService(input?: { now?: () => number; bucket?: () => Promise<number> }) {
|
||||||
@@ -148,6 +208,33 @@ describe("app update service", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("waits for an automatic poll before starting a manual rollout-bypassing check", async () => {
|
||||||
|
const { runtime, service } = createService();
|
||||||
|
const automaticCheck = runtime.deferNextCheck();
|
||||||
|
const automaticPending = service.checkForAppUpdate({
|
||||||
|
currentVersion: "1.2.3",
|
||||||
|
releaseChannel: "stable",
|
||||||
|
intent: "automatic",
|
||||||
|
});
|
||||||
|
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||||
|
const manualPending = service.checkForAppUpdate({
|
||||||
|
currentVersion: "1.2.3",
|
||||||
|
releaseChannel: "stable",
|
||||||
|
intent: "manual",
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(runtime.checkCount).toBe(1);
|
||||||
|
|
||||||
|
automaticCheck.resolve({ isUpdateAvailable: false, updateInfo: rolledOutUpdate });
|
||||||
|
await automaticPending;
|
||||||
|
const manualResult = await manualPending;
|
||||||
|
|
||||||
|
expect(runtime.checkCount).toBe(2);
|
||||||
|
expect(manualResult.hasUpdate).toBe(true);
|
||||||
|
expect(manualResult.latestVersion).toBe("1.2.4");
|
||||||
|
});
|
||||||
|
|
||||||
it("performs a fresh manual check when an update is already cached", async () => {
|
it("performs a fresh manual check when an update is already cached", async () => {
|
||||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||||
@@ -179,6 +266,218 @@ 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",
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(installed).toBe(true);
|
||||||
|
expect(runtime.installedVersions).toEqual(["1.2.5"]);
|
||||||
|
expect(runtime.installModes).toEqual([{ isSilent: true, isForceRunAfter: false }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(installed).toBe(false);
|
||||||
|
expect(runtime.installedVersions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not install after quit-time revalidation expires", 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 deadline = new AbortController();
|
||||||
|
deadline.abort();
|
||||||
|
runtime.nextCheck({
|
||||||
|
isUpdateAvailable: true,
|
||||||
|
updateInfo: { ...rolledOutUpdate, version: "1.2.5" },
|
||||||
|
});
|
||||||
|
const installed = await service.installUpdateOnQuit({
|
||||||
|
currentVersion: "1.2.3",
|
||||||
|
releaseChannel: "stable",
|
||||||
|
signal: deadline.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(installed).toBe(false);
|
||||||
|
expect(runtime.installedVersions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not install an unvalidated download when the quit-time check fails", 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);
|
||||||
|
|
||||||
|
runtime.failNextCheck(new Error("offline"));
|
||||||
|
const installed = await service.installUpdateOnQuit({
|
||||||
|
currentVersion: "1.2.3",
|
||||||
|
releaseChannel: "stable",
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
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"]);
|
||||||
|
expect(runtime.installModes).toEqual([{ isSilent: false, isForceRunAfter: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for a stale active download before downloading and installing the rechecked version", 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",
|
||||||
|
});
|
||||||
|
const staleDownload = runtime.beginUpdateDownload(rolledOutUpdate);
|
||||||
|
|
||||||
|
const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" };
|
||||||
|
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
|
||||||
|
const installPending = service.downloadAndInstallUpdate({
|
||||||
|
currentVersion: "1.2.3",
|
||||||
|
releaseChannel: "stable",
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(runtime.installedVersions).toEqual([]);
|
||||||
|
|
||||||
|
staleDownload.resolve();
|
||||||
|
const result = await installPending;
|
||||||
|
|
||||||
|
expect(result.installed).toBe(true);
|
||||||
|
expect(runtime.downloadedVersions).toEqual(["1.2.4", "1.2.5"]);
|
||||||
|
expect(runtime.installedVersions).toEqual(["1.2.5"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installs the rechecked version when the stale active download fails", 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",
|
||||||
|
});
|
||||||
|
const staleDownload = runtime.beginUpdateDownload(rolledOutUpdate);
|
||||||
|
|
||||||
|
const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" };
|
||||||
|
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
|
||||||
|
const installPending = service.downloadAndInstallUpdate({
|
||||||
|
currentVersion: "1.2.3",
|
||||||
|
releaseChannel: "stable",
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
expect(runtime.downloadCallCount).toBe(1);
|
||||||
|
|
||||||
|
staleDownload.reject(new Error("old download failed"));
|
||||||
|
const result = await installPending;
|
||||||
|
|
||||||
|
expect(result.installed).toBe(true);
|
||||||
|
expect(runtime.downloadedVersions).toEqual(["1.2.5"]);
|
||||||
|
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,28 +621,26 @@ describe("app update service", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps preparation errors emitted before the update check rejects", async () => {
|
it("surfaces preparation errors without blocking newer releases", async () => {
|
||||||
const { runtime, service } = createService();
|
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||||
const deferredCheck = runtime.deferNextCheck();
|
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||||
const pending = service.checkForAppUpdate({
|
|
||||||
|
await service.checkForAppUpdate({
|
||||||
currentVersion: "1.2.3",
|
currentVersion: "1.2.3",
|
||||||
releaseChannel: "stable",
|
releaseChannel: "stable",
|
||||||
intent: "automatic",
|
intent: "manual",
|
||||||
});
|
});
|
||||||
|
|
||||||
runtime.prepareUpdate(rolledOutUpdate);
|
runtime.prepareUpdate(rolledOutUpdate);
|
||||||
runtime.failRuntime(new Error("sha512 checksum mismatch"));
|
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({
|
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||||
|
const failedPreparation = await service.checkForAppUpdate({
|
||||||
currentVersion: "1.2.3",
|
currentVersion: "1.2.3",
|
||||||
releaseChannel: "stable",
|
releaseChannel: "stable",
|
||||||
intent: "automatic",
|
intent: "automatic",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(automaticResult).toEqual({
|
expect(failedPreparation).toEqual({
|
||||||
hasUpdate: true,
|
hasUpdate: true,
|
||||||
readyToInstall: false,
|
readyToInstall: false,
|
||||||
currentVersion: "1.2.3",
|
currentVersion: "1.2.3",
|
||||||
@@ -352,19 +649,9 @@ describe("app update service", () => {
|
|||||||
date: "2026-04-28T00:00:00.000Z",
|
date: "2026-04-28T00:00:00.000Z",
|
||||||
errorMessage: "sha512 checksum mismatch",
|
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 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,13 +662,43 @@ 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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("attributes a late preparation failure to the download that started it", 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.prepareUpdate(rolledOutUpdate);
|
||||||
|
|
||||||
|
const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" };
|
||||||
|
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
|
||||||
|
await service.checkForAppUpdate({
|
||||||
|
currentVersion: "1.2.3",
|
||||||
|
releaseChannel: "stable",
|
||||||
|
intent: "automatic",
|
||||||
|
});
|
||||||
|
runtime.failRuntime(new Error("old download failed"));
|
||||||
|
|
||||||
|
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
|
||||||
|
const result = await service.checkForAppUpdate({
|
||||||
|
currentVersion: "1.2.3",
|
||||||
|
releaseChannel: "stable",
|
||||||
|
intent: "automatic",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.latestVersion).toBe("1.2.5");
|
||||||
|
expect(result.errorMessage).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("performs a fresh manual check after an update preparation error", async () => {
|
it("performs a fresh manual check after an update preparation error", async () => {
|
||||||
const { runtime, service } = createService();
|
const { runtime, service } = createService();
|
||||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||||
@@ -391,6 +708,7 @@ describe("app update service", () => {
|
|||||||
releaseChannel: "stable",
|
releaseChannel: "stable",
|
||||||
intent: "manual",
|
intent: "manual",
|
||||||
});
|
});
|
||||||
|
runtime.prepareUpdate(rolledOutUpdate);
|
||||||
runtime.failRuntime(new Error("sha512 checksum mismatch"));
|
runtime.failRuntime(new Error("sha512 checksum mismatch"));
|
||||||
|
|
||||||
runtime.nextCheck({
|
runtime.nextCheck({
|
||||||
@@ -445,91 +763,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,11 @@ export interface AppUpdateService {
|
|||||||
},
|
},
|
||||||
onBeforeQuit?: () => Promise<void>,
|
onBeforeQuit?: () => Promise<void>,
|
||||||
): Promise<AppUpdateInstallResult>;
|
): Promise<AppUpdateInstallResult>;
|
||||||
|
installUpdateOnQuit(input: {
|
||||||
|
currentVersion: string;
|
||||||
|
releaseChannel: AppReleaseChannel;
|
||||||
|
signal: AbortSignal;
|
||||||
|
}): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppUpdateServiceDeps {
|
export interface AppUpdateServiceDeps {
|
||||||
@@ -96,10 +101,16 @@ function buildCheckResult(input: {
|
|||||||
|
|
||||||
async function performQuitAndInstall(
|
async function performQuitAndInstall(
|
||||||
runtime: AppUpdateRuntime,
|
runtime: AppUpdateRuntime,
|
||||||
onBeforeQuit?: () => Promise<void>,
|
{
|
||||||
|
onBeforeQuit,
|
||||||
|
restart,
|
||||||
|
}: {
|
||||||
|
onBeforeQuit?: () => Promise<void>;
|
||||||
|
restart: boolean;
|
||||||
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (onBeforeQuit) await onBeforeQuit();
|
if (onBeforeQuit) await onBeforeQuit();
|
||||||
runtime.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
runtime.quitAndInstall(/* isSilent */ !restart, /* isForceRunAfter */ restart);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getErrorMessage(error: unknown): string {
|
function getErrorMessage(error: unknown): string {
|
||||||
@@ -109,13 +120,21 @@ function getErrorMessage(error: unknown): string {
|
|||||||
return String(error);
|
return String(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDeferredInstallResult(currentVersion: string): AppUpdateInstallResult {
|
||||||
|
return {
|
||||||
|
installed: false,
|
||||||
|
version: currentVersion,
|
||||||
|
message: "Update validation timed out. The update will be installed later.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
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 preparationError: { version: string; message: string } | null = null;
|
||||||
let inFlightUpdateCheckCount = 0;
|
let preparingUpdateVersion: string | null = null;
|
||||||
|
let checkQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
function isReadyToInstallVersion(version: string): boolean {
|
function isReadyToInstallVersion(version: string): boolean {
|
||||||
return downloadedUpdateVersion === version;
|
return downloadedUpdateVersion === version;
|
||||||
@@ -124,23 +143,8 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
|
|||||||
function clearUpdateState(): void {
|
function clearUpdateState(): void {
|
||||||
cachedUpdateInfo = null;
|
cachedUpdateInfo = null;
|
||||||
downloadedUpdateVersion = null;
|
downloadedUpdateVersion = null;
|
||||||
downloading = false;
|
preparationError = null;
|
||||||
runtimeErrorMessage = null;
|
preparingUpdateVersion = 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,28 +170,47 @@ 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;
|
if (!alreadyReady && preparingUpdateVersion === null) {
|
||||||
runtimeErrorMessage = null;
|
preparingUpdateVersion = info.version;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onUpdateDownloaded(info) {
|
onUpdateDownloaded(info) {
|
||||||
cachedUpdateInfo = info;
|
// A superseded download can finish after a newer manifest check. Keep
|
||||||
|
// the validated manifest as the install target in that case.
|
||||||
|
cachedUpdateInfo ??= info;
|
||||||
downloadedUpdateVersion = info.version;
|
downloadedUpdateVersion = info.version;
|
||||||
downloading = false;
|
if (preparingUpdateVersion === info.version) {
|
||||||
runtimeErrorMessage = null;
|
preparingUpdateVersion = null;
|
||||||
|
}
|
||||||
|
if (preparationError?.version === info.version) {
|
||||||
|
preparationError = null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onUpdateNotAvailable() {
|
onUpdateNotAvailable() {
|
||||||
clearUpdateState();
|
clearUpdateState();
|
||||||
},
|
},
|
||||||
onError(error) {
|
onError(error) {
|
||||||
downloading = false;
|
if (preparingUpdateVersion) {
|
||||||
if (inFlightUpdateCheckCount === 0 || cachedUpdateInfo) {
|
preparationError = {
|
||||||
runtimeErrorMessage = getErrorMessage(error);
|
version: preparingUpdateVersion,
|
||||||
|
message: getErrorMessage(error),
|
||||||
|
};
|
||||||
|
preparingUpdateVersion = null;
|
||||||
}
|
}
|
||||||
deps.reportRuntimeError?.(error);
|
deps.reportRuntimeError?.(error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runCheckExclusively<T>(check: () => Promise<T>): Promise<T> {
|
||||||
|
const result = checkQueue.then(check, check);
|
||||||
|
checkQueue = result.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async function checkForAppUpdate({
|
async function checkForAppUpdate({
|
||||||
currentVersion,
|
currentVersion,
|
||||||
releaseChannel,
|
releaseChannel,
|
||||||
@@ -205,73 +228,56 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
configureRuntime(releaseChannel, intent);
|
return runCheckExclusively(async () => {
|
||||||
|
configureRuntime(releaseChannel, intent);
|
||||||
|
|
||||||
const runtimeErrorResult = buildRuntimeErrorResult(currentVersion);
|
try {
|
||||||
if (runtimeErrorResult && intent === "automatic") {
|
const result = await deps.runtime.checkForUpdates();
|
||||||
return runtimeErrorResult;
|
if (!result || !result.updateInfo || !result.isUpdateAvailable) {
|
||||||
}
|
clearUpdateState();
|
||||||
|
return buildCheckResult({
|
||||||
|
currentVersion,
|
||||||
|
hasUpdate: false,
|
||||||
|
readyToInstall: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const cachedVersion = cachedUpdateInfo?.version ?? null;
|
const info = result.updateInfo;
|
||||||
if (
|
const latestVersion = info.version;
|
||||||
!runtimeErrorResult &&
|
const hasUpdate = latestVersion !== currentVersion;
|
||||||
intent === "automatic" &&
|
|
||||||
cachedVersion &&
|
if (hasUpdate) {
|
||||||
cachedVersion !== currentVersion
|
cachedUpdateInfo = info;
|
||||||
) {
|
const errorMessage =
|
||||||
return buildCheckResult({
|
preparationError?.version === latestVersion ? preparationError.message : null;
|
||||||
currentVersion,
|
if (!errorMessage) {
|
||||||
hasUpdate: true,
|
preparationError = null;
|
||||||
readyToInstall: isReadyToInstallVersion(cachedVersion),
|
}
|
||||||
info: cachedUpdateInfo,
|
return buildCheckResult({
|
||||||
});
|
currentVersion,
|
||||||
}
|
hasUpdate: true,
|
||||||
|
readyToInstall: isReadyToInstallVersion(latestVersion),
|
||||||
|
info,
|
||||||
|
errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
inFlightUpdateCheckCount += 1;
|
|
||||||
const result = await deps.runtime.checkForUpdates();
|
|
||||||
if (!result || !result.updateInfo || !result.isUpdateAvailable) {
|
|
||||||
clearUpdateState();
|
clearUpdateState();
|
||||||
return buildCheckResult({
|
return buildCheckResult({
|
||||||
currentVersion,
|
currentVersion,
|
||||||
hasUpdate: false,
|
hasUpdate: false,
|
||||||
readyToInstall: false,
|
readyToInstall: false,
|
||||||
});
|
});
|
||||||
}
|
} catch (error) {
|
||||||
|
deps.reportCheckError?.(error);
|
||||||
const info = result.updateInfo;
|
|
||||||
const latestVersion = info.version;
|
|
||||||
const hasUpdate = latestVersion !== currentVersion;
|
|
||||||
|
|
||||||
if (hasUpdate) {
|
|
||||||
cachedUpdateInfo = info;
|
|
||||||
downloading = !isReadyToInstallVersion(latestVersion);
|
|
||||||
runtimeErrorMessage = null;
|
|
||||||
return buildCheckResult({
|
return buildCheckResult({
|
||||||
currentVersion,
|
currentVersion,
|
||||||
hasUpdate: true,
|
hasUpdate: false,
|
||||||
readyToInstall: isReadyToInstallVersion(latestVersion),
|
readyToInstall: false,
|
||||||
info,
|
errorMessage: getErrorMessage(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
clearUpdateState();
|
|
||||||
return buildCheckResult({
|
|
||||||
currentVersion,
|
|
||||||
hasUpdate: false,
|
|
||||||
readyToInstall: false,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
deps.reportCheckError?.(error);
|
|
||||||
return buildCheckResult({
|
|
||||||
currentVersion,
|
|
||||||
hasUpdate: false,
|
|
||||||
readyToInstall: false,
|
|
||||||
errorMessage: getErrorMessage(error),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
inFlightUpdateCheckCount -= 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadAndInstallUpdate(
|
async function downloadAndInstallUpdate(
|
||||||
@@ -292,6 +298,69 @@ 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, restart: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureUpdateDownloaded(
|
||||||
|
readyVersion: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<"ready" | "aborted" | "superseded"> {
|
||||||
|
while (!isReadyToInstallVersion(readyVersion)) {
|
||||||
|
if (signal?.aborted) return "aborted";
|
||||||
|
if (cachedUpdateInfo?.version !== readyVersion) return "superseded";
|
||||||
|
|
||||||
|
const attemptedVersion: string = preparingUpdateVersion ?? readyVersion;
|
||||||
|
preparingUpdateVersion ??= readyVersion;
|
||||||
|
try {
|
||||||
|
await deps.runtime.downloadUpdate();
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
attemptedVersion !== readyVersion &&
|
||||||
|
cachedUpdateInfo?.version === readyVersion &&
|
||||||
|
!signal?.aborted
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// electron-updater can return an older, already-running download. Its
|
||||||
|
// event clears that version, then the next iteration starts the newly
|
||||||
|
// validated release instead of treating the stale artifact as ready.
|
||||||
|
if (attemptedVersion === readyVersion && !isReadyToInstallVersion(readyVersion)) {
|
||||||
|
downloadedUpdateVersion = readyVersion;
|
||||||
|
preparingUpdateVersion = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return signal?.aborted ? "aborted" : "ready";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installCachedUpdate(
|
||||||
|
currentVersion: string,
|
||||||
|
{
|
||||||
|
onBeforeQuit,
|
||||||
|
signal,
|
||||||
|
restart,
|
||||||
|
}: {
|
||||||
|
onBeforeQuit?: () => Promise<void>;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
restart: boolean;
|
||||||
|
},
|
||||||
|
): Promise<AppUpdateInstallResult> {
|
||||||
if (!cachedUpdateInfo) {
|
if (!cachedUpdateInfo) {
|
||||||
return {
|
return {
|
||||||
installed: false,
|
installed: false,
|
||||||
@@ -300,11 +369,13 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
configureRuntime(releaseChannel, "manual");
|
|
||||||
|
|
||||||
const readyVersion = cachedUpdateInfo.version;
|
const readyVersion = cachedUpdateInfo.version;
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return buildDeferredInstallResult(currentVersion);
|
||||||
|
}
|
||||||
|
|
||||||
if (isReadyToInstallVersion(readyVersion)) {
|
if (isReadyToInstallVersion(readyVersion)) {
|
||||||
await performQuitAndInstall(deps.runtime, onBeforeQuit);
|
await performQuitAndInstall(deps.runtime, { onBeforeQuit, restart });
|
||||||
return {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
version: readyVersion,
|
version: readyVersion,
|
||||||
@@ -312,21 +383,19 @@ 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();
|
const preparation = await ensureUpdateDownloaded(readyVersion, signal);
|
||||||
downloadedUpdateVersion = readyVersion;
|
if (preparation === "aborted") {
|
||||||
downloading = false;
|
return buildDeferredInstallResult(currentVersion);
|
||||||
await performQuitAndInstall(deps.runtime, onBeforeQuit);
|
}
|
||||||
|
if (preparation === "superseded") {
|
||||||
|
return {
|
||||||
|
installed: false,
|
||||||
|
version: currentVersion,
|
||||||
|
message: "A newer update was found and will be installed later.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await performQuitAndInstall(deps.runtime, { onBeforeQuit, restart });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
@@ -334,7 +403,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 +413,35 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function installUpdateOnQuit({
|
||||||
|
currentVersion,
|
||||||
|
releaseChannel,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
currentVersion: string;
|
||||||
|
releaseChannel: AppReleaseChannel;
|
||||||
|
signal: AbortSignal;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (!deps.isPackaged() || !downloadedUpdateVersion) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const check = await checkForAppUpdate({
|
||||||
|
currentVersion,
|
||||||
|
releaseChannel,
|
||||||
|
intent: "automatic",
|
||||||
|
});
|
||||||
|
if (signal.aborted || !check.hasUpdate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await installCachedUpdate(currentVersion, { signal, restart: false });
|
||||||
|
return result.installed;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
checkForAppUpdate,
|
checkForAppUpdate,
|
||||||
downloadAndInstallUpdate,
|
downloadAndInstallUpdate,
|
||||||
|
installUpdateOnQuit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,15 @@ import {
|
|||||||
resolveStagingUserId,
|
resolveStagingUserId,
|
||||||
rolloutManifestSchema,
|
rolloutManifestSchema,
|
||||||
shouldAdmitToRollout,
|
shouldAdmitToRollout,
|
||||||
shouldAutoInstallOnQuit,
|
shouldInstallAppUpdateOnQuit,
|
||||||
} from "./auto-updater";
|
} from "./auto-updater";
|
||||||
|
|
||||||
describe("shouldAutoInstallOnQuit", () => {
|
describe("shouldInstallAppUpdateOnQuit", () => {
|
||||||
it("auto-installs on quit everywhere except Linux AppImage", () => {
|
it("keeps Linux AppImage updates on the manual install path", () => {
|
||||||
expect(shouldAutoInstallOnQuit({ platform: "linux", isAppImage: true })).toBe(false);
|
expect(shouldInstallAppUpdateOnQuit({ platform: "linux", isAppImage: true })).toBe(false);
|
||||||
expect(shouldAutoInstallOnQuit({ platform: "linux", isAppImage: false })).toBe(true);
|
expect(shouldInstallAppUpdateOnQuit({ platform: "linux", isAppImage: false })).toBe(true);
|
||||||
expect(shouldAutoInstallOnQuit({ platform: "darwin", isAppImage: false })).toBe(true);
|
expect(shouldInstallAppUpdateOnQuit({ platform: "darwin", isAppImage: false })).toBe(true);
|
||||||
expect(shouldAutoInstallOnQuit({ platform: "win32", isAppImage: false })).toBe(true);
|
expect(shouldInstallAppUpdateOnQuit({ platform: "win32", isAppImage: false })).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -76,18 +76,12 @@ export function getStagingUserId(): Promise<string> {
|
|||||||
return cachedStagingUserIdPromise;
|
return cachedStagingUserIdPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppImages have no install step. electron-updater "installs" by unlinking the
|
export function shouldInstallAppUpdateOnQuit(input: {
|
||||||
// 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;
|
platform: NodeJS.Platform;
|
||||||
isAppImage: boolean;
|
isAppImage: boolean;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
|
// AppImage's no-relaunch install path blocks while launching the replacement
|
||||||
|
// binary, which can hang after the running file has already been replaced.
|
||||||
return !(input.platform === "linux" && input.isAppImage);
|
return !(input.platform === "linux" && input.isAppImage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,10 +91,10 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime {
|
|||||||
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;
|
||||||
@@ -143,6 +137,7 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void {
|
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void {
|
||||||
|
autoUpdater.autoRunAppAfterInstall = isForceRunAfter;
|
||||||
autoUpdater.quitAndInstall(isSilent, isForceRunAfter);
|
autoUpdater.quitAndInstall(isSilent, isForceRunAfter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,3 +189,24 @@ export async function downloadAndInstallUpdate(
|
|||||||
onBeforeQuit,
|
onBeforeQuit,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function installAppUpdateOnQuit({
|
||||||
|
currentVersion,
|
||||||
|
releaseChannel,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
currentVersion: string;
|
||||||
|
releaseChannel: AppReleaseChannel;
|
||||||
|
signal: AbortSignal;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (
|
||||||
|
!shouldInstallAppUpdateOnQuit({
|
||||||
|
platform: process.platform,
|
||||||
|
isAppImage: Boolean(process.env.APPIMAGE),
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return appUpdateService.installUpdateOnQuit({ currentVersion, releaseChannel, signal });
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { existsSync } from "node:fs";
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
|
autoUpdater as electronAutoUpdater,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
clipboard,
|
clipboard,
|
||||||
Menu,
|
Menu,
|
||||||
@@ -81,19 +82,21 @@ import {
|
|||||||
stopDesktopDaemonViaCli,
|
stopDesktopDaemonViaCli,
|
||||||
} from "./daemon/daemon-manager.js";
|
} from "./daemon/daemon-manager.js";
|
||||||
import {
|
import {
|
||||||
createBeforeQuitHandler,
|
createQuitLifecycle,
|
||||||
stopDesktopManagedDaemonOnQuitIfNeeded,
|
stopDesktopManagedDaemonOnQuitIfNeeded,
|
||||||
} from "./daemon/quit-lifecycle.js";
|
} from "./daemon/quit-lifecycle.js";
|
||||||
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 { BrowserKeyboard } from "./features/browser-keyboard/index.js";
|
import { BrowserKeyboard } from "./features/browser-keyboard/index.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";
|
||||||
const PASEO_DEBUG = process.env.PASEO_DEBUG === "1";
|
const PASEO_DEBUG = process.env.PASEO_DEBUG === "1";
|
||||||
const DISABLE_SINGLE_INSTANCE_LOCK = process.env.PASEO_DISABLE_SINGLE_INSTANCE_LOCK === "1";
|
const DISABLE_SINGLE_INSTANCE_LOCK = process.env.PASEO_DISABLE_SINGLE_INSTANCE_LOCK === "1";
|
||||||
const APP_NAME = process.env.PASEO_TEST_APP_NAME?.trim() || "Paseo";
|
const APP_NAME = process.env.PASEO_TEST_APP_NAME?.trim() || "Paseo";
|
||||||
|
const UPDATE_QUIT_DEADLINE_MS = 5_000;
|
||||||
const pendingBrowserWindowOpenRequests = new PendingBrowserWindowOpenRequests();
|
const pendingBrowserWindowOpenRequests = new PendingBrowserWindowOpenRequests();
|
||||||
|
|
||||||
app.setName(APP_NAME);
|
app.setName(APP_NAME);
|
||||||
@@ -928,23 +931,36 @@ function showDaemonShutdownDialog(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.on(
|
const quitLifecycle = createQuitLifecycle({
|
||||||
"before-quit",
|
app,
|
||||||
createBeforeQuitHandler({
|
closeTransportSessions: closeAllTransportSessions,
|
||||||
app,
|
stopDesktopManagedDaemonIfNeeded: () =>
|
||||||
closeTransportSessions: closeAllTransportSessions,
|
stopDesktopManagedDaemonOnQuitIfNeeded({
|
||||||
stopDesktopManagedDaemonIfNeeded: () =>
|
settingsStore: getDesktopSettingsStore(),
|
||||||
stopDesktopManagedDaemonOnQuitIfNeeded({
|
isDesktopManagedDaemonRunning: isDesktopManagedDaemonRunningSync,
|
||||||
settingsStore: getDesktopSettingsStore(),
|
stopDaemon: () => stopDesktopDaemonViaCli("quit"),
|
||||||
isDesktopManagedDaemonRunning: isDesktopManagedDaemonRunningSync,
|
showShutdownFeedback: showDaemonShutdownDialog,
|
||||||
stopDaemon: () => stopDesktopDaemonViaCli("quit"),
|
}),
|
||||||
showShutdownFeedback: showDaemonShutdownDialog,
|
installAppUpdateOnQuit: async (signal) => {
|
||||||
}),
|
const settings = await getDesktopSettingsStore().get();
|
||||||
onStopError: (error) => {
|
return installAppUpdateOnQuit({
|
||||||
log.error("[desktop daemon] failed to stop managed daemon on quit", error);
|
currentVersion: app.getVersion(),
|
||||||
},
|
releaseChannel: settings.releaseChannel,
|
||||||
}),
|
signal,
|
||||||
);
|
});
|
||||||
|
},
|
||||||
|
createUpdateDeadlineSignal: () => AbortSignal.timeout(UPDATE_QUIT_DEADLINE_MS),
|
||||||
|
onStopError: (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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// electron-updater forwards this event through Electron's built-in autoUpdater.
|
||||||
|
electronAutoUpdater.on("before-quit-for-update", quitLifecycle.handleBeforeQuitForUpdate);
|
||||||
|
app.on("before-quit", quitLifecycle.handleBeforeQuit);
|
||||||
|
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
if (process.platform !== "darwin") {
|
if (process.platform !== "darwin") {
|
||||||
|
|||||||
Reference in New Issue
Block a user