Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions electron/background-update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from "vitest";
import {
planBackgroundUpdate,
runUnblockedDownloadAndInstall,
shouldQuitAndInstallAfterRestartPrompt,
shouldStartBackgroundUpdateTimer,
} from "./background-update";

describe("background update policy", () => {
it("starts no timer on a Store / non-owning channel", () => {
expect(shouldStartBackgroundUpdateTimer({ isPackaged: true, ownsItsUpdates: false })).toBe(
false,
);
expect(shouldStartBackgroundUpdateTimer({ isPackaged: false, ownsItsUpdates: true })).toBe(
false,
);
expect(shouldStartBackgroundUpdateTimer({ isPackaged: true, ownsItsUpdates: true })).toBe(true);
});

it("does not plan a current-version dialog on the background path", () => {
expect(planBackgroundUpdate({ outcome: { kind: "current" }, mode: "notify" })).toEqual({
action: "none",
});
expect(
planBackgroundUpdate({ outcome: { kind: "unsupported" }, mode: "download-and-install" }),
).toEqual({ action: "none" });
});

it("plans notify / download / download-and-install from an available update", () => {
const outcome = { kind: "downloaded" as const, version: "1.10.0" };
expect(planBackgroundUpdate({ outcome, mode: "notify" })).toEqual({
action: "notify-available",
version: "1.10.0",
});
expect(planBackgroundUpdate({ outcome, mode: "download" })).toEqual({
action: "download",
version: "1.10.0",
});
expect(planBackgroundUpdate({ outcome, mode: "download-and-install" })).toEqual({
action: "download-and-install",
version: "1.10.0",
});
});

it("does not call quitAndInstall until Restart Now returns 0", async () => {
const install = vi.fn();
const cancelled = await runUnblockedDownloadAndInstall({
download: async () => ({ kind: "downloaded", version: "1.10.0" }),
blocked: () => null,
confirmRestart: async () => 1,
install,
});
expect(cancelled).toEqual({ status: "cancelled" });
expect(install).not.toHaveBeenCalled();
expect(shouldQuitAndInstallAfterRestartPrompt(1)).toBe(false);

const installed = await runUnblockedDownloadAndInstall({
download: async () => ({ kind: "downloaded", version: "1.10.0" }),
blocked: () => null,
confirmRestart: async () => 0,
install,
});
expect(installed).toEqual({ status: "installed" });
expect(install).toHaveBeenCalledTimes(1);
expect(shouldQuitAndInstallAfterRestartPrompt(0)).toBe(true);
});

it("hands the download error back so the dialog can show its message", async () => {
const error = new Error("ECONNRESET mid-download");
const failed = await runUnblockedDownloadAndInstall({
download: async () => ({ kind: "failed", error }),
blocked: () => null,
confirmRestart: async () => 0,
install: vi.fn(),
});
expect(failed).toEqual({ status: "failed", error });
});

it("never reaches the restart prompt without a downloaded update", async () => {
// downloadSelfUpdate cannot return these today, but the type admits
// them; a current/unsupported outcome must not prompt or install.
for (const kind of ["current", "unsupported"] as const) {
const confirmRestart = vi.fn(async () => 0);
const install = vi.fn();
const result = await runUnblockedDownloadAndInstall({
download: async () => ({ kind }),
blocked: () => null,
confirmRestart,
install,
});
expect(result).toEqual({ status: "unavailable" });
expect(confirmRestart).not.toHaveBeenCalled();
expect(install).not.toHaveBeenCalled();
}
});
});
79 changes: 79 additions & 0 deletions electron/background-update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { UpdateOutcome } from "./auto-updater";

/** How far a background-discovered update may go on its own. No mode ever
* flips `autoInstallOnAppQuit`: install is always an explicit
* `quitAndInstall` behind a restart prompt, because `window-all-closed`
* quits this app and the HUD is a window — install-on-quit would fire a
* ~243 MB installer when the user merely closed the HUD. */
export type UpdateMode = "notify" | "download" | "download-and-install";

export const DEFAULT_UPDATE_MODE: UpdateMode = "notify";
export const BACKGROUND_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000;

export function parseUpdateMode(raw: unknown): UpdateMode {
if (raw === "notify" || raw === "download" || raw === "download-and-install") return raw;
return DEFAULT_UPDATE_MODE;
}

export function shouldStartBackgroundUpdateTimer(input: {
isPackaged: boolean;
ownsItsUpdates: boolean;
}): boolean {
return input.isPackaged && input.ownsItsUpdates;
}

export type BackgroundUpdatePlan =
| { action: "none" }
| { action: "notify-available"; version: string }
| { action: "download"; version: string }
| { action: "download-and-install"; version: string };

/** Background path: never plan a "you are current" dialog. */
export function planBackgroundUpdate(input: {
outcome: UpdateOutcome;
mode: UpdateMode;
}): BackgroundUpdatePlan {
if (input.outcome.kind !== "downloaded") return { action: "none" };
switch (input.mode) {
case "notify":
return { action: "notify-available", version: input.outcome.version };
case "download":
return { action: "download", version: input.outcome.version };
case "download-and-install":
return { action: "download-and-install", version: input.outcome.version };
}
}

export function shouldQuitAndInstallAfterRestartPrompt(response: number): boolean {
return response === 0;
}

/** The failure carries the download error: the dialog that reports it shows
* `error.message` as detail, and collapsing the outcome to a bare string
* here is exactly how that detail once got lost between the helper and the
* caller. */
export type DownloadAndInstallResult =
| { status: "failed"; error: Error }
| { status: "unavailable" }
| { status: "blocked" }
| { status: "cancelled" }
| { status: "installed" };

export async function runUnblockedDownloadAndInstall(deps: {
download: () => Promise<UpdateOutcome>;
blocked: () => string | null;
confirmRestart: () => Promise<number>;
install: () => Promise<void>;
}): Promise<DownloadAndInstallResult> {
const downloaded = await deps.download();
if (downloaded.kind === "failed") return { status: "failed", error: downloaded.error };
// `downloadSelfUpdate` only ever reports downloaded|failed today, but the
// UpdateOutcome type admits current|unsupported — neither of which may
// reach the restart prompt, let alone quitAndInstall.
if (downloaded.kind !== "downloaded") return { status: "unavailable" };
if (deps.blocked()) return { status: "blocked" };
const choice = await deps.confirmRestart();
if (!shouldQuitAndInstallAfterRestartPrompt(choice)) return { status: "cancelled" };
await deps.install();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return { status: "installed" };
}
Loading
Loading