From a40050a5b7bab3cf82a9b3f90b6df00a5818cbac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 05:23:53 +0000 Subject: [PATCH 1/5] Serve the board locally from the agent (v2 local runtime) and package signed app releases The page now decides once at startup whether it is served by the device agent (local runtime: 127.0.0.1:8080, localhost:8080, ?runtime=local or v1's ?mode=offline) or is the hosted Cloudflare site, per the agent's docs/architecture.md section 15. Local runtime: same-origin API, device id from /api/local/status instead of the cookie, /api/local/events (content -> re-fetch in place, app -> reload), no backend refresh socket, status polled every minute, and a full-screen "Waiting for content" screen when the agent has no content installed that says whether it is downloading from LensBridge or needs a USB stick / laptop on the service port. The staleness note and diagnostics read the v2 status (content, sync, agent/app versions) and still accept v1's bundle fields. The build embeds its own version (package.json via Vite define), shown in diagnostics and returned by window.MusallahBoard.getStatus() with runtime. Hosted runtime is unchanged. scripts/package-mbu.mjs is a dependency-free packer that writes a signed format-2 `app` .mbu (own zip writer, Ed25519 over the domain-prefixed mbu.json) and app-channel.json; `npm run package` builds and packs, and .github/workflows/release.yml publishes both on v* tags. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- .github/workflows/release.yml | 55 +++++ .gitignore | 2 + PLAN.md | 7 + README.md | 28 +++ package.json | 1 + scripts/package-mbu.mjs | 413 ++++++++++++++++++++++++++++++++++ scripts/package-mbu.test.mjs | 209 +++++++++++++++++ src/App.jsx | 216 ++++++++++++++---- src/api/index.js | 5 +- src/api/localEvents.js | 56 +++++ src/api/localStatus.js | 83 +++++-- src/api/payloadService.js | 35 ++- src/api/prayerService.js | 4 +- src/components/SetupModal.jsx | 56 +++-- src/main.jsx | 21 +- src/runtime.js | 64 ++++++ src/runtime.test.js | 28 +++ src/styles.css | 13 ++ src/utils/cookies.js | 30 +-- vite.config.js | 13 +- 20 files changed, 1222 insertions(+), 117 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 scripts/package-mbu.mjs create mode 100644 scripts/package-mbu.test.mjs create mode 100644 src/api/localEvents.js create mode 100644 src/runtime.js create mode 100644 src/runtime.test.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..14d705d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,55 @@ +name: Release + +# Publishes the board app for the device agents (agent/docs/architecture.md, +# sections 4, 9.4 and 15). Pushing a tag vX.Y.Z builds the SPA, signs it into +# musallahboard-app-X.Y.Z.mbu with the release key, and attaches that plus +# app-channel.json to the GitHub release. Boards poll +# releases/latest/download/app-channel.json, so publishing the release is what +# rolls the fleet forward; the package signature, not this workflow, is what +# makes that safe. +# +# The Cloudflare deployment is unaffected: it builds from the branch as before. + +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - run: npm ci + + # The channel file advertises package.json's version under this tag's + # download URL. A tag that disagrees would publish a pointer to a file + # that is not there. + - name: Check the tag matches package.json + run: | + version="$(node -p "require('./package.json').version")" + if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then + echo "tag ${GITHUB_REF_NAME} does not match package.json version ${version}" >&2 + exit 1 + fi + + - run: npm test + + - name: Build and sign the app package + env: + MB_RELEASE_SIGNING_KEY: ${{ secrets.MB_RELEASE_SIGNING_KEY }} + run: npm run package + + - uses: softprops/action-gh-release@v2 + with: + files: | + release/*.mbu + release/app-channel.json diff --git a/.gitignore b/.gitignore index e37b285..23ecf0c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/* dist/* +# npm run package: signed board app packages and the channel pointer +release/ .env .DS_Store .vscode/* diff --git a/PLAN.md b/PLAN.md index c9f7f97..dfa88c4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -90,6 +90,13 @@ elevated privileges, so they don't touch the agent. ### 1. Service worker (offline) +> **Superseded.** Offline operation is no longer a service-worker concern: +> every board is now served from its own disk by the device agent, with +> signed content and app packages (local-first, no online/offline mode). See +> `docs/architecture.md` in the [agent repo](https://github.com/LensBridge/agent). +> The notes below, and the related non-goals and tech choices in this plan, +> describe the earlier approach. + In the existing Vite app, using `vite-plugin-pwa` (Workbox under the hood). - Precache app shell (HTML/CSS/JS/fonts/logo). diff --git a/README.md b/README.md index 9e88b11..394c139 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,34 @@ curl -fsSL https://raw.githubusercontent.com/LensBridge/agent/main/setup.sh | su This will install the agent and all of its dependencies, setup the configuration for the window manager, and configure the system. Upon reboot, the board will display a "Waiting for Enrollment" screen until you enroll it in your Minbar tenancy. +## Running on a board + +Every board runs this app from its own disk. The [device agent](https://github.com/LensBridge/agent) serves it at `http://127.0.0.1:8080/` together with the day's content, and the kiosk always loads that address, online or not. The network only changes how fresh the content is. The agent's `docs/architecture.md` is the contract for all of this. + +The same build also runs as the hosted site on Cloudflare. It picks its runtime once at startup (`src/runtime.js`): + +- **local** when served from `127.0.0.1:8080` or `localhost:8080`, or with `?runtime=local` (or the older `?mode=offline`). The API is same-origin, the board learns its device id from `/api/local/status`, and `/api/local/events` tells it when new content (re-fetch in place) or a new app release (reload) is installed. With no content installed yet it shows a "Waiting for content" screen saying whether it is downloading or needs a USB stick or a laptop on its ethernet port. +- **hosted** everywhere else: device id from the `deviceId` cookie or `?deviceId=`, backend from `VITE_API_BASE_URL`, live refresh over the backend's WebSocket. + +Alt+Shift+F shows diagnostics: runtime, app and agent versions, installed content and sync status. + +### Releasing the board app + +Boards only install app packages (`.mbu`) signed with the release key, so the key is created once and kept out of the repo: + +```bash +# in the agent repo +mbpack keygen +# or with Node alone: prints the seed (keep secret) and the public key (give to the agent) +node -e "const c=require('crypto');const s=c.randomBytes(32);const k=c.createPrivateKey({key:Buffer.concat([Buffer.from('302e020100300506032b657004220420','hex'),s]),format:'der',type:'pkcs8'});const p=c.createPublicKey(k).export({format:'der',type:'spki'}).subarray(-32);console.log('seed: ',s.toString('base64'));console.log('public:',p.toString('base64'));console.log('keyId: ',c.createHash('sha256').update(p).digest().subarray(0,8).toString('hex'))" +``` + +Store the seed as the repository secret `MB_RELEASE_SIGNING_KEY`, and compile the public key into the agent (or add it with `musallahboard-agent trust add release `). + +To release, bump `version` in `package.json` (`MAJOR.MINOR.PATCH`), commit, and push a matching tag (`v2.1.0`). The `Release` workflow tests, builds and signs `musallahboard-app-.mbu`, and attaches it and `app-channel.json` to the GitHub release; online boards pick it up from the channel within a few hours, offline ones from a USB stick or `mbpush`. + +Locally, `MB_RELEASE_SIGNING_KEY= npm run package` writes both files to `release/`. `MB_RELEASE_BASE_URL` changes where `app-channel.json` says the package can be downloaded. + ## Epilogue This README is vastly incomplete due to me focusing on development. Docs and setup instructions will lag behind actual development work for another ~month-ish or so. Rest assured, I do plan on writing high-quality docs from scratch (no AI :P) once the system is in a more stable state. For now, please feel free to open an issue if you have any questions or need help with setup. I will do my best to respond in a timely manner. \ No newline at end of file diff --git a/package.json b/package.json index 89b79e4..80ac005 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "npm run typecheck && vite build", "preview": "vite preview --host", "deploy": "npm run build && wrangler deploy", + "package": "npm run build && node scripts/package-mbu.mjs", "api:generate": "node scripts/generate-api.mjs", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/package-mbu.mjs b/scripts/package-mbu.mjs new file mode 100644 index 0000000..4295c6a --- /dev/null +++ b/scripts/package-mbu.mjs @@ -0,0 +1,413 @@ +#!/usr/bin/env node +/** + * ===================================================== + * Board app package (.mbu) builder + * ===================================================== + * Turns the Vite build into a signed `app` package the + * device agent will install, plus the release channel + * pointer boards poll (agent/docs/architecture.md, + * sections 4, 9.4 and 15): + * + * release/musallahboard-app-.mbu + * release/app-channel.json + * {"version","url","sha256","bytes"} + * + * Run after `vite build` (`npm run package` does both). + * Dependency-free on purpose: this script holds the + * release key in CI, so it is small enough to read in one + * sitting and pulls nothing from npm. It produces the + * same format as `mbpack app` in the agent repo. + * + * Environment: + * MB_RELEASE_SIGNING_KEY base64 of the 32-byte Ed25519 + * seed. Required unless + * --unsigned (tests only: the + * agent refuses such a package). + * MB_RELEASE_BASE_URL where the .mbu is downloaded + * from; default the GitHub + * release for v. + * + * Options: + * --dir build output (default: dist/client + * if it holds index.html, else dist) + * --out-dir default: release + * --unsigned write without mbu.sig + * ===================================================== + */ + +import { + createHash, createPrivateKey, createPublicKey, sign as edSign, +} from 'node:crypto'; +import { + existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync, +} from 'node:fs'; +import { join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { crc32, deflateRawSync } from 'node:zlib'; + +// --------------------------------------------------------------------------- +// Contract constants (architecture doc, section 4) +// --------------------------------------------------------------------------- + +/** The local API version (section 7) this build needs from the agent. */ +export const LOCAL_API = 2; + +const SIGNING_PREFIX = Buffer.from('musallahboard-mbu-v2\n', 'ascii'); + +const SEGMENT_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/; +const MAX_SEGMENTS = 8; +const MAX_NAME_BYTES = 200; +const MAX_FILES = 20000; +const MAX_TOTAL_FILE_BYTES = 1024 * 1024 * 1024; // sum of files[].bytes +const MAX_PACKAGE_BYTES = 512 * 1024 * 1024; +const MAX_MANIFEST_BYTES = 1024 * 1024; + +/** + * Files the Cloudflare Vite plugin writes into the build output for + * `wrangler deploy`. They describe the hosted deployment, not the app: a board + * has no use for them, `wrangler.json` embeds absolute paths from the build + * machine, and `.assetsignore` is a hidden name the entry rules reject. Left + * out by name, at the root only; any other file that breaks the rules still + * fails the build. + */ +const HOSTING_ONLY = new Set(['.assetsignore', 'wrangler.json']); + +// --------------------------------------------------------------------------- +// Entry names +// --------------------------------------------------------------------------- + +/** + * Throw unless `name` is a valid package entry name: slash-separated segments + * matching SEGMENT_RE (so no empty, `.`, `..` or hidden segments), at most 8 + * segments and 200 bytes, no backslash, no leading slash. The agent rejects the + * whole package over one bad name, so this fails here, loudly, instead. + * @param {string} name + */ +export function checkEntryName(name) { + const bad = (why) => { throw new Error(`invalid package entry name ${JSON.stringify(name)}: ${why}`); }; + if (typeof name !== 'string' || name === '') bad('empty'); + if (Buffer.byteLength(name, 'utf8') > MAX_NAME_BYTES) bad(`longer than ${MAX_NAME_BYTES} bytes`); + if (name.includes('\\')) bad('contains a backslash'); + if (name.startsWith('/')) bad('leading slash'); + const segments = name.split('/'); + if (segments.length > MAX_SEGMENTS) bad(`more than ${MAX_SEGMENTS} segments`); + for (const s of segments) { + if (!SEGMENT_RE.test(s)) bad(`segment ${JSON.stringify(s)} must match ${SEGMENT_RE}`); + } +} + +// --------------------------------------------------------------------------- +// Build output +// --------------------------------------------------------------------------- + +/** + * Where the build is. The Cloudflare plugin writes a static-assets-only project + * straight to dist/, and moves the assets to dist/client/ once the project + * gains a Worker; take whichever holds the SPA. + * @param {string} root repository root + */ +export function findBuildDir(root) { + const client = join(root, 'dist', 'client'); + if (existsSync(join(client, 'index.html'))) return client; + return join(root, 'dist'); +} + +/** + * Every regular file under `dir`, as sorted slash-separated paths relative to + * it, minus HOSTING_ONLY at the root. Symlinks and other special files are an + * error: the package may only carry regular files, and following a link could + * pull in something from outside the build. + * @param {string} dir + * @returns {string[]} + */ +export function listBuildFiles(dir) { + const out = []; + const walk = (d) => { + for (const ent of readdirSync(d, { withFileTypes: true })) { + const full = join(d, ent.name); + const rel = relative(dir, full).split(sep).join('/'); + if (ent.isDirectory()) walk(full); + else if (ent.isFile()) { if (!HOSTING_ONLY.has(rel)) out.push(rel); } + else throw new Error(`${rel}: not a regular file (symlink or special file)`); + } + }; + walk(dir); + // Byte order, so the manifest is the same on every platform and locale. + return out.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); +} + +const sha256hex = (buf) => createHash('sha256').update(buf).digest('hex'); + +/** RFC 3339 UTC, second precision: `2026-09-24T14:02:11Z`. */ +export function rfc3339Seconds(date) { + return date.toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +/** + * Build the `app` manifest for a set of files. + * @param {{ version: string, createdAt: string, files: {path:string, data:Buffer}[] }} args + * @returns {object} + */ +export function buildAppManifest({ version, createdAt, files }) { + if (!/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error(`version ${JSON.stringify(version)} must be MAJOR.MINOR.PATCH (digits only)`); + } + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(createdAt)) { + throw new Error(`createdAt ${JSON.stringify(createdAt)} must be RFC 3339 UTC with second precision`); + } + if (files.length > MAX_FILES) throw new Error(`${files.length} files; the limit is ${MAX_FILES}`); + const seen = new Set(); + let total = 0; + for (const f of files) { + checkEntryName(f.path); + if (f.path === 'mbu.json' || f.path === 'mbu.sig') { + throw new Error(`${f.path}: reserved for the package manifest`); + } + if (seen.has(f.path)) throw new Error(`${f.path}: listed twice`); + seen.add(f.path); + total += f.data.length; + } + if (total > MAX_TOTAL_FILE_BYTES) throw new Error(`build is ${total} bytes; the limit is ${MAX_TOTAL_FILE_BYTES}`); + if (!seen.has('index.html')) throw new Error('index.html is not in the build output'); + + return { + format: 'mbu', + formatVersion: 2, + type: 'app', + createdAt, + version, + files: files.map((f) => ({ path: f.path, sha256: sha256hex(f.data), bytes: f.data.length })), + app: { localApi: LOCAL_API }, + }; +} + +// --------------------------------------------------------------------------- +// Signing +// --------------------------------------------------------------------------- + +// PKCS#8 wrapper for a raw Ed25519 seed (RFC 8410): SEQUENCE { version 0, +// AlgorithmIdentifier { 1.3.101.112 }, OCTET STRING { OCTET STRING seed } }. +const PKCS8_ED25519_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex'); + +/** + * @param {string} seedB64 base64 of the 32-byte seed, as the architecture doc + * stores private keys (section 4.2) + */ +export function releaseKeyFromSeed(seedB64) { + const seed = Buffer.from(String(seedB64 ?? '').trim(), 'base64'); + if (seed.length !== 32) { + throw new Error(`signing key must be the base64 of a 32-byte Ed25519 seed (got ${seed.length} bytes)`); + } + const privateKey = createPrivateKey({ + key: Buffer.concat([PKCS8_ED25519_PREFIX, seed]), + format: 'der', + type: 'pkcs8', + }); + // The raw public key is the tail of its SPKI encoding. + const publicRaw = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }).subarray(-32); + const keyId = createHash('sha256').update(publicRaw).digest().subarray(0, 8).toString('hex'); + return { privateKey, publicRaw: Buffer.from(publicRaw), keyId }; +} + +/** The exact bytes an Ed25519 signature covers: domain prefix + mbu.json. */ +export function signedMessage(manifestBytes) { + return Buffer.concat([SIGNING_PREFIX, manifestBytes]); +} + +/** + * The mbu.sig document for `manifestBytes`. + * @param {Buffer} manifestBytes exactly the bytes stored as mbu.json + * @param {ReturnType} key + */ +export function signManifest(manifestBytes, key) { + const sig = edSign(null, signedMessage(manifestBytes), key.privateKey); + return { signatures: [{ keyId: key.keyId, sig: sig.toString('base64') }] }; +} + +// --------------------------------------------------------------------------- +// Zip writer +// --------------------------------------------------------------------------- + +const U32_MAX = 0xffffffff; + +/** MS-DOS date and time fields for `date` (UTC; the format has no zone). */ +function dosDateTime(date) { + const y = Math.max(1980, date.getUTCFullYear()); + return { + time: (date.getUTCHours() << 11) | (date.getUTCMinutes() << 5) | (date.getUTCSeconds() >> 1), + date: ((y - 1980) << 9) | ((date.getUTCMonth() + 1) << 5) | date.getUTCDate(), + }; +} + +/** + * Write a zip (PKWARE APPNOTE, no zip64) of `entries`, in order. Each entry is + * deflated when that makes it smaller and stored otherwise. No directory + * entries: the agent's reader wants files only, and names imply their + * directories. Deterministic: the same entries and `modified` give the same + * bytes, so a rebuild of a tag is byte-identical. + * + * Refuses anything that would need zip64 (an entry or the archive past 4 GiB, + * more than 65535 entries) rather than writing a truncated header. + * + * @param {{name: string, data: Buffer}[]} entries + * @param {Date} modified timestamp recorded on every entry + * @returns {Buffer} + */ +export function createZip(entries, modified = new Date()) { + if (entries.length > 0xffff) throw new Error('too many entries for a zip without zip64'); + const { time, date } = dosDateTime(modified); + const locals = []; + const centrals = []; + let offset = 0; + + for (const { name, data } of entries) { + const nameBytes = Buffer.from(name, 'utf8'); + if (data.length > U32_MAX) throw new Error(`${name}: larger than 4 GiB (zip64 is not supported)`); + const crc = crc32(data) >>> 0; + const deflated = deflateRawSync(data, { level: 9 }); + const useDeflate = deflated.length < data.length; + const body = useDeflate ? deflated : data; + const method = useDeflate ? 8 : 0; + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); // local file header signature + local.writeUInt16LE(20, 4); // version needed: 2.0 (deflate) + local.writeUInt16LE(0x0800, 6); // flags: UTF-8 names + local.writeUInt16LE(method, 8); + local.writeUInt16LE(time, 10); + local.writeUInt16LE(date, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(body.length, 18); // compressed size + local.writeUInt32LE(data.length, 22); // uncompressed size + local.writeUInt16LE(nameBytes.length, 26); + local.writeUInt16LE(0, 28); // extra field length + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); // central directory header signature + central.writeUInt16LE(0x0314, 4); // made by: Unix, spec 2.0 + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(method, 10); + central.writeUInt16LE(time, 12); + central.writeUInt16LE(date, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(body.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt16LE(0, 30); // extra field length + central.writeUInt16LE(0, 32); // comment length + central.writeUInt16LE(0, 34); // disk number start + central.writeUInt16LE(0, 36); // internal attributes + central.writeUInt32LE(((0o100644 << 16) >>> 0), 38); // regular file, rw-r--r-- + central.writeUInt32LE(offset, 42); // local header offset + + locals.push(local, nameBytes, body); + centrals.push(central, nameBytes); + offset += local.length + nameBytes.length + body.length; + if (offset > U32_MAX) throw new Error('archive larger than 4 GiB (zip64 is not supported)'); + } + + const cdSize = centrals.reduce((n, b) => n + b.length, 0); + if (offset + cdSize + 22 > U32_MAX) throw new Error('archive larger than 4 GiB (zip64 is not supported)'); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); // end of central directory signature + end.writeUInt16LE(0, 4); // this disk + end.writeUInt16LE(0, 6); // disk with the central directory + end.writeUInt16LE(entries.length, 8); // entries on this disk + end.writeUInt16LE(entries.length, 10); // entries in total + end.writeUInt32LE(cdSize, 12); + end.writeUInt32LE(offset, 16); // central directory offset + end.writeUInt16LE(0, 20); // comment length + + return Buffer.concat([...locals, ...centrals, end]); +} + +// --------------------------------------------------------------------------- +// Package +// --------------------------------------------------------------------------- + +/** + * Build a complete app package in memory. + * @param {{ version: string, createdAt: string, files: {path:string, data:Buffer}[], + * key: ReturnType|null }} args + * `key` null writes an unsigned package (no mbu.sig). + * @returns {{ zip: Buffer, manifest: object, manifestBytes: Buffer, sig: object|null }} + */ +export function buildAppPackage({ version, createdAt, files, key }) { + const manifest = buildAppManifest({ version, createdAt, files }); + const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + if (manifestBytes.length > MAX_MANIFEST_BYTES) throw new Error('mbu.json is larger than 1 MiB'); + const sig = key ? signManifest(manifestBytes, key) : null; + + const entries = [{ name: 'mbu.json', data: manifestBytes }]; + if (sig) entries.push({ name: 'mbu.sig', data: Buffer.from(`${JSON.stringify(sig)}\n`, 'utf8') }); + for (const f of files) entries.push({ name: f.path, data: f.data }); + + const zip = createZip(entries, new Date(createdAt)); + if (zip.length > MAX_PACKAGE_BYTES) throw new Error(`package is ${zip.length} bytes; the limit is ${MAX_PACKAGE_BYTES}`); + return { zip, manifest, manifestBytes, sig }; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function parseArgs(argv) { + const opts = { dir: null, outDir: 'release', unsigned: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--unsigned') opts.unsigned = true; + else if (a === '--dir') opts.dir = argv[++i]; + else if (a === '--out-dir') opts.outDir = argv[++i]; + else throw new Error(`unknown argument ${a}`); + } + return opts; +} + +function main() { + const root = resolve(fileURLToPath(new URL('..', import.meta.url))); + const opts = parseArgs(process.argv.slice(2)); + const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + + const seed = process.env.MB_RELEASE_SIGNING_KEY; + let key = null; + if (seed && seed.trim()) key = releaseKeyFromSeed(seed); + else if (!opts.unsigned) { + throw new Error( + 'MB_RELEASE_SIGNING_KEY is not set. Boards refuse unsigned packages; pass --unsigned ' + + 'only to test the packer.' + ); + } + + const dir = opts.dir ? resolve(opts.dir) : findBuildDir(root); + if (!existsSync(join(dir, 'index.html')) || !lstatSync(dir).isDirectory()) { + throw new Error(`${dir} does not hold a build (no index.html). Run vite build first.`); + } + const files = listBuildFiles(dir).map((path) => ({ path, data: readFileSync(join(dir, ...path.split('/'))) })); + const createdAt = rfc3339Seconds(new Date()); + const { zip, manifest } = buildAppPackage({ version, createdAt, files, key }); + + const outDir = resolve(opts.outDir); + mkdirSync(outDir, { recursive: true }); + const name = `musallahboard-app-${version}.mbu`; + writeFileSync(join(outDir, name), zip); + + const base = (process.env.MB_RELEASE_BASE_URL || + `https://github.com/LensBridge/MusallahBoard/releases/download/v${version}`).replace(/\/+$/, ''); + const channel = { version, url: `${base}/${name}`, sha256: sha256hex(zip), bytes: zip.length }; + writeFileSync(join(outDir, 'app-channel.json'), `${JSON.stringify(channel, null, 2)}\n`); + + console.log(`build: ${relative(root, dir) || '.'} (${manifest.files.length} files)`); + console.log(`package: ${join(relative(root, outDir) || '.', name)} (${zip.length} bytes, sha256 ${channel.sha256})`); + console.log(key ? `signed: keyId ${key.keyId}` : 'UNSIGNED: boards will refuse this package'); + console.log(`channel: ${channel.url}`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(); + } catch (e) { + console.error(`package-mbu: ${e.message}`); + process.exit(1); + } +} diff --git a/scripts/package-mbu.test.mjs b/scripts/package-mbu.test.mjs new file mode 100644 index 0000000..b191d1c --- /dev/null +++ b/scripts/package-mbu.test.mjs @@ -0,0 +1,209 @@ +// @vitest-environment node +// +// The packer is the one piece of this repo that talks to the agent's verifier +// rather than to a browser, so its tests read the output the way the agent +// does: a from-scratch zip reader below (not the writer's own bookkeeping), and +// crypto.verify against the public key alone. +import { createHash, createPublicKey, verify, randomBytes } from 'node:crypto'; +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { crc32, inflateRawSync } from 'node:zlib'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + buildAppManifest, buildAppPackage, checkEntryName, createZip, listBuildFiles, + releaseKeyFromSeed, rfc3339Seconds, signManifest, signedMessage, +} from './package-mbu.mjs'; + +/** Minimal zip reader: EOCD, then the central directory, then each local header. */ +function readZip(buf) { + const eocd = buf.length - 22; + expect(buf.readUInt32LE(eocd)).toBe(0x06054b50); + const count = buf.readUInt16LE(eocd + 10); + const cdSize = buf.readUInt32LE(eocd + 12); + let p = buf.readUInt32LE(eocd + 16); + expect(p + cdSize).toBe(eocd); + + const out = []; + for (let i = 0; i < count; i++) { + expect(buf.readUInt32LE(p)).toBe(0x02014b50); + const method = buf.readUInt16LE(p + 10); + const crc = buf.readUInt32LE(p + 16); + const csize = buf.readUInt32LE(p + 20); + const usize = buf.readUInt32LE(p + 24); + const nlen = buf.readUInt16LE(p + 28); + const xlen = buf.readUInt16LE(p + 30); + const clen = buf.readUInt16LE(p + 32); + const mode = buf.readUInt32LE(p + 38) >>> 16; + const local = buf.readUInt32LE(p + 42); + const name = buf.subarray(p + 46, p + 46 + nlen).toString('utf8'); + p += 46 + nlen + xlen + clen; + + expect(buf.readUInt32LE(local)).toBe(0x04034b50); + expect(buf.readUInt32LE(local + 14)).toBe(crc); + expect(buf.subarray(local + 30, local + 30 + buf.readUInt16LE(local + 26)).toString('utf8')).toBe(name); + const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28); + const body = buf.subarray(start, start + csize); + const data = method === 8 ? inflateRawSync(body) : Buffer.from(body); + expect([0, 8]).toContain(method); + expect(data.length).toBe(usize); + expect(crc32(data) >>> 0).toBe(crc); + out.push({ name, method, mode, data }); + } + return out; +} + +const SEED_1_TO_32 = Buffer.from(Array.from({ length: 32 }, (_, i) => i + 1)).toString('base64'); + +describe('createZip', () => { + it('round-trips stored and deflated entries with correct CRCs', () => { + const entries = [ + { name: 'mbu.json', data: Buffer.from('{"a":1}\n') }, + { name: 'index.html', data: Buffer.from(''.repeat(200)) }, // compresses + { name: 'assets/x.bin', data: randomBytes(300) }, // does not + { name: 'empty.txt', data: Buffer.alloc(0) }, + ]; + const got = readZip(createZip(entries, new Date('2026-09-24T14:02:11Z'))); + expect(got.map((e) => e.name)).toEqual(entries.map((e) => e.name)); + got.forEach((e, i) => expect(e.data.equals(entries[i].data)).toBe(true)); + expect(got[1].method).toBe(8); + expect(got[2].method).toBe(0); + expect(got.every((e) => e.mode === 0o100644)).toBe(true); + }); + + it('is deterministic for the same input and timestamp', () => { + const entries = [{ name: 'a', data: Buffer.from('hello') }]; + const at = new Date('2026-01-01T00:00:00Z'); + expect(createZip(entries, at).equals(createZip(entries, at))).toBe(true); + }); +}); + +describe('signing', () => { + it('matches the Go verifier test vector for seed 0x01..0x20', () => { + expect(releaseKeyFromSeed(SEED_1_TO_32).keyId).toBe('65b60673d6ed884b'); + }); + + it('signs prefix + exact mbu.json bytes, verifiable from the raw public key', () => { + const key = releaseKeyFromSeed(SEED_1_TO_32); + const bytes = Buffer.from('{"format":"mbu"}\n'); + const { signatures } = signManifest(bytes, key); + expect(signatures).toHaveLength(1); + expect(signatures[0].keyId).toBe(key.keyId); + const sig = Buffer.from(signatures[0].sig, 'base64'); + expect(sig.length).toBe(64); + + // Rebuild the public key from its raw 32 bytes, as a board's trust store has it. + const pub = createPublicKey({ + key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), key.publicRaw]), + format: 'der', + type: 'spki', + }); + const msg = Buffer.concat([Buffer.from('musallahboard-mbu-v2\n'), bytes]); + expect(signedMessage(bytes).equals(msg)).toBe(true); + expect(verify(null, msg, pub, sig)).toBe(true); + expect(verify(null, bytes, pub, sig)).toBe(false); // prefix is part of it + }); + + it('rejects a seed that is not 32 bytes', () => { + expect(() => releaseKeyFromSeed(Buffer.alloc(31).toString('base64'))).toThrow(/32-byte/); + expect(() => releaseKeyFromSeed('')).toThrow(/32-byte/); + }); +}); + +describe('entry names', () => { + it.each(['index.html', 'assets/index-BHAz-QHd.js', '_x/a.b', 'a/b/c/d/e/f/g/h'])('accepts %s', (n) => { + expect(() => checkEntryName(n)).not.toThrow(); + }); + it.each([ + '', '/index.html', 'a//b', './a', 'a/../b', '.assetsignore', 'assets/.hidden', + 'a\\b', 'a/b/c/d/e/f/g/h/i', '-x', 'a b', `${'a'.repeat(201)}`, + ])('rejects %j', (n) => { + expect(() => checkEntryName(n)).toThrow(/invalid package entry name/); + }); +}); + +describe('manifest and package', () => { + const files = [ + { path: 'assets/app.js', data: Buffer.from('console.log(1)') }, + { path: 'index.html', data: Buffer.from('') }, + ]; + + it('builds an app manifest per section 4.1', () => { + const m = buildAppManifest({ version: '2.1.0', createdAt: '2026-09-24T14:02:11Z', files }); + expect(m).toMatchObject({ + format: 'mbu', formatVersion: 2, type: 'app', version: '2.1.0', + createdAt: '2026-09-24T14:02:11Z', app: { localApi: 2 }, + }); + expect(m.files.map((f) => f.path)).toEqual(['assets/app.js', 'index.html']); + expect(m.files[1]).toEqual({ + path: 'index.html', + sha256: createHash('sha256').update('').digest('hex'), + bytes: 15, + }); + }); + + it('refuses bad versions, timestamps, missing index.html and bad names', () => { + const at = '2026-09-24T14:02:11Z'; + expect(() => buildAppManifest({ version: '2.1', createdAt: at, files })).toThrow(/MAJOR/); + expect(() => buildAppManifest({ version: 'v2.1.0', createdAt: at, files })).toThrow(/MAJOR/); + expect(() => buildAppManifest({ version: '2.1.0', createdAt: '2026-09-24T14:02:11.123Z', files })).toThrow(/second/); + expect(() => buildAppManifest({ version: '2.1.0', createdAt: at, files: files.slice(0, 1) })).toThrow(/index.html/); + expect(() => buildAppManifest({ + version: '2.1.0', createdAt: at, files: [...files, { path: '.env', data: Buffer.alloc(1) }], + })).toThrow(/invalid package entry name/); + expect(() => buildAppManifest({ + version: '2.1.0', createdAt: at, files: [...files, { path: 'mbu.sig', data: Buffer.alloc(1) }], + })).toThrow(/reserved/); + }); + + it('writes mbu.json, mbu.sig and every file, with the signature over the stored mbu.json', () => { + const key = releaseKeyFromSeed(SEED_1_TO_32); + const { zip, manifestBytes } = buildAppPackage({ + version: '2.1.0', createdAt: '2026-09-24T14:02:11Z', files, key, + }); + const got = readZip(zip); + expect(got.map((e) => e.name)).toEqual(['mbu.json', 'mbu.sig', 'assets/app.js', 'index.html']); + expect(got.some((e) => e.name.endsWith('/'))).toBe(false); // no directory entries + expect(got[0].data.equals(manifestBytes)).toBe(true); + + const sig = JSON.parse(got[1].data.toString('utf8')); + const pub = createPublicKey({ + key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), key.publicRaw]), + format: 'der', + type: 'spki', + }); + expect(verify(null, signedMessage(got[0].data), pub, Buffer.from(sig.signatures[0].sig, 'base64'))).toBe(true); + }); + + it('leaves mbu.sig out of an unsigned package', () => { + const { zip } = buildAppPackage({ version: '2.1.0', createdAt: '2026-09-24T14:02:11Z', files, key: null }); + expect(readZip(zip).map((e) => e.name)).toEqual(['mbu.json', 'assets/app.js', 'index.html']); + }); + + it('formats createdAt at second precision in UTC', () => { + expect(rfc3339Seconds(new Date('2026-09-24T14:02:11.987Z'))).toBe('2026-09-24T14:02:11Z'); + }); +}); + +describe('listBuildFiles', () => { + let dir; + afterEach(() => { if (dir) rmSync(dir, { recursive: true, force: true }); }); + + it('lists regular files sorted, without the Cloudflare deploy files', () => { + dir = mkdtempSync(join(tmpdir(), 'mbu-build-')); + mkdirSync(join(dir, 'assets')); + writeFileSync(join(dir, 'index.html'), 'x'); + writeFileSync(join(dir, 'assets', 'b.js'), 'x'); + writeFileSync(join(dir, 'assets', 'a.css'), 'x'); + writeFileSync(join(dir, 'wrangler.json'), '{}'); + writeFileSync(join(dir, '.assetsignore'), 'wrangler.json'); + expect(listBuildFiles(dir)).toEqual(['assets/a.css', 'assets/b.js', 'index.html']); + }); + + it('refuses symlinks', () => { + dir = mkdtempSync(join(tmpdir(), 'mbu-build-')); + writeFileSync(join(dir, 'index.html'), 'x'); + symlinkSync('/etc/hostname', join(dir, 'link')); + expect(() => listBuildFiles(dir)).toThrow(/not a regular file/); + }); +}); diff --git a/src/App.jsx b/src/App.jsx index 2f187c0..a3a9d4d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { getBoardPayload, getPrayerData, connectRefreshSocket, getLocalStatus, + connectLocalEvents, isNoContentError, statusContent, contentCreatedAt, } from './api/index.js'; import { prefetchPayloadImages } from './utils/prefetch.js'; import { @@ -15,8 +16,9 @@ import { resolveTheme, DEFAULT_THEME } from './themes/registry.js'; import './frames/builders.jsx'; // registers default builders import { getSetupConfig, applyCursorPreference, isValidDeviceId, - setDeviceId as saveDeviceId, readBoardMode, + setDeviceId as saveDeviceId, } from './utils/cookies.js'; +import { RUNTIME, APP_VERSION } from './runtime.js'; import SetupModal from './components/SetupModal.jsx'; import DebugMenu from './components/DebugMenu.jsx'; import TopBar from './components/TopBar.jsx'; @@ -24,10 +26,12 @@ import PrayerRail from './components/PrayerRail.jsx'; import Ticker from './components/Ticker.jsx'; const PAYLOAD_REFRESH_MS = 10 * 60 * 1000; +const LOCAL_STATUS_REFRESH_MS = 60 * 1000; -// 'offline' when the agent serves this page from a content bundle (see -// agent/docs/offline.md). Fixed for the life of the page. -const MODE = readBoardMode(); +// 'local' when the device agent serves this page from the board's own disk, +// 'hosted' on the Cloudflare site. Fixed for the life of the page; see +// runtime.js and agent/docs/architecture.md, section 15. +const LOCAL = RUNTIME === 'local'; // Presentation overrides driven by the debug drawer (Alt+Shift+D). `null` on a // tri-state field means "don't override, use the board's own logic". Held in @@ -52,29 +56,80 @@ function ScaledStage({ children }) { ); } -function Status({ title, detail, error }) { +// `large` is for screens that someone has to act on from across the room (the +// waiting-for-content screen); `hint` is a second, plainer line under detail. +function Status({ title, detail, error, hint, large = false }) { return ( -
+

{title}

{detail}

{error &&
{error}
} + {hint &&
{hint}
}
); } +const SERVICE_PORT_URL = 'http://10.77.0.1/'; + /** - * "Content last updated …" note for an offline board whose bundle has run + * Local runtime, agent up, but no content package installed yet. What an + * operator can do about it depends on whether the agent is trying to sync: + * + * - sync on and no error yet: it is downloading; nothing to do but wait. + * - sync on and failing: say why (sync.lastError), and offer the offline + * routes too, since a board with no internet also lands here (its sync + * attempts fail fast with no route, section 11 of the architecture doc). + * - sync off, or no status at all: only the offline routes can help. + */ +function WaitingForContent({ status }) { + const sync = status?.sync; + const offline = + `Plug in a USB stick with a MusallahBoard update, or connect a laptop or phone ` + + `to the board's ethernet port and open ${SERVICE_PORT_URL}`; + if (sync?.enabled && !sync.lastError) { + return ( + + ); + } + if (sync?.enabled) { + return ( + + ); + } + return ( + + ); +} + +/** + * "Content last updated …" note for a board whose installed content has run * out. Past lastDay the agent keeps serving lastDay's payload, so the board - * still looks alive — this is the one visible hint that nobody has pushed new - * content. Dated by when the bundle was generated, in the bundle's zone. + * still looks alive, and this is the one visible hint that nobody has sent new + * content. Dated by when the package was made, in the content's zone. */ function StaleNote({ status }) { if (!(status?.staleDays > 0)) return null; - const b = status.bundle; + const b = statusContent(status); let when = b?.lastDay ?? ''; - const at = b?.generatedAt ? new Date(b.generatedAt) : null; + const createdAt = contentCreatedAt(b); + const at = createdAt ? new Date(createdAt) : null; if (at && !Number.isNaN(at.getTime())) { try { when = new Intl.DateTimeFormat('en-US', { @@ -96,15 +151,22 @@ function gregorian(now, timezone) { } export default function App() { - // Identity is settled by resolveDeviceId() in main.jsx before the first - // render, so the cookie is already authoritative here — no reconciliation - // effect, and no unpaired flash on a board the agent provisioned. - const [deviceId, setDeviceIdState] = useState(() => getSetupConfig().deviceId); + // Hosted: identity is settled by resolveDeviceId() in main.jsx before the + // first render, so the cookie is already authoritative here: no + // reconciliation effect, and no unpaired flash on a board the agent + // provisioned. Local: the agent says who we are in /api/local/status, and + // until that answers the id is simply unknown (never "unpaired"). + const [deviceId, setDeviceIdState] = useState(() => + LOCAL ? null : getSetupConfig().deviceId + ); const [payload, setPayload] = useState(null); const [prayerInfo, setPrayerInfo] = useState(null); // { prayers, hijri } const [error, setError] = useState(null); + // Local runtime: the last payload fetch said the agent has no content yet. + const [noContent, setNoContent] = useState(false); const [lastPayloadAt, setLastPayloadAt] = useState(null); - // Offline mode only: the agent's /api/local/status, refreshed with the payload. + // Local runtime only: the agent's /api/local/status, refreshed with the + // payload and every minute (see applyLocalStatus below). const [localStatus, setLocalStatus] = useState(null); const [realNow, setRealNow] = useState(new Date()); const [slideIdx, setSlideIdx] = useState(0); @@ -123,7 +185,10 @@ export default function App() { [realNow, debug.timeOffsetMs] ); - const needsSetup = !isValidDeviceId(deviceId); + // Only the hosted site can be unpaired. A local board's kiosk points at the + // agent only once it is enrolled (architecture doc, section 14), and the id + // arrives with the status; a missing cookie there means nothing. + const needsSetup = !LOCAL && !isValidDeviceId(deviceId); // Force the cursor visible while either operator panel is open, otherwise // an operator on a touchscreen-less board can't aim at its own fields. Both @@ -176,17 +241,23 @@ export default function App() { // setDeviceId() and refresh() run in the same tick — before React has // re-rendered with the new id. Closing over `deviceId` would refetch with // the old one (or refuse as unpaired) on exactly the call that matters. + // + // Local: no id is needed at all. The agent serves only its own board, so the + // payload is fetched without one, and the id for diagnostics comes with the + // status. const loadPayload = useCallback(async () => { - const id = getSetupConfig().deviceId; - if (!isValidDeviceId(id)) return { ok: false, reason: 'unpaired' }; - // Alongside the payload, not after it: when the agent has no bundle the - // payload 503s, and that is exactly when the diagnostics need the status. - if (MODE === 'offline') getLocalStatus().then(setLocalStatus); + const id = LOCAL ? null : getSetupConfig().deviceId; + if (!LOCAL && !isValidDeviceId(id)) return { ok: false, reason: 'unpaired' }; + // Alongside the payload, not after it: when the agent has no content the + // payload 503s, and that is exactly when the waiting screen and the + // diagnostics need the status. + if (LOCAL) getLocalStatus().then((s) => applyLocalStatusRef.current(s)); try { - const p = await getBoardPayload(id); + const p = await getBoardPayload(id, { local: LOCAL }); prefetchPayloadImages(p); setPayload(p); setError(null); + setNoContent(false); setLastPayloadAt(Date.now()); // Computed locally (adhan + Intl), recomputed on every payload load — // which is also what rolls the times over after midnight. @@ -197,7 +268,11 @@ export default function App() { } return { ok: true, deviceId: id, at: new Date().toISOString() }; } catch (e) { - console.error('payload fetch failed', e); + // Not an error on a board that has simply never been given content: it + // gets its own screen instead of "Reconnecting…". + const empty = LOCAL && isNoContentError(e); + if (!empty) console.error('payload fetch failed', e); + setNoContent(empty); setError(e?.message || 'Unknown error'); return { ok: false, deviceId: id, error: e?.message || 'Unknown error' }; } @@ -211,6 +286,49 @@ export default function App() { return () => clearInterval(id); }, [loadPayload]); + // --------------------------------------------------------------------- + // Local runtime: status. Polled every minute (and with every payload load) + // so the waiting screen, the staleness note and the diagnostics stay current + // without depending on the event stream. It is also the backstop for events + // missed while the stream was down: + // - content appearing while we sit on the waiting screen loads it at once + // rather than at the next 10-minute payload poll; + // - a change of installed app version between two polls reloads the page. + // Compared poll to poll, never against APP_VERSION: a package whose + // signed version differs from the package.json it was built from would + // otherwise reload the board forever. + // Held in a ref so loadPayload (above) and the interval always call the + // version that sees current state. + // --------------------------------------------------------------------- + const seenAppVersion = useRef(undefined); + // + // Only the minute poll may trigger the content catch-up. loadPayload fetches + // the status too, and if it could also call back into loadPayload, an agent + // whose status and payload disagree would have the two chase each other. + const applyLocalStatus = (s, { fromPoll = false } = {}) => { + setLocalStatus(s); + if (!s) return; + if (isValidDeviceId(s.deviceId)) setDeviceIdState(s.deviceId.trim()); + const appVersion = s.app?.version ?? null; + if (seenAppVersion.current === undefined) seenAppVersion.current = appVersion; + else if (appVersion && appVersion !== seenAppVersion.current) { + window.location.reload(); + return; + } + if (fromPoll && noContent && statusContent(s)) refreshRef.current(); + }; + const applyLocalStatusRef = useRef(applyLocalStatus); + useEffect(() => { applyLocalStatusRef.current = applyLocalStatus; }); + + useEffect(() => { + if (!LOCAL) return undefined; + const id = setInterval( + () => getLocalStatus().then((s) => applyLocalStatusRef.current(s, { fromPoll: true })), + LOCAL_STATUS_REFRESH_MS + ); + return () => clearInterval(id); + }, []); + const tz = payload?.deviceConfig?.location?.timezone; // Compose the design data shape. @@ -289,9 +407,14 @@ export default function App() { useEffect(() => { statusRef.current = () => ({ deviceId: deviceId ?? null, - paired: !needsSetup, - mode: MODE, - bundle: localStatus?.bundle ?? null, + paired: LOCAL ? isValidDeviceId(deviceId) : !needsSetup, + runtime: RUNTIME, + appVersion: APP_VERSION, + // v1 names, kept for agent builds that still read them. + mode: LOCAL ? 'offline' : 'online', + bundle: statusContent(localStatus), + content: statusContent(localStatus), + noContent, slideKey: slides[slideIdx]?.key ?? null, slideIndex: slides.length ? slideIdx : null, slideCount: slides.length, @@ -334,18 +457,27 @@ export default function App() { }; }, []); - // Live content push. Only opened once this board is enrolled: the channel - // carries enrolled devices only, and the backend closes anything that cannot - // name a known device. The cookie poll above re-runs this on enrollment. - // Goes through the ref so it always calls the current loader. + // Hosted: live content push. Only opened once this board is enrolled: the + // channel carries enrolled devices only, and the backend closes anything that + // cannot name a known device. The cookie poll above re-runs this on + // enrollment. Goes through the ref so it always calls the current loader. // - // Offline there is no backend to push anything; new content arrives as a - // bundle install, which reloads the page or is picked up by the 10-minute - // poll. + // Local: the agent listens on that channel itself and turns it into content + // syncs. The page only hears the outcome, from the agent's event stream: new + // content re-fetches in place, a new app release reloads into it. useEffect(() => { - if (MODE === 'offline' || !isValidDeviceId(deviceId)) return undefined; + if (LOCAL || !isValidDeviceId(deviceId)) return undefined; return connectRefreshSocket(deviceId, () => refreshRef.current()); }, [deviceId]); + // Opened once, not per device id: the id arrives with the first status, and + // re-opening the stream then would only drop and redo the connection. + useEffect(() => { + if (!LOCAL) return undefined; + return connectLocalEvents({ + onContent: () => refreshRef.current(), + onApp: () => window.location.reload(), + }); + }, []); // The drawer is reachable from every render path — a board stuck on the // loading screen is exactly when you want to force a theme or hold the deck. @@ -395,11 +527,15 @@ export default function App() { if (!data || !slides.length) { return ( <> - + {LOCAL && noContent ? ( + + ) : ( + + )} {renderDebug()} {setupOpen && ( unknown, onApp: () => unknown }} handlers + * @returns {() => void} disposer: closes the stream. Safe to call twice. + */ +export function connectLocalEvents({ onContent, onApp }) { + if (typeof EventSource === 'undefined') return () => {}; + + const source = new EventSource('/api/local/events'); + let opened = false; + + const run = (fn, what) => { + try { + Promise.resolve(fn()).catch((e) => console.error(`local events: ${what} failed`, e)); + } catch (e) { + console.error(`local events: ${what} threw`, e); + } + }; + + source.addEventListener('open', () => { + // The first open is the page starting up, which loads the payload anyway. + // A later one is a reconnect, and a `content` event may have been missed + // while the stream was down. + if (opened) run(onContent, 'catch-up refresh'); + opened = true; + }); + source.addEventListener('content', () => run(onContent, 'content refresh')); + source.addEventListener('app', () => run(onApp, 'app reload')); + + return () => source.close(); +} diff --git a/src/api/localStatus.js b/src/api/localStatus.js index b29e474..ff8b7b9 100644 --- a/src/api/localStatus.js +++ b/src/api/localStatus.js @@ -1,37 +1,66 @@ /** * ===================================================== - * Local Status (offline mode only) + * Local Status (local runtime only) * ===================================================== - * In offline mode the device agent serves this page, and - * the payload, from 127.0.0.1 off an installed content - * bundle. GET /api/local/status says which bundle and how - * fresh it is (agent/docs/offline.md, "Local HTTP - * server"). It is the agent's, not LensBridge's, so it is - * not in the OpenAPI schema: a plain same-origin fetch. + * On a board the device agent serves this page, and the + * payload, from 127.0.0.1:8080 off an installed content + * package. GET /api/local/status says who this board is, + * what is installed and how fresh it is + * (agent/docs/architecture.md, section 7). It is the + * agent's, not LensBridge's, so it is not in the OpenAPI + * schema: a plain same-origin fetch. * - * { mode, deviceId, - * bundle: { firstDay, lastDay, generatedAt, timezone } | null, - * today, servingDay, daysRemaining, staleDays } + * { localApi, agentVersion, deviceId, + * app: { version } | null, + * content: { sequence, createdAt, firstDay, lastDay, + * timezone, source, installedAt } | null, + * today, servingDay, daysRemaining, staleDays, + * sync: { enabled, lastSuccessAt, lastAttemptAt, + * lastError }, + * update: { active } } + * + * v1 agents answered with `bundle` (and `generatedAt`) + * where v2 has `content` (and `createdAt`); the helpers + * below read either, so a board whose agent is one step + * behind its app still shows its staleness note. * ===================================================== */ const TIMEOUT_MS = 5000; +/** + * @typedef {Object} LocalContent + * @property {string} firstDay + * @property {string} lastDay + * @property {string} [timezone] + * @property {number} [sequence] + * @property {string} [createdAt] v2 + * @property {string} [generatedAt] v1 + * @property {string} [source] sync | usb | upload | cli + * @property {string} [installedAt] + */ + /** * @typedef {Object} LocalStatus - * @property {string} mode + * @property {number} [localApi] + * @property {string} [agentVersion] * @property {string} [deviceId] - * @property {{firstDay:string,lastDay:string,generatedAt:string,timezone:string}|null} bundle + * @property {{version:string}|null} [app] + * @property {LocalContent|null} [content] v2 + * @property {LocalContent|null} [bundle] v1 * @property {string} [today] - * @property {string} [servingDay] - * @property {number} [daysRemaining] - * @property {number} [staleDays] + * @property {string|null} [servingDay] + * @property {number|null} [daysRemaining] + * @property {number|null} [staleDays] + * @property {{enabled:boolean,lastSuccessAt:string|null,lastAttemptAt:string|null,lastError:string|null}} [sync] + * @property {{active:boolean}} [update] + * @property {string} [error] installed content could not be read */ /** - * Fetch the agent's bundle status. Never throws: the status only feeds the - * staleness note and the diagnostics panel, and a board must keep showing its - * payload even if the agent is too old to answer this. + * Fetch the agent's status. Never throws: the status feeds the device id, the + * waiting screen, the staleness note and the diagnostics panel, and a board + * must keep showing its payload even if the agent is too old to answer this. * @returns {Promise} */ export async function getLocalStatus() { @@ -52,3 +81,21 @@ export async function getLocalStatus() { clearTimeout(timeoutId); } } + +/** + * The installed content, whichever agent generation described it. + * @param {LocalStatus|null|undefined} status + * @returns {LocalContent|null} + */ +export function statusContent(status) { + return status?.content ?? status?.bundle ?? null; +} + +/** + * When the installed content was made: v2 `createdAt`, v1 `generatedAt`. + * @param {LocalContent|null|undefined} content + * @returns {string|null} + */ +export function contentCreatedAt(content) { + return content?.createdAt ?? content?.generatedAt ?? null; +} diff --git a/src/api/payloadService.js b/src/api/payloadService.js index f9688a0..eedb542 100644 --- a/src/api/payloadService.js +++ b/src/api/payloadService.js @@ -6,6 +6,12 @@ * flattens it into the design data shape. Retries with * jittered backoff on transient failures so a fleet of * boards doesn't reconnect in lockstep. + * + * In the local runtime the same path is answered by the + * device agent, for which `deviceId` is optional (it only + * ever serves its own board), and a 503 "no content + * installed" is a state, not an outage: see + * isNoContentError(). * ===================================================== */ @@ -15,20 +21,41 @@ import { normalizePayload } from '../models/index.js'; const RETRY_DELAYS_MS = [2000, 5000, 12000, 30000]; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +/** + * The local agent's answer when no content package is installed yet + * (agent/docs/architecture.md, section 7). Retrying it a few seconds later only + * delays the "Waiting for content" screen: content arrives by sync, USB stick + * or upload, and the agent announces it on /api/local/events. + * @param {unknown} error + */ +export function isNoContentError(error) { + return ( + error instanceof ApiError && + error.status === 503 && + /no content/i.test(error.message || '') + ); +} + /** 429 / 5xx / network errors are worth retrying; other 4xx are not. */ function isTransient(error) { if (!(error instanceof ApiError)) return false; + if (isNoContentError(error)) return false; const { status } = error; return status === 429 || status === 0 || status >= 500; } /** * Fetch and normalize the board payload for a device. - * @param {string} deviceId device UUID (from enrollment / setup) + * @param {string|null} deviceId device UUID (from enrollment / setup). May be + * null only with `local`, when the agent on this board answers. + * @param {{ local?: boolean }} [options] * @returns {Promise>} */ -export async function getBoardPayload(deviceId) { - if (!deviceId) throw new ApiError('Missing deviceId', 400); +export async function getBoardPayload(deviceId, { local = false } = {}) { + if (!deviceId && !local) throw new ApiError('Missing deviceId', 400); + // The schema marks deviceId required because the backend needs it; the + // agent does not, so the local runtime may send no query at all. + const query = deviceId ? { deviceId } : /** @type {{deviceId: string}} */ ({}); let lastError = null; @@ -36,7 +63,7 @@ export async function getBoardPayload(deviceId) { try { const payload = unwrap( await getClient().GET('/api/musallah/payload', { - params: { query: { deviceId } }, + params: { query }, }) ); return normalizePayload(payload); diff --git a/src/api/prayerService.js b/src/api/prayerService.js index 46f8642..441e59d 100644 --- a/src/api/prayerService.js +++ b/src/api/prayerService.js @@ -8,8 +8,8 @@ * location + calculation method (DeviceConfig.location). * * This used to call the Aladhan API. It is computed - * locally now so a board with no internet (offline mode, - * see agent/docs/offline.md) still shows correct times + * locally now so a board with no internet (local runtime, + * see agent/docs/architecture.md) still shows correct times * indefinitely — and so an online board has one fewer * third party that can blank its prayer rail. * ===================================================== diff --git a/src/components/SetupModal.jsx b/src/components/SetupModal.jsx index bfb9fd1..7520e78 100644 --- a/src/components/SetupModal.jsx +++ b/src/components/SetupModal.jsx @@ -1,9 +1,10 @@ // Operator diagnostics panel. Opened by Alt+Shift+F or `window.MusallahBoard.openSetup()`. Sibling to DebugMenu.jsx (Alt+Shift+D) import { useState, useEffect } from 'react'; import { - saveSetupConfig, getSetupConfig, isValidDeviceId, readBoardMode, + saveSetupConfig, getSetupConfig, isValidDeviceId, } from '../utils/cookies.js'; -import { getApiConfig } from '../api/index.js'; +import { getApiConfig, statusContent } from '../api/index.js'; +import { RUNTIME, APP_VERSION } from '../runtime.js'; function Row({ label, value }) { return ( @@ -14,22 +15,40 @@ function Row({ label, value }) { ); } -/** Offline-mode rows, from the agent's /api/local/status (may be null). */ -function OfflineRows({ local }) { - const b = local?.bundle; +/** Local-runtime rows, from the agent's /api/local/status (may be null). */ +function LocalRows({ local }) { + if (!local) return ; + const c = statusContent(local); + const sync = local.sync; let remaining = null; - if (local && b) { + if (c) { remaining = local.staleDays > 0 ? `0 — content ran out ${local.staleDays} day${local.staleDays === 1 ? '' : 's'} ago` : String(local.daysRemaining ?? '—'); } + // The version the agent installed, next to the one actually running: they + // differ for the moment between an app install and the reload it triggers, + // and a board stuck in that state is worth seeing. + const installed = local.app?.version; return ( <> - + + + {c && } + {c && } + + {sync?.lastError && } ); } @@ -61,7 +80,9 @@ export default function SetupModal({ onComplete, onCancel, status, localStatus } } const backend = getApiConfig().baseUrl || `${window.location.origin} (same-origin)`; - const mode = readBoardMode(); + const local = RUNTIME === 'local'; + // Local: the agent's word on identity, not the cookie (which it never sets). + const device = local ? localStatus?.deviceId : existing.deviceId; return (
@@ -70,16 +91,19 @@ export default function SetupModal({ onComplete, onCancel, status, localStatus }
UTM MSA · MusallahBoard kiosk
- - - - {mode === 'offline' && } + + + + + {local && } {status?.error && }
-
+ {/* The override writes the cookie, which only the hosted site reads. A + local board is whoever its agent says it is. */} + {!local &&
-
+
}
diff --git a/src/runtime.js b/src/runtime.js new file mode 100644 index 0000000..397b32d --- /dev/null +++ b/src/runtime.js @@ -0,0 +1,64 @@ +/** + * ===================================================== + * Runtime + * ===================================================== + * One build serves two very different hosts, and this + * module decides, once, which one loaded the page + * (agent/docs/architecture.md, section 15): + * + * local the device agent serves the page from the + * board's own disk at 127.0.0.1:8080. Payload, + * status and events are same-origin; the device + * id comes from /api/local/status; new content + * and new app releases are announced over SSE. + * + * hosted the Cloudflare deployment. Cross-origin + * backend (VITE_API_BASE_URL), device id from + * the cookie or `?deviceId=`, live refresh over + * the backend's WebSocket. + * + * Decided at module load and never again: the runtime is + * fixed for the life of the page, and a later + * history.replaceState() (resolveDeviceId strips its + * query param) must not flip it. Deliberately not a + * cookie either, so nothing a board once did can pin it + * to the wrong runtime after its kiosk URL changes. + * ===================================================== + */ + +/** Hosts the agent's local server answers on (loopback only). */ +const LOCAL_HOSTS = new Set(['127.0.0.1:8080', 'localhost:8080']); + +/** + * @param {{ host: string, search: string }} loc a Location, or anything shaped + * like one (the tests pass plain objects) + * @returns {'local'|'hosted'} + */ +export function detectRuntime(loc) { + try { + if (LOCAL_HOSTS.has(String(loc.host).toLowerCase())) return 'local'; + const q = new URLSearchParams(loc.search); + if ((q.get('runtime') || '').trim().toLowerCase() === 'local') return 'local'; + // v1 agents pointed the kiosk at `?mode=offline`. Such a board is by + // definition served by its agent, so it is the local runtime too. + if ((q.get('mode') || '').trim().toLowerCase() === 'offline') return 'local'; + } catch { /* fall through: a page we cannot inspect is the hosted site */ } + return 'hosted'; +} + +/** @type {'local'|'hosted'} */ +export const RUNTIME = + typeof window === 'undefined' ? 'hosted' : detectRuntime(window.location); + +/** True when the device agent serves this page. */ +export const isLocalRuntime = () => RUNTIME === 'local'; + +/** + * This build's own version, from package.json via Vite's `define` (see + * vite.config.js). Embedded rather than read from the agent so the hosted site + * can show it too, and so the diagnostics can tell "the agent installed 2.1.0" + * apart from "the page actually running is 2.1.0". + */ +/* global __MB_APP_VERSION__ */ +export const APP_VERSION = + typeof __MB_APP_VERSION__ === 'string' ? __MB_APP_VERSION__ : 'dev'; diff --git a/src/runtime.test.js b/src/runtime.test.js new file mode 100644 index 0000000..eec33d5 --- /dev/null +++ b/src/runtime.test.js @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { detectRuntime } from './runtime.js'; + +// The runtime decides where the board gets its identity and its content, so a +// hosted page mistaken for a local one would sit on "Waiting for content" and +// a local one mistaken for hosted would wait for an enrollment cookie forever. +describe('detectRuntime', () => { + const at = (host, search = '') => detectRuntime({ host, search }); + + it('is local when served by the agent', () => { + expect(at('127.0.0.1:8080')).toBe('local'); + expect(at('localhost:8080')).toBe('local'); + expect(at('LOCALHOST:8080')).toBe('local'); + }); + + it('is local when asked for, or for a v1 offline kiosk URL', () => { + expect(at('board.example.org', '?runtime=local')).toBe('local'); + expect(at('localhost:3000', '?deviceId=x&mode=offline')).toBe('local'); + }); + + it('is hosted otherwise', () => { + expect(at('musallahboard.example.workers.dev')).toBe('hosted'); + expect(at('localhost:3000')).toBe('hosted'); // vite dev server + expect(at('127.0.0.1:8081')).toBe('hosted'); + expect(at('board.example.org', '?mode=online')).toBe('hosted'); + expect(at('board.example.org', '?runtime=hosted')).toBe('hosted'); + }); +}); diff --git a/src/styles.css b/src/styles.css index 016f909..2773a5b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1464,6 +1464,19 @@ body.cursor-hidden, body.cursor-hidden * { cursor: none !important; } margin-top: 18px; font-size: 12px; color: oklch(0.72 0.13 30); word-break: break-word; text-transform: none; letter-spacing: 0.02em; } +.board-status .bs-hint { + margin-top: 28px; font-size: 15px; line-height: 1.6; + color: oklch(0.96 0.01 95); +} +/* Screens someone has to act on from across the room (waiting for content). + Same look as the loading screen, sized for distance rather than a glance + from up close. Not scaled with the stage: this sits outside it, so these are + real pixels on a 1080p panel. */ +.board-status.bs-large .bs-inner { max-width: 1280px; } +.board-status.bs-large h1 { font-size: 72px; margin-bottom: 28px; } +.board-status.bs-large p { font-size: 28px; letter-spacing: 0.12em; } +.board-status.bs-large .bs-err { margin-top: 24px; font-size: 24px; } +.board-status.bs-large .bs-hint { margin-top: 48px; font-size: 32px; } .setup-modal { position: fixed; inset: 0; z-index: 50; diff --git a/src/utils/cookies.js b/src/utils/cookies.js index 92bcdad..63a435f 100644 --- a/src/utils/cookies.js +++ b/src/utils/cookies.js @@ -2,6 +2,10 @@ * Cookie helpers + kiosk setup persistence. * The board identifies itself to the backend by `deviceId` * (replaces the old per-location `boardLocation`). + * + * The device id cookie is the hosted runtime's identity only. In the local + * runtime the agent says who the board is (/api/local/status), see runtime.js. + * `hideCursor` applies to both. */ export function setCookie(name, value, days = 365) { @@ -104,32 +108,6 @@ export function resolveDeviceId() { return isValidDeviceId(stored) ? stored.trim() : null; } -let boardMode = null; - -/** - * 'offline' when the page was opened with `?mode=offline`, else 'online'. - * - * The agent sets this in kiosk-url when it serves the board itself from - * 127.0.0.1 off an installed content bundle (agent/docs/offline.md): no refresh - * socket, plus /api/local/status for bundle freshness. Read once and - * remembered — the mode is fixed for the life of the page, and a later - * history.replaceState() must not flip it. Deliberately not a cookie: a board - * switched back to online gets a kiosk-url without the param and must not stay - * offline. - * @returns {'online'|'offline'} - */ -export function readBoardMode() { - if (boardMode === null) { - try { - const v = new URLSearchParams(window.location.search).get('mode'); - boardMode = v && v.trim().toLowerCase() === 'offline' ? 'offline' : 'online'; - } catch { - boardMode = 'online'; - } - } - return boardMode; -} - export function applyCursorPreference() { document.body.classList.toggle('cursor-hidden', getSetupConfig().hideCursor); } diff --git a/vite.config.js b/vite.config.js index 673d282..1e6c40e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs' import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' @@ -9,8 +10,18 @@ const BACKEND = process.env.VITE_DEV_BACKEND || 'http://localhost:8080' import { cloudflare } from "@cloudflare/vite-plugin"; +// Baked into the bundle as the build's own version (src/runtime.js). It is the +// same number scripts/package-mbu.mjs signs into the app package, so the +// diagnostics can show which build is really running, hosted or on a board. +const { version: APP_VERSION } = JSON.parse( + readFileSync(new URL('./package.json', import.meta.url), 'utf8') +) + export default defineConfig({ plugins: [cloudflare(), react()], + define: { + __MB_APP_VERSION__: JSON.stringify(APP_VERSION), + }, server: { host: '0.0.0.0', port: 3000, @@ -31,6 +42,6 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, - include: ['src/**/*.test.{js,jsx}'], + include: ['src/**/*.test.{js,jsx}', 'scripts/**/*.test.mjs'], }, }) From 4e7f74a6ff57a55ffabe3b317a3f9bbfef5a2ee7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:47:53 +0000 Subject: [PATCH 2/5] Make the board app local-only: drop the hosted runtime and Cloudflare deploy The backend's live payload endpoint is being retired, so the app now only ever runs served by the device agent at 127.0.0.1:8080. - Remove the local/hosted runtime split (src/runtime.js); APP_VERSION moves to src/version.js. - Remove the cookie / ?deviceId= identity flow, the unpaired "Waiting for device enrollment" screen, the SetupModal device id override, and the setDeviceId / getConfig / __refreshBoard hooks. The device id comes from /api/local/status only; hideCursor stays a cookie. - Remove the backend refresh WebSocket (src/api/refreshSocket.js) and the openapi-fetch client (src/api/client.js, VITE_API_BASE_URL). The payload is a plain same-origin fetch of the agent's /api/musallah/payload, typed with the generated MusallahBoardPayload schema component. - Remove the Cloudflare deployment: wrangler.jsonc, @cloudflare/vite-plugin, wrangler, the deploy script, and the packer's dist/client and .assetsignore / wrangler.json special cases. The build is plain dist/. - npm run dev proxies /api (including SSE) and /media to VITE_DEV_AGENT, default http://127.0.0.1:8080, for use with ssh -L to a real board. - README documents the dev workflow; PLAN.md is marked superseded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- .github/workflows/ci.yml | 7 +- .github/workflows/release.yml | 2 - .gitignore | 4 - PLAN.md | 2 + README.md | 18 +- package-lock.json | 1201 +++----------------------------- package.json | 6 +- scripts/package-mbu.mjs | 31 +- scripts/package-mbu.test.mjs | 4 +- src/App.jsx | 184 ++--- src/api/client.js | 113 --- src/api/index.js | 8 +- src/api/localEvents.js | 5 +- src/api/localStatus.js | 19 +- src/api/payloadService.js | 93 ++- src/api/payloadService.test.js | 40 ++ src/api/prayerService.js | 2 +- src/api/refreshSocket.js | 131 ---- src/components/SetupModal.jsx | 67 +- src/main.jsx | 15 - src/models/index.js | 2 +- src/runtime.js | 64 -- src/runtime.test.js | 28 - src/styles.css | 19 - src/utils/cookies.js | 99 +-- src/version.js | 9 + vite.config.js | 31 +- wrangler.jsonc | 15 - 28 files changed, 351 insertions(+), 1868 deletions(-) delete mode 100644 src/api/client.js create mode 100644 src/api/payloadService.test.js delete mode 100644 src/api/refreshSocket.js delete mode 100644 src/runtime.js delete mode 100644 src/runtime.test.js create mode 100644 src/version.js delete mode 100644 wrangler.jsonc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 472e20e..d7abdc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,9 @@ name: CI -# src/api is generated from LensBridgeBackend/openapi.yaml. The kiosk reads one -# endpoint, but it runs unattended on a wall -- a silently changed payload shape -# shows up as a blank board, so the contract is checked here instead. +# src/api/schema.d.ts is generated from LensBridgeBackend/openapi.yaml. The kiosk +# reads one payload shape (MusallahBoardPayload, served by its agent), but it +# runs unattended on a wall -- a silently changed payload shape shows up as a +# blank board, so the contract is checked here instead. on: push: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14d705d..96357be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,8 +7,6 @@ name: Release # releases/latest/download/app-channel.json, so publishing the release is what # rolls the fleet forward; the package signature, not this workflow, is what # makes that safe. -# -# The Cloudflare deployment is unaffected: it builds from the branch as before. on: push: diff --git a/.gitignore b/.gitignore index 23ecf0c..00466d7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,10 +9,6 @@ logs/* npm-debug.log* new/* -# wrangler files -.wrangler -.dev.vars* -!.dev.vars.example !.env.example # design-sync: staged converter scripts, build output, and machine state. diff --git a/PLAN.md b/PLAN.md index dfa88c4..cc4d64e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,5 +1,7 @@ # MusallahBoard Production Plan +> Superseded: the board app is no longer hosted; it runs only served by the device agent (see README and agent/docs/architecture.md). + Path from the current PoC (hosted Vite site + `setup.sh`-hardened Pi kiosk) to a production fleet with per-device identity, remote control from the admin portal, granular per-board configuration, and offline resilience. diff --git a/README.md b/README.md index 394c139..4d65437 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,22 @@ This will install the agent and all of its dependencies, setup the configuration Every board runs this app from its own disk. The [device agent](https://github.com/LensBridge/agent) serves it at `http://127.0.0.1:8080/` together with the day's content, and the kiosk always loads that address, online or not. The network only changes how fresh the content is. The agent's `docs/architecture.md` is the contract for all of this. -The same build also runs as the hosted site on Cloudflare. It picks its runtime once at startup (`src/runtime.js`): +The page is same-origin with the agent: it fetches today's payload from `/api/musallah/payload`, learns its device id from `/api/local/status`, and `/api/local/events` tells it when new content (re-fetch in place) or a new app release (reload) is installed. With no content installed yet it shows a "Waiting for content" screen saying whether it is downloading or needs a USB stick or a laptop on its ethernet port. -- **local** when served from `127.0.0.1:8080` or `localhost:8080`, or with `?runtime=local` (or the older `?mode=offline`). The API is same-origin, the board learns its device id from `/api/local/status`, and `/api/local/events` tells it when new content (re-fetch in place) or a new app release (reload) is installed. With no content installed yet it shows a "Waiting for content" screen saying whether it is downloading or needs a USB stick or a laptop on its ethernet port. -- **hosted** everywhere else: device id from the `deviceId` cookie or `?deviceId=`, backend from `VITE_API_BASE_URL`, live refresh over the backend's WebSocket. +Alt+Shift+F shows diagnostics: app and agent versions, installed content and sync status. -Alt+Shift+F shows diagnostics: runtime, app and agent versions, installed content and sync status. +### Developing + +`npm run dev` serves the app on port 3000 and proxies `/api` (including the `/api/local/events` stream) and `/media` to a device agent, `http://127.0.0.1:8080` by default. To develop against a real board's content, forward its agent port first: + +```bash +ssh -L 8080:127.0.0.1:8080 +npm run dev +``` + +Set `VITE_DEV_AGENT` to proxy to an agent somewhere else instead (`VITE_DEV_AGENT=http://127.0.0.1:18080 npm run dev`). + +`npm test` runs the unit tests and `npm run typecheck` checks the API layer. The payload types in `src/api/schema.d.ts` are generated from the backend's `openapi.yaml` (`MusallahBoardPayload`); regenerate them with `npm run api:generate`, with LensBridgeBackend checked out beside this repository or `OPENAPI_SPEC` pointing at the spec. ### Releasing the board app diff --git a/package-lock.json b/package-lock.json index 3f457ff..1be83ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,13 +12,11 @@ "@fontsource/cinzel": "^5.3.0", "@fontsource/montserrat": "^5.3.0", "adhan": "^4.4.6", - "openapi-fetch": "^0.17.0", "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { - "@cloudflare/vite-plugin": "^1.41.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@vitejs/plugin-react": "^5.2.0", @@ -26,8 +24,7 @@ "openapi-typescript": "^7.13.0", "typescript": "^5.9.3", "vite": "^8.2.0", - "vitest": "^4.1.10", - "wrangler": "^4.101.0" + "vitest": "^4.1.10" } }, "node_modules/@asamuzakjp/css-color": { @@ -112,7 +109,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -387,163 +383,6 @@ "specificity": "bin/cli.js" } }, - "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", - "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", - "dev": true, - "license": "MIT OR Apache-2.0", - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@cloudflare/unenv-preset": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", - "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", - "dev": true, - "license": "MIT OR Apache-2.0", - "peerDependencies": { - "unenv": "2.0.0-rc.24", - "workerd": ">1.20260305.0 <2.0.0-0" - }, - "peerDependenciesMeta": { - "workerd": { - "optional": true - } - } - }, - "node_modules/@cloudflare/vite-plugin": { - "version": "1.54.11", - "resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.54.11.tgz", - "integrity": "sha512-TJoTX1MvKXijrLVciUc66xR0Ss199au/9uiTP4H0QDNrUYpTHUU5V+X3opt0IU9f41MXStuy0oPkrZqGM0LRww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cloudflare/unenv-preset": "2.16.1", - "miniflare": "5.20260916.0-alpha", - "unenv": "2.0.0-rc.24", - "workerd": "1.20260916.1", - "wrangler": "4.133.0", - "ws": "8.21.0" - }, - "bin": { - "cf-vite": "bin/cf-vite" - }, - "peerDependencies": { - "vite": "^6.1.0 || ^7.0.0 || ^8.0.0", - "wrangler": "^4.133.0" - } - }, - "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260916.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260916.1.tgz", - "integrity": "sha512-h/xA7foQncooOpO3HF4IaCR/8NAy1qN5SjL0+nMTT8FgJyYE/SAGiLgSCusXc15yh7upibaYFrjHK5928nFIEQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260916.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260916.1.tgz", - "integrity": "sha512-triQBeZCJkCAbugMK/2/K3sqY1neUPXMDbWtoGqpKmzWXi4C2VW2Z+VSobQwJCHluP9wu0qP3O8G0+YNmsJCEg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260916.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260916.1.tgz", - "integrity": "sha512-a4gRHOI2Iv9wRbFkSGqcDFyfgo4pthTKeBUkdKhL7fxyNUghPNs9rHw+d2Q9aZyeOW2TDa2FI7ELg09eDJOPxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260916.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260916.1.tgz", - "integrity": "sha512-qWIZZtd1ATaRI53hKBTxpLKGQJcwOwpPsqfIUaRv8ouq2yJkzJxlG9hReacOjpSh1br3edPxysNa5KBOkqpn9Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260916.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260916.1.tgz", - "integrity": "sha512-Fa14o+wq2QI4/Lsez609sNmoS9dRliSZwgmDdLLG0wZzmf3s7n0Xs2ZDAvaO2iZgH8WEk+hBfHuOeWM0P7LF0Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@csstools/color-helpers": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", @@ -632,7 +471,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -681,22 +519,10 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } }, - "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -710,6 +536,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -727,6 +554,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -744,6 +572,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -761,6 +590,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -778,6 +608,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -795,6 +626,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -812,6 +644,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -829,6 +662,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -846,6 +680,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -863,6 +698,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -880,6 +716,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -897,6 +734,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -914,6 +752,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -931,6 +770,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -948,6 +788,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -965,6 +806,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -982,6 +824,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -999,6 +842,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1016,6 +860,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1033,6 +878,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1050,648 +896,126 @@ "os": [ "openbsd" ], + "peer": true, "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@fontsource/amiri": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource/amiri/-/amiri-5.3.0.tgz", - "integrity": "sha512-i4SWJnVIphj8556EdVzNrEG/Qu82k6JXjXTtSUIohx3tHmo2GHw1Kv60TWo+xjk7Yf1Etb2Gskh0lUFclTKyUw==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@fontsource/cinzel": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource/cinzel/-/cinzel-5.3.0.tgz", - "integrity": "sha512-KEOYTsrRppW0uRIPWBDPrQgunCS6f8u4nzZxYRBfo9SCknKojLYjK7B0ZoVmJhyVLefz271ZQRF9EOpHZlfhRw==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@fontsource/montserrat": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.3.0.tgz", - "integrity": "sha512-iQPDtZOnmM5ih4JBGaMSXisRap0ixb9bAUw2hDtY7EjVfaqiiNlKtVuJl9XclxUwtwMMHNcWXfQR6zI9x05+Tg==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.4" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + "node": ">=18" } }, - "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.11.3" - }, + "os": [ + "openharmony" + ], + "peer": true, "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ - "wasm32" + "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, - "dependencies": { - "@img/sharp-wasm32": "0.35.4" - }, + "os": [ + "sunos" + ], + "peer": true, "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { - "node": "^20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", - "cpu": [ - "x64" - ], + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", "engines": { - "node": ">=20.9.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@fontsource/amiri": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/amiri/-/amiri-5.3.0.tgz", + "integrity": "sha512-i4SWJnVIphj8556EdVzNrEG/Qu82k6JXjXTtSUIohx3tHmo2GHw1Kv60TWo+xjk7Yf1Etb2Gskh0lUFclTKyUw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/cinzel": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/cinzel/-/cinzel-5.3.0.tgz", + "integrity": "sha512-KEOYTsrRppW0uRIPWBDPrQgunCS6f8u4nzZxYRBfo9SCknKojLYjK7B0ZoVmJhyVLefz271ZQRF9EOpHZlfhRw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/montserrat": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.3.0.tgz", + "integrity": "sha512-iQPDtZOnmM5ih4JBGaMSXisRap0ixb9bAUw2hDtY7EjVfaqiiNlKtVuJl9XclxUwtwMMHNcWXfQR6zI9x05+Tg==", + "license": "OFL-1.1", "funding": { - "url": "https://opencollective.com/libvips" + "url": "https://github.com/sponsors/ayuhito" } }, "node_modules/@jridgewell/gen-mapping": { @@ -1754,35 +1078,6 @@ "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@poppinss/colors": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", - "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^4.1.5" - } - }, - "node_modules/@poppinss/dumper": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", - "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/colors": "^4.1.5", - "@sindresorhus/is": "^7.0.2", - "supports-color": "^10.0.0" - } - }, - "node_modules/@poppinss/exception": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", - "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", - "dev": true, - "license": "MIT" - }, "node_modules/@redocly/ajv": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", @@ -2074,26 +1369,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@sindresorhus/is": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", - "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@speed-highlight/core": { - "version": "1.2.24", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", - "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", - "dev": true, - "license": "CC0-1.0" - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2107,7 +1382,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2467,13 +1741,6 @@ "require-from-string": "^2.0.2" } }, - "node_modules/blake3-wasm": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", - "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", - "dev": true, - "license": "MIT" - }, "node_modules/brace-expansion": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", @@ -2504,7 +1771,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", @@ -2571,20 +1837,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -2685,16 +1937,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/error-stack-parser-es": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", - "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/es-module-lexer": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", @@ -2709,6 +1951,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "optional": true, "peer": true, "bin": { "esbuild": "bin/esbuild" @@ -2758,6 +2001,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -3012,16 +2256,6 @@ "node": ">=6" } }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -3332,24 +2566,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/miniflare": { - "version": "5.20260916.0-alpha", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260916.0-alpha.tgz", - "integrity": "sha512-rffIUYE5tPwhvUtVYZ4LEGaQ2XkXdBsq/flJOKODumR3gbBgHFyqPkobQEB7Ocqesz5wSUhpV8C2R57JYOsyhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "0.8.1", - "sharp": "0.35.4", - "undici": "7.29.0", - "workerd": "1.20260916.1", - "ws": "8.21.0", - "youch": "4.1.0-beta.10" - }, - "engines": { - "node": ">=22.0.0" - } - }, "node_modules/minimatch": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", @@ -3413,15 +2629,6 @@ "node": ">=12.20.0" } }, - "node_modules/openapi-fetch": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", - "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", - "license": "MIT", - "dependencies": { - "openapi-typescript-helpers": "^0.1.0" - } - }, "node_modules/openapi-typescript": { "version": "7.13.0", "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", @@ -3443,12 +2650,6 @@ "typescript": "^5.x" } }, - "node_modules/openapi-typescript-helpers": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", - "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", - "license": "MIT" - }, "node_modules/parse-json": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", @@ -3480,13 +2681,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, - "license": "MIT" - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3507,7 +2701,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3593,7 +2786,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3606,7 +2798,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -3714,69 +2905,6 @@ "semver": "bin/semver.js" } }, - "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.1.0", - "detect-libc": "^2.1.2", - "semver": "^7.8.5" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/sharp/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/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3918,14 +3046,6 @@ "node": ">=20" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -3945,7 +3065,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3964,17 +3083,6 @@ "node": ">=20.18.1" } }, - "node_modules/unenv": { - "version": "2.0.0-rc.24", - "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", - "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "pathe": "^2.0.3" - } - }, "node_modules/update-browserslist-db": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.0.tgz", @@ -4019,7 +3127,6 @@ "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", @@ -4247,85 +3354,6 @@ "node": ">=8" } }, - "node_modules/workerd": { - "version": "1.20260916.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260916.1.tgz", - "integrity": "sha512-yGPvK1Tg6l80QNp7uIs3fAlWBmw+ftgn3QK4uqH3bHoS+PeJNARjm6HoWGmkYl1zaqFO0iR31sdwnFKHoHOhUQ==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "bin": { - "workerd": "bin/workerd" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260916.1", - "@cloudflare/workerd-darwin-arm64": "1.20260916.1", - "@cloudflare/workerd-linux-64": "1.20260916.1", - "@cloudflare/workerd-linux-arm64": "1.20260916.1", - "@cloudflare/workerd-windows-64": "1.20260916.1" - } - }, - "node_modules/wrangler": { - "version": "4.133.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.133.0.tgz", - "integrity": "sha512-Iv+feMJ3CzBvWcJM1OjtRL/0ujr1Oqmg6H/356hJrnqf8AEkazhUVqjgcRer5HGhuc9KhrsyxUpKTRMnMeUmeA==", - "dev": true, - "license": "MIT OR Apache-2.0", - "dependencies": { - "@cloudflare/kv-asset-handler": "0.5.0", - "@cloudflare/unenv-preset": "2.16.1", - "blake3-wasm": "2.1.5", - "esbuild": "0.28.1", - "miniflare": "5.20260916.0-alpha", - "path-to-regexp": "6.3.0", - "unenv": "2.0.0-rc.24", - "workerd": "1.20260916.1" - }, - "bin": { - "cf-wrangler": "bin/cf-wrangler.js", - "wrangler": "bin/wrangler.js", - "wrangler2": "bin/wrangler.js" - }, - "engines": { - "node": ">=22.0.0" - }, - "optionalDependencies": { - "fsevents": "2.3.3" - }, - "peerDependencies": { - "@cloudflare/workers-types": "^5.20260916.1" - }, - "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - } - } - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -4366,31 +3394,6 @@ "engines": { "node": ">=12" } - }, - "node_modules/youch": { - "version": "4.1.0-beta.10", - "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", - "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/colors": "^4.1.5", - "@poppinss/dumper": "^0.6.4", - "@speed-highlight/core": "^1.2.7", - "cookie": "^1.0.2", - "youch-core": "^0.3.3" - } - }, - "node_modules/youch-core": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", - "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/exception": "^1.2.2", - "error-stack-parser-es": "^1.0.5" - } } } } diff --git a/package.json b/package.json index 80ac005..bb35275 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ "dev": "vite --host", "build": "npm run typecheck && vite build", "preview": "vite preview --host", - "deploy": "npm run build && wrangler deploy", "package": "npm run build && node scripts/package-mbu.mjs", "api:generate": "node scripts/generate-api.mjs", "test": "vitest run", @@ -19,13 +18,11 @@ "@fontsource/cinzel": "^5.3.0", "@fontsource/montserrat": "^5.3.0", "adhan": "^4.4.6", - "openapi-fetch": "^0.17.0", "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { - "@cloudflare/vite-plugin": "^1.41.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@vitejs/plugin-react": "^5.2.0", @@ -33,7 +30,6 @@ "openapi-typescript": "^7.13.0", "typescript": "^5.9.3", "vite": "^8.2.0", - "vitest": "^4.1.10", - "wrangler": "^4.101.0" + "vitest": "^4.1.10" } } diff --git a/scripts/package-mbu.mjs b/scripts/package-mbu.mjs index 4295c6a..a6d5d83 100644 --- a/scripts/package-mbu.mjs +++ b/scripts/package-mbu.mjs @@ -28,8 +28,7 @@ * release for v. * * Options: - * --dir build output (default: dist/client - * if it holds index.html, else dist) + * --dir build output (default: dist) * --out-dir default: release * --unsigned write without mbu.sig * ===================================================== @@ -62,16 +61,6 @@ const MAX_TOTAL_FILE_BYTES = 1024 * 1024 * 1024; // sum of files[].bytes const MAX_PACKAGE_BYTES = 512 * 1024 * 1024; const MAX_MANIFEST_BYTES = 1024 * 1024; -/** - * Files the Cloudflare Vite plugin writes into the build output for - * `wrangler deploy`. They describe the hosted deployment, not the app: a board - * has no use for them, `wrangler.json` embeds absolute paths from the build - * machine, and `.assetsignore` is a hidden name the entry rules reject. Left - * out by name, at the root only; any other file that breaks the rules still - * fails the build. - */ -const HOSTING_ONLY = new Set(['.assetsignore', 'wrangler.json']); - // --------------------------------------------------------------------------- // Entry names // --------------------------------------------------------------------------- @@ -100,21 +89,9 @@ export function checkEntryName(name) { // Build output // --------------------------------------------------------------------------- -/** - * Where the build is. The Cloudflare plugin writes a static-assets-only project - * straight to dist/, and moves the assets to dist/client/ once the project - * gains a Worker; take whichever holds the SPA. - * @param {string} root repository root - */ -export function findBuildDir(root) { - const client = join(root, 'dist', 'client'); - if (existsSync(join(client, 'index.html'))) return client; - return join(root, 'dist'); -} - /** * Every regular file under `dir`, as sorted slash-separated paths relative to - * it, minus HOSTING_ONLY at the root. Symlinks and other special files are an + * it. Symlinks and other special files are an * error: the package may only carry regular files, and following a link could * pull in something from outside the build. * @param {string} dir @@ -127,7 +104,7 @@ export function listBuildFiles(dir) { const full = join(d, ent.name); const rel = relative(dir, full).split(sep).join('/'); if (ent.isDirectory()) walk(full); - else if (ent.isFile()) { if (!HOSTING_ONLY.has(rel)) out.push(rel); } + else if (ent.isFile()) out.push(rel); else throw new Error(`${rel}: not a regular file (symlink or special file)`); } }; @@ -379,7 +356,7 @@ function main() { ); } - const dir = opts.dir ? resolve(opts.dir) : findBuildDir(root); + const dir = resolve(opts.dir ?? join(root, 'dist')); if (!existsSync(join(dir, 'index.html')) || !lstatSync(dir).isDirectory()) { throw new Error(`${dir} does not hold a build (no index.html). Run vite build first.`); } diff --git a/scripts/package-mbu.test.mjs b/scripts/package-mbu.test.mjs index b191d1c..6b1af9b 100644 --- a/scripts/package-mbu.test.mjs +++ b/scripts/package-mbu.test.mjs @@ -189,14 +189,12 @@ describe('listBuildFiles', () => { let dir; afterEach(() => { if (dir) rmSync(dir, { recursive: true, force: true }); }); - it('lists regular files sorted, without the Cloudflare deploy files', () => { + it('lists regular files sorted', () => { dir = mkdtempSync(join(tmpdir(), 'mbu-build-')); mkdirSync(join(dir, 'assets')); writeFileSync(join(dir, 'index.html'), 'x'); writeFileSync(join(dir, 'assets', 'b.js'), 'x'); writeFileSync(join(dir, 'assets', 'a.css'), 'x'); - writeFileSync(join(dir, 'wrangler.json'), '{}'); - writeFileSync(join(dir, '.assetsignore'), 'wrangler.json'); expect(listBuildFiles(dir)).toEqual(['assets/a.css', 'assets/b.js', 'index.html']); }); diff --git a/src/App.jsx b/src/App.jsx index a3a9d4d..a9d776f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,10 +1,11 @@ // MusallahBoard — root orchestrator. -// Setup gate → fetch payload (weather rides along) + prayer → compose the -// design data shape → drive the slideshow built from the frame-builder registry. +// Served by the device agent at 127.0.0.1:8080: fetch the payload (weather +// rides along) + prayer → compose the design data shape → drive the slideshow +// built from the frame-builder registry. import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { - getBoardPayload, getPrayerData, connectRefreshSocket, getLocalStatus, - connectLocalEvents, isNoContentError, statusContent, contentCreatedAt, + getBoardPayload, getPrayerData, getLocalStatus, connectLocalEvents, + isNoContentError, statusContent, contentCreatedAt, isValidDeviceId, } from './api/index.js'; import { prefetchPayloadImages } from './utils/prefetch.js'; import { @@ -14,11 +15,8 @@ import { classifyPrayers } from './utils/prayers.js'; import { buildSlideshow } from './frames/registry.js'; import { resolveTheme, DEFAULT_THEME } from './themes/registry.js'; import './frames/builders.jsx'; // registers default builders -import { - getSetupConfig, applyCursorPreference, isValidDeviceId, - setDeviceId as saveDeviceId, -} from './utils/cookies.js'; -import { RUNTIME, APP_VERSION } from './runtime.js'; +import { applyCursorPreference } from './utils/cookies.js'; +import { APP_VERSION } from './version.js'; import SetupModal from './components/SetupModal.jsx'; import DebugMenu from './components/DebugMenu.jsx'; import TopBar from './components/TopBar.jsx'; @@ -28,11 +26,6 @@ import Ticker from './components/Ticker.jsx'; const PAYLOAD_REFRESH_MS = 10 * 60 * 1000; const LOCAL_STATUS_REFRESH_MS = 60 * 1000; -// 'local' when the device agent serves this page from the board's own disk, -// 'hosted' on the Cloudflare site. Fixed for the life of the page; see -// runtime.js and agent/docs/architecture.md, section 15. -const LOCAL = RUNTIME === 'local'; - // Presentation overrides driven by the debug drawer (Alt+Shift+D). `null` on a // tri-state field means "don't override, use the board's own logic". Held in // state only — see DebugMenu.jsx for why none of this is persisted. @@ -74,7 +67,7 @@ function Status({ title, detail, error, hint, large = false }) { const SERVICE_PORT_URL = 'http://10.77.0.1/'; /** - * Local runtime, agent up, but no content package installed yet. What an + * Agent up, but no content package installed yet. What an * operator can do about it depends on whether the agent is trying to sync: * * - sync on and no error yet: it is downloading; nothing to do but wait. @@ -151,27 +144,22 @@ function gregorian(now, timezone) { } export default function App() { - // Hosted: identity is settled by resolveDeviceId() in main.jsx before the - // first render, so the cookie is already authoritative here: no - // reconciliation effect, and no unpaired flash on a board the agent - // provisioned. Local: the agent says who we are in /api/local/status, and - // until that answers the id is simply unknown (never "unpaired"). - const [deviceId, setDeviceIdState] = useState(() => - LOCAL ? null : getSetupConfig().deviceId - ); + // The agent says who we are in /api/local/status. Until that answers the id + // is simply unknown; it is for diagnostics only, since the agent serves just + // its own board and the payload is fetched without it. + const [deviceId, setDeviceId] = useState(null); const [payload, setPayload] = useState(null); const [prayerInfo, setPrayerInfo] = useState(null); // { prayers, hijri } const [error, setError] = useState(null); - // Local runtime: the last payload fetch said the agent has no content yet. + // The last payload fetch said the agent has no content yet. const [noContent, setNoContent] = useState(false); const [lastPayloadAt, setLastPayloadAt] = useState(null); - // Local runtime only: the agent's /api/local/status, refreshed with the - // payload and every minute (see applyLocalStatus below). + // The agent's /api/local/status, refreshed with the payload and every + // minute (see applyLocalStatus below). const [localStatus, setLocalStatus] = useState(null); const [realNow, setRealNow] = useState(new Date()); const [slideIdx, setSlideIdx] = useState(0); - // Setup modal force-opened by the operator hotkey. It is a diagnostics - // panel now — never a gate. An unpaired board waits, it does not prompt. + // Diagnostics panel, opened by the operator hotkey. Never a gate. const [setupOpen, setSetupOpen] = useState(false); const [debugOpen, setDebugOpen] = useState(false); const [debug, setDebug] = useState(NO_DEBUG); @@ -185,11 +173,6 @@ export default function App() { [realNow, debug.timeOffsetMs] ); - // Only the hosted site can be unpaired. A local board's kiosk points at the - // agent only once it is enrolled (architecture doc, section 14), and the id - // arrives with the status; a missing cookie there means nothing. - const needsSetup = !LOCAL && !isValidDeviceId(deviceId); - // Force the cursor visible while either operator panel is open, otherwise // an operator on a touchscreen-less board can't aim at its own fields. Both // panels share one effect: as two, closing either would re-hide the cursor @@ -197,20 +180,7 @@ export default function App() { useEffect(() => { if (setupOpen || debugOpen) document.body.classList.remove('cursor-hidden'); else applyCursorPreference(); - }, [needsSetup, setupOpen, debugOpen]); - - // While unpaired, watch for the cookie appearing. The agent's normal path is - // to rewrite /etc/musallahboard/kiosk-url and let the systemd .path watcher - // bounce the kiosk, but it can also provision in-place over CDP — this is - // what makes that land without a reload. - useEffect(() => { - if (!needsSetup) return; - const id = setInterval(() => { - const current = getSetupConfig().deviceId; - if (isValidDeviceId(current)) setDeviceIdState(current); - }, 2000); - return () => clearInterval(id); - }, [needsSetup]); + }, [setupOpen, debugOpen]); // Operator hotkeys: Alt+Shift+F opens setup, Alt+Shift+D the debug drawer. // Matched on e.code too because Alt+Shift on some layouts yields a dead key @@ -236,24 +206,15 @@ export default function App() { return () => clearInterval(id); }, []); - // Reads identity from the cookie at call time rather than from the closure: - // the agent provisions and refreshes in a single evaluated expression, so - // setDeviceId() and refresh() run in the same tick — before React has - // re-rendered with the new id. Closing over `deviceId` would refetch with - // the old one (or refuse as unpaired) on exactly the call that matters. - // - // Local: no id is needed at all. The agent serves only its own board, so the - // payload is fetched without one, and the id for diagnostics comes with the - // status. + // No device id is needed: the agent serves only its own board. The result + // is what window.MusallahBoard.refresh() resolves to for the agent. const loadPayload = useCallback(async () => { - const id = LOCAL ? null : getSetupConfig().deviceId; - if (!LOCAL && !isValidDeviceId(id)) return { ok: false, reason: 'unpaired' }; // Alongside the payload, not after it: when the agent has no content the // payload 503s, and that is exactly when the waiting screen and the // diagnostics need the status. - if (LOCAL) getLocalStatus().then((s) => applyLocalStatusRef.current(s)); + getLocalStatus().then((s) => applyLocalStatusRef.current(s)); try { - const p = await getBoardPayload(id, { local: LOCAL }); + const p = await getBoardPayload(); prefetchPayloadImages(p); setPayload(p); setError(null); @@ -266,19 +227,17 @@ export default function App() { } catch (e) { console.error('prayer computation failed', e); } - return { ok: true, deviceId: id, at: new Date().toISOString() }; + return { ok: true, at: new Date().toISOString() }; } catch (e) { // Not an error on a board that has simply never been given content: it // gets its own screen instead of "Reconnecting…". - const empty = LOCAL && isNoContentError(e); + const empty = isNoContentError(e); if (!empty) console.error('payload fetch failed', e); setNoContent(empty); setError(e?.message || 'Unknown error'); - return { ok: false, deviceId: id, error: e?.message || 'Unknown error' }; + return { ok: false, error: e?.message || 'Unknown error' }; } - // deviceId is not read here, but a change to it must restart the polling - // effect below so a freshly-paired board fetches immediately. - }, [deviceId]); + }, []); useEffect(() => { loadPayload(); @@ -287,7 +246,7 @@ export default function App() { }, [loadPayload]); // --------------------------------------------------------------------- - // Local runtime: status. Polled every minute (and with every payload load) + // Agent status. Polled every minute (and with every payload load) // so the waiting screen, the staleness note and the diagnostics stay current // without depending on the event stream. It is also the backstop for events // missed while the stream was down: @@ -308,7 +267,7 @@ export default function App() { const applyLocalStatus = (s, { fromPoll = false } = {}) => { setLocalStatus(s); if (!s) return; - if (isValidDeviceId(s.deviceId)) setDeviceIdState(s.deviceId.trim()); + if (isValidDeviceId(s.deviceId)) setDeviceId(s.deviceId.trim()); const appVersion = s.app?.version ?? null; if (seenAppVersion.current === undefined) seenAppVersion.current = appVersion; else if (appVersion && appVersion !== seenAppVersion.current) { @@ -321,7 +280,6 @@ export default function App() { useEffect(() => { applyLocalStatusRef.current = applyLocalStatus; }); useEffect(() => { - if (!LOCAL) return undefined; const id = setInterval( () => getLocalStatus().then((s) => applyLocalStatusRef.current(s, { fromPoll: true })), LOCAL_STATUS_REFRESH_MS @@ -390,15 +348,14 @@ export default function App() { // --------------------------------------------------------------------- // Agent-facing surface. // - // The device agent drives this page over the DevTools Protocol. Two paths - // provision a board, both implemented: - // • navigate to ?deviceId= (kiosk-url + .path watcher) - // • Runtime.evaluate: window.MusallahBoard.setDeviceId('') + // The device agent drives this page over the DevTools Protocol: its + // config.refresh command calls window.MusallahBoard.refresh(), and its + // telemetry reads getStatus(). // // The globals must always see current values, but they are registered once // so the agent never races a re-render and finds them missing. Hence the // latest-value refs: registering them in an effect that closed over the - // first render would refetch with a stale deviceId forever. + // first render would report stale state forever. // --------------------------------------------------------------------- const refreshRef = useRef(loadPayload); const statusRef = useRef(null); @@ -407,12 +364,8 @@ export default function App() { useEffect(() => { statusRef.current = () => ({ deviceId: deviceId ?? null, - paired: LOCAL ? isValidDeviceId(deviceId) : !needsSetup, - runtime: RUNTIME, + paired: isValidDeviceId(deviceId), appVersion: APP_VERSION, - // v1 names, kept for agent builds that still read them. - mode: LOCAL ? 'offline' : 'online', - bundle: statusContent(localStatus), content: statusContent(localStatus), noContent, slideKey: slides[slideIdx]?.key ?? null, @@ -425,17 +378,6 @@ export default function App() { useEffect(() => { window.MusallahBoard = { - /** Provision (or re-point) this board. Rejects anything but a UUID. */ - setDeviceId(id, { reload = true } = {}) { - const next = String(id ?? '').trim(); - if (!isValidDeviceId(next)) { - throw new Error(`setDeviceId: not a UUID: ${JSON.stringify(id)}`); - } - saveDeviceId(next); - if (reload) window.location.reload(); - else setDeviceIdState(next); - return next; - }, /** Re-fetch the payload and repaint in place. No reload. */ refresh: () => refreshRef.current(), /** What this board thinks it is and what is currently on screen. */ @@ -447,32 +389,15 @@ export default function App() { /** Apply presentation overrides without the drawer. Partial patch. */ setDebug: (patch) => setDebug((d) => ({ ...d, ...(patch || {}) })), resetDebug: () => setDebug(NO_DEBUG), - getConfig: () => getSetupConfig(), - }; - // Alias for agent builds that predate MusallahBoard.refresh(). - window.__refreshBoard = () => window.MusallahBoard.refresh(); - return () => { - delete window.MusallahBoard; - delete window.__refreshBoard; }; + return () => { delete window.MusallahBoard; }; }, []); - // Hosted: live content push. Only opened once this board is enrolled: the - // channel carries enrolled devices only, and the backend closes anything that - // cannot name a known device. The cookie poll above re-runs this on - // enrollment. Goes through the ref so it always calls the current loader. - // - // Local: the agent listens on that channel itself and turns it into content - // syncs. The page only hears the outcome, from the agent's event stream: new - // content re-fetches in place, a new app release reloads into it. - useEffect(() => { - if (LOCAL || !isValidDeviceId(deviceId)) return undefined; - return connectRefreshSocket(deviceId, () => refreshRef.current()); - }, [deviceId]); - // Opened once, not per device id: the id arrives with the first status, and - // re-opening the stream then would only drop and redo the connection. + // The agent listens on the backend's refresh channel itself and turns it + // into content syncs. The page only hears the outcome, from the agent's event + // stream: new content re-fetches in place, a new app release reloads into it. + // Goes through the ref so it always calls the current loader. useEffect(() => { - if (!LOCAL) return undefined; return connectLocalEvents({ onContent: () => refreshRef.current(), onApp: () => window.location.reload(), @@ -499,35 +424,12 @@ export default function App() { /> ); - // Unpaired: wait, never prompt. This is a wall-mounted screen with no - // keyboard and a hidden cursor — a form here is a dead end. The agent - // provisions us on its own schedule and the poll above picks it up. - if (needsSetup) { - return ( - <> - - {renderDebug()} - {setupOpen && ( - setSetupOpen(false)} - onComplete={(id) => { setSetupOpen(false); setDeviceIdState(id); }} - /> - )} - - ); - } - - // Loading / error screen — still allow the operator hotkey to summon setup - // so a mis-provisioned board can be fixed on the spot. + // Loading / error screen: the operator hotkeys still work, so a stuck board + // can be diagnosed on the spot. if (!data || !slides.length) { return ( <> - {LOCAL && noContent ? ( + {noContent ? ( ) : ( setSetupOpen(false)} - onComplete={(id) => { setSetupOpen(false); setDeviceIdState(id); }} + onClose={() => setSetupOpen(false)} /> )} @@ -617,8 +518,7 @@ export default function App() { setSetupOpen(false)} - onComplete={(id) => { setSetupOpen(false); setDeviceIdState(id); }} + onClose={() => setSetupOpen(false)} /> )} diff --git a/src/api/client.js b/src/api/client.js deleted file mode 100644 index 4c79700..0000000 --- a/src/api/client.js +++ /dev/null @@ -1,113 +0,0 @@ -/** - * ===================================================== - * API Client - * ===================================================== - * Typed client for the LensBridge backend. - * - * Paths, parameters and response shapes come from - * src/api/schema.d.ts, generated from - * LensBridgeBackend/openapi.yaml. Regenerate with - * `npm run api:generate`; never edit the schema by hand. - * - * What OpenAPI does not provide, and so is kept here: - * per-request timeouts, and ApiError carrying the HTTP - * status so payloadService can decide what to retry. - * ===================================================== - */ - -import createClient from 'openapi-fetch'; - -/** - * @typedef {Object} ApiClientConfig - * @property {string} baseUrl - Base URL prefixed to every request path - * @property {number} timeout - Request timeout in ms - * @property {Record} headers - Default headers - */ - -/** @type {ApiClientConfig} */ -const defaultConfig = { - // Empty => same-origin (dev proxy / reverse proxy in prod). Overridden by - // VITE_API_BASE_URL when the kiosk talks to the backend cross-origin. - baseUrl: import.meta.env.VITE_API_BASE_URL ?? '', - timeout: 30000, - headers: { 'Content-Type': 'application/json' }, -}; - -let globalConfig = { ...defaultConfig }; - -/** Custom API error carrying the HTTP status. */ -export class ApiError extends Error { - /** @param {string} message @param {number} status @param {any} [data] */ - constructor(message, status, data = null) { - super(message); - this.name = 'ApiError'; - this.status = status; - this.data = data; - } -} - -/** - * Applies the configured timeout to every request. - * - * A kiosk left on a dead network will otherwise hang on a fetch forever and - * never reach the retry logic in payloadService. - * @param {Request} request - */ -async function timeoutFetch(request) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), globalConfig.timeout); - try { - return await fetch(request, { signal: controller.signal }); - } catch (error) { - if (error?.name === 'AbortError') throw new ApiError('Request timeout', 408); - throw new ApiError(error?.message || 'Network error', 0); - } finally { - clearTimeout(timeoutId); - } -} - -/** @type {ReturnType>} */ -let client = createClient({ baseUrl: globalConfig.baseUrl, fetch: timeoutFetch }); - -/** @param {Partial} config */ -export function configureApiClient(config) { - globalConfig = { - ...globalConfig, - ...config, - headers: { ...globalConfig.headers, ...config.headers }, - }; - // baseUrl is fixed at construction, so rebuild when it changes. - client = createClient({ baseUrl: globalConfig.baseUrl, fetch: timeoutFetch }); -} - -/** @returns {ApiClientConfig} */ -export function getApiConfig() { - return { ...globalConfig }; -} - -/** The generated, type-checked client. */ -export function getClient() { - return client; -} - -/** - * Turn an openapi-fetch result into a value or an ApiError. - * - * openapi-fetch reports failures as a value; the retry logic in payloadService - * is built around exceptions carrying a status, so convert here rather than - * reshaping every caller. - * - * @template T - * @param {{ data?: T, error?: any, response: Response }} result - * @returns {T} - */ -export function unwrap({ data, error, response }) { - if (error !== undefined || !response.ok) { - throw new ApiError( - error?.message || `Request failed with status ${response.status}`, - response.status, - error ?? null - ); - } - return data; -} diff --git a/src/api/index.js b/src/api/index.js index d0d1766..a8c6c8b 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -1,6 +1,6 @@ -export { configureApiClient, getApiConfig, ApiError } from './client.js'; -export { getBoardPayload, isNoContentError } from './payloadService.js'; +export { getBoardPayload, isNoContentError, ApiError } from './payloadService.js'; export { getPrayerData } from './prayerService.js'; -export { connectRefreshSocket } from './refreshSocket.js'; -export { getLocalStatus, statusContent, contentCreatedAt } from './localStatus.js'; +export { + getLocalStatus, statusContent, contentCreatedAt, isValidDeviceId, +} from './localStatus.js'; export { connectLocalEvents } from './localEvents.js'; diff --git a/src/api/localEvents.js b/src/api/localEvents.js index ac22ccf..1c6ab2b 100644 --- a/src/api/localEvents.js +++ b/src/api/localEvents.js @@ -1,6 +1,6 @@ /** * ===================================================== - * Local Events (local runtime only) + * Local Events * ===================================================== * The agent announces installs on GET /api/local/events, * a Server-Sent Events stream (agent/docs/architecture.md, @@ -16,8 +16,7 @@ * * EventSource reconnects on its own after a dropped * connection (an agent restart, say), so there is no - * backoff loop here as there is in refreshSocket.js. An - * event can still be missed while disconnected; the + * backoff loop here. An event can still be missed while disconnected; the * payload poll (10 min) and status poll (1 min) in * App.jsx bound what that costs, and a re-opened stream * triggers one catch-up refresh. diff --git a/src/api/localStatus.js b/src/api/localStatus.js index ff8b7b9..f1832fb 100644 --- a/src/api/localStatus.js +++ b/src/api/localStatus.js @@ -1,10 +1,9 @@ /** * ===================================================== - * Local Status (local runtime only) + * Local Status * ===================================================== - * On a board the device agent serves this page, and the - * payload, from 127.0.0.1:8080 off an installed content - * package. GET /api/local/status says who this board is, + * The device agent serves this page, and the payload, + * from 127.0.0.1:8080 off an installed content package. GET /api/local/status says who this board is, * what is installed and how fresh it is * (agent/docs/architecture.md, section 7). It is the * agent's, not LensBridge's, so it is not in the OpenAPI @@ -99,3 +98,15 @@ export function statusContent(status) { export function contentCreatedAt(content) { return content?.createdAt ?? content?.generatedAt ?? null; } + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Whether the agent's `deviceId` is a real device id (a UUID). An agent that + * is not enrolled yet has none, and the board then reports itself unpaired. + * @param {unknown} value + */ +export function isValidDeviceId(value) { + return typeof value === 'string' && UUID_RE.test(value.trim()); +} diff --git a/src/api/payloadService.js b/src/api/payloadService.js index eedb542..9027a62 100644 --- a/src/api/payloadService.js +++ b/src/api/payloadService.js @@ -2,27 +2,79 @@ * ===================================================== * Payload Service * ===================================================== - * Fetches GET /api/musallah/payload?deviceId= and - * flattens it into the design data shape. Retries with - * jittered backoff on transient failures so a fleet of - * boards doesn't reconnect in lockstep. + * Fetches today's payload from the device agent that + * serves this page (same-origin GET /api/musallah/payload, + * agent/docs/architecture.md, section 7) and flattens it + * into the design data shape. Retries with jittered + * backoff on transient failures. * - * In the local runtime the same path is answered by the - * device agent, for which `deviceId` is optional (it only - * ever serves its own board), and a 503 "no content - * installed" is a state, not an outage: see - * isNoContentError(). + * The agent serves only its own board, so no deviceId is + * sent, and a 503 "no content installed" is a state, not + * an outage: see isNoContentError(). + * + * The path is the agent's, not the backend's, so it is + * not in the generated `paths`; the body is still the + * backend's MusallahBoardPayload schema component, which + * the agent serves verbatim from its content package. * ===================================================== */ -import { getClient, unwrap, ApiError } from './client.js'; import { normalizePayload } from '../models/index.js'; +/** @typedef {import('./schema').components['schemas']['MusallahBoardPayload']} MusallahBoardPayload */ + +const PAYLOAD_URL = '/api/musallah/payload'; +const TIMEOUT_MS = 30000; const RETRY_DELAYS_MS = [2000, 5000, 12000, 30000]; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +/** Error carrying the HTTP status (0 for a network failure). */ +export class ApiError extends Error { + /** @param {string} message @param {number} status @param {any} [data] */ + constructor(message, status, data = null) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.data = data; + } +} + +/** + * GET the raw payload once. Times out rather than hanging: a fetch that never + * settles would never reach the retry loop below. + * @returns {Promise} + */ +async function fetchPayload() { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); + let res; + try { + res = await fetch(PAYLOAD_URL, { + cache: 'no-store', + headers: { Accept: 'application/json' }, + signal: controller.signal, + }); + } catch (error) { + if (error?.name === 'AbortError') throw new ApiError('Request timeout', 408); + throw new ApiError(error?.message || 'Network error', 0); + } finally { + clearTimeout(timeoutId); + } + + // The agent's errors are all {"message": "..."} (section 7). + const body = await res.json().catch(() => null); + if (!res.ok) { + throw new ApiError( + body?.message || `Request failed with status ${res.status}`, + res.status, + body + ); + } + return /** @type {MusallahBoardPayload} */ (body); +} + /** - * The local agent's answer when no content package is installed yet + * The agent's answer when no content package is installed yet * (agent/docs/architecture.md, section 7). Retrying it a few seconds later only * delays the "Waiting for content" screen: content arrives by sync, USB stick * or upload, and the agent announces it on /api/local/events. @@ -45,28 +97,15 @@ function isTransient(error) { } /** - * Fetch and normalize the board payload for a device. - * @param {string|null} deviceId device UUID (from enrollment / setup). May be - * null only with `local`, when the agent on this board answers. - * @param {{ local?: boolean }} [options] + * Fetch and normalize this board's payload. * @returns {Promise>} */ -export async function getBoardPayload(deviceId, { local = false } = {}) { - if (!deviceId && !local) throw new ApiError('Missing deviceId', 400); - // The schema marks deviceId required because the backend needs it; the - // agent does not, so the local runtime may send no query at all. - const query = deviceId ? { deviceId } : /** @type {{deviceId: string}} */ ({}); - +export async function getBoardPayload() { let lastError = null; for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) { try { - const payload = unwrap( - await getClient().GET('/api/musallah/payload', { - params: { query }, - }) - ); - return normalizePayload(payload); + return normalizePayload(await fetchPayload()); } catch (error) { lastError = error; if (!isTransient(error) || attempt === RETRY_DELAYS_MS.length) break; diff --git a/src/api/payloadService.test.js b/src/api/payloadService.test.js new file mode 100644 index 0000000..44b1d4c --- /dev/null +++ b/src/api/payloadService.test.js @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getBoardPayload, isNoContentError, ApiError } from './payloadService.js'; + +// The payload is a plain same-origin fetch to the agent, not the generated +// client, so its error handling is this module's own. +describe('getBoardPayload', () => { + afterEach(() => vi.unstubAllGlobals()); + + const respond = (status, body) => + vi.fn(async (_url, _init) => new Response(JSON.stringify(body), { + status, headers: { 'Content-Type': 'application/json' }, + })); + + it('fetches the agent payload same-origin, with no device id', async () => { + const fetch = respond(200, { deviceConfig: {}, frames: [] }); + vi.stubGlobal('fetch', fetch); + await getBoardPayload(); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch.mock.calls[0][0]).toBe('/api/musallah/payload'); + }); + + it('reports "no content installed" once, without retrying', async () => { + const fetch = respond(503, { message: 'no content installed' }); + vi.stubGlobal('fetch', fetch); + const error = await getBoardPayload().catch((e) => e); + expect(error).toBeInstanceOf(ApiError); + expect(error.status).toBe(503); + expect(isNoContentError(error)).toBe(true); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('does not retry a 404', async () => { + const fetch = respond(404, { message: 'not found' }); + vi.stubGlobal('fetch', fetch); + const error = await getBoardPayload().catch((e) => e); + expect(error.status).toBe(404); + expect(isNoContentError(error)).toBe(false); + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/api/prayerService.js b/src/api/prayerService.js index 441e59d..fafe28e 100644 --- a/src/api/prayerService.js +++ b/src/api/prayerService.js @@ -8,7 +8,7 @@ * location + calculation method (DeviceConfig.location). * * This used to call the Aladhan API. It is computed - * locally now so a board with no internet (local runtime, + * locally now so a board with no internet (served by its agent, * see agent/docs/architecture.md) still shows correct times * indefinitely — and so an online board has one fewer * third party that can blank its prayer rail. diff --git a/src/api/refreshSocket.js b/src/api/refreshSocket.js deleted file mode 100644 index 159164b..0000000 --- a/src/api/refreshSocket.js +++ /dev/null @@ -1,131 +0,0 @@ -/** - * ===================================================== - * Refresh Socket - * ===================================================== - * Listens on the backend's board-refresh channel and asks the app to re-fetch - * in place. - * - * The backend (`BoardStreamHandler`) pushes JSON: - * - * { type: "refresh", reason: "posters"|"events"|"weekly-content"|"config"|"manual-refresh", - * deviceId: , at: } - * - * Content edits fan out to every board; `reason: "config"` is addressed to one. - * `?deviceId=` is mandatory — the channel carries enrolled devices only and the - * backend closes a connection that cannot name a known, unrevoked one. Older - * backends broadcast the bare string "REFRESH", so the matcher still accepts - * anything containing that word. - * - * We deliberately soft-refresh rather than `location.reload()`: a reload on a - * kiosk means a visible white flash, a lost slide position, and a fresh round - * of font/image fetches on a screen people are looking at. - * ===================================================== - */ - -import { getApiConfig } from './client.js'; - -const BASE_MS = 1000; -const MAX_MS = 60000; - -/** Derive the ws(s):// endpoint from the configured API base. */ -function socketUrl(deviceId) { - const base = getApiConfig().baseUrl || window.location.origin; - const origin = base.replace(/\/$/, '').replace(/^http/, 'ws'); - return `${origin}/api/refresh-musallahboard?deviceId=${encodeURIComponent(deviceId)}`; -} - -/** - * Decide whether a frame is a refresh for us. - * @returns {string|null} the reason, or null to ignore the frame - */ -function refreshReason(text) { - try { - const msg = JSON.parse(text); - if (msg?.type !== 'refresh') return null; - return msg.reason || 'unknown'; - } catch { - // Legacy bare-string broadcast. - return text.toUpperCase().includes('REFRESH') ? 'legacy' : null; - } -} - -/** - * Open the refresh channel. - * - * @param {string} deviceId this board's enrolled device UUID. Required: an - * unenrolled board has nothing to refetch, and the backend would reject it. - * @param {(reason: string) => unknown} onRefresh called when the backend asks - * us to re-fetch - * @returns {() => void} disposer — closes the socket and cancels any pending - * reconnect. Safe to call more than once. - */ -export function connectRefreshSocket(deviceId, onRefresh) { - if (!deviceId) throw new Error('connectRefreshSocket: deviceId is required'); - - let socket = null; - let timer = null; - let attempts = 0; - let closed = false; - - const scheduleReconnect = () => { - if (closed || timer) return; - // Full jitter over an exponential window: a fleet of boards coming back - // from a backend restart must not reconnect in lockstep. - const window_ = Math.min(MAX_MS, BASE_MS * 2 ** attempts); - const delay = Math.floor(Math.random() * window_); - attempts += 1; - timer = setTimeout(() => { - timer = null; - open(); - }, delay); - }; - - function open() { - if (closed) return; - let ws; - try { - ws = new WebSocket(socketUrl(deviceId)); - } catch (e) { - console.warn('refresh socket: could not open', e); - scheduleReconnect(); - return; - } - socket = ws; - - ws.onopen = () => { attempts = 0; }; - - ws.onmessage = (event) => { - const text = typeof event.data === 'string' ? event.data : ''; - const reason = refreshReason(text); - if (!reason) return; - try { - Promise.resolve(onRefresh(reason)).catch((e) => - console.error('refresh socket: refresh failed', e) - ); - } catch (e) { - console.error('refresh socket: refresh threw', e); - } - }; - - // No onerror handler beyond logging: an error is always followed by a - // close, and reconnecting from both would double-schedule. - ws.onerror = () => { /* close handler owns recovery */ }; - ws.onclose = () => { - if (socket === ws) socket = null; - scheduleReconnect(); - }; - } - - open(); - - return () => { - closed = true; - if (timer) { clearTimeout(timer); timer = null; } - if (socket) { - // Drop the handler first so our own close doesn't queue a reconnect. - socket.onclose = null; - socket.close(); - socket = null; - } - }; -} diff --git a/src/components/SetupModal.jsx b/src/components/SetupModal.jsx index 7520e78..b735029 100644 --- a/src/components/SetupModal.jsx +++ b/src/components/SetupModal.jsx @@ -1,10 +1,8 @@ // Operator diagnostics panel. Opened by Alt+Shift+F or `window.MusallahBoard.openSetup()`. Sibling to DebugMenu.jsx (Alt+Shift+D) import { useState, useEffect } from 'react'; -import { - saveSetupConfig, getSetupConfig, isValidDeviceId, -} from '../utils/cookies.js'; -import { getApiConfig, statusContent } from '../api/index.js'; -import { RUNTIME, APP_VERSION } from '../runtime.js'; +import { getHideCursor, setHideCursor as saveHideCursor } from '../utils/cookies.js'; +import { statusContent } from '../api/index.js'; +import { APP_VERSION } from '../version.js'; function Row({ label, value }) { return ( @@ -15,7 +13,7 @@ function Row({ label, value }) { ); } -/** Local-runtime rows, from the agent's /api/local/status (may be null). */ +/** Rows from the agent's /api/local/status (may be null). */ function LocalRows({ local }) { if (!local) return ; const c = statusContent(local); @@ -53,37 +51,26 @@ function LocalRows({ local }) { ); } -export default function SetupModal({ onComplete, onCancel, status, localStatus }) { - const existing = getSetupConfig(); - const [deviceId, setDeviceId] = useState(existing.deviceId || ''); - const [hideCursor, setHideCursor] = useState(existing.hideCursor); - - const trimmed = deviceId.trim(); - const valid = isValidDeviceId(trimmed); - const idDirty = trimmed !== (existing.deviceId || ''); - const cursorDirty = hideCursor !== existing.hideCursor; - const canApply = (idDirty ? valid : true) && (idDirty || cursorDirty); +export default function SetupModal({ onClose, status, localStatus }) { + const savedHideCursor = getHideCursor(); + const [hideCursor, setHideCursor] = useState(savedHideCursor); + const canApply = hideCursor !== savedHideCursor; useEffect(() => { const onKey = (e) => { - if (e.key === 'Escape') { e.preventDefault(); onCancel?.(); } + if (e.key === 'Escape') { e.preventDefault(); onClose?.(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [onCancel]); + }, [onClose]); function submit(e) { e.preventDefault(); if (!canApply) return; - saveSetupConfig({ deviceId: idDirty && valid ? trimmed : null, hideCursor }); - onComplete?.(idDirty && valid ? trimmed : existing.deviceId); + saveHideCursor(hideCursor); + onClose?.(); } - const backend = getApiConfig().baseUrl || `${window.location.origin} (same-origin)`; - const local = RUNTIME === 'local'; - // Local: the agent's word on identity, not the cookie (which it never sets). - const device = local ? localStatus?.deviceId : existing.deviceId; - return (
@@ -91,37 +78,15 @@ export default function SetupModal({ onComplete, onCancel, status, localStatus }
UTM MSA · MusallahBoard kiosk
- - + - - {local && } + + {status?.error && }
- {/* The override writes the cookie, which only the hosted site reads. A - local board is whoever its agent says it is. */} - {!local &&
- - setDeviceId(e.target.value)} - placeholder="00000000-0000-0000-0000-000000000000" - autoFocus - /> - {idDirty && trimmed && !valid && ( -
Must be a UUID.
- )} -
- Normally set by the device agent. Change this only to re-point a - board by hand. -
-
} -
- diff --git a/src/main.jsx b/src/main.jsx index 4dcfd42..53f7ad0 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,9 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App.jsx'; -import { resolveDeviceId } from './utils/cookies.js'; -import { isLocalRuntime } from './runtime.js'; -import { configureApiClient } from './api/index.js'; // Fonts are bundled, not loaded from Google Fonts: an offline board has no // route to it, and an online one shouldn't blank its type when Google is slow. // Weights match what styles.css uses; each file carries every subset (Amiri's @@ -22,18 +19,6 @@ import '@fontsource/amiri/400.css'; import '@fontsource/amiri/700.css'; import './styles.css'; -if (isLocalRuntime()) { - // The agent on 127.0.0.1 serves the payload itself, and says who this board - // is on /api/local/status, so there is no identity to settle here. The build - // may carry VITE_API_BASE_URL for the hosted deployment; ignore it, since the - // board must render from its own disk whether or not that backend is up. - configureApiClient({ baseUrl: '' }); -} else { - // Hosted: settle identity before the first render so a board provisioned - // via `?deviceId=` never paints its unpaired screen. See resolveDeviceId(). - resolveDeviceId(); -} - ReactDOM.createRoot(document.getElementById('root')).render( diff --git a/src/models/index.js b/src/models/index.js index 3c97451..628bd52 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -13,7 +13,7 @@ // Wire contract (authoritative — from /v3/api-docs) // --------------------------------------------------------------------------- // -// GET /api/musallah/payload?deviceId= -> MusallahBoardPayload +// GET /api/musallah/payload (served by the agent) -> MusallahBoardPayload // { deviceConfig: DeviceConfig, frames: FrameDefinition[] } // // DeviceConfig { diff --git a/src/runtime.js b/src/runtime.js deleted file mode 100644 index 397b32d..0000000 --- a/src/runtime.js +++ /dev/null @@ -1,64 +0,0 @@ -/** - * ===================================================== - * Runtime - * ===================================================== - * One build serves two very different hosts, and this - * module decides, once, which one loaded the page - * (agent/docs/architecture.md, section 15): - * - * local the device agent serves the page from the - * board's own disk at 127.0.0.1:8080. Payload, - * status and events are same-origin; the device - * id comes from /api/local/status; new content - * and new app releases are announced over SSE. - * - * hosted the Cloudflare deployment. Cross-origin - * backend (VITE_API_BASE_URL), device id from - * the cookie or `?deviceId=`, live refresh over - * the backend's WebSocket. - * - * Decided at module load and never again: the runtime is - * fixed for the life of the page, and a later - * history.replaceState() (resolveDeviceId strips its - * query param) must not flip it. Deliberately not a - * cookie either, so nothing a board once did can pin it - * to the wrong runtime after its kiosk URL changes. - * ===================================================== - */ - -/** Hosts the agent's local server answers on (loopback only). */ -const LOCAL_HOSTS = new Set(['127.0.0.1:8080', 'localhost:8080']); - -/** - * @param {{ host: string, search: string }} loc a Location, or anything shaped - * like one (the tests pass plain objects) - * @returns {'local'|'hosted'} - */ -export function detectRuntime(loc) { - try { - if (LOCAL_HOSTS.has(String(loc.host).toLowerCase())) return 'local'; - const q = new URLSearchParams(loc.search); - if ((q.get('runtime') || '').trim().toLowerCase() === 'local') return 'local'; - // v1 agents pointed the kiosk at `?mode=offline`. Such a board is by - // definition served by its agent, so it is the local runtime too. - if ((q.get('mode') || '').trim().toLowerCase() === 'offline') return 'local'; - } catch { /* fall through: a page we cannot inspect is the hosted site */ } - return 'hosted'; -} - -/** @type {'local'|'hosted'} */ -export const RUNTIME = - typeof window === 'undefined' ? 'hosted' : detectRuntime(window.location); - -/** True when the device agent serves this page. */ -export const isLocalRuntime = () => RUNTIME === 'local'; - -/** - * This build's own version, from package.json via Vite's `define` (see - * vite.config.js). Embedded rather than read from the agent so the hosted site - * can show it too, and so the diagnostics can tell "the agent installed 2.1.0" - * apart from "the page actually running is 2.1.0". - */ -/* global __MB_APP_VERSION__ */ -export const APP_VERSION = - typeof __MB_APP_VERSION__ === 'string' ? __MB_APP_VERSION__ : 'dev'; diff --git a/src/runtime.test.js b/src/runtime.test.js deleted file mode 100644 index eec33d5..0000000 --- a/src/runtime.test.js +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { detectRuntime } from './runtime.js'; - -// The runtime decides where the board gets its identity and its content, so a -// hosted page mistaken for a local one would sit on "Waiting for content" and -// a local one mistaken for hosted would wait for an enrollment cookie forever. -describe('detectRuntime', () => { - const at = (host, search = '') => detectRuntime({ host, search }); - - it('is local when served by the agent', () => { - expect(at('127.0.0.1:8080')).toBe('local'); - expect(at('localhost:8080')).toBe('local'); - expect(at('LOCALHOST:8080')).toBe('local'); - }); - - it('is local when asked for, or for a v1 offline kiosk URL', () => { - expect(at('board.example.org', '?runtime=local')).toBe('local'); - expect(at('localhost:3000', '?deviceId=x&mode=offline')).toBe('local'); - }); - - it('is hosted otherwise', () => { - expect(at('musallahboard.example.workers.dev')).toBe('hosted'); - expect(at('localhost:3000')).toBe('hosted'); // vite dev server - expect(at('127.0.0.1:8081')).toBe('hosted'); - expect(at('board.example.org', '?mode=online')).toBe('hosted'); - expect(at('board.example.org', '?runtime=hosted')).toBe('hosted'); - }); -}); diff --git a/src/styles.css b/src/styles.css index 2773a5b..0b8a1df 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1524,25 +1524,6 @@ body.cursor-hidden, body.cursor-hidden * { cursor: none !important; } overflow-wrap: anywhere; } -.setup-hint { - font-family: var(--f-sans); font-size: 12px; line-height: 1.45; - color: oklch(0.68 0.03 95); -} -.setup-hint-bad { color: oklch(0.72 0.15 30); } - -.setup-field { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; } -.setup-field label { - font-family: var(--f-mono); font-size: 11px; letter-spacing: 0.2em; - text-transform: uppercase; color: oklch(0.85 0.04 95); -} -.setup-field input[type="text"], -.setup-field input[type="password"] { - background: oklch(0.20 0.08 258); - border: 1px solid oklch(0.38 0.07 258); - border-radius: 8px; padding: 12px 14px; - color: oklch(0.96 0.01 95); font-family: var(--f-mono); font-size: 14px; -} -.setup-field input:focus { outline: 2px solid oklch(0.91 0.16 95); } .setup-row { display: flex; align-items: center; gap: 10px; margin-bottom: 26px; } .setup-row label { font-family: var(--f-sans); font-size: 14px; color: oklch(0.85 0.04 95); } .setup-card button { diff --git a/src/utils/cookies.js b/src/utils/cookies.js index 63a435f..28bb25d 100644 --- a/src/utils/cookies.js +++ b/src/utils/cookies.js @@ -1,11 +1,7 @@ /** - * Cookie helpers + kiosk setup persistence. - * The board identifies itself to the backend by `deviceId` - * (replaces the old per-location `boardLocation`). - * - * The device id cookie is the hosted runtime's identity only. In the local - * runtime the agent says who the board is (/api/local/status), see runtime.js. - * `hideCursor` applies to both. + * Kiosk preferences, kept in a cookie on the agent's origin (127.0.0.1:8080). + * Only the cursor preference lives here: who the board is comes from the + * agent's /api/local/status, not from anything the page stores. */ export function setCookie(name, value, days = 365) { @@ -25,89 +21,18 @@ export function getCookie(name) { return null; } -/** @returns {{deviceId:string|null, hideCursor:boolean}} */ -export function getSetupConfig() { - const hideRaw = getCookie('hideCursor'); - return { - deviceId: getCookie('deviceId'), - // Kiosks are the common case → default to a hidden cursor. - hideCursor: hideRaw === null ? true : hideRaw === 'true', - }; +/** @returns {boolean} */ +export function getHideCursor() { + const raw = getCookie('hideCursor'); + // Kiosks are the common case → default to a hidden cursor. + return raw === null ? true : raw === 'true'; } -export function saveSetupConfig({ deviceId, hideCursor }) { - if (deviceId) setCookie('deviceId', deviceId); - if (typeof hideCursor === 'boolean') setCookie('hideCursor', String(hideCursor)); -} - -export function isSetupComplete() { - return isValidDeviceId(getCookie('deviceId')); -} - -/** - * Persist just the device id. Used by the headless provisioning path: the - * device agent drives Chromium over the DevTools Protocol and calls - * `window.MusallahBoard.setDeviceId(...)` (or sets the `deviceId` cookie / - * `?deviceId=` query param) so the kiosk boots without any human touching - * the setup screen. - * @param {string} deviceId - */ -export function setDeviceId(deviceId) { - if (deviceId) setCookie('deviceId', String(deviceId).trim()); -} - -/** Read a deviceId provided via the URL (`?deviceId=...`), if any. */ -export function getDeviceIdFromQuery() { - try { - const v = new URLSearchParams(window.location.search).get('deviceId'); - return v ? v.trim() : null; - } catch { - return null; - } -} - -const UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -/** The backend keys every board read on a UUID, so anything else is garbage. */ -export function isValidDeviceId(value) { - return typeof value === 'string' && UUID_RE.test(value.trim()); -} - -/** - * Settle the board's identity and return it, or null if unpaired. - * - * Must run BEFORE the first React render. The agent provisions a board by - * navigating to `?deviceId=`; if that reconciliation happens - * in an effect it lands after paint, and the board flashes its unpaired screen - * on every healthy cold boot. - * - * A malformed id is dropped rather than persisted — storing it would leave the - * board 404ing against the backend forever with no way to tell why. - */ -export function resolveDeviceId() { - const fromQuery = getDeviceIdFromQuery(); - - if (fromQuery !== null) { - // Strip the param either way: valid ones are now in the cookie, and a bad - // one shouldn't linger in the address bar or survive a reload. - try { - const u = new URL(window.location.href); - u.searchParams.delete('deviceId'); - window.history.replaceState({}, '', u); - } catch { /* non-fatal */ } - - if (isValidDeviceId(fromQuery)) { - setDeviceId(fromQuery); - return fromQuery.trim(); - } - console.warn(`Ignoring malformed deviceId in URL: ${fromQuery}`); - } - - const stored = getCookie('deviceId'); - return isValidDeviceId(stored) ? stored.trim() : null; +/** @param {boolean} hideCursor */ +export function setHideCursor(hideCursor) { + setCookie('hideCursor', String(Boolean(hideCursor))); } export function applyCursorPreference() { - document.body.classList.toggle('cursor-hidden', getSetupConfig().hideCursor); + document.body.classList.toggle('cursor-hidden', getHideCursor()); } diff --git a/src/version.js b/src/version.js new file mode 100644 index 0000000..65fd096 --- /dev/null +++ b/src/version.js @@ -0,0 +1,9 @@ +/** + * This build's own version, from package.json via Vite's `define` (see + * vite.config.js). Embedded rather than read from the agent so the diagnostics + * can tell "the agent installed 2.1.0" apart from "the page actually running + * is 2.1.0". + */ +/* global __MB_APP_VERSION__ */ +export const APP_VERSION = + typeof __MB_APP_VERSION__ === 'string' ? __MB_APP_VERSION__ : 'dev'; diff --git a/vite.config.js b/vite.config.js index 1e6c40e..54022c4 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2,23 +2,22 @@ import { readFileSync } from 'node:fs' import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -// The kiosk talks to the LensBridge backend. In dev we proxy /api to the -// local Spring app so the browser stays same-origin (no CORS). In prod the -// backend origin is injected via VITE_API_BASE_URL at build time; when unset -// the app falls back to same-origin /api (reverse-proxied in deployment). -const BACKEND = process.env.VITE_DEV_BACKEND || 'http://localhost:8080' +// The board app only ever runs served by the device agent at 127.0.0.1:8080, +// which answers /api (payload, status, SSE events) and /media same-origin. In +// dev we proxy both to an agent so the page stays same-origin there too. Point +// it at a real board with `ssh -L 8080:127.0.0.1:8080 ` (the default), +// or set VITE_DEV_AGENT to another agent. +const AGENT = process.env.VITE_DEV_AGENT || 'http://127.0.0.1:8080' -import { cloudflare } from "@cloudflare/vite-plugin"; - -// Baked into the bundle as the build's own version (src/runtime.js). It is the +// Baked into the bundle as the build's own version (src/version.js). It is the // same number scripts/package-mbu.mjs signs into the app package, so the -// diagnostics can show which build is really running, hosted or on a board. +// diagnostics can show which build is really running. const { version: APP_VERSION } = JSON.parse( readFileSync(new URL('./package.json', import.meta.url), 'utf8') ) export default defineConfig({ - plugins: [cloudflare(), react()], + plugins: [react()], define: { __MB_APP_VERSION__: JSON.stringify(APP_VERSION), }, @@ -27,18 +26,18 @@ export default defineConfig({ port: 3000, open: true, proxy: { - // ws: the refresh channel upgrades under /api too, and without this the - // upgrade isn't forwarded — the socket reconnect-loops silently in dev - // while every HTTP call works fine. - '/api': { target: BACKEND, changeOrigin: true, ws: true }, + // /api/local/events is Server-Sent Events: a plain long-lived HTTP + // response, which the proxy streams through as it arrives. + '/api': { target: AGENT, changeOrigin: true }, + '/media': { target: AGENT, changeOrigin: true }, }, }, preview: { host: '0.0.0.0', port: 3000, }, - // Vitest reads this config, so the React plugin and the aliases above apply - // to tests too — a component test compiles exactly like the bundle does. + // Vitest reads this config, so the React plugin applies to tests too: a + // component test compiles exactly like the bundle does. test: { environment: 'jsdom', globals: true, diff --git a/wrangler.jsonc b/wrangler.jsonc deleted file mode 100644 index 273e610..0000000 --- a/wrangler.jsonc +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "musallahboard", - "compatibility_date": "2026-06-17", - "observability": { - "enabled": true - }, - "assets": { - "directory": "./dist", - "not_found_handling": "single-page-application" - }, - "compatibility_flags": [ - "nodejs_compat" - ] -} From 24a953cf321cc870834b160b466b8ce4930ecb1b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:51:42 +0000 Subject: [PATCH 3/5] Regenerate the API schema from the backend branch (payload path removed, weather endpoint added) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- src/api/schema.d.ts | 390 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 342 insertions(+), 48 deletions(-) diff --git a/src/api/schema.d.ts b/src/api/schema.d.ts index ea1c932..447dc8c 100644 --- a/src/api/schema.d.ts +++ b/src/api/schema.d.ts @@ -223,6 +223,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/board/devices/{deviceId}/offline-bundle": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Download a signed content package for a board + * @description A signed .mbu content package (format version 2) holding one fully assembled payload per day, starting today in the device's timezone, plus every poster image those payloads reference. Taken to the board by USB stick, laptop or phone. Requires board:device:read. + */ + get: operations["downloadOfflineBundle"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/board/devices/{deviceId}/revoke": { parameters: { query?: never; @@ -786,6 +806,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/agent/content-bundle": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Fetch this board's signed content package (online sync) + * @description Device-authenticated with the X-MB-* headers (Ed25519 signature by the enrolled device key over musallahboard-http-v1, method, path, device id, timestamp and the body's SHA-256). Returns a signed .mbu content package starting today in the device's timezone. Media whose SHA-256 is in haveMedia is listed and signed in mbu.json but left out of the zip. + */ + post: operations["downloadContentBundle"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/agent/enroll": { parameters: { query?: never; @@ -797,7 +837,7 @@ export interface paths { put?: never; /** * Exchange a one-time enrollment token for a device identity - * @description Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. + * @description Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested again. contentSigningKeys are the public content keys the agent pins in its trust store. */ post: operations["enrollAgent"]; delete?: never; @@ -806,6 +846,46 @@ export interface paths { patch?: never; trace?: never; }; + "/api/agent/signing-keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Public content signing keys boards should trust + * @description Current key first, then previous keys still valid during a rotation. Empty when the server has no content signing key. Used by `musallahboard-agent trust fetch` on boards enrolled before signed content existed. + */ + get: operations["getSigningKeys"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/agent/weather": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Current weather for this board + * @description Device-authenticated with the X-MB-* headers, like the content bundle; the signed body hash is the SHA-256 of the empty string. `weather` is the OpenWeatherMap current weather JSON exactly as the server last fetched it, or null when the server has none. `fetchedAt` is when the server answered. + */ + get: operations["getAgentWeather"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/change-password": { parameters: { query?: never; @@ -1127,22 +1207,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/musallah/payload": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["getBoardPayload"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/musallah/weekly-content": { parameters: { query?: never; @@ -1370,10 +1434,21 @@ export interface components { token: string; }; AgentEnrollResponse: { + /** @description Public content signing keys the agent pins at enrollment. Empty when the server has no content key configured. Optional so agents built before it existed still decode the response. */ + contentSigningKeys?: components["schemas"]["SigningKeyView"][]; /** Format: uuid */ deviceId?: string; websocketUrl?: string; }; + AgentWeatherResponse: { + /** + * Format: date-time + * @example 2026-09-24T14:05:00Z + */ + fetchedAt: string; + /** @description OpenWeatherMap current weather JSON, verbatim; null when unavailable */ + weather: components["schemas"]["JsonNode"]; + }; AuditEventDto: { /** @enum {string} */ action?: "APPROVE_UPLOAD" | "UNAPPROVE_UPLOAD" | "DELETE_UPLOAD" | "FEATURE_UPLOAD" | "UNFEATURE_UPLOAD" | "CREATE_EVENT" | "UPDATE_EVENT" | "DELETE_EVENT" | "CREATE_CALENDAR_EVENT" | "UPDATE_CALENDAR_EVENT" | "DELETE_CALENDAR_EVENT" | "CREATE_POSTER" | "UPDATE_POSTER" | "DELETE_POSTER" | "CREATE_SOCIAL" | "UPDATE_SOCIAL" | "DELETE_SOCIAL" | "SAVE_WEEKLY_CONTENT" | "DELETE_WEEKLY_CONTENT" | "UPDATE_BOARD_CONFIG" | "UPDATE_BOARD_TICKER" | "REFRESH_BOARDS" | "ISSUE_ENROLLMENT_TOKEN" | "REVOKE_DEVICE" | "ISSUE_DEVICE_COMMAND" | "PROMOTE_USER" | "DEMOTE_USER" | "DISABLE_USER" | "ENABLE_USER" | "VIEW_AUDIT_LOGS" | "EXPORT_DATA" | "SYSTEM_MAINTENANCE" | "VERIFY_USER" | "UNVERIFY_USER" | "RESET_USER_PASSWORD" | "TRIGGER_PASSWORD_RESET_EMAIL" | "ADD_USER_ROLE" | "REMOVE_USER_ROLE" | "GRANT_PERMISSION" | "REVOKE_PERMISSION" | "ADD_USER" | "REMOVE_USER" | "UPDATE_USER"; @@ -1451,6 +1526,14 @@ export interface components { /** @enum {string} */ status?: "PENDING" | "DELIVERED" | "ACKED" | "RUNNING" | "SUCCEEDED" | "FAILED" | "TIMEOUT" | "REJECTED" | "EXPIRED"; }; + ContentBundleRequest: { + /** + * Format: int32 + * @default 7 + */ + days: number; + haveMedia?: string[]; + }; CreateCalendarEventRequest: { allDay?: boolean; /** @enum {string} */ @@ -1675,6 +1758,7 @@ export interface components { MessageResponse: { message?: string; }; + /** @description Format of each payloads/.json file inside a signed content package (.mbu). Not returned by any endpoint; boards read it from installed packages. weather is always null here: boards fetch current weather from GET /api/agent/weather. */ MusallahBoardPayload: { deviceConfig?: components["schemas"]["DeviceConfig"]; frames?: components["schemas"]["FrameDefinition"][]; @@ -1876,6 +1960,14 @@ export interface components { name?: string; permissions?: string[]; }; + SigningKeyView: { + /** @example 0123456789abcdef */ + keyId: string; + publicKey: string; + }; + SigningKeysResponse: { + content: components["schemas"]["SigningKeyView"][]; + }; SignupRequest: { /** Format: email */ email: string; @@ -2657,6 +2749,87 @@ export interface operations { }; }; }; + downloadOfflineBundle: { + parameters: { + query?: { + /** @description Number of days in the bundle, starting today in the device's timezone */ + days?: number; + }; + header?: never; + path: { + deviceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The package */ + 200: { + headers: { + /** @description attachment; filename="musallahboard-content--.mbu" */ + "Content-Disposition"?: string; + [name: string]: unknown; + }; + content: { + "application/vnd.musallahboard.mbu": string; + }; + }; + /** @description days outside 1-31 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description No device with that id */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description Device is revoked */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description A poster image could not be fetched; the message names the poster */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description No content signing key is configured on the server */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description Request failed; body carries a human-readable message */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + }; + }; revokeDevice: { parameters: { query?: never; @@ -4155,6 +4328,84 @@ export interface operations { }; }; }; + downloadContentBundle: { + parameters: { + query?: never; + header: { + /** @description Enrolled device id */ + "X-MB-Device-Id": string; + /** @description Unix milliseconds; must be within 5 minutes of the server clock */ + "X-MB-Timestamp": number; + /** @description Base64 Ed25519 signature by the device key */ + "X-MB-Signature": string; + }; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ContentBundleRequest"]; + }; + }; + responses: { + /** @description The signed content package */ + 200: { + headers: { + /** @description attachment; filename="musallahboard-content--.mbu" */ + "Content-Disposition"?: string; + [name: string]: unknown; + }; + content: { + "application/vnd.musallahboard.mbu": string; + }; + }; + /** @description Malformed body, days outside 1-31, or a bad haveMedia entry */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description Missing or bad device signature, clock skew over 5 minutes, or an unknown or revoked device */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description A poster image could not be fetched; the message names the poster */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description No content signing key is configured on the server */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description Request failed; body carries a human-readable message */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + }; + }; enrollAgent: { parameters: { query?: never; @@ -4206,6 +4457,80 @@ export interface operations { }; }; }; + getSigningKeys: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SigningKeysResponse"]; + }; + }; + /** @description Request failed; body carries a human-readable message */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + }; + }; + getAgentWeather: { + parameters: { + query?: never; + header: { + /** @description Enrolled device id */ + "X-MB-Device-Id": string; + /** @description Unix milliseconds; must be within 5 minutes of the server clock */ + "X-MB-Timestamp": number; + /** @description Base64 Ed25519 signature by the device key */ + "X-MB-Signature": string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current weather, or null weather when unavailable */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentWeatherResponse"]; + }; + }; + /** @description Missing or bad device signature, clock skew over 5 minutes, or an unknown or revoked device */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description Request failed; body carries a human-readable message */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + }; + }; changePassword: { parameters: { query?: never; @@ -4896,37 +5221,6 @@ export interface operations { }; }; }; - getBoardPayload: { - parameters: { - query: { - deviceId: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MusallahBoardPayload"]; - }; - }; - /** @description Request failed; body carries a human-readable message */ - default: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MessageResponse"]; - }; - }; - }; - }; getCurrentWeeklyContent: { parameters: { query?: never; From 8a98f49c5b74daac0d7668638bd4d6e69145946e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 18:41:56 +0000 Subject: [PATCH 4/5] Read only the current agent status fields (content, createdAt) Nothing ran the v1 agent, so the status helpers that also accepted its bundle/generatedAt names are gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- src/App.jsx | 10 +++++----- src/api/index.js | 2 +- src/api/localStatus.js | 31 +++---------------------------- src/components/SetupModal.jsx | 3 +-- 4 files changed, 10 insertions(+), 36 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index a9d776f..c8b3099 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,7 +5,7 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { getBoardPayload, getPrayerData, getLocalStatus, connectLocalEvents, - isNoContentError, statusContent, contentCreatedAt, isValidDeviceId, + isNoContentError, isValidDeviceId, } from './api/index.js'; import { prefetchPayloadImages } from './utils/prefetch.js'; import { @@ -119,9 +119,9 @@ function WaitingForContent({ status }) { */ function StaleNote({ status }) { if (!(status?.staleDays > 0)) return null; - const b = statusContent(status); + const b = status?.content; let when = b?.lastDay ?? ''; - const createdAt = contentCreatedAt(b); + const createdAt = b?.createdAt; const at = createdAt ? new Date(createdAt) : null; if (at && !Number.isNaN(at.getTime())) { try { @@ -274,7 +274,7 @@ export default function App() { window.location.reload(); return; } - if (fromPoll && noContent && statusContent(s)) refreshRef.current(); + if (fromPoll && noContent && s?.content) refreshRef.current(); }; const applyLocalStatusRef = useRef(applyLocalStatus); useEffect(() => { applyLocalStatusRef.current = applyLocalStatus; }); @@ -366,7 +366,7 @@ export default function App() { deviceId: deviceId ?? null, paired: isValidDeviceId(deviceId), appVersion: APP_VERSION, - content: statusContent(localStatus), + content: localStatus?.content ?? null, noContent, slideKey: slides[slideIdx]?.key ?? null, slideIndex: slides.length ? slideIdx : null, diff --git a/src/api/index.js b/src/api/index.js index a8c6c8b..11d7efe 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -1,6 +1,6 @@ export { getBoardPayload, isNoContentError, ApiError } from './payloadService.js'; export { getPrayerData } from './prayerService.js'; export { - getLocalStatus, statusContent, contentCreatedAt, isValidDeviceId, + getLocalStatus, isValidDeviceId, } from './localStatus.js'; export { connectLocalEvents } from './localEvents.js'; diff --git a/src/api/localStatus.js b/src/api/localStatus.js index f1832fb..4a62f1d 100644 --- a/src/api/localStatus.js +++ b/src/api/localStatus.js @@ -17,11 +17,6 @@ * sync: { enabled, lastSuccessAt, lastAttemptAt, * lastError }, * update: { active } } - * - * v1 agents answered with `bundle` (and `generatedAt`) - * where v2 has `content` (and `createdAt`); the helpers - * below read either, so a board whose agent is one step - * behind its app still shows its staleness note. * ===================================================== */ @@ -33,8 +28,7 @@ const TIMEOUT_MS = 5000; * @property {string} lastDay * @property {string} [timezone] * @property {number} [sequence] - * @property {string} [createdAt] v2 - * @property {string} [generatedAt] v1 + * @property {string} [createdAt] * @property {string} [source] sync | usb | upload | cli * @property {string} [installedAt] */ @@ -45,8 +39,7 @@ const TIMEOUT_MS = 5000; * @property {string} [agentVersion] * @property {string} [deviceId] * @property {{version:string}|null} [app] - * @property {LocalContent|null} [content] v2 - * @property {LocalContent|null} [bundle] v1 + * @property {LocalContent|null} [content] * @property {string} [today] * @property {string|null} [servingDay] * @property {number|null} [daysRemaining] @@ -59,7 +52,7 @@ const TIMEOUT_MS = 5000; /** * Fetch the agent's status. Never throws: the status feeds the device id, the * waiting screen, the staleness note and the diagnostics panel, and a board - * must keep showing its payload even if the agent is too old to answer this. + * must keep showing its payload even if the agent cannot answer right now. * @returns {Promise} */ export async function getLocalStatus() { @@ -81,24 +74,6 @@ export async function getLocalStatus() { } } -/** - * The installed content, whichever agent generation described it. - * @param {LocalStatus|null|undefined} status - * @returns {LocalContent|null} - */ -export function statusContent(status) { - return status?.content ?? status?.bundle ?? null; -} - -/** - * When the installed content was made: v2 `createdAt`, v1 `generatedAt`. - * @param {LocalContent|null|undefined} content - * @returns {string|null} - */ -export function contentCreatedAt(content) { - return content?.createdAt ?? content?.generatedAt ?? null; -} - const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; diff --git a/src/components/SetupModal.jsx b/src/components/SetupModal.jsx index b735029..62bd3d2 100644 --- a/src/components/SetupModal.jsx +++ b/src/components/SetupModal.jsx @@ -1,7 +1,6 @@ // Operator diagnostics panel. Opened by Alt+Shift+F or `window.MusallahBoard.openSetup()`. Sibling to DebugMenu.jsx (Alt+Shift+D) import { useState, useEffect } from 'react'; import { getHideCursor, setHideCursor as saveHideCursor } from '../utils/cookies.js'; -import { statusContent } from '../api/index.js'; import { APP_VERSION } from '../version.js'; function Row({ label, value }) { @@ -16,7 +15,7 @@ function Row({ label, value }) { /** Rows from the agent's /api/local/status (may be null). */ function LocalRows({ local }) { if (!local) return ; - const c = statusContent(local); + const c = local?.content; const sync = local.sync; let remaining = null; if (c) { From c16844f6246cfb18391b3df601f04e0775abf5f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 00:26:42 +0000 Subject: [PATCH 5/5] Regenerate the API schema from backend master (contentSigningKeys required) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- src/api/schema.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/schema.d.ts b/src/api/schema.d.ts index 447dc8c..a55b5ce 100644 --- a/src/api/schema.d.ts +++ b/src/api/schema.d.ts @@ -1434,8 +1434,8 @@ export interface components { token: string; }; AgentEnrollResponse: { - /** @description Public content signing keys the agent pins at enrollment. Empty when the server has no content key configured. Optional so agents built before it existed still decode the response. */ - contentSigningKeys?: components["schemas"]["SigningKeyView"][]; + /** @description Public content signing keys the agent pins at enrollment. Always present; empty when the server has no content key configured. */ + contentSigningKeys: components["schemas"]["SigningKeyView"][]; /** Format: uuid */ deviceId?: string; websocketUrl?: string;