Skip to content
Merged
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
106 changes: 92 additions & 14 deletions src/main/updates/autoUpdater.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,49 @@
import { describe, expect, it, vi } from "vitest";

const autoUpdaterMock = vi.hoisted(() => ({
autoDownload: false,
autoInstallOnAppQuit: true,
forceDevUpdateConfig: false,
allowPrerelease: false,
channel: "",
checkForUpdates: vi.fn<() => Promise<void>>(),
downloadUpdate: vi.fn<() => Promise<void>>(),
on: vi.fn<(event: string, listener: (...args: unknown[]) => void) => void>(),
quitAndInstall: vi.fn<(isSilent?: boolean, isForceRunAfter?: boolean) => void>(),
setFeedURL: vi.fn<(options: unknown) => void>(),
}));
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const autoUpdaterMock = vi.hoisted(() => {
const handlers = new Map<string, (...args: unknown[]) => void>();
return {
autoDownload: false,
autoInstallOnAppQuit: true,
forceDevUpdateConfig: false,
allowPrerelease: false,
channel: "",
checkForUpdates: vi.fn<() => Promise<void>>(),
downloadUpdate: vi.fn<() => Promise<void>>(),
on: vi.fn<(event: string, listener: (...args: unknown[]) => void) => void>(
(event, listener) => {
handlers.set(event, listener);
},
),
quitAndInstall: vi.fn<(isSilent?: boolean, isForceRunAfter?: boolean) => void>(),
setFeedURL: vi.fn<(options: unknown) => void>(),
/** Test helper: invoke a registered electron-updater event listener. */
emit(event: string, ...args: unknown[]) {
handlers.get(event)?.(...args);
},
};
});

vi.mock("electron-updater", () => ({
autoUpdater: autoUpdaterMock,
}));

import { createAutoUpdaterController } from "./autoUpdater";

const INITIAL_CHECK_DELAY_MS = 30_000;
const PERIODIC_CHECK_INTERVAL_MS = 60 * 60 * 1_000;

describe("createAutoUpdaterController", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined);
});

afterEach(() => {
vi.useRealTimers();
});

it("runs the install hook before quitAndInstall", () => {
const beforeInstall = vi.fn<() => void>();
const controller = createAutoUpdaterController(
Expand All @@ -37,4 +61,58 @@ describe("createAutoUpdaterController", () => {
);
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(process.platform === "win32", true);
});

it("runs an initial check after launch and then keeps checking on the hourly interval", async () => {
const controller = createAutoUpdaterController(vi.fn(), "stable", false);
controller.initialize();

expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled();

await vi.advanceTimersByTimeAsync(INITIAL_CHECK_DELAY_MS);
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1);

// Settle the in-flight flag (nothing new found) so the next tick may run.
autoUpdaterMock.emit("update-not-available");

await vi.advanceTimersByTimeAsync(PERIODIC_CHECK_INTERVAL_MS);
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2);
});

it("skips a periodic check while a check or download is still in flight", async () => {
const controller = createAutoUpdaterController(vi.fn(), "stable", false);
controller.initialize();

await vi.advanceTimersByTimeAsync(INITIAL_CHECK_DELAY_MS);
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1);

// Simulate a check that found an update and is mid-download (no terminal
// event yet), so the updater is still busy when the interval fires.
autoUpdaterMock.emit("checking-for-update");
autoUpdaterMock.emit("download-progress", {
percent: 42,
bytesPerSecond: 1000,
transferred: 420,
total: 1000,
});

await vi.advanceTimersByTimeAsync(PERIODIC_CHECK_INTERVAL_MS);
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1);
});

it("stops checking once an update is downloaded and after install", async () => {
const controller = createAutoUpdaterController(vi.fn(), "stable", false);
controller.initialize();

await vi.advanceTimersByTimeAsync(INITIAL_CHECK_DELAY_MS);
autoUpdaterMock.emit("update-downloaded", { version: "1.2.3" });

// An update is staged for install — no point polling further.
await vi.advanceTimersByTimeAsync(PERIODIC_CHECK_INTERVAL_MS);
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1);

// Installing clears the interval, so advancing time does nothing more.
controller.installUpdate();
await vi.advanceTimersByTimeAsync(PERIODIC_CHECK_INTERVAL_MS);
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1);
});
});
58 changes: 53 additions & 5 deletions src/main/updates/autoUpdater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@ import type { LightcodeChannel } from "@/shared/channel";
import type { UpdateStatus } from "@/shared/ipc";
import type { LightcodeDiagnosticTags } from "@/shared/diagnostics/sentryPrivacy";

