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
4 changes: 4 additions & 0 deletions docs/updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ electron-builder wrote into the app (its `publish` config). Without `updates` th
checked. With it, it runs **only in a packaged app** (`app.isPackaged`; electron-updater isn't even loaded
otherwise), and checks **only when the app says so**: 5 seconds after launch (`updates: { checkOnLaunch:
false }` turns that off), and whenever the page asks (`checkForUpdates()`). It makes no other request.
Settings > Update opened before the launch check has run runs it then, and a check run before it (either
way) takes its place, so the launch never checks twice. Until a check runs, the tab says "Updates haven't
been checked yet."

- **Channels:** `stable` offers finished releases only (electron-updater's `latest`), `beta` betas too, and
`alpha` anything. With no channel saved, the updater saves the running build's own when it starts (an
Expand Down Expand Up @@ -50,6 +53,7 @@ The state, from `getUpdateStatus()` and `onUpdateStatus()`:
| `percent` | the download's, 0–100, or `null` |
| `dot` | whether the update dot shows |
| `auto` | whether it downloaded by itself (the page shows a toast then) |
| `pending` | whether the launch check is still waiting to run |
| `current`, `channel` | the version running, and the channel in use |

The version rules are `require("@diamonddigitaldev/electron-kit/main").version` (`parse`, `compare`,
Expand Down
35 changes: 32 additions & 3 deletions e2e/update-tab.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const { test, expect } = require("./helpers/demo");
const { contrastRatio, parseColor, over } = require("../testing/contrast");

/** The updater's state at rest, as main/updater.js reports it; each test changes what it needs. */
const IDLE = { state: "idle", reason: null, version: null, percent: null, dot: false, auto: false, error: null, current: "0.0.0", channel: "stable" };
const IDLE = { state: "idle", reason: null, version: null, percent: null, dot: false, auto: false, error: null, pending: false, current: "0.0.0", channel: "stable" };

/** Push a state to the page on update:status, as the kit's updater does. */
const push = (demo, status) => demo.app.evaluate(({ BrowserWindow }, status) => {
Expand Down Expand Up @@ -45,7 +45,8 @@ test("the Update tab: the app and version, Check for Updates, automatic download
await expect(pane.locator(".update-version")).toHaveText(`Version ${version}`);
const check = pane.getByRole("button", { name: "Check for Updates" });
if (demo.packaged) {
await expect(pane.locator("#update-status")).toHaveText("");
// Opened before the launch check, the tab runs it: never an empty line.
await expect(pane.locator("#update-status")).not.toHaveText("");
await expect(check).toBeEnabled();
} else {
await expect(pane.getByRole("status")).toHaveText("Updates are checked in the installed app.");
Expand Down Expand Up @@ -105,7 +106,7 @@ test("the status line says each state of the updater, in a live region that keep
[{ state: "error", error: "download", version: "1.1.0", dot: true }, "Version 1.1.0 couldn't be downloaded.", { check: true, download: true, tone: "danger" }],
[{ state: "unavailable", reason: "not-packaged" }, "Updates are checked in the installed app.", { check: false, download: false }],
[{ state: "unavailable", reason: "off" }, "electron-kit Demo doesn't update itself.", { check: false, download: false, off: true }],
[{ state: "idle" }, "", { check: true, download: false }],
[{ state: "idle" }, "Updates haven't been checked yet.", { check: true, download: false }],
];
for (const [pushed, text, expected] of states) {
await push(demo, pushed);
Expand Down Expand Up @@ -354,6 +355,34 @@ test("the download's bar shows only while it downloads, in the accent, and is go
expect(await bar.evaluate((el) => el.querySelector(".progress-bar").style.width)).toBe("0%");
});

test("the launch check waiting runs as soon as Settings > Update is on screen, once, and not before", async ({ demo }) => {
const main = await demo.mainWindow();
await spyOnUpdates(demo);
const calls = () => demo.app.evaluate(() => globalThis.updateCalls);
// Away from the tab, it waits.
await main.getByRole("button", { name: "Settings", exact: true }).click();
await main.getByRole("tab", { name: "Credits" }).click();
await push(demo, { state: "idle", pending: true });
await main.waitForTimeout(300);
expect(await calls()).toEqual([]);

const pane = await openUpdate(main);
await expect.poll(calls).toEqual(["update:check"]);
await expect(pane.locator("#update-status")).toHaveText("Updates haven't been checked yet.");
// Back and forth, before main's answer: asked once.
await main.getByRole("tab", { name: "Credits" }).click();
await main.getByRole("tab", { name: /^Update/ }).click();
await main.waitForTimeout(300);
expect(await calls()).toEqual(["update:check"]);

// On the tab when it arrives, it runs at once; with nothing pending, it never does.
await push(demo, { state: "idle", pending: true });
await expect.poll(calls).toEqual(["update:check", "update:check"]);
await push(demo, { state: "idle" });
await main.waitForTimeout(300);
expect(await calls()).toEqual(["update:check", "update:check"]);
});

test("Restart Now takes Check for Updates' place once it has downloaded, with the focus, and installs at once", async ({ demo }) => {
const main = await demo.mainWindow();
const pane = await openUpdate(main);
Expand Down
19 changes: 15 additions & 4 deletions main/updater.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ const CHANNEL_SETTINGS = Object.freeze({
/** The ID electron-updater is given in place of making one: what every update request sends as x-user-staging-id. */
const STAGING_ID = "00000000-0000-0000-0000-000000000000";

/** How long after launch the launch check runs, so the window is up to show what it finds. */
/**
* How long after launch the launch check runs, so the window is up to show
* what it finds. Settings > Update, opened sooner, runs it then (pending).
*/
const LAUNCH_CHECK_DELAY = 5000;

/**
Expand Down Expand Up @@ -190,6 +193,7 @@ function createUpdater({ options, app, settings, send, load = loadAutoUpdater, s
let dot = false;
let auto = false;
let error = null;
let pending = false;

let autoUpdater = null;
let checking = null;
Expand All @@ -204,10 +208,12 @@ function createUpdater({ options, app, settings, send, load = loadAutoUpdater, s
* - version: the update found, if any, and tag, its release's tag where
* the server has one (GitHub); percent: the download's, 0–100;
* - dot: whether the update dot shows; auto: whether it downloaded by
* itself (the toast shows then).
* itself (the toast shows then);
* - pending: whether the launch check is still waiting to run (the
* Update tab runs it at once when it's opened first).
*/
function status() {
return { state, reason, version: found, tag, percent, dot, auto, error, current, channel: channel() };
return { state, reason, version: found, tag, percent, dot, auto, error, pending, current, channel: channel() };
}

function report() {
Expand Down Expand Up @@ -246,7 +252,11 @@ function createUpdater({ options, app, settings, send, load = loadAutoUpdater, s
report();
}
});
if (options.checkOnLaunch) schedule(() => check().catch(() => {}), LAUNCH_CHECK_DELAY);
if (options.checkOnLaunch) {
pending = true;
// Unless a check has run since: the page's, or the Update tab's at once.
schedule(() => pending && check().catch(() => {}), LAUNCH_CHECK_DELAY);
}
}

/**
Expand All @@ -267,6 +277,7 @@ function createUpdater({ options, app, settings, send, load = loadAutoUpdater, s
async function runCheck() {
const chosen = channel();
state = "checking";
pending = false;
error = null;
reason = null;
report();
Expand Down
29 changes: 25 additions & 4 deletions page/kit.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,9 @@
* tabs sit on a thin line, and a thicker accent bar under the selected
* one slides to the next tab chosen, taking its text's width.
* @param {{ id: string, label: string, render: (pane: HTMLElement) => void }[]} tabs
* @param {(id: string) => void} [onSelect] - told each tab selected
*/
function buildSettings(tabs) {
function buildSettings(tabs, onSelect) {
const view = el("section", { className: "settings-view", attrs: { "aria-labelledby": "settings-title" } });
const tablist = el("div", { className: "settings-tabs", attrs: { role: "tablist", "aria-labelledby": "settings-title" } });
const indicator = el("span", { className: "settings-tab-indicator", attrs: { "aria-hidden": "true" } });
Expand Down Expand Up @@ -175,7 +176,7 @@
}));

/** Show a tab and its pane. Only the selected tab is in the Tab order; the arrow keys move between them. */
function select(id, { focus = false } = {}) {
function select(id, { focus = false, quiet = false } = {}) {
const index = tabs.findIndex((tab) => tab.id === id);
if (index === -1) return;
buttons.forEach((button, i) => {
Expand All @@ -187,6 +188,7 @@
});
placeIndicator({ animate: true });
if (focus) buttons[index].focus();
if (!quiet) onSelect?.(id);
}

tablist.addEventListener("click", (event) => {
Expand All @@ -202,7 +204,8 @@
select(tabs[(next + buttons.length) % buttons.length].id, { focus: true });
});

select(tabs[0].id);
// Quiet: the view is still being built.
select(tabs[0].id, { quiet: true });
return { view, select, pane: (id) => paneOf.get(id), tab: (id) => buttons[tabs.findIndex((tab) => tab.id === id)], selected: () => tabs[buttons.findIndex((b) => b.getAttribute("aria-selected") === "true")].id };
}

Expand Down Expand Up @@ -253,6 +256,8 @@
*/
function updateMessage({ state, reason, error, version, percent }, appName) {
switch (state) {
case "idle":
return { text: "Updates haven't been checked yet." };
case "unavailable":
return { text: reason === "not-packaged" ? "Updates are checked in the installed app." : `${appName} doesn't update itself.` };
case "checking":
Expand Down Expand Up @@ -810,7 +815,7 @@
...settingsTabs.map(({ id, label }) => ({ id, label })),
{ id: "update", label: "Update" },
{ id: "credits", label: "Credits" },
]);
], () => checkIfSeen());
const views = new Map([...sections.map(({ view, element }) => [view, element]), ["settings", settings.view]]);
// The toolbar is for the app's sections: hidden on Settings (kit.css).
toolbar?.classList.add("app-toolbar");
Expand All @@ -834,8 +839,11 @@
// "Checking for updates…" shows for CHECK_SHOWN_FOR at least: what comes sooner waits for it (the latest only).
let checkingSince = 0;
let held = null;
let latest = null;
function showUpdate(status, { pushed = false } = {}) {
clearTimeout(held);
latest = status;
checkIfSeen();
if (status.state === "checking") {
checkingSince = Date.now();
} else if (checkingSince) {
Expand All @@ -855,6 +863,18 @@
}
}

/**
* Settings > Update on screen while the launch check waits: it runs
* now, so the tab says where the updates stand rather than that
* they haven't been checked. The window is up to show what it finds.
*/
function checkIfSeen() {
if (latest?.state === "idle" && latest.pending && current === "settings" && settings.selected() === "update") {
latest = { ...latest, pending: false };
bridge()?.checkForUpdates().catch(() => {});
}
}

// Routing: one route between views, from the rail, the menu, or the app.
let current = null;
function showView(view) {
Expand All @@ -868,6 +888,7 @@
else item.removeAttribute("aria-current");
}
for (const [name, element] of views) element.classList.toggle("kit-view-hidden", name !== view);
checkIfSeen();
onViewChange?.(view);
}

Expand Down
18 changes: 17 additions & 1 deletion test/updater.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,22 @@ test("nothing is checked until the app asks: the launch check is scheduled 5 sec
const off = await updaterFor({ version: "2.0.0", channels: { latest: "2.0.0" }, options: { checkOnLaunch: false } }, t);
assert.deepEqual(off.scheduled, []);
assert.deepEqual(off.server.requests, []);
assert.equal(off.updater.status().pending, false);
});

test("the launch check is pending until a check runs, and a check run sooner takes its place: the launch never checks twice", async (t) => {
const { updater, server, scheduled, sent } = await updaterFor({ version: "2.0.0", channels: { latest: "2.0.0" } }, t);
assert.deepEqual([updater.status().state, updater.status().pending], ["idle", true]);
const checked = updater.check();
assert.equal(sent.at(-1).state, "checking");
assert.equal(sent.at(-1).pending, false, "the page is told at once");
await checked;
const requests = server.requests.length;
assert.ok(requests > 0);
scheduled[0].run();
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(server.requests.length, requests, "the timer checks nothing");
assert.equal(updater.status().state, "none");
});

test("an app run from its source never loads electron-updater, and never checks", async (t) => {
Expand Down Expand Up @@ -455,7 +471,7 @@ test("kit.start() answers update:get-status, update:check, update:download and u
const page = eventFrom(appPage());
// Run by plain Node, the stand-in app isn't packaged.
assert.deepEqual(await handlers.get(INVOKE.UPDATE_GET_STATUS)(page), {
state: "unavailable", reason: "not-packaged", version: null, tag: null, percent: null, dot: false, auto: false, error: null, current: "2.0.0", channel: "stable",
state: "unavailable", reason: "not-packaged", version: null, tag: null, percent: null, dot: false, auto: false, error: null, pending: false, current: "2.0.0", channel: "stable",
});
assert.equal((await handlers.get(INVOKE.UPDATE_CHECK)(page)).state, "unavailable");
assert.equal((await handlers.get(INVOKE.UPDATE_DOWNLOAD)(page)).state, "unavailable");
Expand Down
Loading