From fed614e79faaf96704a833fdeae824b80fb1844a Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Tue, 6 Oct 2026 01:40:06 +0100 Subject: [PATCH] Settings > Update never shows an empty status line: before the first check it says so, and opening the tab runs the launch check at once The updater's idle state had no message, so for the 5 seconds before the launch check the status line was blank. Idle now reads "Updates haven't been checked yet." The status gains pending, true while the launch check waits: Settings > Update on screen then runs it at once, and any check run sooner takes the timer's place, so a launch never checks twice. --- docs/updates.md | 4 ++++ e2e/update-tab.spec.js | 35 ++++++++++++++++++++++++++++++++--- main/updater.js | 19 +++++++++++++++---- page/kit.js | 29 +++++++++++++++++++++++++---- test/updater.test.js | 18 +++++++++++++++++- 5 files changed, 93 insertions(+), 12 deletions(-) diff --git a/docs/updates.md b/docs/updates.md index d717e58..8228ec9 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -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 @@ -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`, diff --git a/e2e/update-tab.spec.js b/e2e/update-tab.spec.js index da6c5ed..9d49fad 100644 --- a/e2e/update-tab.spec.js +++ b/e2e/update-tab.spec.js @@ -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) => { @@ -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."); @@ -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); @@ -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); diff --git a/main/updater.js b/main/updater.js index d251767..03bed01 100644 --- a/main/updater.js +++ b/main/updater.js @@ -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; /** @@ -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; @@ -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() { @@ -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); + } } /** @@ -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(); diff --git a/page/kit.js b/page/kit.js index d7e1d16..338990a 100644 --- a/page/kit.js +++ b/page/kit.js @@ -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" } }); @@ -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) => { @@ -187,6 +188,7 @@ }); placeIndicator({ animate: true }); if (focus) buttons[index].focus(); + if (!quiet) onSelect?.(id); } tablist.addEventListener("click", (event) => { @@ -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 }; } @@ -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": @@ -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"); @@ -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) { @@ -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) { @@ -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); } diff --git a/test/updater.test.js b/test/updater.test.js index 416cd9a..a59932f 100644 --- a/test/updater.test.js +++ b/test/updater.test.js @@ -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) => { @@ -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");