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..9927677 --- /dev/null +++ b/.github/workflows/openflow-updater-e2e.yml @@ -0,0 +1,85 @@ +# 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 + 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: + 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..b67e3d8 --- /dev/null +++ b/scripts/updater-e2e.mjs @@ -0,0 +1,183 @@ +// 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; + // 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: { + 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));