From f8cca7a4939a5d0154681f01d65321c17158062c Mon Sep 17 00:00:00 2001 From: Shreyan C Date: Sat, 3 Oct 2026 17:36:14 +0530 Subject: [PATCH] fix(updates): no install without a staged update, and a tested update flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Restart & Install" showed under "Up to Date" because .btn-p/.btn-g set a display of their own, which beats the user agent's [hidden] rule. Clicking it asked electron-updater to install nothing, which surfaced as UPD-99. The base rule now honours [hidden], and a test fails on any statically hidden element whose class overrides it. The main-process updater moves to electron/updates.cjs so it can be driven by a fake updater in tests/updates.test.js. Along the way: - install is refused unless an update is staged; a failed install is UPD-07 and stops offering the restart - autoInstallOnAppQuit is set on purpose, and "Update Ready" says a staged update installs at the next quit - a long session rechecks every six hours - release notes reach the dialog as plain text under "What's new" - a background download that fails marks the menu item "Update Failed — Retry" - the download promise's rejection is handled; its error is reported once --- css/modals.css | 48 ++++- css/panels.css | 5 - docs/update-error-codes.md | 17 +- electron/main.cjs | 221 ++------------------ electron/preload.cjs | 2 +- electron/updates.cjs | 395 ++++++++++++++++++++++++++++++++++++ index.html | 5 + js/electron-bridge.js | 89 ++++---- js/update-view.js | 72 +++++++ tests/hidden-markup.test.js | 48 +++++ tests/updates.test.js | 320 +++++++++++++++++++++++++++++ 11 files changed, 968 insertions(+), 254 deletions(-) create mode 100644 electron/updates.cjs create mode 100644 js/update-view.js create mode 100644 tests/hidden-markup.test.js create mode 100644 tests/updates.test.js diff --git a/css/modals.css b/css/modals.css index 931c15e..7f606a7 100644 --- a/css/modals.css +++ b/css/modals.css @@ -2537,6 +2537,15 @@ button.sm-stat:disabled { transform var(--transition-fast); } +/* The display above outranks the user agent's [hidden] rule, so without this a + button hidden from script stays on screen. #update-install was the one that + showed: "Restart & Install" sat under "Up to Date", and clicking it asked the + updater to install a download that did not exist. */ +.btn-p[hidden], +.btn-g[hidden] { + display: none; +} + .btn-icon { width: 13px; height: 13px; @@ -3187,11 +3196,48 @@ button.sm-stat:disabled { transition: width var(--transition-fast); } +/* "What's new" in #update-modal: the release body as plain text, one line per + paragraph or bullet. It scrolls on its own so a long changelog cannot push the + buttons off the dialog. No display rule here on purpose, so [hidden] still hides. */ +.update-notes { + margin-top: 16px; +} + +.update-notes-head { + font-size: .7rem; + font-weight: 600; + letter-spacing: .04em; + text-transform: uppercase; + color: var(--text3); + margin-bottom: 6px; +} + +.update-notes-body { + max-height: 180px; + overflow-y: auto; + padding: 10px 12px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 7px; + font-family: var(--sans); + font-size: .78rem; + line-height: 1.5; + color: var(--text2); + white-space: pre-line; + overflow-wrap: anywhere; +} + .ctx-i.active { color: var(--accent); } -.ctx-i.active svg { +/* The header's update item after a download failed with nobody watching. */ +.ctx-i.is-failed { + color: var(--red); +} + +.ctx-i.active svg, +.ctx-i.is-failed svg { opacity: 1; } diff --git a/css/panels.css b/css/panels.css index 4b51cc4..78835c8 100644 --- a/css/panels.css +++ b/css/panels.css @@ -2841,11 +2841,6 @@ button.dt-cell:hover { padding: 5px 12px; } -/* `.btn-g` sets a display of its own, which beats the UA's [hidden]. */ -.panel-float-return[hidden] { - display: none; -} - /* ─── desktop and Electron only ─── Not a breakpoint but a device test, and the same one js/panel-float.js asks: a window wants a pointer that can hover a 6px resize band and hold a title diff --git a/docs/update-error-codes.md b/docs/update-error-codes.md index 52b37f0..9f13d6b 100644 --- a/docs/update-error-codes.md +++ b/docs/update-error-codes.md @@ -1,8 +1,13 @@ # Update error codes -The desktop app checks for updates on startup, and again whenever you pick -**⋯ → Check for Updates**. If a check fails it shows a short explanation and a code -like `UPD-02`. This page says what each code means. +The desktop app checks for updates on startup, every few hours while it stays open, +and whenever you pick **⋯ → Check for Updates**. If a check, a download or an +install fails it shows a short explanation and a code like `UPD-02`. This page says +what each code means. + +A download that fails in the background, with no dialog open, turns the menu item +red and changes it to **Update Failed — Retry**; picking it checks again and shows +the code if it fails a second time. Codes only appear in the desktop app (Windows and the Linux AppImage). The website is always up to date, and the macOS and `.deb` builds are updated by hand. @@ -17,6 +22,7 @@ is always up to date, and the macOS and `.deb` builds are updated by hand. | `UPD-04` | The update server reported a problem on its side. | Nothing to fix locally; try again later. | | `UPD-05` | An update downloaded, but its contents did not match what the server said they should be, so it was discarded rather than installed. | Try again. If it keeps happening, report it — see below. | | `UPD-06` | This copy of the app has no update channel, so it cannot update itself. Expected for the macOS and `.deb` builds, and when running from source. | [Download the latest version](https://github.com/thethinkmachine/AutomataStudio/releases/latest) manually. | +| `UPD-07` | An update was downloaded, but its installer could not be started — usually because the downloaded file has since been removed (by a disk cleaner or antivirus, say) or was blocked from running. | Pick **Check for Updates** to download it again, or [download the latest version](https://github.com/thethinkmachine/AutomataStudio/releases/latest) and run its installer. | | `UPD-99` | Something failed that does not match any case above. | Report it — see below. | `UPD-05` is a safety feature, not a bug in itself: the app refuses to install @@ -36,6 +42,7 @@ updater are prefixed `[updater]`. ## For maintainers The codes are defined in the `UpdateErrors` table in -[`electron/main.cjs`](../electron/main.cjs), and `classifyUpdateError()` beside it +[`electron/updates.cjs`](../electron/updates.cjs), and `classifyUpdateError()` beside it decides which one an error maps to. Adding a code means adding a row there **and** a -row here — a code with no entry on this page is worse than no code at all. +row here — a code with no entry on this page is worse than no code at all, and +`tests/updates.test.js` fails until both exist. diff --git a/electron/main.cjs b/electron/main.cjs index 4ccd900..d5982bc 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -6,6 +6,7 @@ const path = require('node:path'); const fs = require('node:fs/promises'); const fsSync = require('node:fs'); const { CLAUDE_CODE_URL, runClaudeCode } = require('./claude-code.cjs'); +const { createUpdateController } = require('./updates.cjs'); // `AutomataStudio --cli …` runs the command line instead of the app: // this executable re-run as Node (ELECTRON_RUN_AS_NODE) on the bundled CLI, @@ -313,35 +314,13 @@ if (process.defaultApp && process.argv.length >= 2) { // canAutoUpdate() the startup check uses -- two copies of that rule would drift, // and the copy in the renderer cannot see app.isPackaged or APPIMAGE anyway. ipcMain.handle('updates-supported', () => canAutoUpdate()); -ipcMain.on('check-for-updates', () => checkForUpdatesManually()); -// The other half of sendUpdateStatus: the page asks for the state it may have -// missed. update-status is a broadcast into a window that is still loading, so -// without this the renderer's only knowledge of the updater is whatever happened -// to arrive after its listener existed. See lastUpdateStatus. -ipcMain.handle('update-state', () => lastUpdateStatus); - -// quitAndInstall closes the window on the way out, which runs the page's -// beforeunload backup save exactly as an ordinary quit does. -// -// It can also decline, and silently: BaseUpdater.install() returns false without -// throwing when quitAndInstallCalled is already set or the downloaded file is no -// longer known, and dispatchError's only listener writes to a console a packaged -// GUI app does not have. The page was left showing "Restart & Install" over a -// button that had become a no-op for the rest of the session -- which reads as an -// update that refuses to install. A refusal is now an error like any other. -ipcMain.on('install-update', () => { - if (!updater) { - sendUpdateStatus({ state: 'error', ...UpdateErrors.UNSUPPORTED }); - return; - } - try { - updater.quitAndInstall(); - } catch (err) { - console.error('[updater] install failed:', err); - const { code, message } = classifyUpdateError(err); - sendUpdateStatus({ state: 'error', code, message }); - } -}); +ipcMain.on('check-for-updates', () => updates.checkManually()); +// The page asks for the status it may have missed: update-status is a broadcast +// into a window that may still be loading. See lastStatus in electron/updates.cjs. +ipcMain.handle('update-state', () => updates.lastStatus()); +// Refused unless something is staged, and a refused install is reported as UPD-07 +// rather than left as a button that does nothing. See install() in updates.cjs. +ipcMain.on('install-update', () => updates.install()); // ── StateMate transport ─────────────────────────────────────────── // The renderer hands over a fully-formed request and gets the raw response @@ -798,18 +777,13 @@ function canAutoUpdate() { return false; } -// The startup check and the Help menu's manual check share one updater, so the -// listeners below are registered exactly once no matter which runs first. +// electron-updater's autoUpdater, or null when it cannot be loaded. Everything the +// updater *does* -- checks, downloads, installs, what the page is told -- lives in +// electron/updates.cjs; this is only the Electron half it cannot reach itself. let updater = null; let updaterUnavailable = false; -// Set only by the manual check, and read once, when the download lands: it is what -// tells 'update-downloaded' whether a human is waiting on this. A startup download -// reports itself `silent` and only marks the menu item; a requested one opens the -// dialog the user asked for. -let promptOnDownloaded = false; -let manualCheckRunning = false; - -function getAutoUpdater() { + +function loadUpdater() { if (updater || updaterUnavailable) return updater; // Required here rather than at the top of the file, and inside the try, because @@ -826,36 +800,6 @@ function getAutoUpdater() { console.error('[updater] unavailable:', err?.message ?? err); return null; } - - // Load-bearing: a failed update check must be a no-op, and by default it is not. - // autoUpdater is an EventEmitter, so an 'error' with no listener registered - // becomes an uncaught exception. Being offline, or hitting a release whose - // latest.yml has not finished uploading, would otherwise take down an app that - // was working fine without ever having updated. The manual check reports failures - // through its own rejected promise; this keeps the process alive either way. - updater.on('error', (err) => { - console.error('[updater]', err?.message ?? err); - // Forwarded as well as logged, because this is the only channel a *failed - // install* has: quitAndInstall() reports through dispatchError rather than by - // throwing. It stays safe for a background check because the renderer drops an - // error arriving while #update-modal is closed -- so a failed startup check is - // still the no-op it has to be, and a failed click is not. - const { code, message } = classifyUpdateError(err); - sendUpdateStatus({ state: 'error', code, message }); - }); - - updater.on('download-progress', ({ percent }) => { - sendUpdateStatus({ state: 'downloading', percent: Math.round(percent) }); - }); - - updater.on('update-downloaded', ({ version }) => { - // `silent` separates the startup check from a click. The startup download - // must not take over the screen, so the page only marks its menu item; a - // click opts into the dialog. Either way the update is already on disk. - sendUpdateStatus({ state: 'downloaded', version, silent: !promptOnDownloaded }); - promptOnDownloaded = false; - }); - return updater; } @@ -864,140 +808,17 @@ function getAutoUpdater() { // is the one piece of window chrome this app does not draw itself, and it would be // the only framed surface in a frameless window. See js/electron-bridge.js for the // receiving end and index.html #update-modal for the markup. -// The last thing sent, replayed over the 'update-state' channel above. Broadcasts -// are fire-and-forget into a window that may not have finished loading: the startup -// check begins at whenReady, while js/electron-bridge.js does not register its -// listener until the whole module graph has evaluated. On the second and later -// launches the installer is already in the pending cache, so 'update-downloaded' -// fires a second or two in -- squarely inside that gap -- and the page never heard -// that an update was staged, never offered the install, and re-checked from scratch -// on the next launch. Forever. -let lastUpdateStatus = null; - -function sendUpdateStatus(payload) { - lastUpdateStatus = payload; - mainWindow?.webContents.send('update-status', payload); -} - -// What the user is shown when a check fails. electron-updater's own errors are -// unusable here: an HttpError stringifies to the entire response -- status, request -// URL, then every response header, Set-Cookie included -- which fills the dialog -// with session cookies and tells a non-developer nothing they can act on. -// -// So each failure becomes one of these: a sentence saying what to do, plus a stable -// code to quote in a bug report. The code is the half that survives translation, -// screenshots and paraphrasing, which is why it is shown even though the sentence -// is the useful part. Keep this table and docs/update-error-codes.md in step -- -// a code with no entry there is worse than no code. -const UpdateErrors = { - OFFLINE: { code: 'UPD-01', message: 'Could not reach the update server. Check your internet connection and try again.' }, - NO_RELEASE: { code: 'UPD-02', message: 'No update information has been published yet. Please try again later.' }, - REFUSED: { code: 'UPD-03', message: 'The update server refused the request. Please try again in a few minutes.' }, - SERVER: { code: 'UPD-04', message: 'The update server is having problems. Please try again later.' }, - CORRUPT: { code: 'UPD-05', message: 'The downloaded update failed its safety check and was discarded. Please try again.' }, - UNSUPPORTED: { code: 'UPD-06', message: 'This copy cannot update itself. Please download the latest version manually.' }, - UNKNOWN: { code: 'UPD-99', message: 'Something went wrong while checking for updates.' }, -}; - -const NETWORK_ERRNOS = new Set([ - 'ENOTFOUND', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', - 'ENETUNREACH', 'EHOSTUNREACH', 'EAI_AGAIN', 'EPIPE', -]); - -function classifyUpdateError(err) { - const text = String(err?.message ?? err); - if (err?.code === 'UPDATER_UNAVAILABLE') return UpdateErrors.UNSUPPORTED; - if (NETWORK_ERRNOS.has(err?.code)) return UpdateErrors.OFFLINE; - if (/checksum|sha512|signature/i.test(text)) return UpdateErrors.CORRUPT; - - // The provider usually rethrows its HttpError wrapped in a plain Error, so the - // status survives only inside the message text -- hence the fallback parse. - const status = typeof err?.statusCode === 'number' - ? err.statusCode - : Number(/HttpError:\s*(\d{3})/.exec(text)?.[1]) || null; - - // "Cannot find latest.yml …" means the release exists but carries no manifest, - // which is the same story for the user as no release at all. - if (status === 404 || /Cannot find .*(?:in the (?:latest )?release|update info)/i.test(text)) { - return UpdateErrors.NO_RELEASE; - } - if (status === 401 || status === 403 || status === 429) return UpdateErrors.REFUSED; - if (status !== null && status >= 500) return UpdateErrors.SERVER; - return UpdateErrors.UNKNOWN; -} - -// electron-updater never empties its own pending directory, so the installer for a -// version that has since been installed stays on disk at full size -- two of them -// here, 100 MB each, for 2.0.0 and 2.5.0. It is only cleared on the way to -// *replacing* it (a cached file whose checksum no longer matches the manifest), and -// "there is nothing newer to install" never takes that path. So do it here, which is -// the one moment the answer is known to be that. -async function clearStaleUpdateCache(u) { - try { - await u.downloadedUpdateHelper?.clear(); - } catch (err) { - // Best-effort: a locked or missing cache is not a reason to fail a check that - // has already succeeded. - console.error('[updater] could not clear pending cache:', err?.message ?? err); - } -} +const updates = createUpdateController({ + loadUpdater, + appVersion: () => app.getVersion(), + send: payload => mainWindow?.webContents.send('update-status', payload), +}); +// checkForUpdates, not checkForUpdatesAndNotify: the latter raises an OS +// notification, and every surface this feature has belongs inside the window. function initAutoUpdater() { if (!canAutoUpdate()) return; - const u = getAutoUpdater(); - if (!u) return; - - // checkForUpdates, not checkForUpdatesAndNotify: the latter raises an OS - // notification, and every surface this feature has belongs inside the window. - // autoDownload is on, so a staged update announces itself through the - // 'update-downloaded' handler above, which marks the menu item and nothing more. - u.checkForUpdates() - .then(result => { if (result && !result.isUpdateAvailable) clearStaleUpdateCache(u); }) - .catch(() => {}); -} - -// Wired to Help > Check for Updates…, which is only built when canAutoUpdate() is -// true -- an always-present item that can only ever answer "not supported here" -// is worse than no item at all. -async function checkForUpdatesManually() { - // checkForUpdates() reuses one in-flight promise internally, so a second click - // would silently resolve against the first check's result. Refusing re-entry - // keeps one click to one visible answer. - if (manualCheckRunning) return; - manualCheckRunning = true; - sendUpdateStatus({ state: 'checking' }); - try { - const u = getAutoUpdater(); - if (!u) { - const unavailable = new Error('The updater module failed to load.'); - unavailable.code = 'UPDATER_UNAVAILABLE'; - throw unavailable; - } - - const result = await u.checkForUpdates(); - // isUpdateAvailable is the provider's own verdict. The manifest names the latest - // version whether or not it is newer, so comparing version strings here would - // reimplement the comparison electron-updater has already done. - if (!result?.isUpdateAvailable) { - sendUpdateStatus({ state: 'up-to-date', version: app.getVersion() }); - clearStaleUpdateCache(u); - return; - } - - // autoDownload is on, so the fetch is already running by the time checkForUpdates - // resolves; 'download-progress' and 'update-downloaded' carry it from here. - promptOnDownloaded = true; - sendUpdateStatus({ state: 'available', version: result.updateInfo.version }); - } catch (err) { - promptOnDownloaded = false; - // The whole error goes here, where a developer can read it; only the code and - // the sentence cross to the window. - console.error('[updater] manual check failed:', err); - const { code, message } = classifyUpdateError(err); - sendUpdateStatus({ state: 'error', code, message }); - } finally { - manualCheckRunning = false; - } + updates.start(); } // Double-clicking a second `.automaton` file while the app is running must open diff --git a/electron/preload.cjs b/electron/preload.cjs index 7ae1ff2..49570f5 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -17,7 +17,7 @@ contextBridge.exposeInMainWorld('electronAPI', { installUpdate: () => ipcRenderer.send('install-update'), // The state the page may have missed. update-status is broadcast into a window // that is still loading, and the startup check can resolve before this script's - // consumer exists — see lastUpdateStatus in electron/main.cjs. Resolves null when + // consumer exists — see lastStatus in electron/updates.cjs. Resolves null when // nothing has been reported yet. updateState: () => ipcRenderer.invoke('update-state'), // callback({ state, version?, percent?, message?, silent? }); returns an diff --git a/electron/updates.cjs b/electron/updates.cjs new file mode 100644 index 0000000..4ffd5ed --- /dev/null +++ b/electron/updates.cjs @@ -0,0 +1,395 @@ +// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0 +// Copyright (c) 2026 Shreyan Chaubey. See LICENSE. +// +// ── Software update ─────────────────────────────────────────────── +// The updater's whole life in the main process: when to check, what a download +// and an install are doing, and what the page is told about it. main.cjs owns only +// the Electron half -- whether this build can update at all, loading +// electron-updater, and the IPC channels -- so this module takes the updater and +// a send() function and nothing else. That is what lets tests/updates.test.js +// drive it with a fake updater: the app's only update bugs so far were in exactly +// this sequencing, and a packaged build is the one place nobody can step through. +// +// The page's half is js/electron-bridge.js, which renders each status into +// #update-modal through js/update-view.js. + +// What the user is shown when something fails. electron-updater's own errors are +// unusable here: an HttpError stringifies to the entire response -- status, request +// URL, then every response header, Set-Cookie included -- which fills the dialog +// with session cookies and tells a non-developer nothing they can act on. +// +// So each failure becomes one of these: a sentence saying what to do, plus a stable +// code to quote in a bug report. The code is the half that survives translation, +// screenshots and paraphrasing, which is why it is shown even though the sentence +// is the useful part. Keep this table and docs/update-error-codes.md in step -- +// tests/updates.test.js fails on a code with no entry there. +const UpdateErrors = { + OFFLINE: { code: 'UPD-01', message: 'Could not reach the update server. Check your internet connection and try again.' }, + NO_RELEASE: { code: 'UPD-02', message: 'No update information has been published yet. Please try again later.' }, + REFUSED: { code: 'UPD-03', message: 'The update server refused the request. Please try again in a few minutes.' }, + SERVER: { code: 'UPD-04', message: 'The update server is having problems. Please try again later.' }, + CORRUPT: { code: 'UPD-05', message: 'The downloaded update failed its safety check and was discarded. Please try again.' }, + UNSUPPORTED: { code: 'UPD-06', message: 'This copy cannot update itself. Please download the latest version manually.' }, + // Its own code because "checking" and "installing" fail for different reasons and + // call for different remedies: a check can simply be retried, while an installer + // that will not start is usually gone from disk or blocked, and the dependable + // way out is the installer from the release page. + INSTALL: { code: 'UPD-07', message: 'The update could not be installed. Please download the latest version manually and run its installer.' }, + UNKNOWN: { code: 'UPD-99', message: 'Something went wrong while updating.' }, +}; + +const NETWORK_ERRNOS = new Set([ + 'ENOTFOUND', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', + 'ENETUNREACH', 'EHOSTUNREACH', 'EAI_AGAIN', 'EPIPE', +]); + +function classifyUpdateError(err) { + const text = String(err?.message ?? err); + if (err?.code === 'UPDATER_UNAVAILABLE') return UpdateErrors.UNSUPPORTED; + if (NETWORK_ERRNOS.has(err?.code)) return UpdateErrors.OFFLINE; + if (/checksum|sha512|signature/i.test(text)) return UpdateErrors.CORRUPT; + + // The provider usually rethrows its HttpError wrapped in a plain Error, so the + // status survives only inside the message text -- hence the fallback parse. + const status = typeof err?.statusCode === 'number' + ? err.statusCode + : Number(/HttpError:\s*(\d{3})/.exec(text)?.[1]) || null; + + // "Cannot find latest.yml …" means the release exists but carries no manifest, + // which is the same story for the user as no release at all. + if (status === 404 || /Cannot find .*(?:in the (?:latest )?release|update info)/i.test(text)) { + return UpdateErrors.NO_RELEASE; + } + if (status === 401 || status === 403 || status === 429) return UpdateErrors.REFUSED; + if (status !== null && status >= 500) return UpdateErrors.SERVER; + return UpdateErrors.UNKNOWN; +} + +// ── Release notes ───────────────────────────────────────────────── +// The GitHub provider hands over the release body as HTML (or, with fullChangelog, +// an array of {version, note}). The page gets plain text and nothing else: it is +// shown with textContent, so markup could not run there anyway, but HTML crossing +// the IPC boundary would invite someone to set it as innerHTML later -- and the +// release body is text anyone with write access to the repo can change. +const NOTES_MAX_CHARS = 4000; + +const NAMED_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }; + +function decodeEntities(text) { + return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, name) => { + if (name[0] === '#') { + const cp = name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10); + return Number.isFinite(cp) && cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : whole; + } + return NAMED_ENTITIES[name.toLowerCase()] ?? whole; + }); +} + +function releaseNotesText(notes) { + if (Array.isArray(notes)) notes = notes.map(n => n?.note).filter(Boolean).join('\n'); + if (typeof notes !== 'string') return null; + const text = decodeEntities(notes + .replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, '') + .replace(//gi, '\n') + // A list item starts a line of its own with a bullet; its closing tag adds + // nothing, or every item would be followed by an empty line. + .replace(/]*>/gi, '\n• ') + .replace(/<\/(?:p|div|h[1-6]|ul|ol|pre|blockquote|tr|table)\s*>/gi, '\n') + .replace(/<[^>]*>/g, '')); + // Blank lines are dropped outright: in a dialog this size they read as gaps, + // and the bullets and headings already separate one thing from the next. + let out = text.split('\n').map(line => line.replace(/\s+/g, ' ').trim()).filter(Boolean).join('\n'); + if (!out) return null; + if (out.length > NOTES_MAX_CHARS) out = `${out.slice(0, NOTES_MAX_CHARS).trimEnd()}…`; + return out; +} + +// ── The controller ──────────────────────────────────────────────── +// A long-running session hears about a release this often. Six hours keeps a +// laptop that is opened in the morning and closed at night to one or two extra +// requests a day -- far inside GitHub's unauthenticated rate limit, which is the +// failure (UPD-03) a tighter loop would buy. +const RECHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; + +/** + * @param {object} o + * @param {() => object|null} o.loadUpdater electron-updater's autoUpdater, or null + * when it cannot be loaded. Called lazily; listeners are attached once. + * @param {() => string} o.appVersion + * @param {(status: object) => void} o.send delivers a status to the page. + * @param {(...args: any[]) => void} [o.log] + * @param {(fn: () => void, ms: number) => any} [o.every] the recheck timer. + */ +function createUpdateController({ loadUpdater, appVersion, send, log = console.error, every = setInterval }) { + let updater = null; + // What the updater is doing. Errors arrive on one 'error' event whatever caused + // them, and this is how one is told from another: a failed install becomes + // UPD-07, a failed download is reported even when nobody opened the dialog, and + // a failed check is left to the check's own promise, which already reports it. + /** @type {'idle'|'checking'|'downloading'|'installing'} */ + let phase = 'idle'; + // One check at a time, shared. electron-updater reuses its in-flight promise as + // well, so without this a manual check landing on the background one would see + // the same result handled twice. + let inFlight = null; + let downloading = null; // {version, notes} while a download runs + let staged = null; // {version, notes} once one is on disk + // Set by the manual check and read once, when the download lands: it is what + // tells 'update-downloaded' whether a human is waiting on this. A background + // download reports itself `silent` and only marks the menu item; a requested + // one opens the dialog the user asked for. + let promptOnDownloaded = false; + let manualCheckRunning = false; + let timer = null; + + // The last thing sent, replayed over the 'update-state' channel. Broadcasts are + // fire-and-forget into a window that may not have finished loading: the startup + // check begins at whenReady, while js/electron-bridge.js does not register its + // listener until the whole module graph has evaluated. On the second and later + // launches the installer is already in the pending cache, so 'update-downloaded' + // fires a second or two in -- squarely inside that gap -- and the page never heard + // that an update was staged, never offered the install, and re-checked from + // scratch on the next launch. Forever. + let lastStatus = null; + const status = payload => { + lastStatus = payload; + send(payload); + }; + + function ensureUpdater() { + if (updater) return updater; + updater = loadUpdater(); + if (!updater) return null; + + // Said out loud rather than left to the default. With it on, an update that is + // on disk installs silently when the app next quits normally, so "Later" in the + // dialog means "next time you close the app" -- which the dialog now says. + // Turning it off would leave a staged installer waiting on a button some + // people never press. + updater.autoInstallOnAppQuit = true; + + // Load-bearing: autoUpdater is an EventEmitter, so an 'error' with no listener + // would be an uncaught exception, and being offline at launch would take down an + // app that was working fine without ever having updated. + updater.on('error', err => { + log('[updater]', err?.message ?? err); + if (phase === 'installing') { + // install() refuses by dispatching an error rather than by throwing -- the + // installer is gone from the cache, or could not be started. Whatever was + // staged is not installable any more, so it stops being offered: the next + // check fetches it again. + phase = 'idle'; + staged = null; + status({ state: 'error', phase: 'install', ...UpdateErrors.INSTALL }); + } else if (phase === 'downloading') { + // Reported even when nobody is watching: the page marks the menu item, so a + // download that fails on every launch is not a silent loop. + phase = 'idle'; + const version = downloading?.version ?? null; + downloading = null; + const silent = !promptOnDownloaded; + promptOnDownloaded = false; + status({ state: 'error', phase: 'download', version, silent, ...classifyUpdateError(err) }); + } + // A failed check rejects the check's promise as well, and that is where it is + // reported -- once, by the manual path, and not at all by the background one. + }); + + updater.on('download-progress', ({ percent }) => { + status({ state: 'downloading', version: downloading?.version ?? null, percent: Math.round(percent) }); + }); + + updater.on('update-downloaded', info => { + const notes = releaseNotesText(info.releaseNotes) + ?? (downloading?.version === info.version ? downloading.notes : null); + staged = { version: info.version, notes }; + downloading = null; + phase = 'idle'; + status({ state: 'downloaded', version: info.version, notes, silent: !promptOnDownloaded }); + promptOnDownloaded = false; + }); + + return updater; + } + + // electron-updater never empties its own pending directory, so the installer for + // a version that has since been installed stays on disk at full size -- two of + // them here, 100 MB each, for 2.0.0 and 2.5.0. It is only cleared on the way to + // *replacing* it, and "there is nothing newer to install" never takes that path. + // So do it here, the one moment the answer is known to be that -- and never while + // something is staged, since that file is the one the next install needs. + async function clearStaleCache(u) { + if (staged) return; + try { + await u.downloadedUpdateHelper?.clear(); + } catch (err) { + // Best-effort: a locked or missing cache is no reason to fail a check that + // has already succeeded. + log('[updater] could not clear pending cache:', err?.message ?? err); + } + } + + function runCheck(u) { + if (inFlight) return inFlight; + phase = 'checking'; + inFlight = (async () => { + try { + const result = await u.checkForUpdates(); + if (!result?.isUpdateAvailable) { + phase = 'idle'; + if (result) await clearStaleCache(u); + return result; + } + const version = result.updateInfo.version; + // A cached installer can validate and land before this line runs; it has + // already set the phase, and starting a "download" here would strand it. + if (staged?.version !== version) { + phase = 'downloading'; + downloading = { version, notes: releaseNotesText(result.updateInfo.releaseNotes) }; + } else { + phase = 'idle'; + } + // autoDownload is on, so the fetch is already running. Its failure arrives + // on 'error' above; this promise rejects with the same error, and nothing + // else awaits it, so left alone it would be an unhandled rejection. + result.downloadPromise?.catch?.(() => {}); + return result; + } catch (err) { + phase = 'idle'; + throw err; + } finally { + inFlight = null; + } + })(); + return inFlight; + } + + // The startup check and every recheck after it. Quiet by construction: a failure + // here is not news (being offline at launch is ordinary), and success only ever + // marks the menu item. + async function backgroundCheck() { + // Nothing to do while busy, and nothing worth fetching once an update is on + // disk -- it installs at the next quit either way. + if (phase !== 'idle' || staged) return; + const u = ensureUpdater(); + if (!u) return; + try { + await runCheck(u); + } catch { + // See above: the background check never reports a failed check. + } + } + + return { + UpdateErrors, + + /** The startup check, then one every RECHECK_INTERVAL_MS. */ + start() { + void backgroundCheck(); + if (!timer) { + timer = every(() => void backgroundCheck(), RECHECK_INTERVAL_MS); + // The timer must not keep a quitting app alive. + timer?.unref?.(); + } + }, + + stop() { + if (timer) clearInterval(timer); + timer = null; + }, + + /** The header's "Check for Updates": one click, one visible answer. */ + async checkManually() { + // checkForUpdates() reuses one in-flight promise internally, so a second click + // would resolve against the first check's result. Refusing re-entry keeps one + // click to one answer. + if (manualCheckRunning) return; + manualCheckRunning = true; + try { + const u = ensureUpdater(); + if (!u) { + const unavailable = new Error('The updater module failed to load.'); + unavailable.code = 'UPDATER_UNAVAILABLE'; + throw unavailable; + } + // Already on disk: the answer is the install, not another check. + if (staged) { + status({ state: 'downloaded', ...staged, silent: false }); + return; + } + // A background download is under way: show it rather than start another, + // and open the dialog when it lands. + if (phase === 'downloading' && downloading) { + promptOnDownloaded = true; + status({ state: 'available', ...downloading }); + return; + } + + status({ state: 'checking' }); + const result = await runCheck(u); + // isUpdateAvailable is the provider's own verdict. The manifest names the + // latest version whether or not it is newer, so comparing version strings + // here would reimplement the comparison electron-updater has already done. + if (!result?.isUpdateAvailable) { + status({ state: 'up-to-date', version: appVersion() }); + return; + } + // A cached installer may have landed while the check resolved -- silently, + // since nobody had asked yet. The person clicking is asking now. + if (staged?.version === result.updateInfo.version) { + status({ state: 'downloaded', ...staged, silent: false }); + return; + } + promptOnDownloaded = true; + status({ state: 'available', ...downloading }); + } catch (err) { + promptOnDownloaded = false; + // The whole error goes here, where a developer can read it; only the code and + // the sentence cross to the window. + log('[updater] manual check failed:', err); + status({ state: 'error', phase: 'check', ...classifyUpdateError(err) }); + } finally { + manualCheckRunning = false; + } + }, + + // quitAndInstall closes the window on the way out, which runs the page's + // beforeunload backup save exactly as an ordinary quit does. + install() { + const u = ensureUpdater(); + if (!u) { + status({ state: 'error', phase: 'install', ...UpdateErrors.UNSUPPORTED }); + return; + } + // Nothing downloaded, so nothing to install -- quitAndInstall() would answer + // with an error. Replaying the last status puts the dialog back to the truth. + if (!staged) { + if (lastStatus) send(lastStatus); + return; + } + // Already on its way out. A second quitAndInstall() is ignored by the updater + // without a word, which is exactly the no-op button this guards against. + if (phase === 'installing') return; + phase = 'installing'; + try { + u.quitAndInstall(); + } catch (err) { + log('[updater] install failed:', err); + phase = 'idle'; + staged = null; + status({ state: 'error', phase: 'install', ...UpdateErrors.INSTALL }); + } + }, + + /** The last status sent, for a page that was not listening yet. */ + lastStatus: () => lastStatus, + }; +} + +module.exports = { + UpdateErrors, + classifyUpdateError, + releaseNotesText, + createUpdateController, + RECHECK_INTERVAL_MS, +}; diff --git a/index.html b/index.html index d9b9d0a..c6015b6 100644 --- a/index.html +++ b/index.html @@ -2461,6 +2461,11 @@