/**
* Delay before the first update check once the app is ready. Matches VS Code's
* update service, which waits ~30s after startup before its first check.
*/
const INITIAL_CHECK_DELAY_MS = 30_000;

/**
* Cadence for recurring background update checks while the app keeps running.
* Modeled on VS Code's update service, which polls hourly after startup so a
* long-lived window still discovers releases without ever being restarted.
*/
const PERIODIC_CHECK_INTERVAL_MS = 60 * 60 * 1_000;

export interface AutoUpdaterController {
initialize(): void;
checkForUpdate(): Promise<void>;
Expand All @@ -18,6 +31,26 @@ export function createAutoUpdaterController(
beforeInstall: () => void = () => {},
): AutoUpdaterController {
let initialized = false;
// True while a check or download is in flight; gates the periodic timer so a
// scheduled tick never stacks a redundant check on top of an active one.
let checkInFlight = false;
// True once an update is downloaded and waiting to install; we stop polling
// until the user restarts to apply it.
let updateReady = false;
let periodicTimer: ReturnType<typeof setInterval> | null = null;

// Fire a background check, but only when the updater is otherwise idle. Used
// by both the initial launch check and the recurring interval.
function runScheduledCheck(): void {
if (checkInFlight || updateReady) {
return;
}
checkInFlight = true;
void autoUpdater.checkForUpdates().catch((error: unknown) => {
checkInFlight = false;
reportError(error, { "lightcode.feature_area": "updates" });
});
}

function initialize(): void {
if (initialized) {
Expand All @@ -42,15 +75,20 @@ export function createAutoUpdaterController(
}

autoUpdater.on("checking-for-update", () => {
checkInFlight = true;
sendStatus({ type: "checking" });
});
autoUpdater.on("update-available", (info) => {
// A download starts automatically (autoDownload), so stay "in flight".
checkInFlight = true;
sendStatus({ type: "update-available", version: info.version });
});
autoUpdater.on("update-not-available", () => {
checkInFlight = false;
sendStatus({ type: "update-not-available" });
});
autoUpdater.on("download-progress", (progress) => {
checkInFlight = true;
sendStatus({
type: "downloading",
percent: progress.percent,
Expand All @@ -60,18 +98,23 @@ export function createAutoUpdaterController(
});
});
autoUpdater.on("update-downloaded", (info) => {
checkInFlight = false;
updateReady = true;
sendStatus({ type: "downloaded", version: info.version });
});
autoUpdater.on("error", (error) => {
checkInFlight = false;
reportError(error, { "lightcode.feature_area": "updates" });
sendStatus({ type: "error", message: error.message });
});

setTimeout(() => {
void autoUpdater.checkForUpdates().catch((error: unknown) => {
reportError(error, { "lightcode.feature_area": "updates" });
});
}, 3000);
// First check ~30s after launch, then keep checking hourly so an app that
// is never restarted still surfaces new releases (the sidebar install
// affordance reacts to the resulting status).
setTimeout(runScheduledCheck, INITIAL_CHECK_DELAY_MS);
periodicTimer = setInterval(runScheduledCheck, PERIODIC_CHECK_INTERVAL_MS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Suppress background polling error toasts

When the update endpoint is temporarily unreachable (offline laptop, captive portal, flaky GitHub releases), this new hourly interval keeps calling runScheduledCheck; the shared autoUpdater.on("error") handler sends an error status, and the renderer's update handler turns every such status into a danger toast. That means users can get an update-error toast every hour from a background poll they did not initiate, so the scheduled path should either suppress UI error statuses or back off separately from manual checks.

Useful? React with 👍 / 👎.

// Don't let the recurring timer keep the process alive on its own.
periodicTimer.unref?.();
}

async function checkForUpdate(): Promise<void> {
Expand All @@ -95,6 +138,11 @@ export function createAutoUpdaterController(
}

function installUpdate(): void {
// Stop the recurring check so it can't race quitAndInstall.
if (periodicTimer) {
clearInterval(periodicTimer);
periodicTimer = null;
}
beforeInstall();
autoUpdater.quitAndInstall(process.platform === "win32", true);
}
Expand Down