diff --git a/demo/src/main.js b/demo/src/main.js index e81abcc..f764bd8 100644 --- a/demo/src/main.js +++ b/demo/src/main.js @@ -101,6 +101,13 @@ kit.ipc.handle(IPC.OPEN_ISOLATED_WINDOW, () => { }); kit.ipc.handle(IPC.GET_ELECTRON_VERSION, () => process.versions.electron); +// For the update tests: every update request through the proxy KIT_DEMO_PROXY +// names (Electron's proxyRules). The demo's update server is on this machine, +// which Chromium never sends through a proxy unless told: <-loopback>. +if (process.env.KIT_DEMO_PROXY) kit.updater.setProxy({ proxyRules: process.env.KIT_DEMO_PROXY, proxyBypassRules: "<-loopback>" }); +// For the tests, which change it from the main process (Playwright's app.evaluate()). +globalThis.demoKit = kit; + kit.ready.then(() => { kit.ipc.handle(IPC.GET_ISOLATED_VERSION, () => process.versions.electron, { session: kit.sessions.isolated(ISOLATED_PARTITION) }); kit.log.info("Opened with", JSON.stringify(process.argv)); diff --git a/docs/README.md b/docs/README.md index 99f48a5..3877cc7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,7 @@ and links here, so these can be corrected without a new release. | [The Shell, Settings and Credits](shell.md) | `kit.ui.mountShell()`: the nav rail, the header, the bottom bar and its panels, and the Settings view with its Update and Credits tabs | | [Page Components](page-components.md) | Toasts, prompts, the parts of a section of files (drop zone, progress, action bar), cards, the status line and busy buttons, the switch and the stepped slider, the key guard, `kit.format`, and the copy rules | | [The Main Process](main-process.md) | The main window, isolated sessions, spell checking, one instance and the files an app is opened with, the settings and their migration, the redacted log, and the menu | -| [Updates](updates.md) | The updater: when it checks, Stable, Beta and Alpha, automatic downloads, and its state | +| [Updates](updates.md) | The updater: when it checks, Stable, Beta and Alpha, automatic downloads, its state, and a proxy | | [Building](building.md) | `config()`, the electron-builder config every app builds with; the files an app opens, on Linux; the update files every release carries; and naming an app's files | | [Theme, Accent and Tokens](theming.md) | The OS theme, an app's accent and its WCAG 2.2 AA check, the house tokens, focus rings and controls | | [Testing an App](testing.md) | The helpers for an app's own tests: nothing reached beyond the machine, the preload, the menu, the accent and the build config | diff --git a/docs/updates.md b/docs/updates.md index f45c0a1..25b3643 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -1,6 +1,6 @@ # Updates -The updater behind Settings > Update: when it checks, the channels, automatic downloads, and its state. +The updater behind Settings > Update: when it checks, the channels, automatic downloads, a proxy, and its state. [All the docs](README.md) @@ -63,3 +63,27 @@ The state, from `getUpdateStatus()` and `onUpdateStatus()`: The version rules are `require("@diamonddigitaldev/electron-kit/main").version` (`parse`, `compare`, `channelOf`, `isOfferableUpdate` and the rest), for an app's own use. + +## Through a Proxy + +`kit.updater.setProxy(config)`, from the app's main process, sends every update check and download through a +proxy: an app whose own requests go through one (Dropgate's chosen network adapter) sends its updates the same way. + +```js +await kit.updater.setProxy({ proxyRules: "socks5://127.0.0.1:1080", proxyBypassRules: "<-loopback>" }); +await kit.updater.setProxy(null); // back to how the app was launched +``` + +- **`proxyRules` and `proxyBypassRules`** are Electron's own (`session.setProxy()`), strings; nothing else is taken, + and anything else throws. `null` goes back to how the app was launched: the system's proxy settings, or none + when it was launched with `--no-proxy-server`, as its tests are (`netLogSwitches()`, [Testing](testing.md)), + since the system's settings can search the network for a proxy. +- **It's electron-updater's own session** (its `netSession`), which the kit never makes itself. A proxy set + before electron-updater loads (before `kit.ready`) is kept, and applied as it loads, before any check. +- **It resolves once applied**, and at once when there's no updater to apply it to (run from source, or without + `updates`). Calls apply in order, and a check or download waits for the one being applied. +- **A change cuts what's under way:** the session's connections are closed, and a download under way is + cancelled, so it fails and the Update tab says so ("Version x couldn't be downloaded."), as any network failure. + Nothing starts again by itself: Check for Updates or Download Update tries again, through the new proxy. +- **Chromium never sends loopback through a proxy** unless `proxyBypassRules` says `<-loopback>`: a proxy that + must carry everything, an update server on this machine included, needs it. diff --git a/e2e/update-server.spec.js b/e2e/update-server.spec.js index c62b5f9..0223b57 100644 --- a/e2e/update-server.spec.js +++ b/e2e/update-server.spec.js @@ -6,7 +6,8 @@ // loads electron-updater from app.asar, and every request goes through // Electron's own network stack. test/updater.test.js covers the rules in // detail against the same library under Node; this checks they hold in a -// packaged app, from the Update tab. +// packaged app, from the Update tab, and that kit.updater.setProxy() sends +// them through a proxy (a local one, test/helpers/proxy.js). // // Run from source, the updater never checks, so these run against a packaged // build only (KIT_DEMO_EXECUTABLE). The server's port is fixed by the build, @@ -21,6 +22,7 @@ const os = require("os"); const path = require("path"); const { test, expect } = require("./helpers/demo"); const { startUpdateServer } = require("../test/helpers/update-server"); +const { startProxy, THROUGH } = require("../test/helpers/proxy"); const { STAGING_ID } = require("../main/updater"); const demoBuild = require("../demo/electron-builder.cjs"); @@ -138,3 +140,117 @@ test("with no update server, the check says it couldn't, and nothing else is ask expect(printed).toContain("Update check failed"); expect(printed).not.toMatch(/Checking for update|^\s+at /m); }); + +// -- Through a proxy --------------------------------------------------------- +// +// kit.updater.setProxy(), from the demo's KIT_DEMO_PROXY (set before the +// updater loads) and from its main process (globalThis.demoKit), with a local +// test proxy that marks what it forwards. The demo adds <-loopback>, so the +// update server on this machine goes through the proxy too. The proxies' +// ports are fixed, as the launch names them. + +const PROXY_PORT = PORT + 1; +const SECOND_PROXY_PORT = PORT + 2; +/** A port nothing listens on. */ +const CLOSED_PORT = PORT + 3; + +/** Give the demo's updater a proxy from its main process, or null for the launch's settings. */ +const setProxy = (demo, proxyRules) => demo.app.evaluate((_electron, proxyRules) => globalThis.demoKit.updater.setProxy(proxyRules === null ? null : { proxyRules, proxyBypassRules: "<-loopback>" }), proxyRules); + +/** The requests the server had that came through the test proxy, and those that didn't. */ +const throughProxy = (server) => server.requests.filter((r) => r.headers[THROUGH] === "1").map((r) => r.file); +const straight = (server) => server.requests.filter((r) => r.headers[THROUGH] === undefined).map((r) => r.file); + +test.describe("through a proxy", () => { + test.use({ demoEnv: { KIT_DEMO_PROXY: `http://127.0.0.1:${PROXY_PORT}` } }); + + test("every request of a check and a download goes through the proxy the app set before the updater loaded", async ({ demo }) => { + const proxy = await startProxy({ port: PROXY_PORT }); + const server = await serve({ latest: "0.0.1" }); + try { + const main = await demo.mainWindow(); + const pane = await openUpdate(main); + await pane.getByRole("button", { name: "Check for Updates" }).click(); + await expect(pane.getByRole("status")).toHaveText("Version 0.0.1 has downloaded and will be installed when you close electron-kit Demo.", { timeout: 30_000 }); + expect(server.channelFiles()).toEqual(["latest.yml"]); + expect(server.installers()).toEqual([`Kit-Test-Setup-0.0.1.${EXT}`]); + expect(straight(server), "requests that went straight to the server").toEqual([]); + expect(proxy.requests.map((r) => decodeURIComponent(new URL(r.url).pathname.slice(1)))).toEqual(throughProxy(server)); + await keepFromInstalling(demo); + } finally { + await server.close(); + await proxy.close(); + } + }); + + test("null goes back to the launch's settings (the tests' --no-proxy-server, so no search for one): the check goes straight to the server", async ({ demo }) => { + const proxy = await startProxy({ port: PROXY_PORT }); + const server = await serve({ latest: "0.0.0" }); + try { + const main = await demo.mainWindow(); + await setProxy(demo, null); + const pane = await openUpdate(main); + await pane.getByRole("button", { name: "Check for Updates" }).click(); + await expect(pane.getByRole("status")).toHaveText("You're up to date."); + expect(server.channelFiles()).toEqual(["latest.yml"]); + expect(throughProxy(server), "requests that went through the proxy").toEqual([]); + expect(proxy.requests).toEqual([]); + } finally { + await server.close(); + await proxy.close(); + } + }); + + test("a change mid-download fails it, saying so, and Download Update tries again through the new proxy", async ({ demo }) => { + const proxy = await startProxy({ port: PROXY_PORT }); + const second = await startProxy({ port: SECOND_PROXY_PORT }); + const server = await serve({ latest: "0.0.1" }); + try { + const main = await demo.mainWindow(); + const pane = await openUpdate(main); + await pane.getByRole("switch", { name: "Download updates automatically" }).click(); + await expect.poll(() => main.evaluate(() => window.kitAPI.getSettings().then((s) => s.autoDownloadUpdates))).toBe(false); + await pane.getByRole("button", { name: "Check for Updates" }).click(); + await expect(pane.getByRole("status")).toHaveText("Version 0.0.1 is available."); + + // Half the installer, then the proxy changes under it. + server.hold(true); + await pane.getByRole("button", { name: "Download Update" }).click(); + await expect.poll(() => server.installers()).toEqual([`Kit-Test-Setup-0.0.1.${EXT}`]); + await setProxy(demo, second.rules); + await expect(pane.getByRole("status")).toHaveText("Version 0.0.1 couldn't be downloaded.", { timeout: 30_000 }); + await expect(main.locator(".update-dot:visible")).toHaveCount(2); + expect(second.requests, "nothing starts again by itself").toEqual([]); + + server.hold(false); + await pane.getByRole("button", { name: "Download Update" }).click(); + await expect(pane.getByRole("status")).toHaveText("Version 0.0.1 has downloaded and will be installed when you close electron-kit Demo.", { timeout: 30_000 }); + // Through the new proxy (with the blockmap, beside the part already downloaded), and nothing straight. + expect(second.requests.map((r) => new URL(r.url).pathname)).toContain(`/Kit-Test-Setup-0.0.1.${EXT}`); + expect(straight(server)).toEqual([]); + await keepFromInstalling(demo); + } finally { + await server.close(); + await second.close(); + await proxy.close(); + } + }); +}); + +test.describe("through a proxy that isn't there", () => { + test.use({ demoEnv: { KIT_DEMO_PROXY: `http://127.0.0.1:${CLOSED_PORT}` } }); + + test("the check fails and says so, and nothing reaches the server", async ({ demo }) => { + const server = await serve({ latest: "0.0.1" }); + try { + const main = await demo.mainWindow(); + const pane = await openUpdate(main); + await pane.getByRole("button", { name: "Check for Updates" }).click(); + await expect(pane.getByRole("status")).toHaveText("Couldn't check for updates. Try again later.", { timeout: 30_000 }); + await expect(pane.getByRole("button", { name: "Check for Updates" })).toBeEnabled(); + expect(server.requests, "Chromium went around the proxy").toEqual([]); + } finally { + await server.close(); + } + }); +}); diff --git a/main/index.js b/main/index.js index e98c6be..f6ec5eb 100644 --- a/main/index.js +++ b/main/index.js @@ -103,6 +103,7 @@ function registerPreload(ses) { * log: { error(...args: unknown[]): void, warn(...args: unknown[]): void, info(...args: unknown[]): void, debug(...args: unknown[]): void, lines(): string[] }, * windows: { createMain(options: object): Electron.BrowserWindow, main(): Electron.BrowserWindow | null }, * files: { open(paths: string[]): void }, + * updater: { setProxy(config: { proxyRules: string, proxyBypassRules?: string } | null): Promise }, * primary: boolean, * }} * ready resolves once the app is ready, the shared preload is registered and @@ -113,7 +114,9 @@ function registerPreload(ses) { * kit's bridge (sessions.js). log is the app's log, redacted, and its * lines() the banner and the run's last lines (log.js). * windows.createMain() makes the main window (windows.js). - * files.open() hands the page files from the app's own menu. primary is + * files.open() hands the page files from the app's own menu. + * updater.setProxy() sends every update request through a proxy, or as + * the app was launched again with null (updater.js). primary is * false in a second launch, which is quitting. */ function start(config = {}) { @@ -214,6 +217,7 @@ function start(config = {}) { log: { error: log.error, warn: log.warn, info: log.info, debug: log.debug, lines: log.lines }, windows: { createMain: windows.createMain, main: windows.main }, files: { open: files.open }, + updater: { setProxy: updates.setProxy }, primary, }; } diff --git a/main/updater.js b/main/updater.js index e961dc9..ba4dd08 100644 --- a/main/updater.js +++ b/main/updater.js @@ -38,11 +38,22 @@ // in every rollout. A .updaterId an earlier version wrote (an app before it // took the kit) is deleted as the updater starts: it's an ID of the install, // kept for nothing. +// - The app can send every request through a proxy (kit.updater.setProxy()), +// on electron-updater's own session, which the kit reaches through its +// netSession and never makes itself: a partition's options apply only where +// it's first made, and electron-updater makes it without a cache. A proxy +// set before electron-updater loads is kept, and applied before any check; +// null goes back to the launch's (the system's settings, or none with +// --no-proxy-server). A change closes the session's connections and +// cancels a download under way (Chromium lets a request already answering +// finish on its old route), so a download it cuts fails and says so, as +// any network failure; nothing starts again by itself. // // Every change of state is pushed to the app's own windows as update:status. const fs = require("fs"); const path = require("path"); +const { createRequire } = require("module"); const version = require("./version"); /** electron-updater's settings for each channel. */ @@ -107,6 +118,28 @@ function checkUpdates(updates) { return { checkOnLaunch }; } +/** + * Check what kit.updater.setProxy() is given. Throws on a mistake. + * @param {unknown} config - { proxyRules, proxyBypassRules? }, Electron's own + * strings, or null for the system's proxy settings + * @returns {{ mode: "fixed_servers", proxyRules: string, proxyBypassRules?: string } | { mode: "system" }} + * what electron-updater's session is given (session.setProxy()) + */ +function checkProxy(config) { + if (config === null) return { mode: "system" }; + const fail = (message) => { + throw new Error(`kit.updater.setProxy(): ${message}`); + }; + if (typeof config !== "object" || Array.isArray(config)) fail("takes { proxyRules, proxyBypassRules }, or null for the system's proxy settings."); + for (const key of Object.keys(config)) { + if (key !== "proxyRules" && key !== "proxyBypassRules") fail(`${key} isn't an option.`); + } + const { proxyRules, proxyBypassRules } = config; + if (typeof proxyRules !== "string" || proxyRules.trim() === "") fail("proxyRules must be a string, such as \"socks5://127.0.0.1:1080\"."); + if (proxyBypassRules !== undefined && typeof proxyBypassRules !== "string") fail("proxyBypassRules must be a string, such as \"<-loopback>\"."); + return { mode: "fixed_servers", proxyRules, ...(proxyBypassRules === undefined ? {} : { proxyBypassRules }) }; +} + /** * Set electron-updater up for a channel, before a check. The order matters: * setting channel turns allowDowngrade back on inside electron-updater. @@ -166,7 +199,7 @@ function deleteOldInstallId(autoUpdater, log) { * The app's updater. * @param {{ * options: { checkOnLaunch: boolean } | null, - * app: { isPackaged: boolean, getVersion(): string }, + * app: { isPackaged: boolean, getVersion(): string, commandLine?: { hasSwitch(name: string): boolean } }, * settings: { get(): Record, set(changes: object): object }, * send: (status: object) => void, * load?: () => import("electron-updater").AppUpdater, @@ -199,6 +232,13 @@ function createUpdater({ options, app, settings, send, load = loadAutoUpdater, s let autoUpdater = null; let checking = null; + /** The proxy the app last asked for, kept until electron-updater loads (undefined: never asked). */ + let proxy; + /** The proxies being applied, in order: a check or download waits for them. Never rejects. */ + let applying = Promise.resolve(); + /** The download under way's cancellation token, which a change of proxy cancels. */ + let downloading = null; + /** * The updater's state, as update:status pushes it and update:get-status * answers: @@ -247,6 +287,7 @@ function createUpdater({ options, app, settings, send, load = loadAutoUpdater, s autoUpdater.autoInstallOnAppQuit = true; // No app on the kit ships a web installer. autoUpdater.disableWebInstaller = true; + if (proxy !== undefined) queueProxy(proxy).catch(() => {}); autoUpdater.on("download-progress", (progress) => { const next = Math.floor(progress.percent); if (state === "downloading" && next !== percent) { @@ -284,6 +325,7 @@ function createUpdater({ options, app, settings, send, load = loadAutoUpdater, s reason = null; report(); try { + await applying; applyChannel(autoUpdater, chosen); const result = await autoUpdater.checkForUpdates(); const candidate = result?.isUpdateAvailable ? result.updateInfo?.version : null; @@ -330,7 +372,13 @@ function createUpdater({ options, app, settings, send, load = loadAutoUpdater, s error = null; auto = automatic; report(); - return autoUpdater.downloadUpdate().then( + let token = null; + return applying.then(() => { + token = downloading = newCancellationToken(); + return autoUpdater.downloadUpdate(token); + }).finally(() => { + if (downloading === token) downloading = null; + }).then( () => { state = "downloaded"; percent = 100; @@ -338,7 +386,7 @@ function createUpdater({ options, app, settings, send, load = loadAutoUpdater, s return status(); }, (err) => { - log?.warn(`Update download failed (version ${found}): ${messageOf(err)}`); + log?.warn(`Update download failed (version ${found}): ${token?.cancelled ? "the proxy changed under it" : messageOf(err)}`); state = "error"; error = "download"; percent = null; @@ -396,7 +444,42 @@ function createUpdater({ options, app, settings, send, load = loadAutoUpdater, s } } - return { start, check, download, install, status, settingsChanged }; + /** + * Send every update request through a proxy, or back the way the app + * was launched with null (kit.updater.setProxy()): the system's proxy + * settings, or none when it was launched with --no-proxy-server, as an + * app's tests launch it (testing's netLogSwitches()), since the + * system's can look for a proxy on the network (WPAD). Resolves + * once electron-updater's session has it and its connections are closed, + * or at once while electron-updater isn't loaded: run from source, with + * no updates option, or before the app is ready, when it's kept and + * applied as the updater starts. Calls apply in order. + * @param {{ proxyRules: string, proxyBypassRules?: string } | null} config + * @returns {Promise} + */ + function setProxy(config) { + proxy = checkProxy(config); + if (proxy.mode === "system" && app.commandLine?.hasSwitch("no-proxy-server")) proxy = { mode: "direct" }; + if (!autoUpdater) return Promise.resolve(); + return queueProxy(proxy); + } + + /** Apply a proxy to electron-updater's session after any before it, then close its connections. */ + function queueProxy(settings) { + const run = applying.then(async () => { + const session = autoUpdater.netSession; + await session.setProxy(settings); + await session.closeAllConnections(); + // A request already being answered keeps its connection and its old route, so a download is cancelled. + downloading?.cancel(); + }); + applying = run.catch((err) => { + log?.warn(`Couldn't set the proxy for update checks: ${messageOf(err)}`); + }); + return run; + } + + return { start, check, download, install, status, settingsChanged, setProxy }; } /** @@ -432,6 +515,15 @@ function updaterLogger(log) { return { info: detail, debug: detail, error: detail, warn: (...args) => log.warn("electron-updater:", ...args) }; } +/** + * A cancellation token for a download, from the builder-util-runtime + * electron-updater itself uses (it doesn't hand its CancellationToken on). + */ +function newCancellationToken() { + const { CancellationToken } = createRequire(require.resolve("electron-updater"))("builder-util-runtime"); + return new CancellationToken(); +} + /** electron-updater's autoUpdater, for this platform. */ function loadAutoUpdater() { return require("electron-updater").autoUpdater; @@ -442,4 +534,4 @@ function unrefTimeout(run, ms) { setTimeout(run, ms).unref?.(); } -module.exports = { createUpdater, checkUpdates, applyChannel, withoutInstallId, checkFailureOf, updaterLogger, CHANNEL_SETTINGS, STAGING_ID, LAUNCH_CHECK_DELAY }; +module.exports = { createUpdater, checkUpdates, checkProxy, applyChannel, withoutInstallId, checkFailureOf, updaterLogger, CHANNEL_SETTINGS, STAGING_ID, LAUNCH_CHECK_DELAY }; diff --git a/test/helpers/proxy.js b/test/helpers/proxy.js new file mode 100644 index 0000000..199d37a --- /dev/null +++ b/test/helpers/proxy.js @@ -0,0 +1,72 @@ +"use strict"; + +// A local HTTP proxy for the update tests, on this machine only: it forwards +// each request it's sent to the server the request names, marks it on the +// way (x-through-test-proxy, so the server can tell what came through it), +// and records it. Chromium sends a proxy the whole URL ("GET +// http://127.0.0.1:47613/latest.yml"), and never sends loopback through one +// unless the rules say <-loopback>. It forwards to loopback only, and refuses +// CONNECT, so nothing it's sent can leave the machine. + +const http = require("http"); +const { LOCAL_HOST } = require("../../testing/netlog"); + +/** The header the proxy adds to every request it forwards. */ +const THROUGH = "x-through-test-proxy"; + +/** + * Start a proxy. + * @param {{ port?: number }} [options] - port: a fixed one (a launch's proxy rules name it), else any free one + * @returns {Promise<{ port: number, rules: string, requests: { url: string, method: string }[], close(): Promise }>} + * rules: the proxy as Electron's proxyRules write it + */ +async function startProxy({ port = 0 } = {}) { + const requests = []; + const sockets = new Set(); + const server = http.createServer((req, res) => { + requests.push({ url: req.url, method: req.method }); + let target; + try { + target = new URL(req.url); + } catch { + target = null; + } + if (!target || target.protocol !== "http:" || !LOCAL_HOST.test(target.hostname)) { + res.statusCode = 403; + res.end(); + return; + } + const upstream = http.request( + { host: target.hostname, port: target.port, path: `${target.pathname}${target.search}`, method: req.method, headers: { ...req.headers, [THROUGH]: "1" } }, + (answer) => { + res.writeHead(answer.statusCode, answer.headers); + answer.pipe(res); + }, + ); + upstream.on("error", () => res.destroy()); + // A client that goes (its connections closed) takes the request to the server with it. + res.on("close", () => upstream.destroy()); + req.pipe(upstream); + }); + server.on("connect", (req, socket) => { + requests.push({ url: req.url, method: req.method }); + socket.end("HTTP/1.1 405 Method Not Allowed\r\n\r\n"); + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + await new Promise((resolve, reject) => server.once("error", reject).listen(port, "127.0.0.1", resolve)); + const bound = server.address().port; + return { + port: bound, + rules: `http://127.0.0.1:${bound}`, + requests, + close: () => new Promise((resolve) => { + for (const socket of sockets) socket.destroy(); + server.close(resolve); + }), + }; +} + +module.exports = { startProxy, THROUGH }; diff --git a/test/helpers/update-server.js b/test/helpers/update-server.js index 7a06f2f..e78b51d 100644 --- a/test/helpers/update-server.js +++ b/test/helpers/update-server.js @@ -78,7 +78,7 @@ function channelFile(version, { sha512, ext, stagingPercentage } = {}) { * @param {{ port?: number, ext?: string }} [options] * port: a fixed one (a packaged app's app-update.yml names it), else any * free one. ext: the installer's, "exe" or "AppImage". - * @returns {Promise<{ url: string, requests: { file: string, headers: http.IncomingHttpHeaders }[], installers: () => string[], close(): Promise }>} + * @returns {Promise<{ url: string, requests: { file: string, headers: http.IncomingHttpHeaders }[], installers: () => string[], hold(on: boolean): void, close(): Promise }>} */ async function startUpdateServer(channels, { port = 0, ext = "exe" } = {}) { const requests = []; @@ -88,6 +88,8 @@ async function startUpdateServer(channels, { port = 0, ext = "exe" } = {}) { byFile.set(`${channel}.yml`, channelFile(version, { sha512, ext, stagingPercentage })); byFile.set(installerName(version, ext), installerBytes(version)); } + let holding = false; + const held = new Set(); const server = http.createServer((req, res) => { const file = decodeURIComponent(new URL(req.url, "http://localhost").pathname.slice(1)); requests.push({ file, headers: req.headers }); @@ -98,6 +100,14 @@ async function startUpdateServer(channels, { port = 0, ext = "exe" } = {}) { res.end(); return; } + if (holding && file.endsWith(`.${ext}`)) { + // Half the installer, then nothing: a download under way, until the client goes. + res.writeHead(200, { "content-length": body.length }); + res.write(body.subarray(0, Math.floor(body.length / 2))); + held.add(res); + res.on("close", () => held.delete(res)); + return; + } res.end(body); }); await new Promise((resolve, reject) => server.once("error", reject).listen(port, "127.0.0.1", resolve)); @@ -108,7 +118,14 @@ async function startUpdateServer(channels, { port = 0, ext = "exe" } = {}) { channelFiles: () => requests.filter((r) => r.file.endsWith(".yml")).map((r) => r.file.replace(/-linux\.yml$/, ".yml")), /** The installers downloaded. */ installers: () => requests.filter((r) => r.file.endsWith(`.${ext}`)).map((r) => r.file), - close: () => new Promise((resolve) => server.close(resolve)), + /** Whether an installer is sent only half way, and held there: a download that's under way. */ + hold: (on) => { + holding = on; + }, + close: () => new Promise((resolve) => { + for (const res of held) res.destroy(); + server.close(resolve); + }), }; } diff --git a/test/updater.test.js b/test/updater.test.js index aad0929..bb8ad28 100644 --- a/test/updater.test.js +++ b/test/updater.test.js @@ -4,15 +4,16 @@ // update server (helpers/update-server.js): each channel, the saved channel // winning, never a downgrade, the channel set before allowDowngrade, a // mis-tagged release refused, both download modes and the dot, no ID of the -// install (the fixed x-user-staging-id, no .updaterId), and no request the app -// didn't ask for. Then how kit.start() wires it: the updates option, -// app.isPackaged, the channels. +// install (the fixed x-user-staging-id, no .updaterId), no request the app +// didn't ask for, and the proxy the app sets (kept until electron-updater +// loads, applied in order, a check waiting for it). Then how kit.start() +// wires it: the updates option, app.isPackaged, the channels, setProxy(). const test = require("node:test"); const assert = require("node:assert/strict"); const fs = require("fs"); const path = require("path"); -const { createUpdater, checkUpdates, applyChannel, withoutInstallId, checkFailureOf, updaterLogger, STAGING_ID, LAUNCH_CHECK_DELAY } = require("../main/updater"); +const { createUpdater, checkUpdates, checkProxy, applyChannel, withoutInstallId, checkFailureOf, updaterLogger, STAGING_ID, LAUNCH_CHECK_DELAY } = require("../main/updater"); const { createLog } = require("../main/log"); const { createSettings } = require("../main/store"); const { startUpdateServer, realAutoUpdater } = require("./helpers/update-server"); @@ -33,16 +34,49 @@ function memorySettings(stored = {}) { return { settings, data }; } +/** + * A stand-in for electron-updater's session (its netSession, which under + * plain Node has no Electron to make): records each setProxy() and + * closeAllConnections() in `calls`, with how many requests the server had + * seen by then (`seen()`). hold() makes the next setProxy() wait until the + * function it returns is called. + */ +function fakeNetSession(seen = () => 0) { + const calls = []; + let held = null; + return { + calls, + hold() { + let release; + held = new Promise((resolve) => (release = resolve)); + return release; + }, + async setProxy(config) { + calls.push({ call: "setProxy", config, seen: seen() }); + const wait = held; + held = null; + await wait; + }, + async closeAllConnections() { + calls.push({ call: "closeAllConnections", seen: seen() }); + }, + }; +} + /** * The kit's updater for an app at `version`, packaged, driving the real * electron-updater at an update server holding `channels`. Started, with its - * launch check scheduled but not run. + * launch check scheduled but not run. proxy: what the app gives + * kit.updater.setProxy() before the updater starts; netSession is the + * stand-in session it's applied to. */ -async function updaterFor({ version, channels, stored = {}, isPackaged = true, options = { checkOnLaunch: true }, online = true, before = () => {} }, t) { +async function updaterFor({ version, channels, stored = {}, isPackaged = true, options = { checkOnLaunch: true }, online = true, before = () => {}, proxy }, t) { const server = await startUpdateServer(channels); t.after(() => server.close()); const { autoUpdater, dir } = realAutoUpdater({ version, url: server.url }); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const netSession = fakeNetSession(() => server.requests.length); + Object.defineProperty(autoUpdater, "netSession", { get: () => netSession }); const { settings, data } = memorySettings(stored); const sent = []; const scheduled = []; @@ -63,8 +97,9 @@ async function updaterFor({ version, channels, stored = {}, isPackaged = true, o log: { warn: (...args) => logged.push(args.join(" ")), info: (...args) => logged.push(args.join(" ")) }, isOnline: () => online, }); + const proxied = proxy === undefined ? null : updater.setProxy(proxy); updater.start(); - return { updater, server, autoUpdater, settings, data, sent, scheduled, logged, dir, loads: () => loads }; + return { updater, server, autoUpdater, netSession, proxied, settings, data, sent, scheduled, logged, dir, loads: () => loads }; } /** Wait for the updater to reach a state, from its pushes. */ @@ -533,6 +568,184 @@ test("without the updates option there's no updater: nothing is loaded, saved or assert.equal(data.settings.updateChannel, undefined); }); +// -- Through a proxy -------------------------------------------------------- + +const PROXY = { proxyRules: "socks5://127.0.0.1:1080", proxyBypassRules: "<-loopback>" }; + +/** The configs setProxy() was given on a stand-in session, each followed by closeAllConnections(). */ +const appliedOn = (netSession) => netSession.calls.map((c) => (c.call === "setProxy" ? c.config : c.call)); + +test("setProxy() refuses anything but { proxyRules, proxyBypassRules? } as strings, or null, and applies nothing", async (t) => { + assert.deepEqual(checkProxy(null), { mode: "system" }); + assert.deepEqual(checkProxy({ proxyRules: "http=127.0.0.1:8080" }), { mode: "fixed_servers", proxyRules: "http=127.0.0.1:8080" }); + assert.deepEqual(checkProxy(PROXY), { mode: "fixed_servers", ...PROXY }); + const refused = [ + [undefined, /takes \{ proxyRules, proxyBypassRules \}, or null/], + ["socks5://127.0.0.1:1080", /takes \{ proxyRules/], + [[PROXY], /takes \{ proxyRules/], + [{}, /proxyRules must be a string/], + [{ proxyRules: "" }, /proxyRules must be a string/], + [{ proxyRules: 1080 }, /proxyRules must be a string/], + [{ proxyRules: "socks5://127.0.0.1:1080", proxyBypassRules: ["<-loopback>"] }, /proxyBypassRules must be a string/], + [{ proxyRules: "socks5://127.0.0.1:1080", mode: "direct" }, /mode isn't an option/], + [{ proxyRules: "socks5://127.0.0.1:1080", pacScript: "http://wpad/wpad.dat" }, /pacScript isn't an option/], + ]; + for (const [bad, error] of refused) assert.throws(() => checkProxy(bad), (err) => error.test(err.message) && err.message.startsWith("kit.updater.setProxy(): "), String(bad)); + + const { updater, netSession } = await updaterFor({ version: "2.0.0", channels: {}, options: { checkOnLaunch: false } }, t); + for (const [bad, error] of refused) assert.throws(() => updater.setProxy(bad), error); + await updater.check(); + assert.deepEqual(netSession.calls, [], "the session is never touched"); +}); + +test("a proxy set before electron-updater loads is kept, and applied as it loads, before the first check, then the connections closed", async (t) => { + const { updater, server, netSession, proxied } = await updaterFor({ version: "2.0.0", channels: { latest: "2.0.0" }, proxy: PROXY }, t); + // Kept, with nothing loaded yet to apply it to: it resolved at once. + await proxied; + assert.equal((await updater.check()).state, "none"); + assert.deepEqual(netSession.calls, [ + { call: "setProxy", config: { mode: "fixed_servers", ...PROXY }, seen: 0 }, + { call: "closeAllConnections", seen: 0 }, + ]); + assert.deepEqual(server.channelFiles(), ["latest.yml"]); +}); + +test("only the last proxy set before electron-updater loads is applied", async (t) => { + const server = await startUpdateServer({ latest: "2.0.0" }); + t.after(() => server.close()); + const { autoUpdater, dir } = realAutoUpdater({ version: "2.0.0", url: server.url }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const netSession = fakeNetSession(); + Object.defineProperty(autoUpdater, "netSession", { get: () => netSession }); + const updater = createUpdater({ options: { checkOnLaunch: false }, app: { isPackaged: true, getVersion: () => "2.0.0" }, settings: memorySettings().settings, send: () => {}, load: () => autoUpdater }); + await updater.setProxy(PROXY); + await updater.setProxy(null); + assert.deepEqual(netSession.calls, []); + updater.start(); + await updater.check(); + assert.deepEqual(appliedOn(netSession), [{ mode: "system" }, "closeAllConnections"]); +}); + +test("null puts the system's proxy settings back, and setProxy() resolves once they're applied and the connections closed", async (t) => { + const { updater, netSession } = await updaterFor({ version: "2.0.0", channels: {}, proxy: PROXY }, t); + const release = netSession.hold(); + let done = false; + const applied = updater.setProxy(null).then(() => (done = true)); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(done, false, "resolved before the session had it"); + release(); + await applied; + assert.deepEqual(appliedOn(netSession), [{ mode: "fixed_servers", ...PROXY }, "closeAllConnections", { mode: "system" }, "closeAllConnections"]); +}); + +test("null goes back to no proxy at all when the app was launched with --no-proxy-server, as its tests launch it, so nothing searches the network for one", async () => { + for (const [switches, expected] of [[["no-proxy-server"], { mode: "direct" }], [[], { mode: "system" }], [["proxy-server"], { mode: "system" }]]) { + const netSession = fakeNetSession(); + const autoUpdater = fakeAutoUpdater({ netSession }); + const app = { isPackaged: true, getVersion: () => "2.0.0", commandLine: { hasSwitch: (name) => switches.includes(name) } }; + const updater = createUpdater({ options: { checkOnLaunch: false }, app, settings: memorySettings().settings, send: () => {}, load: () => autoUpdater }); + updater.start(); + await updater.setProxy(PROXY); + await updater.setProxy(null); + assert.deepEqual(appliedOn(netSession).filter((c) => c !== "closeAllConnections"), [{ mode: "fixed_servers", ...PROXY }, expected], switches.join() || "no switch"); + } +}); + +test("calls apply in order, each one's connections closed before the next is set", async (t) => { + const { updater, netSession } = await updaterFor({ version: "2.0.0", channels: {} }, t); + const release = netSession.hold(); + const first = { proxyRules: "http=127.0.0.1:8001" }; + const last = { proxyRules: "http=127.0.0.1:8003" }; + const all = Promise.all([updater.setProxy(first), updater.setProxy(null), updater.setProxy(last)]); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(netSession.calls.length, 1, "the next waits for the one being applied"); + release(); + await all; + assert.deepEqual(appliedOn(netSession), [ + { mode: "fixed_servers", ...first }, "closeAllConnections", + { mode: "system" }, "closeAllConnections", + { mode: "fixed_servers", ...last }, "closeAllConnections", + ]); +}); + +test("a check waits for a proxy being applied: nothing reaches the server until it's in place", async (t) => { + const { updater, server, netSession } = await updaterFor({ version: "2.0.0", channels: { latest: "2.0.0" } }, t); + const release = netSession.hold(); + const applied = updater.setProxy(PROXY); + const checked = updater.check(); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(updater.status().state, "checking", "the page is told it's checking"); + assert.deepEqual(server.requests, []); + release(); + await applied; + assert.equal((await checked).state, "none"); + assert.deepEqual(netSession.calls.map((c) => [c.call, c.seen]), [["setProxy", 0], ["closeAllConnections", 0]]); + assert.deepEqual(server.channelFiles(), ["latest.yml"]); +}); + +test("Download Update waits for a proxy being applied too", async (t) => { + const { updater, server, netSession } = await updaterFor({ version: "2.0.0", channels: { latest: "2.1.0" }, stored: { autoDownloadUpdates: false } }, t); + assert.equal((await updater.check()).state, "available"); + const release = netSession.hold(); + updater.setProxy(PROXY); + const downloaded = updater.download(); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.deepEqual(server.installers(), []); + release(); + assert.equal((await downloaded).state, "downloaded"); + assert.deepEqual(server.installers(), ["Kit-Test-Setup-2.1.0.exe"]); +}); + +test("a change of proxy mid-download cancels it: it fails and says so, nothing starts again, and Download Update tries again", async (t) => { + const { updater, server, sent, logged, netSession } = await updaterFor({ version: "2.0.0", channels: { latest: "2.1.0" }, stored: { autoDownloadUpdates: false } }, t); + await updater.check(); + server.hold(true); + const downloading = updater.download(); + for (let i = 0; i < 200 && server.installers().length === 0; i++) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(updater.status().state, "downloading"); + await updater.setProxy(PROXY); + const failed = await downloading; + assert.deepEqual([failed.state, failed.error, failed.dot, failed.version], ["error", "download", true, "2.1.0"]); + assert.deepEqual(appliedOn(netSession), [{ mode: "fixed_servers", ...PROXY }, "closeAllConnections"]); + assert.deepEqual(logged, ["Update download failed (version 2.1.0): the proxy changed under it"]); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.deepEqual(server.installers(), ["Kit-Test-Setup-2.1.0.exe"], "nothing starts again by itself"); + assert.equal(sent.at(-1).state, "error"); + + server.hold(false); + assert.equal((await updater.download()).state, "downloaded"); + assert.equal(server.installers().length, 2); + // Settled, a later change cancels nothing. + await updater.setProxy(null); + assert.equal(updater.status().state, "downloaded"); +}); + +test("a proxy the session refuses rejects setProxy(), is one warning in the log, and the next check still runs", async (t) => { + const { updater, netSession, logged } = await updaterFor({ version: "2.0.0", channels: { latest: "2.0.0" } }, t); + netSession.setProxy = async () => { + throw new Error("Invalid proxy rules"); + }; + await assert.rejects(updater.setProxy(PROXY), /Invalid proxy rules/); + assert.equal((await updater.check()).state, "none"); + assert.deepEqual(logged, ["Couldn't set the proxy for update checks: Invalid proxy rules"]); +}); + +test("with no updater loaded, setProxy() resolves at once and touches no session: run from source, or with no updates option", async (t) => { + for (const how of [{ isPackaged: false }, { options: null }]) { + const { updater, netSession, loads } = await updaterFor({ version: "2.0.0", channels: {}, ...how }, t); + assert.equal(await updater.setProxy(PROXY), undefined); + assert.equal(await updater.setProxy(null), undefined); + assert.equal(loads(), 0, "electron-updater isn't loaded for it"); + assert.deepEqual(netSession.calls, []); + } +}); + +test("with no proxy ever set, electron-updater's session is left as it is", async (t) => { + const { updater, netSession } = await updaterFor({ version: "2.0.0", channels: { latest: "2.1.0" } }, t); + await updater.check(); + assert.deepEqual(netSession.calls, []); +}); + // -- kit.start() ------------------------------------------------------------ test("the updates option is checked when kit.start() is called", () => { @@ -605,3 +818,29 @@ test("a change of channel from the app's main, not only the page, checks again", assert.equal(checks, 1); assert.equal(autoUpdater.channel, "beta"); }); + +test("kit.start() gives the app updater.setProxy(): checked, and resolved at once from source", async () => { + const { main } = loadMain({ version: "2.0.0" }); + const kit = main.start({ updates: {} }); + await kit.ready; + assert.equal(await kit.updater.setProxy(PROXY), undefined); + assert.equal(await kit.updater.setProxy(null), undefined); + assert.throws(() => kit.updater.setProxy({ proxyRules: 1 }), /^Error: kit\.updater\.setProxy\(\): proxyRules must be a string/); +}); + +test("kit.updater.setProxy() before the app is ready is applied to electron-updater's session as it loads", async () => { + const netSession = fakeNetSession(); + const checked = []; + const autoUpdater = fakeAutoUpdater({ + netSession, + checkForUpdates: async () => (checked.push(netSession.calls.length), { isUpdateAvailable: false, updateInfo: { version: "2.0.0" } }), + }); + const { main, handlers, eventFrom } = loadMain({ version: "2.0.0", isPackaged: true, autoUpdater }); + const kit = main.start({ updates: { checkOnLaunch: false } }); + await kit.updater.setProxy(PROXY); + assert.deepEqual(netSession.calls, []); + await kit.ready; + assert.equal((await handlers.get(INVOKE.UPDATE_CHECK)(eventFrom(appPage()))).state, "none"); + assert.deepEqual(appliedOn(netSession), [{ mode: "fixed_servers", ...PROXY }, "closeAllConnections"]); + assert.deepEqual(checked, [2], "the check came after both"); +});