From 229ea3eaeeb0231c34528a8597e1fea9140ed0fe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 17:33:45 +0000 Subject: [PATCH 1/6] Replace kiosk browser detection with an Electron shell start-kiosk.sh previously shelled out to whichever of chromium-browser/chromium/google-chrome/firefox happened to be installed. Add an Electron kiosk shell (ui/electron/main.cjs) that loads the same URL in a pinned, chromeless BrowserWindow, and make it the default; the old browser detection remains only as a fallback if Electron isn't installed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015QvFHgWxQdwJCQNZ1DRg2B --- scripts/start-kiosk.sh | 34 ++++----- ui/electron/main.cjs | 43 ++++++++++++ ui/package-lock.json | 153 ++++++++++++++++++++++++++++++++++++----- ui/package.json | 3 + 4 files changed, 198 insertions(+), 35 deletions(-) create mode 100644 ui/electron/main.cjs diff --git a/scripts/start-kiosk.sh b/scripts/start-kiosk.sh index ad8f0a72..26d297b1 100755 --- a/scripts/start-kiosk.sh +++ b/scripts/start-kiosk.sh @@ -1,7 +1,7 @@ #!/bin/bash # # OpenFlight Kiosk Startup Script -# Starts the radar server and launches Chromium in kiosk mode +# Starts the radar server and launches the Electron kiosk shell # set -e @@ -507,20 +507,19 @@ error() { launch_kiosk_browser() { local url="$1" - local chrome_flags="--kiosk --noerrdialogs --disable-infobars --disable-session-crashed-bubble --password-store=basic" - - log "Launching kiosk browser..." - if command -v chromium-browser &> /dev/null; then - DISPLAY=:0 chromium-browser $chrome_flags "$url" & + local electron_bin="$PROJECT_DIR/ui/node_modules/.bin/electron" + + log "Launching kiosk shell (Electron)..." + if [ -x "$electron_bin" ]; then + DISPLAY=:0 OPENFLIGHT_URL="$url" "$electron_bin" "$PROJECT_DIR/ui" & + elif command -v chromium-browser &> /dev/null; then + warn "Electron kiosk shell not installed (run 'npm install' in ui/); falling back to chromium-browser" + DISPLAY=:0 chromium-browser --kiosk --noerrdialogs --disable-infobars --disable-session-crashed-bubble --password-store=basic "$url" & elif command -v chromium &> /dev/null; then - DISPLAY=:0 chromium $chrome_flags "$url" & - elif command -v google-chrome &> /dev/null; then - DISPLAY=:0 google-chrome $chrome_flags "$url" & - elif command -v firefox &> /dev/null; then - DISPLAY=:0 firefox --kiosk "$url" & + warn "Electron kiosk shell not installed (run 'npm install' in ui/); falling back to chromium" + DISPLAY=:0 chromium --kiosk --noerrdialogs --disable-infobars --disable-session-crashed-bubble --password-store=basic "$url" & else - warn "No supported browser found. Open $url manually." - warn "Supported browsers: chromium-browser, chromium, google-chrome, firefox" + warn "No Electron kiosk shell and no fallback browser found. Open $url manually." return 1 fi @@ -703,7 +702,8 @@ cleanup() { if [ -n "$BROWSER_PID" ]; then kill "$BROWSER_PID" 2>/dev/null || true fi - # Chromium forks child processes that survive kill — clean them all + # Electron/Chromium fork child processes that survive kill — clean them all + pkill -f "ui/node_modules/electron/dist/electron" 2>/dev/null || true pkill -f "chromium.*--kiosk" 2>/dev/null || true pkill -f "chrome.*--kiosk" 2>/dev/null || true exit "$exit_code" @@ -1020,9 +1020,9 @@ fi configure_kld7_latency -# Check if UI is built -if [ ! -d "ui/dist" ]; then - warn "UI not built. Building now..." +# Check if UI is built and the Electron kiosk shell is installed +if [ ! -d "ui/dist" ] || [ ! -x "ui/node_modules/.bin/electron" ]; then + warn "UI not built or Electron shell missing. Building now..." cd ui if ! npm install || ! npm run build; then cd .. diff --git a/ui/electron/main.cjs b/ui/electron/main.cjs new file mode 100644 index 00000000..e63f0f7b --- /dev/null +++ b/ui/electron/main.cjs @@ -0,0 +1,43 @@ +'use strict'; + +// Kiosk shell for the OpenFlight React UI. Loads whatever URL the launcher +// script gives it (the startup splash, then the app itself once it +// navigates there) in a chromeless, fullscreen window — this replaces +// scripts/start-kiosk.sh's old system-browser detection (chromium-browser / +// chromium / google-chrome / firefox) with one pinned Chromium version. + +const { app, BrowserWindow, Menu } = require('electron'); + +const DEFAULT_URL = 'http://localhost:8080'; +const targetUrl = process.env.OPENFLIGHT_URL || process.argv[2] || DEFAULT_URL; + +Menu.setApplicationMenu(null); + +function createWindow() { + const win = new BrowserWindow({ + kiosk: true, + fullscreen: true, + autoHideMenuBar: true, + backgroundColor: '#000000', + webPreferences: { + contextIsolation: true, + sandbox: true, + }, + }); + + win.setMenuBarVisibility(false); + // The kiosk shell only ever shows the OpenFlight UI itself; deny any + // attempt (e.g. target="_blank" links) to pop a second window. + win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + win.loadURL(targetUrl); + + win.on('closed', () => { + app.quit(); + }); +} + +app.whenReady().then(createWindow); + +app.on('window-all-closed', () => { + app.quit(); +}); diff --git a/ui/package-lock.json b/ui/package-lock.json index a78ebf6e..555a3754 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -22,6 +22,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.1.0", "concurrently": "^9.2.1", + "electron": "^44.1.0", "eslint": "^10.8.1", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.4", @@ -276,6 +277,50 @@ "node": ">=6.9.0" } }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/get": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -1076,9 +1121,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1096,9 +1138,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1116,9 +1155,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1136,9 +1172,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1156,9 +1189,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1176,9 +1206,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2347,6 +2374,25 @@ "dev": true, "license": "MIT" }, + "node_modules/electron": { + "version": "44.1.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.1.0.tgz", + "integrity": "sha512-kmLg8axOg22DC3fXx5NCwBYW8fx0rK2zzJ3Tf67GjNCkxfoxEcd+yo0QfDjlBtHJs3xeA8fTg64b6iVzFTVErg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -2354,6 +2400,23 @@ "dev": true, "license": "ISC" }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2501,6 +2564,19 @@ } } }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3127,6 +3203,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4083,6 +4166,16 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -4648,6 +4741,19 @@ "node": ">=8" } }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -4851,6 +4957,17 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", diff --git a/ui/package.json b/ui/package.json index b21e2f8d..bd313cf3 100644 --- a/ui/package.json +++ b/ui/package.json @@ -3,7 +3,9 @@ "private": true, "version": "1.0.0", "type": "module", + "main": "electron/main.cjs", "scripts": { + "electron": "electron .", "dev": "vite", "dev:mock": "concurrently -k \"npm:mock-server\" \"npm:dev\"", "mock-server": "tsx mock-server/index.ts", @@ -33,6 +35,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.1.0", "concurrently": "^9.2.1", + "electron": "^44.1.0", "eslint": "^10.8.1", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.4", From 8982e9d3a92af1ffdc2afdc9ef601ac3586a7949 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 17:36:12 +0000 Subject: [PATCH 2/6] Document the Electron kiosk shell rationale and future auto-update paths Explain why Electron replaces ad hoc system-browser detection (pinned runtime, consistent kiosk lockdown, single code path, main-process OS access), and sketch two designs for self-updating later: a main-process git-pull-driven update (recommended first) versus a packaged electron-builder/electron-updater release pipeline. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015QvFHgWxQdwJCQNZ1DRg2B --- README.md | 1 + docs/electron-kiosk-shell.md | 142 +++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 docs/electron-kiosk-shell.md diff --git a/README.md b/README.md index 9e6c9063..ae5d7cc9 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,7 @@ uv run pytest tests/ -v - **[Parts List](docs/PARTS.md)** — What to buy - **[Sound Trigger Wiring](docs/sound-trigger-wiring.md)** — How to wire the sound trigger - **[Raspberry Pi Setup](docs/raspberry-pi-setup.md)** — Full setup guide +- **[Electron Kiosk Shell](docs/electron-kiosk-shell.md)** — Why the kiosk runs in Electron and how self-updating could work later - **[Battery Monitoring](docs/battery/README.md)** — Provider architecture, UI states, and shared Pi support - **[Geekworm X1202/X1206 Operator Guide](docs/battery/geekworm.md)** — Batteries, Pi setup, native telemetry, and warnings - **[IWR6843 Operator Guide](docs/iwr6843/README.md)** — Wire, flash, mount, aim, and calibrate the angle radar diff --git a/docs/electron-kiosk-shell.md b/docs/electron-kiosk-shell.md new file mode 100644 index 00000000..0c90f1a2 --- /dev/null +++ b/docs/electron-kiosk-shell.md @@ -0,0 +1,142 @@ +# Electron Kiosk Shell + +`scripts/start-kiosk.sh` launches the React UI inside Electron +(`ui/electron/main.cjs`) rather than shelling out to whatever browser +happens to be installed on the Pi. This document explains why that's an +improvement, and sketches how it could support self-updating later. It does +not describe anything implemented yet beyond the shell itself — see +[Auto-Updates (Future Work)](#auto-updates-future-work). + +## Why Electron Instead Of A System Browser + +The old `launch_kiosk_browser` tried `chromium-browser`, then `chromium`, +then `google-chrome`, then `firefox` — whichever the OS image happened to +have, with `--kiosk` flags tuned mostly for Chromium. That worked, but it +carried a few risks an Electron shell removes: + +| Concern | System browser | Electron shell | +|---|---|---| +| Rendering engine version | Whatever `apt` installed/upgraded on that Pi — can silently drift between units or after an OS update | Pinned in `ui/package-lock.json` (`electron@44.1.0` today), identical across every Pi until deliberately bumped | +| Kiosk lockdown | `--kiosk` behaves differently across Chromium, Chrome, and Firefox; Firefox's kiosk mode in particular is looser (menu/shortcuts still reachable) | One `BrowserWindow` with `kiosk: true`, no application menu, and `setWindowOpenHandler` denying any popup — the same guarantees everywhere | +| Startup noise | Chromium's "restore previous session" / crash bubbles needed extra flags (`--disable-session-crashed-bubble`) to suppress | Electron starts a fresh profile each launch; there's no session-restore prompt to suppress | +| Maintenance surface | A 4-branch `if/elif` detection ladder to keep working across Raspberry Pi OS Bookworm/Bullseye, Lite/Desktop images | One binary, one launch path; `npm ci` makes the exact runtime reproducible in CI the same way any other dependency is | +| Extensibility | A browser tab is sandboxed from the OS — no filesystem, process, or native API access | The Electron **main process** is a regular Node.js process with full OS access, which is what makes [self-updating](#auto-updates-future-work) possible at all | + +The old detection ladder is kept as a fallback (`launch_kiosk_browser` still +tries `chromium-browser`/`chromium` if `ui/node_modules/.bin/electron` is +missing), so a Pi that hasn't run `npm install` yet doesn't lose its kiosk +entirely — it just loses the guarantees above until Electron is installed. + +## What Didn't Change + +Electron here is a shell, not a rewrite: `ui/electron/main.cjs` opens a +`BrowserWindow` and points it at the same URL the browser used to load +(`http://localhost:8080`, served by Flask from `ui/dist`). The React app, +the WebSocket connection (`socketService.ts`), and the Flask server are +untouched — `getServerOrigin()` still resolves to `window.location.origin`, +which is the Electron window's origin now instead of a browser tab's. + +## Auto-Updates (Future Work) + +Nothing below is implemented. It's worth writing down now because "Electron +shell" and "auto-update" are usually mentioned in the same breath, and +because OpenFlight's deployment shape (a small fleet of Pis you personally +maintain, not a public app store release) points toward a different design +than the default Electron answer. + +There are two separate things that could be "updated," and they call for +different mechanisms. + +### 1. UI content (the React build) — already effectively live + +Electron loads a URL, not a bundled copy of `ui/dist`. Whatever Flask is +currently serving is what the window shows. So once a Pi has pulled a new +`ui/dist` (via the existing `git pull && npm run build` flow in +[splash-screen.md](splash-screen.md#updating-an-existing-pi)) and the +service restarts, the Electron window shows the new UI on its next launch — +no Electron-specific update logic needed for this layer. This is already +true today. + +### 2. The Electron shell itself + +`electron` is a normal `devDependency` in `ui/package.json`. Bumping its +version is a normal dependency bump: change the version, `npm install`, +commit the updated lockfile, `git pull` on each Pi. No runtime auto-update +machinery is needed for this either, as long as updates continue to arrive +through `git pull` + reinstall rather than an out-of-band download. + +### 3. The interesting case: OpenFlight self-updating without an SSH session + +The capability an Electron main process adds that a browser tab never had +is **the kiosk can update itself**, because `main.cjs` runs as a full +Node.js process on the Pi rather than inside a sandboxed tab. Two designs, +in increasing order of complexity: + +**A. Main-process-driven `git pull` (recommended starting point)** + +The main process periodically (or on a UI-triggered "Check for Updates" +action, via a `contextBridge` preload script) does the same thing an +operator does by hand today: + +1. `git fetch` and compare `HEAD` against `origin/`. +2. If behind: `git pull`, `uv sync`, `npm run build` (in `ui/`). +3. Decide how to apply it: + - Content-only change (`ui/` touched, `ui/electron/` and + `ui/package.json`'s `electron` version untouched) → `win.loadURL()` + again, or just wait for the operator's next launch. + - Shell change (Electron itself bumped, or `main.cjs` changed) → + `app.relaunch(); app.exit(0)`, or restart the systemd unit + (`systemctl --user restart openflight` / `sudo systemctl restart + openflight`, per `scripts/setup/openflight.service`) so the new + `main.cjs` is picked up. + +This reuses the exact update path already documented for manual updates — +it just runs it from inside the app instead of over SSH. It also keeps +using GitHub as the source of truth, so no new release infrastructure, +signing, or hosting is required. + +Things to get right if this is built: +- **Trust boundary:** whatever triggers the pull (a timer or a UI button) + must not be reachable by anything the Flask server exposes over the + network — this must stay a main-process-only action, not a socket event + or HTTP endpoint, so a device on the same LAN can't trigger arbitrary + `git pull`/`uv sync` execution on the Pi. +- **Partial-failure safety:** a `git pull` that succeeds but an `npm run + build` that fails should not leave the Pi worse off than before — keep + the previous `ui/dist` until the new build succeeds (e.g. build to a + temp directory and swap), and skip the restart on build failure. +- **Mid-round updates:** don't apply an update (especially the + shell-restart kind) while a shot/session is in progress; gate it on + session/idle state the same way the splash screen gates on startup state. +- **Network dependence:** the Pi may be on a golf-sim LAN with no general + internet access even when it can reach GitHub, or vice versa — the check + should fail closed (skip silently) rather than block startup. + +**B. `electron-updater` + a packaged build** + +The conventional Electron answer — `electron-builder` packages the app, +`electron-updater`'s `autoUpdater.checkForUpdatesAndNotify()` polls a feed +(GitHub Releases, S3, or a self-hosted static server) and swaps the +installed build. This is the right model for shipping to users you don't +operate the hardware for. + +It's a bigger lift than option A here, for two reasons specific to this +project: +- It requires the packaging step this shell deliberately skipped (see the + original Electron-shell decision: "just run from source, no installers"). + `ui/dist` would need to be bundled into the package rather than loaded + live from Flask, which reintroduces the "which layer updates independently" + question this doc just resolved for the source-checkout model. +- `electron-updater`'s Linux auto-update support is limited to the AppImage + format. That's buildable for `arm64` (Raspberry Pi OS 64-bit, which this + fleet already requires), but it's a new build target, a new artifact to + test on real hardware, and a release/signing pipeline to stand up — none + of which exists for this project today. + +**Recommendation:** start with (A) if/when self-updating is prioritized. It +matches the fleet's actual shape (Pis you `git pull` on, not an app store +audience), reuses infrastructure that already exists (`uv sync`, `npm run +build`, the systemd unit), and doesn't require adopting a packaging and +release pipeline before there's a concrete need for one. Revisit (B) only if +OpenFlight starts distributing prebuilt images to people who don't run `git +pull` themselves. From 38d60060e3206109df2344dfa520adb8cff85cfb Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Wed, 2 Sep 2026 14:51:05 +0100 Subject: [PATCH 3/6] Update Node.js requirements across documentation and scripts for compatibility with Electron 44. --- CONTRIBUTING.md | 2 +- docs/electron-kiosk-shell.md | 5 +++++ docs/raspberry-pi-setup.md | 10 ++++++++++ scripts/require-node.sh | 35 +++++++++++++++++++++++++++++++++++ scripts/setup/setup.sh | 17 +++++++---------- scripts/start-kiosk.sh | 9 +++++++++ ui/README.md | 2 +- ui/package.json | 3 +++ 8 files changed, 71 insertions(+), 12 deletions(-) create mode 100644 scripts/require-node.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db4b253a..5c2fd426 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ Thank you for your interest in contributing to OpenFlight! This document provide ### Prerequisites - Python 3.10 or higher -- Node.js 20+ (for UI development) +- Node.js 22.12 or newer (for UI development; CI uses the version in `.node-version`) - Git - [uv](https://github.com/astral-sh/uv) package manager (required) diff --git a/docs/electron-kiosk-shell.md b/docs/electron-kiosk-shell.md index 0c90f1a2..d3b2e117 100644 --- a/docs/electron-kiosk-shell.md +++ b/docs/electron-kiosk-shell.md @@ -65,6 +65,11 @@ commit the updated lockfile, `git pull` on each Pi. No runtime auto-update machinery is needed for this either, as long as updates continue to arrive through `git pull` + reinstall rather than an out-of-band download. +Installing that package (not running the Electron binary) needs **Node.js +22.12+** on the Pi. Node 20 prints `npm WARN EBADENGINE` for `electron@44` +and its `@electron/get` helper. See the Node install step in +[raspberry-pi-setup.md](raspberry-pi-setup.md). + ### 3. The interesting case: OpenFlight self-updating without an SSH session The capability an Electron main process adds that a browser tab never had diff --git a/docs/raspberry-pi-setup.md b/docs/raspberry-pi-setup.md index a0de4ea0..03cf25ab 100644 --- a/docs/raspberry-pi-setup.md +++ b/docs/raspberry-pi-setup.md @@ -36,6 +36,16 @@ Run the following command: sudo apt update && sudo apt install -y swig liblgpio-dev python3-dev ``` +The UI/Electron kiosk shell needs **Node.js 22.12 or newer**. Raspberry Pi OS +`apt` Node is often 18 or 20 and will print `EBADENGINE` (or fail) on first +build. Install Node 22 LTS before setup: + +```bash +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - +sudo apt-get install -y nodejs +node -v # should report v22.12.0 or later +``` + If `./scripts/setup/setup.sh` updates `~/.bashrc`, you may need to run `source ~/.bashrc` (or open a new terminal) so your current shell picks up the new environment variables immediately without needing to reboot or re-login. ### 2. Run the setup script diff --git a/scripts/require-node.sh b/scripts/require-node.sh new file mode 100644 index 00000000..d8390ad6 --- /dev/null +++ b/scripts/require-node.sh @@ -0,0 +1,35 @@ +# Sourced by setup and kiosk scripts. Electron 44's npm installer requires +# Node 22.12+ (see ui/package.json engines and electron's own engines field). +OPENFLIGHT_MIN_NODE="22.12.0" + +openflight_node_version() { + command -v node >/dev/null 2>&1 || return 1 + local v + v="$(node -v 2>/dev/null || true)" + v="${v#v}" + printf '%s' "${v%%[-+]*}" +} + +openflight_node_meets_min() { + local current lowest + current="$(openflight_node_version)" || return 1 + [ -n "$current" ] || return 1 + lowest="$(printf '%s\n%s\n' "$OPENFLIGHT_MIN_NODE" "$current" | sort -V | head -n1)" + [ "$lowest" = "$OPENFLIGHT_MIN_NODE" ] +} + +openflight_node_install_hint() { + cat <<'EOF' +OpenFlight needs Node.js 22.12 or newer to install the Electron kiosk shell. +Raspberry Pi OS / Debian apt Node is often older than that (Node 18 or 20). + +Raspberry Pi (64-bit): + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + sudo apt-get install -y nodejs + +macOS: + brew install node + +Then confirm with: node -v +EOF +} diff --git a/scripts/setup/setup.sh b/scripts/setup/setup.sh index 2cc39bfb..72de33bd 100755 --- a/scripts/setup/setup.sh +++ b/scripts/setup/setup.sh @@ -134,18 +134,15 @@ else exit 1 fi -# Check for Node.js +# Check for Node.js (Electron 44's npm installer requires 22.12+) +# shellcheck source=../require-node.sh +source "$SCRIPT_DIR/../require-node.sh" log "Checking Node.js..." -if command -v node &> /dev/null; then - NODE_VERSION=$(node --version) - log "Node.js $NODE_VERSION found ✓" +if openflight_node_meets_min; then + log "Node.js $(openflight_node_version) found ✓" else - error "Node.js not found. Please install Node.js 18+" - if [ "$PLATFORM" == "pi" ]; then - info "On Raspberry Pi, run: sudo apt install nodejs npm" - elif [ "$PLATFORM" == "macos" ]; then - info "On macOS, run: brew install node" - fi + error "Node.js $OPENFLIGHT_MIN_NODE+ required, found $(openflight_node_version 2>/dev/null || echo none)" + openflight_node_install_hint exit 1 fi diff --git a/scripts/start-kiosk.sh b/scripts/start-kiosk.sh index 26d297b1..8c5a94d4 100755 --- a/scripts/start-kiosk.sh +++ b/scripts/start-kiosk.sh @@ -1023,6 +1023,15 @@ configure_kld7_latency # Check if UI is built and the Electron kiosk shell is installed if [ ! -d "ui/dist" ] || [ ! -x "ui/node_modules/.bin/electron" ]; then warn "UI not built or Electron shell missing. Building now..." + # shellcheck source=require-node.sh + source "$SCRIPT_DIR/require-node.sh" + if ! openflight_node_meets_min; then + openflight_node_install_hint + show_startup_failure \ + "server" \ + "Node.js is too old to build the UI" \ + "OpenFlight needs Node.js ${OPENFLIGHT_MIN_NODE} or newer (found $(openflight_node_version 2>/dev/null || echo none)). Upgrade Node, then relaunch." + fi cd ui if ! npm install || ! npm run build; then cd .. diff --git a/ui/README.md b/ui/README.md index be6969fc..0c9615a6 100644 --- a/ui/README.md +++ b/ui/README.md @@ -9,7 +9,7 @@ and how the whole system fits together, see the [root README](../README.md). ## Quick start -You need Node 20+. +You need Node 22.12 or newer (`electron@44` will not install cleanly on Node 20). ### Frontend-only (recommended for UI work) diff --git a/ui/package.json b/ui/package.json index bd313cf3..5867877c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -4,6 +4,9 @@ "version": "1.0.0", "type": "module", "main": "electron/main.cjs", + "engines": { + "node": ">=22.12.0" + }, "scripts": { "electron": "electron .", "dev": "vite", From b2578b70fe7fdbc9cd226d54a2111fc2459e7315 Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Wed, 2 Sep 2026 15:19:51 +0100 Subject: [PATCH 4/6] refactor(kiosk): migrate Electron main file from .cjs to .js and update related documentation --- docs/electron-kiosk-shell.md | 10 +++--- tests/test_start_kiosk.py | 49 ++++++++++++++++++++++++++++ ui/electron/{main.cjs => main.js} | 8 ++--- ui/electron/resolveTargetUrl.js | 7 ++++ ui/electron/resolveTargetUrl.test.js | 30 +++++++++++++++++ ui/package.json | 2 +- ui/vite.config.ts | 2 +- 7 files changed, 96 insertions(+), 12 deletions(-) rename ui/electron/{main.cjs => main.js} (84%) create mode 100644 ui/electron/resolveTargetUrl.js create mode 100644 ui/electron/resolveTargetUrl.test.js diff --git a/docs/electron-kiosk-shell.md b/docs/electron-kiosk-shell.md index d3b2e117..5ed33993 100644 --- a/docs/electron-kiosk-shell.md +++ b/docs/electron-kiosk-shell.md @@ -1,7 +1,7 @@ # Electron Kiosk Shell `scripts/start-kiosk.sh` launches the React UI inside Electron -(`ui/electron/main.cjs`) rather than shelling out to whatever browser +(`ui/electron/main.js`) rather than shelling out to whatever browser happens to be installed on the Pi. This document explains why that's an improvement, and sketches how it could support self-updating later. It does not describe anything implemented yet beyond the shell itself — see @@ -29,7 +29,7 @@ entirely — it just loses the guarantees above until Electron is installed. ## What Didn't Change -Electron here is a shell, not a rewrite: `ui/electron/main.cjs` opens a +Electron here is a shell, not a rewrite: `ui/electron/main.js` opens a `BrowserWindow` and points it at the same URL the browser used to load (`http://localhost:8080`, served by Flask from `ui/dist`). The React app, the WebSocket connection (`socketService.ts`), and the Flask server are @@ -73,7 +73,7 @@ and its `@electron/get` helper. See the Node install step in ### 3. The interesting case: OpenFlight self-updating without an SSH session The capability an Electron main process adds that a browser tab never had -is **the kiosk can update itself**, because `main.cjs` runs as a full +is **the kiosk can update itself**, because `main.js` runs as a full Node.js process on the Pi rather than inside a sandboxed tab. Two designs, in increasing order of complexity: @@ -89,11 +89,11 @@ operator does by hand today: - Content-only change (`ui/` touched, `ui/electron/` and `ui/package.json`'s `electron` version untouched) → `win.loadURL()` again, or just wait for the operator's next launch. - - Shell change (Electron itself bumped, or `main.cjs` changed) → + - Shell change (Electron itself bumped, or `main.js` changed) → `app.relaunch(); app.exit(0)`, or restart the systemd unit (`systemctl --user restart openflight` / `sudo systemctl restart openflight`, per `scripts/setup/openflight.service`) so the new - `main.cjs` is picked up. + `main.js` is picked up. This reuses the exact update path already documented for manual updates — it just runs it from inside the app instead of over SSH. It also keeps diff --git a/tests/test_start_kiosk.py b/tests/test_start_kiosk.py index 90e3a72c..535b20ac 100644 --- a/tests/test_start_kiosk.py +++ b/tests/test_start_kiosk.py @@ -552,3 +552,52 @@ def test_iwr6843_horizontal_phase_reference_is_forwarded(): def test_iwr6843_horizontal_phase_reference_is_omitted_by_default(): command = _dry_run("--iwr6843").stdout.strip() assert "--iwr6843-horizontal-phase-reference-rad" not in command + + +def _read_script() -> str: + return (Path(__file__).resolve().parents[1] / "scripts/start-kiosk.sh").read_text( + encoding="utf-8" + ) + + +def test_launch_kiosk_browser_prefers_the_electron_shell(): + """The pinned Electron runtime must be tried before any system browser.""" + script = _read_script() + launcher = script[ + script.index("launch_kiosk_browser() {") : script.index("stop_startup_splash_server() {") + ] + + electron_idx = launcher.index('if [ -x "$electron_bin" ]; then') + chromium_browser_idx = launcher.index("command -v chromium-browser") + chromium_idx = launcher.index("command -v chromium &> /dev/null") + + assert electron_idx < chromium_browser_idx < chromium_idx + assert 'local electron_bin="$PROJECT_DIR/ui/node_modules/.bin/electron"' in launcher + assert '"$electron_bin" "$PROJECT_DIR/ui"' in launcher + + +def test_launch_kiosk_browser_still_falls_back_without_electron(): + """A Pi that hasn't run `npm install` yet must not lose its kiosk entirely.""" + script = _read_script() + launcher = script[ + script.index("launch_kiosk_browser() {") : script.index("stop_startup_splash_server() {") + ] + + assert "chromium-browser --kiosk" in launcher + assert "chromium --kiosk" in launcher + assert "No Electron kiosk shell and no fallback browser found" in launcher + + +def test_cleanup_kills_the_electron_process_tree(): + """Electron, like Chromium, forks children that survive a signal to the launcher PID.""" + script = _read_script() + cleanup_fn = script[script.index("cleanup() {") : script.index("configure_kld7_latency() {")] + + assert 'pkill -f "ui/node_modules/electron/dist/electron"' in cleanup_fn + + +def test_ui_build_check_also_requires_the_electron_shell(): + """Rebuilding the UI must also install Electron if a checkout predates it.""" + script = _read_script() + + assert 'if [ ! -d "ui/dist" ] || [ ! -x "ui/node_modules/.bin/electron" ]; then' in script diff --git a/ui/electron/main.cjs b/ui/electron/main.js similarity index 84% rename from ui/electron/main.cjs rename to ui/electron/main.js index e63f0f7b..265617cb 100644 --- a/ui/electron/main.cjs +++ b/ui/electron/main.js @@ -1,15 +1,13 @@ -'use strict'; - // Kiosk shell for the OpenFlight React UI. Loads whatever URL the launcher // script gives it (the startup splash, then the app itself once it // navigates there) in a chromeless, fullscreen window — this replaces // scripts/start-kiosk.sh's old system-browser detection (chromium-browser / // chromium / google-chrome / firefox) with one pinned Chromium version. -const { app, BrowserWindow, Menu } = require('electron'); +import { app, BrowserWindow, Menu } from 'electron'; +import { resolveTargetUrl } from './resolveTargetUrl.js'; -const DEFAULT_URL = 'http://localhost:8080'; -const targetUrl = process.env.OPENFLIGHT_URL || process.argv[2] || DEFAULT_URL; +const targetUrl = resolveTargetUrl(process.env, process.argv); Menu.setApplicationMenu(null); diff --git a/ui/electron/resolveTargetUrl.js b/ui/electron/resolveTargetUrl.js new file mode 100644 index 00000000..2bb2a419 --- /dev/null +++ b/ui/electron/resolveTargetUrl.js @@ -0,0 +1,7 @@ +export const DEFAULT_URL = 'http://localhost:8080'; + +// Pulled out of main.js so it can be unit-tested without importing the +// `electron` module, which throws outside an actual Electron runtime. +export function resolveTargetUrl(env, argv) { + return env.OPENFLIGHT_URL || argv[2] || DEFAULT_URL; +} diff --git a/ui/electron/resolveTargetUrl.test.js b/ui/electron/resolveTargetUrl.test.js new file mode 100644 index 00000000..9f965913 --- /dev/null +++ b/ui/electron/resolveTargetUrl.test.js @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { resolveTargetUrl, DEFAULT_URL } from './resolveTargetUrl.js'; + +describe('resolveTargetUrl', () => { + it('defaults to the local Flask server when nothing else is set', () => { + expect(resolveTargetUrl({}, ['electron', 'main.js'])).toBe(DEFAULT_URL); + }); + + it('prefers OPENFLIGHT_URL over the CLI argument', () => { + expect( + resolveTargetUrl({ OPENFLIGHT_URL: 'http://pi.local:8080' }, [ + 'electron', + 'main.js', + 'http://cli-arg:8080', + ]) + ).toBe('http://pi.local:8080'); + }); + + it('falls back to a CLI argument when the env var is unset', () => { + expect(resolveTargetUrl({}, ['electron', 'main.js', 'http://cli-arg:8080'])).toBe( + 'http://cli-arg:8080' + ); + }); + + it('ignores an empty OPENFLIGHT_URL rather than passing it through', () => { + expect( + resolveTargetUrl({ OPENFLIGHT_URL: '' }, ['electron', 'main.js', 'http://cli-arg:8080']) + ).toBe('http://cli-arg:8080'); + }); +}); diff --git a/ui/package.json b/ui/package.json index 8e1b7804..b6bd567d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -3,7 +3,7 @@ "private": true, "version": "1.0.0", "type": "module", - "main": "electron/main.cjs", + "main": "electron/main.js", "engines": { "node": ">=22.12.0" }, diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 03ce9656..eb7c2b99 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ }, }, test: { - include: ['src/**/*.test.{ts,tsx}', 'tests/**/*.test.{ts,tsx}'], + include: ['src/**/*.test.{ts,tsx}', 'tests/**/*.test.{ts,tsx}', 'electron/**/*.test.js'], exclude: ['tests/e2e/**'], }, }); From 5d601689514ea0bc36bd9a6a32e237e204f36b1d Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Wed, 2 Sep 2026 15:24:47 +0100 Subject: [PATCH 5/6] feat(kiosk): add Electron kiosk shell for a consistent UI experience --- docs/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1ed1c655..008f1bed 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -20,6 +20,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 full horizontal speed, overstating attack angle on any shot with club path. ### Added +- **Electron kiosk shell.** `scripts/start-kiosk.sh` now opens the UI in a pinned + Electron window (`electron@44`) instead of whichever system browser happens to + be installed. Chromium remains a fallback if Electron is not installed. This + needs **Node.js 22.12 or newer** (`npm WARN EBADENGINE` on Node 20). See + [Electron Kiosk Shell](electron-kiosk-shell.md). - **Profiles replace players.** Shots are now attributed to a server-owned profile (a person *or* a place) with a stable id, persisted to `~/.config/openflight/profiles.json` (override with `OPENFLIGHT_PROFILES_PATH` From 31ee7e15c378a2db76ffa6190c4c0ab4d4f0ff82 Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Wed, 2 Sep 2026 15:40:41 +0100 Subject: [PATCH 6/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- scripts/require-node.sh | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/scripts/require-node.sh b/scripts/require-node.sh index d8390ad6..823f63a3 100644 --- a/scripts/require-node.sh +++ b/scripts/require-node.sh @@ -11,11 +11,21 @@ openflight_node_version() { } openflight_node_meets_min() { - local current lowest + local current cmaj cmin cpat mmaj mmin mpat current="$(openflight_node_version)" || return 1 [ -n "$current" ] || return 1 - lowest="$(printf '%s\n%s\n' "$OPENFLIGHT_MIN_NODE" "$current" | sort -V | head -n1)" - [ "$lowest" = "$OPENFLIGHT_MIN_NODE" ] + + IFS=. read -r cmaj cmin cpat <<<"$current" + IFS=. read -r mmaj mmin mpat <<<"$OPENFLIGHT_MIN_NODE" + + cmaj=${cmaj:-0}; cmin=${cmin:-0}; cpat=${cpat:-0} + mmaj=${mmaj:-0}; mmin=${mmin:-0}; mpat=${mpat:-0} + + if (( cmaj > mmaj )); then return 0; fi + if (( cmaj < mmaj )); then return 1; fi + if (( cmin > mmin )); then return 0; fi + if (( cmin < mmin )); then return 1; fi + (( cpat >= mpat )) } openflight_node_install_hint() {