From 9aded0310fa96525f31a60593ab1491ae6511d88 Mon Sep 17 00:00:00 2001 From: traviswye Date: Fri, 9 Oct 2026 16:06:00 -0400 Subject: [PATCH 1/2] updates: OpenFlow updates itself from its GitHub releases, asked once, never mid-flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit electron-updater in the shell (electron/updater.js) with the same contract as Create Companion: the Hub asks once whether to check weekly; nothing is checked before a yes; an update downloads and installs only on a click, in Settings › About or on the Hub. A check fetches the latest published release's latest.yml; drafts are invisible, so a build reaches installed copies only when published. Installing quits the app and so the backend. The new /api/update-safe answers false while a keyboard or module flash runs, and the shell asks it before and after the download, then stops the backend before the installer starts (on Windows the installer runs while the app closes and must not find the backend's files in use). The portable exe, a .deb install and a dev run cannot replace themselves and get a link to the release instead. Release: a GitHub publish target makes electron-builder write latest*.yml; the Mac rows build a zip beside the dmg (the updater needs it) and the release job merges the two Mac update files. openflow-updater-e2e installs an old signed build on Windows, macOS and Linux runners and has it update itself to 9.9.9 from localhost. --- .github/workflows/openflow-release.yml | 16 +- .github/workflows/openflow-updater-e2e.yml | 80 +++++++ backend/openflow_backend/api/rest.py | 16 ++ backend/tests/route-manifest.json | 1 + backend/tests/test_update_safe.py | 25 +++ electron/main.js | 44 +++- electron/preload.js | 21 +- electron/updater.js | 203 ++++++++++++++++++ frontend/src/components/AppUpdates.jsx | 145 +++++++++++++ frontend/src/pages/Hub.jsx | 23 +- frontend/src/pages/Settings.jsx | 24 +-- frontend/src/styles/app.css | 5 + frontend/tests/e2e/updates.spec.js | 77 +++++++ .../tests/unit/settingsFirmwareTab.test.jsx | 4 +- package-lock.json | 65 ++++-- package.json | 13 +- scripts/merge-mac-update-info.mjs | 73 +++++++ scripts/updater-e2e.mjs | 181 ++++++++++++++++ 18 files changed, 969 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/openflow-updater-e2e.yml create mode 100644 backend/tests/test_update_safe.py create mode 100644 electron/updater.js create mode 100644 frontend/src/components/AppUpdates.jsx create mode 100644 frontend/tests/e2e/updates.spec.js create mode 100644 scripts/merge-mac-update-info.mjs create mode 100644 scripts/updater-e2e.mjs diff --git a/.github/workflows/openflow-release.yml b/.github/workflows/openflow-release.yml index 520612c..31e2924 100644 --- a/.github/workflows/openflow-release.yml +++ b/.github/workflows/openflow-release.yml @@ -6,6 +6,11 @@ # row per architecture) and Linux (AppImage + deb, SCRUM-70) are here. Everyday checks live in # openflow-ci.yml. # +# In-app updates: electron-builder writes latest.yml / latest-mac.yml / latest-linux.yml next to +# the installers (the GitHub publish target in package.json; nothing is uploaded by it). They go +# on the draft release with the installers, the two Mac rows' files merged into one by the +# release job; installed copies read them only once the owner publishes the release. +# # Signing: Windows through Azure Artifact Signing, macOS with a Developer ID certificate plus # notarization. Both switch on by themselves once the secrets and variables named in the # "code signing" step exist (scripts/ci-signing.mjs has the list); until then the builds are @@ -107,13 +112,16 @@ jobs: run: npm run build:app -- ${{ matrix.target }} - name: the artefacts are signed (when signing is on) run: node scripts/ci-signing.mjs --verify + - name: name this Mac row's update file by its architecture (the release job merges them) + if: runner.os == 'macOS' + run: mv release/latest-mac.yml "release/latest-mac-$(uname -m).yml" - name: checksums env: SUMS_FOR: ${{ matrix.os }} # not process.platform: both Mac rows report darwin run: | node -e ' const fs = require("fs"), crypto = require("crypto"); - const out = fs.readdirSync("release").filter(f => /\.(exe|dmg|AppImage|deb)$/.test(f)) + const out = fs.readdirSync("release").filter(f => /\.(exe|dmg|zip|AppImage|deb)$/.test(f)) .map(f => crypto.createHash("sha256").update(fs.readFileSync("release/" + f)).digest("hex") + " " + f); fs.writeFileSync("release/SHA256SUMS-" + process.env.SUMS_FOR + ".txt", out.join("\n") + "\n"); console.log(out.join("\n"));' @@ -123,8 +131,11 @@ jobs: path: | release/*.exe release/*.dmg + release/*.zip release/*.AppImage release/*.deb + release/latest*.yml + release/*.blockmap release/SHA256SUMS-*.txt if-no-files-found: error @@ -135,10 +146,13 @@ jobs: permissions: contents: write steps: + - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: path: artifacts merge-multiple: true + - name: one latest-mac.yml for both Mac builds + run: node scripts/merge-mac-update-info.mjs artifacts - uses: softprops/action-gh-release@v2 with: files: artifacts/* diff --git a/.github/workflows/openflow-updater-e2e.yml b/.github/workflows/openflow-updater-e2e.yml new file mode 100644 index 0000000..92092bc --- /dev/null +++ b/.github/workflows/openflow-updater-e2e.yml @@ -0,0 +1,80 @@ +# The in-app updater, end to end: an installed OpenFlow updates itself to 9.9.9 from a release +# served on localhost (scripts/updater-e2e.mjs), on Windows, macOS and Linux. The builds are +# signed exactly as the release signs them, because the updater checks that: Squirrel.Mac takes +# only an update signed like the running app, and on Windows the installer's publisher is +# compared with the one the app was built for. Nothing here is uploaded anywhere but the logs. + +name: openflow-updater-e2e + +on: + workflow_dispatch: + pull_request: + paths: + - "electron/**" + - "package.json" + - "scripts/updater-e2e.mjs" + - "scripts/ci-signing.mjs" + - ".github/workflows/openflow-updater-e2e.yml" + +defaults: + run: + shell: bash + +jobs: + e2e: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest, ubuntu-22.04] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + - name: backend with the build extra + working-directory: backend + run: pip install -e ".[build]" + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: | + package-lock.json + frontend/package-lock.json + - name: npm ci (shell and renderer) + run: | + npm ci + npm --prefix frontend ci + - name: AppImage runtime and a display (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libfuse2 xvfb + - name: code signing, as the release does it + env: + APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ vars.AZURE_SIGNING_ACCOUNT }} + AZURE_SIGNING_PROFILE: ${{ vars.AZURE_SIGNING_PROFILE }} + AZURE_SIGNING_PUBLISHER: ${{ vars.AZURE_SIGNING_PUBLISHER }} + run: node scripts/ci-signing.mjs + - name: an old build updates itself to 9.9.9 + run: node scripts/updater-e2e.mjs + - name: updater logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: updater-e2e-logs-${{ matrix.os }} + path: | + ~/AppData/Roaming/OpenFlow/logs/ + ~/Library/Application Support/OpenFlow/logs/ + ~/.local/share/OpenFlow/logs/ + if-no-files-found: ignore diff --git a/backend/openflow_backend/api/rest.py b/backend/openflow_backend/api/rest.py index a79f2cd..5aa3b87 100644 --- a/backend/openflow_backend/api/rest.py +++ b/backend/openflow_backend/api/rest.py @@ -1669,6 +1669,22 @@ async def check_for_updates() -> dict: return {"updateAvailable": False, "reason": "OpenFlow has no external update source"} +@router.get("/api/update-safe") +async def update_safe() -> dict: + """Whether OpenFlow may close now to install an update of itself. + + The shell asks before it stops the backend for an install (electron/updater.js), and again + after the download: quitting ends the backend, and a keyboard or module flash cut off that + way can leave a half that will not boot. Keyboard and module flashes both run through + flash_runs, so its flag covers both. + """ + from ..device import flash_runs as runs + if runs.active(): + return {"safe": False, + "reason": "A firmware flash is running. Install the update once it has finished."} + return {"safe": True, "reason": None} + + # --- flash (write) : preview only for now (dry-run); real write is Phase C --- @router.get("/api/module-variants") diff --git a/backend/tests/route-manifest.json b/backend/tests/route-manifest.json index d4cb481..a70c6cc 100644 --- a/backend/tests/route-manifest.json +++ b/backend/tests/route-manifest.json @@ -35,6 +35,7 @@ "GET /api/status/last", "GET /api/status/live", "GET /api/ui/state", + "GET /api/update-safe", "GET /api/userdata", "GET /docs", "GET /docs/oauth2-redirect", diff --git a/backend/tests/test_update_safe.py b/backend/tests/test_update_safe.py new file mode 100644 index 0000000..cfc05d3 --- /dev/null +++ b/backend/tests/test_update_safe.py @@ -0,0 +1,25 @@ +"""The shell asks /api/update-safe before closing OpenFlow to install an update of itself. + +Quitting ends the backend, and a keyboard or module flash cut off that way can leave a half that +will not boot. Both kinds of flash run through flash_runs, so its flag is the whole answer. +""" +import asyncio + +from openflow_backend.api import rest +from openflow_backend.device import flash_runs + + +def ask(): + return asyncio.run(rest.update_safe()) + + +def test_safe_when_nothing_is_flashing(monkeypatch): + monkeypatch.setattr(flash_runs, "_active", False) + assert ask() == {"safe": True, "reason": None} + + +def test_not_safe_while_a_flash_runs(monkeypatch): + monkeypatch.setattr(flash_runs, "_active", True) + answer = ask() + assert answer["safe"] is False + assert "flash" in answer["reason"] diff --git a/electron/main.js b/electron/main.js index f7b88ad..8d9c370 100644 --- a/electron/main.js +++ b/electron/main.js @@ -17,7 +17,8 @@ // * a clean stop: /rpc/shutdown with the token (the backend closes the keyboard's port), // then the process tree is killed if it has not gone within two seconds; // * the sidecar's output lands in /logs/sidecar.log, named in the error dialog when -// the backend fails to start or dies. +// the backend fails to start or dies; +// * an update installs only on a click and never during a flash (electron/updater.js). const { app, BrowserWindow, Menu, dialog, screen, shell } = require("electron"); const { spawn, spawnSync } = require("child_process"); @@ -27,6 +28,7 @@ const http = require("http"); const net = require("net"); const os = require("os"); const path = require("path"); +const { setupUpdates } = require("./updater"); const DEV = process.env.OPENFLOW_DEV === "1"; const PREFERRED_PORT = 3001; @@ -228,7 +230,40 @@ function createWindow(port) { } } +// The backend's answer to "may OpenFlow close for an update now?". No answer counts as no: a +// flash cannot be ruled out. +async function updateSafe() { + try { + const { status, json } = await httpJson("GET", `http://127.0.0.1:${backendPort}/api/update-safe`, null, 3000); + if (status === 200 && json && typeof json.safe === "boolean") return json; + } catch {} + return { safe: false, reason: "OpenFlow's backend did not answer; try again in a moment." }; +} + +// `--update-now`: check, download and install with no window and no backend, then exit. For +// the updater test (scripts/updater-e2e.mjs); the window's Install button runs the same code. +async function updateNow() { + backendStopped = true; + const updates = setupUpdates({ + dataDir, getWindow: () => null, + updateSafe: async () => ({ safe: true, reason: null }), + stopBackend: async () => {}, + }); + const found = await updates.check().catch((e) => ({ error: e.message })); + if (found.error || !found.available) { + updates.log(`--update-now: ${found.error || "nothing to install"}`); + app.exit(found.error ? 1 : 0); + return; + } + const r = await updates.install({ silent: true, restart: false }); + if (!r.installing) { + updates.log(`--update-now: ${r.error || r.blocked}`); + app.exit(1); + } +} + async function start() { + if (process.argv.includes("--update-now")) return updateNow(); buildMenu(); backendPort = await findFreePort(PREFERRED_PORT); startBackend(backendPort); @@ -241,6 +276,13 @@ async function start() { app.exit(1); return; } + setupUpdates({ + dataDir, + getWindow: () => win, + updateSafe, + // Closing for an install is a quit like any other: no "backend stopped" dialog. + stopBackend: () => { quitting = true; return stopBackend(); }, + }); createWindow(backendPort); } diff --git a/electron/preload.js b/electron/preload.js index c8fbb83..714efa0 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -3,9 +3,28 @@ // The port is the second-to-last argv entry (Electron appends "/prefetch:1"), // which is exactly how NayaFlow's preload.mjs read it. -const { contextBridge } = require("electron"); +// +// And OpenFlow's own updates (electron/updater.js) as window.openflowUpdates: the page asks, the +// shell checks, downloads and installs. Absent in a plain browser (the Vite dev server, tests). + +const { contextBridge, ipcRenderer } = require("electron"); const args = process.argv.filter((a) => a !== "/prefetch:1"); const bgServerPort = parseInt(args[args.length - 1], 10) || 3001; contextBridge.exposeInMainWorld("EXPOSED", { bgServerPort }); + +const subscribe = (channel) => (cb) => { + const handler = (_event, payload) => cb(payload); + ipcRenderer.on(channel, handler); + return () => ipcRenderer.removeListener(channel, handler); +}; + +contextBridge.exposeInMainWorld("openflowUpdates", { + state: () => ipcRenderer.invoke("updates:state"), + setCheck: (on) => ipcRenderer.invoke("updates:set-check", !!on), + check: () => ipcRenderer.invoke("updates:check"), + install: () => ipcRenderer.invoke("updates:install"), + onProgress: subscribe("updates:progress"), + onAvailable: subscribe("updates:available"), +}); diff --git a/electron/updater.js b/electron/updater.js new file mode 100644 index 0000000..e44a3e3 --- /dev/null +++ b/electron/updater.js @@ -0,0 +1,203 @@ +// In-app updates: electron-updater, reading the GitHub releases of create-collective/openflow. +// +// The same contract as Create Companion's: nothing is checked until the user has said yes once +// (Hub asks; Settings › About changes it), then one look a week; downloading and installing +// happen only on a click. A check fetches the latest release's latest.yml, a few hundred bytes +// naming the version and its files; nothing about the user or the keyboard is sent. A DRAFT +// release is invisible to it, so a build reaches installed copies only once it is published. +// +// What cannot replace itself: the portable Windows exe, a .deb install and a development run. +// Those still learn about a new version and get a link to the release. +// +// Installing quits OpenFlow, which ends the backend, and a flash cut off that way can leave a +// half that will not boot. So the backend is asked first (/api/update-safe) and again after the +// download, and the backend is stopped before the installer starts: on Windows the installer +// runs while the app is closing, and must not find the backend's files still in use. + +const { app, ipcMain } = require("electron"); +const fs = require("fs"); +const path = require("path"); + +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; +const TICK_MS = 60 * 60 * 1000; // how often to see whether the weekly look is due +const FIRST_DELAY_MS = 60 * 1000; // the first look waits until startup has settled +const RELEASES_URL = "https://github.com/create-collective/openflow/releases/latest"; + +// "1.2.3" > "1.2.0"; a pre-release sorts before its release; anything else is never newer. +function newer(candidate, current) { + const parse = (v) => { + const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([\w.]+))?/.exec(String(v || "").trim()); + return m ? [Number(m[1]), Number(m[2]), Number(m[3]), m[4] ? 0 : 1] : null; + }; + const a = parse(candidate), b = parse(current); + if (!a || !b) return false; + for (let i = 0; i < 4; i++) if (a[i] !== b[i]) return a[i] > b[i]; + return false; +} + +// Why this copy cannot replace itself, or null when it can. +function unsupportedReason() { + if (!app.isPackaged) return "a development run"; + if (process.env.PORTABLE_EXECUTABLE_DIR) return "the portable build"; + if (process.platform === "linux" && !process.env.APPIMAGE) return "a package install (.deb)"; + return null; +} + +function notesText(notes) { + if (!notes) return null; + if (typeof notes === "string") return notes.replace(/<[^>]+>/g, "").trim() || null; + return notes.map((n) => n.note || "").join("\n").replace(/<[^>]+>/g, "").trim() || null; +} + +/** + * Wire the updater into the shell. + * dataDir() the app's data folder (main.js) + * getWindow() the window to send update events to, or null + * updateSafe() resolves { safe, reason } from the backend + * stopBackend() resolves once the backend has stopped + */ +function setupUpdates({ dataDir, getWindow, updateSafe, stopBackend }) { + const prefsPath = path.join(dataDir(), "shell", "updates.json"); + const logPath = path.join(dataDir(), "logs", "updater.log"); + const unsupported = unsupportedReason(); + let updater = null; + let downloaded = null; // the version whose installer is on disk + let busy = false; + + const log = (msg) => { + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `${new Date().toISOString()} ${msg}\n`); + } catch { /* logging never breaks an update */ } + }; + const readPrefs = () => { + try { return JSON.parse(fs.readFileSync(prefsPath, "utf8")); } catch { return {}; } + }; + const writePrefs = (p) => { + try { + fs.mkdirSync(path.dirname(prefsPath), { recursive: true }); + fs.writeFileSync(prefsPath, JSON.stringify(p, null, 2)); + } catch (e) { log(`could not save ${prefsPath}: ${e.message}`); } + }; + const send = (channel, payload) => { + const w = getWindow(); + if (w && !w.isDestroyed()) w.webContents.send(channel, payload); + }; + + function getUpdater() { + if (updater) return updater; + const { autoUpdater } = require("electron-updater"); + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = false; + autoUpdater.allowPrerelease = false; + autoUpdater.logger = { + info: (m) => log(String(m)), warn: (m) => log(`warn ${m}`), + error: (m) => log(`error ${m}`), debug: () => {}, + }; + // The updater test serves a release from localhost; nothing else sets this. + if (process.env.OPENFLOW_UPDATE_URL) { + autoUpdater.setFeedURL({ provider: "generic", url: process.env.OPENFLOW_UPDATE_URL }); + } + autoUpdater.on("download-progress", (p) => + send("updates:progress", { percent: p.percent, transferred: p.transferred, total: p.total })); + updater = autoUpdater; + return updater; + } + + async function check() { + if (!app.isPackaged) { + return { current: app.getVersion(), error: "Updates are for installed copies; this is a development run." }; + } + const result = await getUpdater().checkForUpdates(); + const info = result && result.updateInfo; + const prefs = readPrefs(); + writePrefs({ ...prefs, lastCheck: Date.now(), latest: info ? info.version : prefs.latest }); + const available = !!(result && result.isUpdateAvailable); + log(`check: ${available ? `${info.version} available` : "up to date"} (running ${app.getVersion()})`); + return { + current: app.getVersion(), + latest: info ? info.version : null, + available, + notes: info ? notesText(info.releaseNotes) : null, + date: info ? info.releaseDate : null, + }; + } + + // Download (once) and install. Resolves { blocked } while a flash runs, { error } on failure; + // on success the app is already closing. + async function install({ silent = true, restart = true } = {}) { + if (unsupported) return { error: `This copy is ${unsupported}; download the new version from GitHub.` }; + if (busy) return { error: "An update is already being installed." }; + busy = true; + try { + let gate = await updateSafe(); + if (!gate.safe) return { blocked: gate.reason }; + const u = getUpdater(); + if (!downloaded) { + const found = await u.checkForUpdates(); + if (!found || !found.isUpdateAvailable) return { error: "There is no newer version to install." }; + await u.downloadUpdate(); + downloaded = found.updateInfo.version; + log(`downloaded ${downloaded}`); + } + // A flash may have started while the download ran. + gate = await updateSafe(); + if (!gate.safe) return { blocked: gate.reason }; + log(`installing ${downloaded}: stopping the backend first`); + await stopBackend(); + u.quitAndInstall(silent, restart); + return { installing: downloaded }; + } catch (e) { + log(`install failed: ${e.stack || e.message}`); + return { error: e.message }; + } finally { + busy = false; + } + } + + // The weekly look, once the user has said yes. A version found earlier is offered again at + // every launch without asking the network. + async function tick() { + const prefs = readPrefs(); + if (prefs.check !== true || !app.isPackaged) return; + const due = !prefs.lastCheck || Date.now() - prefs.lastCheck >= WEEK_MS || Date.now() < prefs.lastCheck; + if (due) { + try { + const r = await check(); + if (r.available) send("updates:available", { version: r.latest }); + } catch (e) { + log(`weekly check failed, will retry: ${e.message}`); + } + } else if (newer(prefs.latest, app.getVersion())) { + send("updates:available", { version: prefs.latest }); + } + } + + ipcMain.handle("updates:state", () => { + const prefs = readPrefs(); + return { + current: app.getVersion(), + check: prefs.check, // undefined until the user has answered + known: prefs.check === true && newer(prefs.latest, app.getVersion()) ? prefs.latest : null, + canInstall: !unsupported, + unsupported, + releasesUrl: RELEASES_URL, + }; + }); + ipcMain.handle("updates:set-check", (_e, on) => { + writePrefs({ ...readPrefs(), check: !!on }); + log(`weekly check ${on ? "on" : "off"}`); + if (on) tick(); + return true; + }); + ipcMain.handle("updates:check", async () => { + try { return await check(); } catch (e) { log(`check failed: ${e.message}`); return { error: e.message }; } + }); + ipcMain.handle("updates:install", () => install()); + + setTimeout(() => { tick(); setInterval(tick, TICK_MS); }, FIRST_DELAY_MS); + + return { check, install, log }; +} + +module.exports = { setupUpdates, newer }; diff --git a/frontend/src/components/AppUpdates.jsx b/frontend/src/components/AppUpdates.jsx new file mode 100644 index 0000000..507d73a --- /dev/null +++ b/frontend/src/components/AppUpdates.jsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from "react"; +import { REPOS, checkRelease } from "../lib/updates"; +import Button from "./ui/Button"; +import Notice from "./ui/Notice"; +import Toggle from "./ui/Toggle"; + +// OpenFlow's own updates. In the desktop app the shell checks, downloads and installs +// (electron/updater.js, reached through the preload's window.openflowUpdates): nothing is checked +// until the user has answered once, then once a week, and an update installs only on a click, +// never while a flash runs. In a plain browser (the Vite dev server, the e2e tests) there is no +// shell, and the opt-in GitHub release check (lib/updates) stands in. +export const shellUpdates = typeof window !== "undefined" ? window.openflowUpdates || null : null; + +/** The shell's update state, refreshed on demand; null outside the desktop app. */ +export function useUpdateState() { + const [state, setState] = useState(null); + const refresh = () => shellUpdates?.state().then(setState).catch(() => {}); + useEffect(() => { + refresh(); + // The weekly check found something while the app was open. + const off = shellUpdates?.onAvailable(() => refresh()); + return () => off?.(); + }, []); + return [state, refresh]; +} + +/** The once-only question, for the Hub. Renders nothing once it has been answered. */ +export function AskAboutUpdates({ state, onAnswered, className }) { + if (!shellUpdates || !state || state.check !== undefined) return null; + const answer = (on) => shellUpdates.setCheck(on).then(onAnswered); + return ( + + + + }> + OpenFlow would download one small file from GitHub that names the newest release. Nothing + about you or your keyboard is sent, and nothing installs until you click Install. You can + change this in Settings › About. + + ); +} + +/** + * Check now, the result, and Install. `compact` is the Hub's line; the full panel (Settings › + * About) adds the weekly switch and the release notes. + */ +export default function AppUpdates({ version, compact = false }) { + const [state, refresh] = useUpdateState(); + const [result, setResult] = useState(null); // { checking } | { error } | check result + const [install, setInstall] = useState(null); // { percent } | { blocked } | { error } | { installing } + + useEffect(() => { + const off = shellUpdates?.onProgress((p) => setInstall({ percent: p.percent })); + return () => off?.(); + }, []); + + if (!shellUpdates) return ; + + const check = async () => { + setResult({ checking: true }); + setInstall(null); + const r = await shellUpdates.check().catch((e) => ({ error: e.message })); + setResult(r); + refresh(); + }; + const doInstall = async () => { + setInstall({ percent: 0 }); + const r = await shellUpdates.install().catch((e) => ({ error: e.message })); + setInstall(r); // { installing } means the app is closing now + }; + + // A version is on offer from this check, or from the weekly one. + const offered = result?.available ? result.latest : state?.known; + const installing = install && ("percent" in install || install.installing); + const busy = result?.checking || installing; + + return ( +
+ {!compact && state && ( + shellUpdates.setCheck(on).then(refresh)} + label="Check for updates automatically, once a week" /> + )} +
+ {offered && state?.canInstall ? ( + + ) : ( + + )} + {offered && !state?.canInstall && state?.releasesUrl && ( + + Download {offered} ↗ + + )} + {!offered && result && !result.checking && !result.error && ( + Up to date{result.current ? ` (${result.current})` : ""} + )} + {offered && state?.canInstall && !installing && ( + {offered} is available + )} +
+ {offered && !state?.canInstall && state?.unsupported && ( +
This copy is {state.unsupported}, which cannot replace itself.
+ )} + {result?.error && Could not check: {result.error}} + {install?.blocked && {install.blocked}} + {install?.error && The update did not install: {install.error}} + {install?.installing && ( +
OpenFlow closes to install {install.installing} and opens again by itself.
+ )} + {!compact && result?.available && result.notes &&
{result.notes}
} +
+ ); +} + +function installLabel(install) { + if (install.installing) return "Installing…"; + const pct = Math.round(install.percent || 0); + return pct > 0 && pct < 100 ? `Downloading… ${pct}%` : "Preparing…"; +} + +// Outside the desktop app: the opt-in GitHub check, as before. +function BrowserCheck({ version, compact }) { + const [update, setUpdate] = useState(null); + return ( +
+
+ + {update?.error && {update.error}} + {update?.latest && ( + update.ahead + ? {update.latest} is available ↗ + : Up to date{update.latest ? ` (${update.latest})` : ""} + )} +
+
+ ); +} diff --git a/frontend/src/pages/Hub.jsx b/frontend/src/pages/Hub.jsx index 5fa828c..42803e2 100644 --- a/frontend/src/pages/Hub.jsx +++ b/frontend/src/pages/Hub.jsx @@ -2,21 +2,23 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { api } from "../lib/api"; import { useDeviceStream } from "../lib/deviceStream"; -import { KNOWLEDGE_BASE_URL, ORG, REPOS, checkRelease } from "../lib/updates"; +import { KNOWLEDGE_BASE_URL, ORG, REPOS } from "../lib/updates"; import { notesFor } from "../changelog"; import Button from "../components/ui/Button"; import Card from "../components/ui/Card"; import { KVList, KVRow } from "../components/ui/KV"; import Notice from "../components/ui/Notice"; import LinuxAccessNotice, { accessFix } from "../components/LinuxAccessNotice"; +import AppUpdates, { AskAboutUpdates, useUpdateState } from "../components/AppUpdates"; // The front door: the header, then the Create with one button into Bindings; beside it the -// backend, the keyboard on USB, what is new in this version with an opt-in release check, -// and the Create Companion, the host-side engine that pairs with this app. +// backend, the keyboard on USB, what is new in this version with OpenFlow's own updates (and, +// once only, the question whether to look for them weekly), and the Create Companion, the +// host-side engine that pairs with this app. export default function Hub() { const [sys, setSys] = useState(null); const [err, setErr] = useState(null); - const [update, setUpdate] = useState(null); + const [updates, refreshUpdates] = useUpdateState(); const { data: stream, connected } = useDeviceStream(); const navigate = useNavigate(); @@ -45,6 +47,7 @@ export default function Hub() { +
@@ -104,17 +107,7 @@ export default function Hub() { {whatsNew.notes.map((n) =>
  • {n}
  • )} )} -
    - - {update?.error && {update.error}} - {update?.latest && ( - update.ahead - ? {update.latest} is available ↗ - : Up to date{update.latest ? ` (${update.latest})` : ""} - )} -
    +

    Other Software and Resources for the Create

    diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index ce32617..19af9cc 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -7,6 +7,7 @@ import { pickFile, downloadJSON, safeName } from "../lib/files"; import FirmwareLibrary from "../components/FirmwareLibrary"; import FirmwareUpdate from "../components/FirmwareUpdate"; import ModuleFirmwareUpdate from "../components/ModuleFirmwareUpdate"; +import AppUpdates from "../components/AppUpdates"; import SettingField from "../components/SettingField"; import SettingRow from "../components/ui/SettingRow"; import useRunLog from "../lib/useRunLog"; @@ -111,7 +112,6 @@ export default function Settings() { const [status, setStatus] = useState([]); const [backups, setBackups] = useState({ backups: [], dir: "" }); const [sys, setSys] = useState(null); - const [update, setUpdate] = useState(null); const [companion, setCompanion] = useState(null); const [firmware, setFirmware] = useState(null); const [devlog, setDevlog] = useState([]); @@ -182,9 +182,8 @@ export default function Settings() { const liveHalves = (stream?.status?.halves || []).filter((h) => h.connected); const firmwareConnected = liveHalves.length > 0; - // One opt-in release check (lib/updates), reused for OpenFlow and Create Companion -- no - // forced updater. - const checkUpdate = () => checkRelease(REPOS.app, sys?.backendVersion, setUpdate); + // OpenFlow's own updates are AppUpdates (the desktop shell's updater); Create Companion keeps + // the opt-in release check (lib/updates), since it updates itself. const checkCompanion = () => checkRelease(REPOS.companion, null, setCompanion); // Route by SCOPE, not by name. This was `group !== "Interface"` vs `=== "Interface"`, so @@ -468,21 +467,20 @@ export default function Settings() { +

    Updates

    + +

    + Updates to OpenFlow itself, from its GitHub releases. A check downloads one + small file naming the newest release; nothing installs until you click + Install, and never while a flash is running. Your keyboard’s own + firmware lives on the Firmware tab. +

    -
    - {update?.checking &&
    Checking OpenFlow…
    } - {update?.error && OpenFlow: {update.error}} - {update?.latest && } {companion?.checking &&
    Checking Create Companion…
    } {companion?.error && Create Companion: {companion.error}} {companion?.latest && } -

    - Opt-in checks against GitHub Releases. OpenFlow has no forced updater. These - are checks on OpenFlow itself — your keyboard’s own firmware lives on - the Firmware tab. -

    Source

    diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 5fc7b5b..7c62f25 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -349,6 +349,11 @@ .hub-update-note.ok { color: var(--ok); } .hub-update-note.err { color: var(--err); } a.hub-update-note { color: var(--accent-text); font-weight: 600; text-decoration: none; } +/* OpenFlow's own updates (components/AppUpdates): the Hub's line and Settings › About's panel. */ +.app-updates { display: grid; gap: 10px; justify-items: start; } +.app-updates.compact { gap: 8px; } +.app-update-notes { margin: 0; max-height: 220px; overflow: auto; white-space: pre-wrap; font: 13px/1.5 var(--font-ui, inherit); color: var(--text-muted); } +.hub-ask-updates { margin: 0 0 16px; } @media (max-width: 1100px) { .hub-body { grid-template-columns: minmax(0, 1fr); } } diff --git a/frontend/tests/e2e/updates.spec.js b/frontend/tests/e2e/updates.spec.js new file mode 100644 index 0000000..4dfe284 --- /dev/null +++ b/frontend/tests/e2e/updates.spec.js @@ -0,0 +1,77 @@ +// OpenFlow's own updates as the desktop app shows them. The shell's half (electron/updater.js, +// window.openflowUpdates from the preload) is stubbed here, so every state the page can be in is +// reachable without a release: the once-only question, an update on offer, an install refused +// because a flash is running, and a copy that cannot replace itself. Screenshots land under +// tests/e2e/shots/updates/. The real check-download-install path is updater-e2e's job. +import { test, expect } from "@playwright/test"; +import { E2E_BACKEND_PORT } from "./env.js"; + +// Installs the stub before the page loads. `calls` records what the page asked for. +async function withShell(page, { state, check, install }) { + await page.addInitScript(([port, s, c, i]) => { + window.EXPOSED = { bgServerPort: port }; + window.__calls = []; + let current = { ...s }; + window.openflowUpdates = { + state: async () => current, + setCheck: async (on) => { window.__calls.push(["setCheck", on]); current = { ...current, check: on }; return true; }, + check: async () => { window.__calls.push(["check"]); return c; }, + install: async () => { window.__calls.push(["install"]); return i; }, + onProgress: () => () => {}, + onAvailable: () => () => {}, + }; + }, [E2E_BACKEND_PORT, state, check, install]); +} + +const BASE = { current: "0.6.0", known: null, canInstall: true, unsupported: null, + releasesUrl: "https://github.com/create-collective/openflow/releases/latest" }; + +test("the Hub asks once, and a yes turns on the weekly check", async ({ page }) => { + await withShell(page, { state: { ...BASE, check: undefined } }); + await page.goto("/#/"); + const ask = page.getByText("Check for updates automatically, once a week?"); + await expect(ask).toBeVisible(); + await page.screenshot({ path: "tests/e2e/shots/updates/hub-question.png" }); + await page.getByRole("button", { name: "Yes", exact: true }).click(); + await expect(ask).toHaveCount(0); + expect(await page.evaluate(() => window.__calls)).toContainEqual(["setCheck", true]); +}); + +test("an update the weekly check found is offered on the Hub, and refused during a flash", async ({ page }) => { + await withShell(page, { + state: { ...BASE, check: true, known: "9.9.9" }, + install: { blocked: "A firmware flash is running. Install the update once it has finished." }, + }); + await page.goto("/#/"); + await expect(page.getByText("Check for updates automatically, once a week?")).toHaveCount(0); + const installButton = page.getByRole("button", { name: "Install 9.9.9 and restart" }); + await expect(installButton).toBeVisible(); + await installButton.click(); + await expect(page.getByText("A firmware flash is running.")).toBeVisible(); + await page.screenshot({ path: "tests/e2e/shots/updates/hub-blocked.png" }); +}); + +test("Settings › About: the weekly switch, Check, the notes and Install", async ({ page }) => { + await withShell(page, { + state: { ...BASE, check: true }, + check: { current: "0.6.0", latest: "9.9.9", available: true, + notes: "Updates from inside the app.\nSigned on Windows and macOS.", date: "2026-10-10" }, + }); + await page.goto("/#/settings?tab=about"); + await expect(page.getByLabel("Check for updates automatically, once a week")).toBeChecked(); + await page.getByRole("button", { name: /^check for updates$/i }).click(); + await expect(page.getByRole("button", { name: "Install 9.9.9 and restart" })).toBeVisible(); + await expect(page.getByText("Signed on Windows and macOS.")).toBeVisible(); + await page.screenshot({ path: "tests/e2e/shots/updates/settings-offer.png" }); +}); + +test("a copy that cannot replace itself links to the release instead", async ({ page }) => { + await withShell(page, { + state: { ...BASE, check: true, known: "9.9.9", canInstall: false, unsupported: "the portable build" }, + }); + await page.goto("/#/settings?tab=about"); + await expect(page.getByRole("link", { name: /Download 9\.9\.9/ })).toBeVisible(); + await expect(page.getByText("This copy is the portable build, which cannot replace itself.")).toBeVisible(); + await expect(page.getByRole("button", { name: /Install/ })).toHaveCount(0); + await page.screenshot({ path: "tests/e2e/shots/updates/settings-portable.png" }); +}); diff --git a/frontend/tests/unit/settingsFirmwareTab.test.jsx b/frontend/tests/unit/settingsFirmwareTab.test.jsx index 3e82462..bb95ae3 100644 --- a/frontend/tests/unit/settingsFirmwareTab.test.jsx +++ b/frontend/tests/unit/settingsFirmwareTab.test.jsx @@ -81,11 +81,11 @@ describe("Settings → Firmware", () => { stream.data = { status: { halves: [half()] } }; await openFirmwareTab(); await screen.findByText("Firmware library"); - expect(screen.queryByRole("button", { name: /check openflow for updates/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^check for updates$/i })).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: /create companion/i })).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("tab", { name: /about/i })); - expect(await screen.findByRole("button", { name: /check openflow for updates/i })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /^check for updates$/i })).toBeInTheDocument(); expect(screen.getByRole("link", { name: /firmware ↗/i })).toBeInTheDocument(); // The About tab says nothing about the board. expect(screen.queryByText("Firmware library")).not.toBeInTheDocument(); diff --git a/package-lock.json b/package-lock.json index 0a9b764..c583db0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "openflow", - "version": "0.0.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openflow", - "version": "0.0.1", + "version": "0.6.0", "license": "Apache-2.0", + "dependencies": { + "electron-updater": "^6.8.9" + }, "devDependencies": { "cross-env": "^10.1.0", "electron": "^44.4.1", @@ -830,7 +833,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/asn1js": { @@ -986,7 +988,6 @@ "version": "9.7.0", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -1240,7 +1241,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1550,6 +1550,34 @@ "mime": "^2.5.2" } }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/electron-winstaller": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", @@ -1838,7 +1866,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -2064,7 +2091,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -2283,7 +2309,6 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, "funding": [ { "type": "github", @@ -2341,7 +2366,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -2364,7 +2388,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/lodash": { @@ -2374,6 +2397,19 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -2534,7 +2570,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/node-abi": { @@ -3094,7 +3129,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -3357,6 +3391,12 @@ "semver": "bin/semver" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3447,7 +3487,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" diff --git a/package.json b/package.json index 206fdc4..90d1129 100644 --- a/package.json +++ b/package.json @@ -74,9 +74,17 @@ "portable": { "artifactName": "${productName}-${version}-win-${arch}-portable.${ext}" }, + "publish": [ + { + "provider": "github", + "owner": "create-collective", + "repo": "openflow" + } + ], "mac": { "target": [ - "dmg" + "dmg", + "zip" ], "category": "public.app-category.utilities", "icon": "build/icon.png", @@ -104,5 +112,8 @@ "build/linux/70-openflow.rules=/usr/lib/udev/rules.d/70-openflow.rules" ] } + }, + "dependencies": { + "electron-updater": "^6.8.9" } } diff --git a/scripts/merge-mac-update-info.mjs b/scripts/merge-mac-update-info.mjs new file mode 100644 index 0000000..04b5abf --- /dev/null +++ b/scripts/merge-mac-update-info.mjs @@ -0,0 +1,73 @@ +// One latest-mac.yml for both Mac builds. +// +// The release builds macOS twice, on two runners (Apple Silicon and Intel), and each writes its +// own latest-mac.yml naming only its own files. An installed copy reads ONE latest-mac.yml and +// picks the arm64 or x64 zip from its `files` list (electron-updater's MacUpdater does that by +// the "arm64" in the file name), so the release job merges the two lists here before the files +// go on the draft release. +// +// node scripts/merge-mac-update-info.mjs +// +// holds latest-mac-arm64.yml and latest-mac-x86_64.yml (the release workflow renames each +// row's file by `uname -m`); latest-mac.yml is written next to them and the two inputs removed. +// The format is electron-builder's own, written by machine, so it is read line by line rather +// than pulling a YAML parser into a job that installs nothing. +import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const dir = process.argv[2]; +if (!dir) { console.error("usage: merge-mac-update-info.mjs "); process.exit(2); } + +const inputs = readdirSync(dir).filter((f) => /^latest-mac-.+\.yml$/.test(f)).sort(); +if (inputs.length === 0) { console.error(`no latest-mac-*.yml in ${dir}`); process.exit(1); } + +// version, the `files` entries (each a " - url:" line and its indented fields), everything else. +function parse(text) { + const lines = text.replace(/\r\n/g, "\n").split("\n"); + const out = { version: null, files: [], rest: [] }; + let inFiles = false; + for (const line of lines) { + if (/^files:\s*$/.test(line)) { inFiles = true; continue; } + if (inFiles && /^\s/.test(line)) { + if (/^\s*- /.test(line)) out.files.push([line]); + else out.files[out.files.length - 1].push(line); + continue; + } + inFiles = false; + const v = /^version:\s*(.+)$/.exec(line); + if (v) out.version = v[1].trim(); + else if (line.trim()) out.rest.push(line); + } + return out; +} + +const parsed = inputs.map((f) => ({ name: f, ...parse(readFileSync(join(dir, f), "utf8")) })); +const versions = new Set(parsed.map((p) => p.version)); +if (versions.size !== 1 || versions.has(null)) { + console.error(`the Mac builds disagree on the version: ${parsed.map((p) => `${p.name}=${p.version}`).join(", ")}`); + process.exit(1); +} +const urls = new Set(); +const files = []; +for (const p of parsed) { + for (const entry of p.files) { + const url = /url:\s*(.+)$/.exec(entry[0])?.[1].trim(); + if (!url) { console.error(`${p.name}: a files entry without a url`); process.exit(1); } + if (urls.has(url)) continue; + urls.add(url); + files.push(entry); + } +} +if (!files.some((e) => /arm64/.test(e[0])) || !files.some((e) => !/arm64/.test(e[0]))) { + console.error(`expected files for both Apple Silicon and Intel, got: ${[...urls].join(", ")}`); + process.exit(1); +} + +// The legacy top-level path/sha512 (old updaters read only these) come from the Intel file, the +// one every Mac can run. +const legacy = parsed.find((p) => !/arm64/.test(p.name)) || parsed[0]; +const text = [`version: ${[...versions][0]}`, "files:", ...files.flat(), ...legacy.rest, ""].join("\n"); +writeFileSync(join(dir, "latest-mac.yml"), text); +for (const f of inputs) rmSync(join(dir, f)); +console.log(`latest-mac.yml: ${[...versions][0]}, ${files.length} files from ${inputs.join(" + ")}`); +if (!existsSync(join(dir, "latest-mac.yml"))) process.exit(1); diff --git a/scripts/updater-e2e.mjs b/scripts/updater-e2e.mjs new file mode 100644 index 0000000..ae4b25c --- /dev/null +++ b/scripts/updater-e2e.mjs @@ -0,0 +1,181 @@ +// End-to-end test of the in-app updater: an installed OpenFlow updates itself. +// +// Builds the app twice for this machine with `npm run build:app`: "old" at the repository's +// version and "new" at 9.9.9. Serves the new build's update file and installer from localhost, +// installs the old one the way a user would (per-user NSIS installer; the zip's .app into +// ~/Applications; the AppImage into ~/Applications), runs its hidden `--update-now` (the same +// check, download and install the Install button runs, with no window and no backend), and +// passes when the installed copy reads 9.9.9. +// +// Signing comes from the job: run scripts/ci-signing.mjs first with the release secrets, as the +// release does. That matters here: Squirrel.Mac only accepts an update signed like the app it +// replaces, and on Windows the updater checks the installer's publisher. +// +// node scripts/updater-e2e.mjs (on a CI runner: it installs OpenFlow for this user) +import { execFileSync, spawn, spawnSync } from "node:child_process"; +import { copyFileSync, createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { homedir, tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const RELEASE = join(ROOT, "release"); +const WORK = join(process.env.RUNNER_TEMP || tmpdir(), "openflow-updater-e2e"); +const SERVE = join(WORK, "serve"); +const PORT = 8766; +const NEW_VERSION = "9.9.9"; +const OS = process.platform; + +const step = (m) => console.log(`\n== ${m} ==`); +const fail = (m) => { console.error(`FAIL: ${m}`); process.exit(1); }; +const sh = (cmd, args, opts = {}) => { + const r = spawnSync(cmd, args, { cwd: ROOT, stdio: "inherit", shell: OS === "win32", ...opts }); + if (r.status !== 0) fail(`${cmd} ${args.join(" ")} exited ${r.status}`); +}; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// What to build and where the update file lands, per platform. +const TARGET = { + win32: { args: ["--win", "nsis", "--x64"], channel: "latest.yml", installer: /-setup\.exe$/ }, + darwin: { args: ["--mac", "zip", process.arch === "arm64" ? "--arm64" : "--x64"], channel: "latest-mac.yml", installer: /\.zip$/ }, + linux: { args: ["--linux", "AppImage", "--x64"], channel: "latest-linux.yml", installer: /\.AppImage$/ }, +}[OS]; +if (!TARGET) fail(`no updater test for ${OS}`); + +function build() { + rmSync(RELEASE, { recursive: true, force: true }); + sh("npm", ["run", "build:app", "--", ...TARGET.args]); + const installer = readdirSync(RELEASE).find((f) => TARGET.installer.test(f)); + if (!installer) fail(`no installer in release/ (${readdirSync(RELEASE).join(", ")})`); + if (!existsSync(join(RELEASE, TARGET.channel))) fail(`no ${TARGET.channel} in release/`); + return join(RELEASE, installer); +} + +// The new build is the repository with __version__ 9.9.9 and the release notes the version gate +// wants; both files, and the two package.json files version:sync stamps, are put back after. +function stampVersion() { + const files = ["backend/openflow_backend/__init__.py", "frontend/src/changelog.js", "package.json", "frontend/package.json"]; + const saved = Object.fromEntries(files.map((f) => [f, readFileSync(join(ROOT, f), "utf8")])); + const init = join(ROOT, files[0]); + writeFileSync(init, saved[files[0]].replace(/^__version__\s*=\s*"[^"]+"/m, `__version__ = "${NEW_VERSION}"`)); + const changelog = join(ROOT, files[1]); + // A Windows checkout has CRLF line ends. + writeFileSync(changelog, saved[files[1]].replace(/const CHANGELOG = \[(\r?\n)/, + (_, eol) => `const CHANGELOG = [${eol} { version: "${NEW_VERSION}", date: "2026-01-01", notes: ["updater end-to-end test"] },${eol}`)); + sh("npm", ["run", "version:sync"]); + return () => { for (const [f, text] of Object.entries(saved)) writeFileSync(join(ROOT, f), text); }; +} + +function serve() { + const server = createServer((req, res) => { + const file = join(SERVE, decodeURIComponent(new URL(req.url, "http://x").pathname).replace(/^\/+/, "")); + if (!file.startsWith(SERVE) || !existsSync(file) || statSync(file).isDirectory()) { + res.writeHead(404).end(); + return; + } + console.log(` served ${basename(file)}`); + res.writeHead(200, { "content-length": statSync(file).size }); + createReadStream(file).pipe(res); + }); + return new Promise((r) => server.listen(PORT, "127.0.0.1", () => r(server))); +} + +// Where a user's install lives, how to put the old build there, and what version it reads. +const INSTALL = { + win32: { + exe: join(process.env.LOCALAPPDATA || "", "Programs", "OpenFlow", "OpenFlow.exe"), + install(installer) { + // Per-user and silent, as the NSIS installer does for an update. spawnSync waits for the + // installer itself, not for anything it starts. + const r = spawnSync(installer, ["/S"], { stdio: "inherit" }); + if (r.status !== 0) fail(`the old installer exited ${r.status}`); + }, + version() { + if (!existsSync(this.exe)) return null; + return execFileSync("powershell", ["-NoProfile", "-Command", `(Get-Item -LiteralPath '${this.exe}').VersionInfo.ProductVersion`], { encoding: "utf8" }).trim(); + }, + }, + darwin: { + app: join(homedir(), "Applications", "OpenFlow.app"), + get exe() { return join(this.app, "Contents", "MacOS", "OpenFlow"); }, + install(zip) { + mkdirSync(dirname(this.app), { recursive: true }); + rmSync(this.app, { recursive: true, force: true }); + sh("ditto", ["-x", "-k", zip, dirname(this.app)]); + }, + version() { + const plist = join(this.app, "Contents", "Info.plist"); + if (!existsSync(plist)) return null; + return execFileSync("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", plist], { encoding: "utf8" }).trim(); + }, + }, + linux: { + exe: join(homedir(), "Applications", "OpenFlow.AppImage"), + install(appImage) { + mkdirSync(dirname(this.exe), { recursive: true }); + copyFileSync(appImage, this.exe); + spawnSync("chmod", ["+x", this.exe]); + }, + // An AppImage carries no readable version; the updater replaces the file, so compare bytes + // with the new build. + version() { + if (!existsSync(this.exe)) return null; + return readFileSync(this.exe).equals(readFileSync(join(SERVE, newInstallerName))) ? NEW_VERSION : "old"; + }, + }, +}[OS]; + +let newInstallerName = ""; + +async function main() { + rmSync(WORK, { recursive: true, force: true }); + mkdirSync(SERVE, { recursive: true }); + const oldVersion = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version; + + step(`old build (${oldVersion})`); + const oldInstaller = join(WORK, basename(build())); + copyFileSync(join(RELEASE, basename(oldInstaller)), oldInstaller); + + step(`new build (${NEW_VERSION})`); + const restore = stampVersion(); + let newInstaller; + try { newInstaller = build(); } finally { restore(); } + newInstallerName = basename(newInstaller); + // The update file names its installer by a relative url, so both go in one folder; the + // blockmap lets the updater download a difference instead of the whole file where it can. + for (const f of readdirSync(RELEASE)) { + if (f === TARGET.channel || f === newInstallerName || f === `${newInstallerName}.blockmap`) copyFileSync(join(RELEASE, f), join(SERVE, f)); + } + console.log(readFileSync(join(SERVE, TARGET.channel), "utf8")); + + step("serve on localhost"); + const server = await serve(); + + step(`install old (${oldVersion})`); + INSTALL.install(oldInstaller); + const before = INSTALL.version(); + if (OS !== "linux" && before !== oldVersion) fail(`installed copy reads ${before}, expected ${oldVersion}`); + console.log(`installed: ${INSTALL.exe} (${before})`); + + step("update through OpenFlow --update-now"); + const env = { ...process.env, OPENFLOW_UPDATE_URL: `http://127.0.0.1:${PORT}/` }; + const launcher = OS === "linux" ? ["xvfb-run", ["-a", INSTALL.exe, "--update-now"]] : [INSTALL.exe, ["--update-now"]]; + const app = spawn(launcher[0], launcher[1], { env, stdio: "inherit", detached: OS !== "win32" }); + app.on("exit", (code) => console.log(` OpenFlow --update-now exited ${code}`)); + let now = null; + for (let i = 0; i < 120 && now !== NEW_VERSION; i++) { + await sleep(3000); + try { now = INSTALL.version(); } catch { now = null; } // mid-swap the files can be absent + } + server.close(); + if (now !== NEW_VERSION) fail(`the installed copy still reads ${now} after 6 minutes`); + console.log(`PASS: ${oldVersion} updated itself to ${NEW_VERSION}`); + + // macOS relaunches the updated app; leave nothing running on the runner. + if (OS === "darwin") spawnSync("pkill", ["-f", "OpenFlow.app"]); + if (OS === "win32") spawnSync("taskkill", ["/IM", "OpenFlow.exe", "/T", "/F"]); + process.exit(0); +} + +main().catch((e) => fail(e.stack || e.message)); From da21f43c0a27a023c436155aba0cfc83acfee946 Mon Sep 17 00:00:00 2001 From: traviswye Date: Fri, 9 Oct 2026 16:16:11 -0400 Subject: [PATCH 2/2] updater-e2e: sign the pull request's builds, and read Windows' four-part version as three --- .github/workflows/openflow-updater-e2e.yml | 5 +++++ scripts/updater-e2e.mjs | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/openflow-updater-e2e.yml b/.github/workflows/openflow-updater-e2e.yml index 92092bc..9927677 100644 --- a/.github/workflows/openflow-updater-e2e.yml +++ b/.github/workflows/openflow-updater-e2e.yml @@ -25,6 +25,11 @@ jobs: name: ${{ matrix.os }} runs-on: ${{ matrix.os }} timeout-minutes: 60 + env: + # electron-builder skips signing in a pull request unless told otherwise; this test needs + # the signature (Squirrel.Mac refuses an unsigned update). A pull request from a fork gets + # no secrets from GitHub anyway, so nothing would be signed there. + CSC_FOR_PULL_REQUEST: "true" strategy: fail-fast: false matrix: diff --git a/scripts/updater-e2e.mjs b/scripts/updater-e2e.mjs index ae4b25c..b67e3d8 100644 --- a/scripts/updater-e2e.mjs +++ b/scripts/updater-e2e.mjs @@ -93,7 +93,9 @@ const INSTALL = { }, version() { if (!existsSync(this.exe)) return null; - return execFileSync("powershell", ["-NoProfile", "-Command", `(Get-Item -LiteralPath '${this.exe}').VersionInfo.ProductVersion`], { encoding: "utf8" }).trim(); + // Windows keeps four parts ("0.6.0.0"); the app's version is the first three. + const v = execFileSync("powershell", ["-NoProfile", "-Command", `(Get-Item -LiteralPath '${this.exe}').VersionInfo.ProductVersion`], { encoding: "utf8" }).trim(); + return v.split(".").slice(0, 3).join("."); }, }, darwin: {