From 7c39383655f248c0184be945a71c96f69f609cc6 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Sun, 20 Sep 2026 17:32:41 -0500 Subject: [PATCH] Wire desktop updates through a generic feed electron-builder now publishes latest-linux.yml / latest.yml and embeds app-update.yml plus package-type, so electron-updater runs pacman -U or dpkg -i through pkexec for packages and updates the per-user NSIS install without elevation. The app checks only when asked (menu item), asks before downloading and before installing, and J621_UPDATE_URL overrides the feed for tests or forks. deploy/push_desktop.sh builds and publishes the artifacts to deploy/data/desktop, which the frontend nginx mounts read-only at /desktop/. Verified detection and up-to-date handling against a local feed with the packaged Arch build. --- deploy/README.md | 12 +++ deploy/compose.frontend.yml | 3 + deploy/compose.tailnet.frontend.yml | 3 + deploy/compose.tailnet.yml | 3 + deploy/compose.yml | 3 + deploy/push_desktop.sh | 62 ++++++++++++ desktop/README.md | 24 ++++- desktop/electron-builder.yml | 7 ++ desktop/package-lock.json | 63 +++++++++--- desktop/package.json | 3 + desktop/src/main.ts | 143 +++++++++++++++++++++++++++- 11 files changed, 312 insertions(+), 14 deletions(-) create mode 100755 deploy/push_desktop.sh diff --git a/deploy/README.md b/deploy/README.md index 86becdf..62995c6 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -136,6 +136,18 @@ Push multi-arch images to the Gitea registry: They tag `:latest` and `:` and expect `docker login gitea.rainbow-herring.ts.net` to succeed. +Push the desktop builds and their update metadata to the frontend's feed: + +```bash +./push_desktop.sh # Linux packages (deb + pacman) +./push_desktop.sh --win # also cross-build the NSIS installer (wine) +``` + +Artifacts land in `deploy/data/desktop/`, which the frontend nginx mounts +read-only and serves at `/desktop/`. The desktop app's "Check for updates…" +menu item reads `latest-linux.yml` / `latest.yml` from there (see +`desktop/README.md`). Backend-only composes have no frontend, so no feed. + ## Scheduled jobs Compose files with a backend also run a **`scheduler`** service — the same diff --git a/deploy/compose.frontend.yml b/deploy/compose.frontend.yml index 32e0089..b491c4c 100644 --- a/deploy/compose.frontend.yml +++ b/deploy/compose.frontend.yml @@ -20,6 +20,9 @@ services: context: .. dockerfile: deploy/J621-Frontend restart: unless-stopped + volumes: + # Desktop update feed (deploy/push_desktop.sh): latest*.yml + installers. + - ./data/desktop:/usr/share/nginx/html/desktop:ro nginx: image: nginx:1.29-alpine diff --git a/deploy/compose.tailnet.frontend.yml b/deploy/compose.tailnet.frontend.yml index b515526..0050c5b 100644 --- a/deploy/compose.tailnet.frontend.yml +++ b/deploy/compose.tailnet.frontend.yml @@ -21,6 +21,9 @@ services: context: .. dockerfile: deploy/J621-Frontend restart: unless-stopped + volumes: + # Desktop update feed (deploy/push_desktop.sh): latest*.yml + installers. + - ./data/desktop:/usr/share/nginx/html/desktop:ro nginx: image: nginx:1.29-alpine diff --git a/deploy/compose.tailnet.yml b/deploy/compose.tailnet.yml index 1db8bd8..c949f6b 100644 --- a/deploy/compose.tailnet.yml +++ b/deploy/compose.tailnet.yml @@ -100,6 +100,9 @@ services: context: .. dockerfile: deploy/J621-Frontend restart: unless-stopped + volumes: + # Desktop update feed (deploy/push_desktop.sh): latest*.yml + installers. + - ./data/desktop:/usr/share/nginx/html/desktop:ro nginx: image: nginx:1.29-alpine diff --git a/deploy/compose.yml b/deploy/compose.yml index 8b9d9fb..d4186c7 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -99,6 +99,9 @@ services: context: .. dockerfile: deploy/J621-Frontend restart: unless-stopped + volumes: + # Desktop update feed (deploy/push_desktop.sh): latest*.yml + installers. + - ./data/desktop:/usr/share/nginx/html/desktop:ro nginx: image: nginx:1.29-alpine diff --git a/deploy/push_desktop.sh b/deploy/push_desktop.sh new file mode 100755 index 0000000..2b2ca37 --- /dev/null +++ b/deploy/push_desktop.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Build the J621 desktop packages and publish them to the frontend's update +# feed (deploy/data/desktop, mounted read-only into the frontend nginx and +# served at /desktop/). electron-updater reads latest-linux.yml / latest.yml +# from there; the app's feed URL comes from desktop/electron-builder.yml. +# +# Usage: ./push_desktop.sh [--win] [--no-build] +# --win also cross-build the Windows NSIS installer (needs wine) +# --no-build publish what is already in desktop/release/ +set -euo pipefail +cd "$(dirname "$0")/.." + +BUILD=1 +WIN=0 +for arg in "$@"; do + case "$arg" in + --win) WIN=1 ;; + --no-build) BUILD=0 ;; + *) + echo "usage: $0 [--win] [--no-build]" >&2 + exit 2 + ;; + esac +done + +if [ "$BUILD" = 1 ]; then + echo "==> Building Linux packages (deb + pacman) ..." + npm --prefix desktop run dist:linux + if [ "$WIN" = 1 ]; then + echo "==> Cross-building the Windows installer (wine) ..." + npm --prefix desktop run dist:win + fi +fi + +FEED=deploy/data/desktop +mkdir -p "$FEED" + +shopt -s nullglob +deb=(desktop/release/*.deb) +zst=(desktop/release/*.pkg.tar.zst) +linux_meta=(desktop/release/latest-linux.yml) +win_exe=("desktop/release/J621 Setup "*.exe) +win_meta=(desktop/release/latest.yml) +blockmaps=(desktop/release/*.blockmap) + +if [ "${#deb[@]}" -eq 0 ] && [ "${#zst[@]}" -eq 0 ]; then + echo "No artifacts in desktop/release/ — run without --no-build first." >&2 + exit 1 +fi + +# Replace the previous release's metadata before copying the new artifacts. +rm -f "$FEED"/latest-linux.yml "$FEED"/latest.yml +cp -f "${deb[@]}" "${zst[@]}" "${linux_meta[@]}" "$FEED"/ 2>/dev/null || true +if [ "${#win_exe[@]}" -gt 0 ]; then + cp -f "${win_exe[@]}" "${win_meta[@]}" "${blockmaps[@]}" "$FEED"/ 2>/dev/null || true +fi + +echo "==> Published to $FEED:" +ls -1sh "$FEED" | sed 's/^/ /' +echo +echo "Served read-only by the frontend nginx at /desktop/ (no restart needed):" +echo " https:///desktop/latest-linux.yml" diff --git a/desktop/README.md b/desktop/README.md index 33ffe68..29d7946 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -55,12 +55,34 @@ It installs to `/opt/J621` with a `/usr/bin/j621-desktop` symlink and a rights are needed to install or update it — but it is unsigned, so SmartScreen will warn, and it has not been smoke-tested on real Windows. +## Updates + +The app checks only when asked (**J621 → Check for updates…** in the menu): +Linux packages install through pacman/dpkg, which needs administrator rights, +and the Windows build is unsigned, so nothing installs silently. The check +reads `latest-linux.yml` / `latest.yml` from the feed configured in +`electron-builder.yml` (`publish.url`, baked into `app-update.yml`); set +`J621_UPDATE_URL` to point a build at another feed (the smoke test uses this). + +Publishing a release: + +1. Bump `version` in `desktop/package.json` — that is what the updater compares. +2. `./deploy/push_desktop.sh --win` builds deb/pacman/NSIS and copies the + artifacts plus both channel files into `deploy/data/desktop/`, which the + frontend nginx serves read-only at `/desktop/`. +3. Existing installs find the new version on their next manual check. + +`package-type` in the app resources tells electron-updater whether to run +`pacman -U` or `dpkg -i` (both via pkexec/sudo); the per-user NSIS install +updates without elevation. + ## Smoke test `npm run smoke` builds everything, runs Electron headless through `xvfb-run` and checks that the bundled SPA loads over `app://j621`: the SPA fallback, an asset fetch, `localStorage`, OPFS, WebCodecs and the preload bridge. It exits -non-zero on failure. +non-zero on failure. With `J621_UPDATE_URL` set it also checks that the feed +reports the expected version. ## Backend CORS diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml index 8610197..a108849 100644 --- a/desktop/electron-builder.yml +++ b/desktop/electron-builder.yml @@ -2,6 +2,13 @@ appId: io.j621.desktop productName: J621 copyright: Copyright (c) 2026 JakeBreath — Jake Labs Non-Commercial Software Licence +# Update feed served by the frontend nginx (deploy/data/desktop, published +# with deploy/push_desktop.sh). Baked into resources/app-update.yml; override +# at runtime with J621_UPDATE_URL for a fork or a test feed. +publish: + provider: generic + url: https://j621.rainbow-herring.ts.net/desktop + directories: output: release buildResources: build diff --git a/desktop/package-lock.json b/desktop/package-lock.json index fd80b7e..5a93be5 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -7,7 +7,10 @@ "": { "name": "j621-desktop", "version": "0.1.0", - "license": "UNLICENSED", + "license": "LicenseRef-Jake-Labs-Non-Commercial", + "dependencies": { + "electron-updater": "^6.8.9" + }, "devDependencies": { "@types/node": "^24.13.6", "electron": "^44.4.3", @@ -798,7 +801,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": { @@ -954,7 +956,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", @@ -1213,7 +1214,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" @@ -1523,6 +1523,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", @@ -1811,7 +1839,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", @@ -2037,7 +2064,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": { @@ -2259,7 +2285,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", @@ -2317,7 +2342,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" @@ -2340,7 +2364,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": { @@ -2350,6 +2373,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", @@ -2510,7 +2546,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": { @@ -3073,7 +3108,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" @@ -3336,6 +3370,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", @@ -3440,7 +3480,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/desktop/package.json b/desktop/package.json index 4036226..b94136d 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -28,5 +28,8 @@ "electron": "^44.4.3", "electron-builder": "^26.15.3", "typescript": "^6.0.3" + }, + "dependencies": { + "electron-updater": "^6.8.9" } } diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 84bd669..efaeb65 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -12,7 +12,8 @@ * the same way it does in a browser. The only desktop-specific bits are the * origin, external-link handling and (later) updates. */ -import { app, BrowserWindow, ipcMain, Menu, protocol, shell } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, Menu, protocol, shell } from "electron"; +import { autoUpdater } from "electron-updater"; import { readFileSync } from "node:fs"; import { readFile, stat, writeFile } from "node:fs/promises"; import path from "node:path"; @@ -187,6 +188,124 @@ function isDownloadNavigation(url: URL): boolean { return DOWNLOAD_EXTENSIONS.has(path.extname(url.pathname).toLowerCase()); } +/** + * Updates are manual by design: Linux packages install through pacman/dpkg + * (pkexec/sudo) and the Windows build is unsigned, so the app asks before + * downloading and again before installing. The feed comes from `publish` in + * electron-builder.yml and can be overridden with J621_UPDATE_URL. + */ +type UpdateEvent = + | { state: "available"; version: string } + | { state: "not-available" } + | { state: "downloaded"; version: string } + | { state: "error"; message: string }; + +let updateReporter: ((event: UpdateEvent) => void) | null = null; +let updateCheckRunning = false; + +function setUpUpdates(win: BrowserWindow): void { + const override = process.env.J621_UPDATE_URL?.trim(); + if (override) autoUpdater.setFeedURL({ provider: "generic", url: override }); + if (!app.isPackaged) autoUpdater.forceDevUpdateConfig = true; + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = false; + + autoUpdater.on("error", (error) => { + updateReporter?.({ state: "error", message: error.message }); + }); + + autoUpdater.on("update-not-available", () => { + if (SMOKE) return updateReporter?.({ state: "not-available" }); + void dialog.showMessageBox(win, { + type: "info", + title: "Updates", + message: `J621 ${app.getVersion()} is up to date.`, + }); + }); + + autoUpdater.on("update-available", (info) => { + if (SMOKE) { + return updateReporter?.({ state: "available", version: info.version }); + } + void (async () => { + const { response } = await dialog.showMessageBox(win, { + type: "info", + title: "Updates", + message: `J621 ${info.version} is available.`, + detail: + "Download it now? Installing it later needs administrator rights " + + "on Linux (pacman/dpkg); the Windows installer runs without them.", + buttons: ["Download", "Later"], + defaultId: 0, + cancelId: 1, + }); + if (response !== 0) return; + try { + await autoUpdater.downloadUpdate(); + } catch (error) { + await dialog.showMessageBox(win, { + type: "error", + title: "Update failed", + message: "The update could not be downloaded.", + detail: error instanceof Error ? error.message : String(error), + }); + } + })(); + }); + + autoUpdater.on("download-progress", (progress) => { + win.setProgressBar(Math.min(progress.percent / 100, 1)); + }); + + autoUpdater.on("update-downloaded", (info) => { + win.setProgressBar(-1); + if (SMOKE) { + return updateReporter?.({ state: "downloaded", version: info.version }); + } + void (async () => { + const { response } = await dialog.showMessageBox(win, { + type: "info", + title: "Updates", + message: `J621 ${info.version} is ready to install.`, + detail: + process.platform === "linux" + ? "Installing asks for administrator rights and then restarts J621." + : "J621 will restart to finish installing.", + buttons: ["Restart and install", "Later"], + defaultId: 0, + cancelId: 1, + }); + if (response === 0) { + autoUpdater.quitAndInstall(false, true); + } else { + // Linux packages elevate, so never install silently on quit there. + autoUpdater.autoInstallOnAppQuit = process.platform !== "linux"; + } + })(); + }); +} + +async function checkForUpdates(win: BrowserWindow): Promise { + if (updateCheckRunning) return; + updateCheckRunning = true; + try { + await autoUpdater.checkForUpdates(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + updateReporter?.({ state: "error", message }); + if (!SMOKE) { + await dialog.showMessageBox(win, { + type: "error", + title: "Updates", + message: "Could not check for updates.", + detail: message, + }); + } + } finally { + updateCheckRunning = false; + } +} + let mainWindow: BrowserWindow | null = null; function buildMenu(win: BrowserWindow): void { @@ -202,6 +321,10 @@ function buildMenu(win: BrowserWindow): void { label: "Open backend in browser", click: () => win.webContents.send("j621:open-backend"), }, + { + label: "Check for updates…", + click: () => void checkForUpdates(win), + }, { type: "separator" }, { role: "quit" }, ], @@ -277,6 +400,7 @@ function createWindow(): BrowserWindow { }); buildMenu(win); + setUpUpdates(win); void win.loadURL(DEV_SERVER || `${APP_ORIGIN}/`); if (SMOKE) runSmokeTest(win); return win; @@ -350,6 +474,23 @@ function runSmokeTest(win: BrowserWindow): void { app.exit(1); return; } + if (process.env.J621_UPDATE_URL) { + const update = await new Promise((resolve) => { + updateReporter = resolve; + const timer = setTimeout( + () => resolve({ state: "error", message: "update check timed out" }), + 60_000, + ); + void checkForUpdates(win).finally(() => clearTimeout(timer)); + }); + updateReporter = null; + console.log("[smoke] update:", JSON.stringify(update)); + if (update.state === "error") { + console.error("[smoke] FAILED: update check"); + app.exit(1); + return; + } + } console.log("[smoke] OK"); app.exit(0); } catch (error) {