From b5c539ba05d6f5fb2da47d694a17202928376a3d Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 24 Sep 2026 17:53:19 -0400 Subject: [PATCH 01/12] Release builds: Intel disk images, a Windows bundle, and where to download them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit launcher/macos/build.sh takes ARCH=x86_64 for Intel Macs — the Swift target and the embedded Node follow (nodejs.org calls that one darwin-x64). The default stays arm64. launcher/windows/build.sh is new: the same payload plus Node for Windows, checksum-verified against nodejs.org, and ProdDash.bat, which keeps the server running and starts it again on exit code 75 — the admin page's update restart — the way the macOS launcher does. No Windows launcher app yet. The README's Run it section now points at the Releases page and the ProdDash-releases repo that holds the files, one line each for Apple silicon, Intel and Windows. Co-Authored-By: Claude Fable 5.1 --- .gitignore | 2 + README.md | 28 ++++++++- launcher/macos/README.md | 1 + launcher/macos/build.sh | 13 ++++- launcher/windows/build.sh | 117 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 launcher/windows/build.sh diff --git a/.gitignore b/.gitignore index e3910f3..b553dd6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ modules/propresenter-timers/ltc-capture # the launcher's build output, and its cache of the Node runtime it embeds launcher/macos/build/ launcher/macos/.cache/ +launcher/windows/build/ +launcher/windows/.cache/ # Compiled per-machine, source sits next to it (see spl-capture.swift). modules/spl-meter/spl-capture diff --git a/README.md b/README.md index 70b747b..b36d66f 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,19 @@ and a clear degraded state, then recover on their own. ## Run it +Ready-made builds are on the +[Releases page](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash/releases); +the files themselves live in +[ProdDash-releases](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash-releases). +There is a disk image for Apple silicon Macs, one for Intel Macs, and a +bundle for 64-bit Windows. Every build updates itself afterwards from the +admin page, so the download only has to be recent, not current. + ### macOS — the app -Open the disk image and drag **ProdDash** to Applications. That's the whole +Download the disk image for your Mac — `ProdDash--arm64.dmg` for +Apple silicon, `ProdDash--x86_64.dmg` for Intel — open it and drag +**ProdDash** to Applications. That's the whole installation: the app carries the dashboard and its own Node runtime, so nothing else has to be installed and nothing can be missing. It lives in the menu bar, starts and stops the server, can open at login, and holds the @@ -43,12 +53,26 @@ a launchd job) simply doesn't have them, and an LTC input goes silently to zeroes. Build the disk image from a checkout with -[launcher/macos](launcher/macos)/`build.sh`: +[launcher/macos](launcher/macos)/`build.sh` (`ARCH=x86_64` in front of it +for Intel): ```bash cd launcher/macos && ./build.sh --dmg ``` +### Windows — the bundle + +Download `ProdDash--win-x64.zip`, unzip it somewhere you can write +to — `C:\ProdDash`, say, not Program Files, because updates write into the +folder — and double-click **ProdDash.bat**. The window it opens is the +dashboard running: it stays open, and it starts the server again when the +admin page applies an update. Allow `node.exe` on private networks when the +firewall asks, so other screens can reach it. Settings and layouts live in +`%APPDATA%\ProdDash`, so a new bundle never loses them. To start with +Windows, put a shortcut to `ProdDash.bat` in `shell:startup`. There is no +menu-bar-style launcher for Windows yet; the bundle is built with +[launcher/windows](launcher/windows)/`build.sh`. + ### Anywhere else, or from a checkout Requires [Node.js](https://nodejs.org) 18 or newer. diff --git a/launcher/macos/README.md b/launcher/macos/README.md index 9ea0e6d..d8a0f16 100644 --- a/launcher/macos/README.md +++ b/launcher/macos/README.md @@ -51,6 +51,7 @@ the cycle rather than spinning forever. ```bash ./build.sh # build/ProdDash.app — the whole application ./build.sh --dmg # …and build/ProdDash--arm64.dmg +ARCH=x86_64 ./build.sh --dmg # the same for Intel Macs (…-x86_64.dmg) ./build.sh --install # …and copy it to /Applications ./build.sh --run # …and open it ./build.sh --notarize # with --dmg: notarise and staple (Developer ID) diff --git a/launcher/macos/build.sh b/launcher/macos/build.sh index 5bb9b0b..8b0b219 100755 --- a/launcher/macos/build.sh +++ b/launcher/macos/build.sh @@ -11,6 +11,8 @@ # ./build.sh --dev launcher only — no Node, no server payload, so # it runs against the checkout. Seconds, not # minutes, while working on the launcher itself. +# ARCH=x86_64 ./build.sh the same for Intel Macs (default: arm64, Apple +# silicon); the Node it embeds follows. # # Signing: a "Developer ID Application" certificate is used when one is # installed (override with CODESIGN_IDENTITY). Without one the app is signed @@ -27,8 +29,13 @@ LAUNCHER_VERSION="1.0.0" # The Node the app ships. Bump it here; the download is checksum-verified # against nodejs.org's own SHASUMS256.txt and cached in .cache/. NODE_VERSION="v24.21.0" -ARCH="arm64" +ARCH="${ARCH:-arm64}" # arm64 (Apple silicon) or x86_64 (Intel) DEPLOYMENT_TARGET="13.0" +case "$ARCH" in + arm64) NODE_ARCH="arm64" ;; + x86_64) NODE_ARCH="x64" ;; # nodejs.org's name for it + *) echo "build.sh: ARCH must be arm64 or x86_64, not $ARCH" >&2; exit 2 ;; +esac REPO_ROOT="../.." OUT_DIR="build" @@ -106,7 +113,7 @@ fi # ── the server, and the Node that runs it ──────────────────────────────── if [[ $DEV -eq 0 ]]; then - TARBALL="node-$NODE_VERSION-darwin-$ARCH.tar.gz" + TARBALL="node-$NODE_VERSION-darwin-$NODE_ARCH.tar.gz" mkdir -p "$CACHE_DIR" if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)" @@ -125,7 +132,7 @@ if [[ $DEV -eq 0 ]]; then echo " embedding Node $NODE_VERSION" mkdir -p "$CONTENTS/Resources/node/bin" tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \ - "node-$NODE_VERSION-darwin-$ARCH/bin/node" + "node-$NODE_VERSION-darwin-$NODE_ARCH/bin/node" echo " embedding ProdDash $VERSION" mkdir -p "$PAYLOAD/config" diff --git a/launcher/windows/build.sh b/launcher/windows/build.sh new file mode 100644 index 0000000..aa150e0 --- /dev/null +++ b/launcher/windows/build.sh @@ -0,0 +1,117 @@ +#!/bin/zsh +# Build the Windows bundle — ProdDash--win-x64.zip: the dashboard, +# its own Node runtime and a start script, so nothing has to be installed on +# the Windows machine. Unzip, double-click ProdDash.bat, done. +# +# ./build.sh build/ProdDash--win-x64.zip +# +# Builds on macOS or Linux (zsh, curl, shasum, unzip, zip). Node for Windows +# comes from nodejs.org, checksum-verified against its SHASUMS256.txt and +# cached in .cache/. There is no Windows launcher app yet: ProdDash.bat keeps +# the server running and starts it again when the admin page applies an +# update (the server exits with code 75 to ask for that). +set -e -u +cd "$(dirname "$0")" + +APP_NAME="ProdDash" +# The Node the bundle ships — keep in step with launcher/macos/build.sh. +NODE_VERSION="v24.21.0" +NODE_DIST="node-$NODE_VERSION-win-x64" + +REPO_ROOT="../.." +OUT_DIR="build" +CACHE_DIR=".cache" + +for tool in curl shasum unzip zip; do + command -v "$tool" >/dev/null || { echo "build.sh: $tool not found" >&2; exit 1; } +done + +VERSION=$(sed -n 's/.*"version" *: *"\([^"]*\)".*/\1/p' "$REPO_ROOT/package.json" | head -1) +[[ -n "$VERSION" ]] || { echo "build.sh: can't read the version from package.json" >&2; exit 1; } +NAME="$APP_NAME-$VERSION-win-x64" +STAGE="$OUT_DIR/$NAME" + +echo "ProdDash $VERSION — building the Windows bundle" +rm -rf "$OUT_DIR" +mkdir -p "$STAGE/node" "$STAGE/config" "$CACHE_DIR" + +# ── Node for Windows ───────────────────────────────────────────────────── +ZIPFILE="$NODE_DIST.zip" +if [[ ! -f "$CACHE_DIR/$ZIPFILE" ]]; then + echo " fetching Node $NODE_VERSION for Windows (~30 MB, cached for next time)" + curl -fsSL --retry 3 -o "$CACHE_DIR/$ZIPFILE.part" "https://nodejs.org/dist/$NODE_VERSION/$ZIPFILE" + mv "$CACHE_DIR/$ZIPFILE.part" "$CACHE_DIR/$ZIPFILE" +fi +SUMS="$CACHE_DIR/SHASUMS256-$NODE_VERSION.txt" +[[ -f "$SUMS" ]] || curl -fsSL --retry 3 -o "$SUMS" "https://nodejs.org/dist/$NODE_VERSION/SHASUMS256.txt" +# This binary ships under ProdDash's name — check it every build. +WANT=$(awk -v f="$ZIPFILE" '$2 == f {print $1}' "$SUMS") +GOT=$(shasum -a 256 "$CACHE_DIR/$ZIPFILE" | awk '{print $1}') +if [[ -z "$WANT" || "$WANT" != "$GOT" ]]; then + echo "build.sh: $ZIPFILE does not match nodejs.org's checksum — refusing to ship it" >&2 + exit 1 +fi +echo " embedding Node $NODE_VERSION (node.exe and its licence)" +unzip -q -j "$CACHE_DIR/$ZIPFILE" "$NODE_DIST/node.exe" "$NODE_DIST/LICENSE" -d "$STAGE/node" + +# ── ProdDash ───────────────────────────────────────────────────────────── +echo " embedding ProdDash $VERSION" +cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$STAGE/" +cp -R "$REPO_ROOT/public" "$REPO_ROOT/modules" "$STAGE/" +# Only the checked-in defaults: config/ also holds this machine's own +# modules.json and layouts when ProdDash has ever run from the checkout. +cp "$REPO_ROOT/config/proddash.json" "$STAGE/config/" +find "$STAGE" -name '.DS_Store' -delete + +# ── the start script and the read-me, with Windows line endings ────────── +crlf() { awk '{ printf "%s\r\n", $0 }' > "$1"; } + +crlf "$STAGE/ProdDash.bat" <<'BAT' +@echo off +rem ProdDash - double-click to start. Keep this window open: closing it stops the dashboard. +title ProdDash +cd /d "%~dp0" +rem The server exits with code 75 after applying an update from the admin page; start it again. +set PRODDASH_LAUNCHER=1 +:run +"%~dp0node\node.exe" server.js +set CODE=%ERRORLEVEL% +if %CODE% EQU 75 goto run +echo. +echo ProdDash stopped (exit code %CODE%). Press any key to close this window. +pause >nul +BAT + +crlf "$STAGE/README.txt" <:24500. Settings are on the admin page, + http://localhost:24500/admin. + +Your settings and layouts live in %APPDATA%\ProdDash, not in this folder, +so unzipping a new version or updating never loses them. + +Start with Windows: press Win+R, type shell:startup, press Enter, and put a +shortcut to ProdDash.bat in the folder that opens. + +Updates: the admin page's Update now fetches the new version into this +folder and restarts the dashboard; the window stays open. A copy of the +version it replaced is kept for a short while in case the new one will not +start. +README + +# ── zip ────────────────────────────────────────────────────────────────── +echo " zipping" +(cd "$OUT_DIR" && zip -q -r -X "$NAME.zip" "$NAME") +echo "built $OUT_DIR/$NAME.zip ($(du -sh "$OUT_DIR/$NAME.zip" | awk '{print $1}'))" From 4e5b60e82b2a1f6e8e30e101e6f4183d505e024d Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 27 Sep 2026 11:18:25 -0400 Subject: [PATCH 02/12] PCO Plan 1.9.0: a Song Key tile, and AbleSet-shaped section chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Song Key**, a third entry in the + picker: the key of the item ProPresenter has live, as large as the tile allows. It is set as a note — B♭, F♯m rather than Bb and F#m — with the song's name and the next keyed song under it. All three are tile settings: the colour, the name and the next key. There is no new connection; it reads the same streamed state the Plan tile does. The accidental and the quality sit on the same line as the letter. Raising the accidental read as two stacked characters at tile sizes, which is worse than plain at a glance from the back of a room. When the ProPresenter feed that picks the item goes away the key stays up — the band still needs it — but faded, and the tile's dot says it is the last item rather than the current one. **The section chips** on the Plan tile's current item are rounded squares now, with the tinted ground and transparent border the AbleSet module gives its own section pills, so the two read as one family. The chip playing keeps its own ground so its progress fills against the chip rather than against the row behind it. Co-Authored-By: Claude Opus 5 --- README.md | 11 +- modules/pco-plan/client.js | 10 +- modules/pco-plan/key-client.js | 186 +++++++++++++++++++++++++++++++++ modules/pco-plan/module.json | 34 +++++- modules/pco-plan/style.css | 92 ++++++++++++++-- 5 files changed, 319 insertions(+), 14 deletions(-) create mode 100644 modules/pco-plan/key-client.js diff --git a/README.md b/README.md index f425f4b..2d9f047 100644 --- a/README.md +++ b/README.md @@ -325,8 +325,9 @@ below. Nothing is installed without someone clicking. session (Start ProdDash.command) and approve the one-time prompt; a server started over SSH or by bare launchd silently captures zeros. (Engine self-test: `node modules/propresenter-timers/ltc.selftest.js`.) -- **PCO Plan** — Planning Center Services, as two tiles in the + picker: - **Plan** (the order of service) and **Teams** (who is scheduled). +- **PCO Plan** — Planning Center Services, as three tiles in the + picker: + **Plan** (the order of service), **Teams** (who is scheduled) and **Song + Key** (the key of the item on screen). **Plan** shows every plan item with its scheduled start time, length, type, song key, @@ -357,6 +358,12 @@ below. Nothing is installed without someone clicking. is switched off, or it has no host — nothing to turn off and nothing to explain. + **Song Key** is one value, as large as the tile allows: the key of the item + ProPresenter has live, set as a note (B♭, F♯m), with the song's name and the + next key under it — both switches, as is the colour. When the feed that + picks the item is gone the key stays up, faded, and the tile's dot says so + rather than pretending to be current. + **Teams** shows the same plan's team and position assignments — each team as a band of its people, with their photo (fetched through ProdDash, never by the browser; initials when Planning Center has none) and a dot for diff --git a/modules/pco-plan/client.js b/modules/pco-plan/client.js index 451bdda..ca2f29d 100644 --- a/modules/pco-plan/client.js +++ b/modules/pco-plan/client.js @@ -1,8 +1,8 @@ /* PCO Plan — client part. - The module presents two tiles and this is the entry for both: the Teams - tile (picker entry "teams") is built by teams-client.js, everything below - draws the Plan tile. + The module presents three tiles and this is the entry for all of them: the + Teams tile (picker entry "teams") is built by teams-client.js and the Song + Key tile ("key") by key-client.js; everything below draws the Plan tile. The server part reads Planning Center and (optionally) follows the ProPresenter module's live item; this tile only renders the plan it is @@ -13,6 +13,7 @@ the connection settings. */ import createTeams from "./teams-client.js"; +import createKey from "./key-client.js"; const REFRESH_SVG = ''; @@ -21,8 +22,9 @@ const MINUS_SVG = ' +
+
+
+ `; + + const wrap = root.querySelector('.pk-wrap'); + const keyEl = root.querySelector('.pk-key'); + const nameEl = root.querySelector('.pk-name'); + const nextEl = root.querySelector('.pk-next'); + + const cfg = () => moduleApi.instanceSettings || {}; + const on = (key) => cfg()[key] !== false; // unset = shown + + /* ── the item on screen, and the one after it ───────────────────── */ + + function items() { + return Array.isArray(state?.items) ? state.items : []; + } + + function currentItem() { + const id = state?.live?.following ? state.live.currentItemId : ''; + return id ? items().find((it) => it.id === id) || null : null; + } + + /** The next item that actually has a key — a talk between two songs is skipped. */ + function nextKeyed(after) { + const list = items(); + const from = after ? list.findIndex((it) => it.id === after.id) + 1 : 0; + if (from <= 0) return null; + for (let i = from; i < list.length; i += 1) { + if (list[i].keyName) return list[i]; + } + return null; + } + + function titleOf(item) { + return item ? (item.song?.title || item.title || '') : ''; + } + + /* ── render ─────────────────────────────────────────────────────── */ + + function renderStatus() { + if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…'); + if (!state) return moduleApi.setStatus('connecting', 'Connecting…'); + if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin'); + if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin'); + if (!state.reachable) { + return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…'); + } + const item = currentItem(); + if (!state.live?.following) return moduleApi.setStatus('ok', 'Not following ProPresenter — no item is current'); + // The key on screen stays — the band still needs it — but a tile must + // never look confidently live while the feed that chose it is gone. + if (!state.live.available) { + return moduleApi.setStatus('error', `${state.live.reason || 'ProPresenter feed unavailable'} — showing the last item`); + } + if (!state.live.ppReachable) return moduleApi.setStatus('error', 'ProPresenter unreachable — showing the last item'); + if (!item) return moduleApi.setStatus('ok', 'Waiting for an item to go live'); + moduleApi.setStatus('ok', `${titleOf(item)}${item.keyName ? ` — ${item.keyName}` : ' — no key in the plan'}`); + } + + /** "Bb" → "B♭", "F#m" → "F♯m" — for the plain-text line under the key. */ + function prettyKey(text) { + const match = /^([A-G])([#b])(.*)$/.exec(String(text || '').trim()); + return match ? `${match[1]}${match[2] === '#' ? '♯' : '♭'}${match[3]}` : String(text || ''); + } + + /** + * A key is a note, so it is worth setting properly: "Bb" and "F#m" read + * better with the accidental raised and the minor kept small. + */ + function drawKey(text) { + keyEl.innerHTML = ''; + const match = /^([A-G])([#b♯♭]?)(.*)$/.exec(String(text).trim()); + if (!match) { + keyEl.textContent = text; + return; + } + const [, note, accidental, rest] = match; + keyEl.appendChild(document.createTextNode(note)); + if (accidental) { + const sup = document.createElement('span'); + sup.className = 'pk-accidental'; + sup.textContent = accidental === '#' ? '♯' : accidental === 'b' ? '♭' : accidental; + keyEl.appendChild(sup); + } + if (rest) { + const tail = document.createElement('span'); + tail.className = 'pk-quality'; + tail.textContent = rest; + keyEl.appendChild(tail); + } + } + + function render() { + wrap.classList.toggle('is-offline', feedOffline); + const live = state?.live; + wrap.classList.toggle('is-stale', Boolean(live?.following && (!live.available || !live.ppReachable))); + const color = String(cfg().keyColor || '').trim(); + wrap.style.setProperty('--pk-color', /^[#a-zA-Z0-9(),.%\s-]+$/.test(color) && color ? color : '#4ea1ff'); + renderStatus(); + + const item = currentItem(); + const key = item?.keyName || ''; + wrap.classList.toggle('has-key', Boolean(key)); + + if (!state || !state.configured || !state.serviceTypeSet) { + drawKey('—'); + nameEl.textContent = state ? 'Set up PCO Plan in Admin' : 'Connecting…'; + nextEl.textContent = ''; + return; + } + if (!item) { + drawKey('—'); + nameEl.textContent = state.live?.following ? 'Nothing live' : 'Not following ProPresenter'; + nextEl.textContent = ''; + return; + } + + drawKey(key || '—'); + nameEl.textContent = on('showSongName') ? titleOf(item) : ''; + nameEl.hidden = !nameEl.textContent; + + const next = on('showNextKey') ? nextKeyed(item) : null; + nextEl.textContent = next ? `Next: ${prettyKey(next.keyName)} · ${titleOf(next)}` : ''; + nextEl.hidden = !nextEl.textContent; + } + + /* ── live feed ──────────────────────────────────────────────────── */ + + function accept(payload) { + if (!payload || typeof payload !== 'object' || !payload.state) return; + state = payload.state; + feedOffline = false; + render(); + } + + function connect() { + stream = moduleApi.sse('/stream', { + open() { feedOffline = false; render(); }, + error() { feedOffline = true; render(); }, + events: { + state(ev) { + try { + accept(JSON.parse(ev.data)); + } catch { /* a bad frame is not fatal */ } + }, + }, + }); + } + + /* ── lifecycle ──────────────────────────────────────────────────── */ + + return { + start() { + moduleApi.setStatus('connecting', 'Connecting…'); + moduleApi.fetch('/state') + .then((res) => (res.ok ? res.json() : null)) + .then((body) => { if (body && !state) accept(body); }) + .catch(() => { /* the stream will cover it */ }); + connect(); + render(); + }, + stop() { + stream?.close(); + stream = null; + root.innerHTML = ''; + }, + onConfigChange() { + // Admin holds the connection only; the server re-inits and re-sends. + }, + }; +} diff --git a/modules/pco-plan/module.json b/modules/pco-plan/module.json index efd7ecd..1b17e5b 100644 --- a/modules/pco-plan/module.json +++ b/modules/pco-plan/module.json @@ -1,9 +1,9 @@ { "id": "pco-plan", "name": "PCO Plan", - "version": "1.8.0", + "version": "1.9.0", "proddash": ">=1.4.0", - "description": "Order of service and team assignments from Planning Center Services", + "description": "Order of service, team assignments and the song key from Planning Center Services", "provides": [ "timers" ], @@ -621,6 +621,36 @@ "columns": 2 } } + }, + { + "id": "key", + "name": "Song Key", + "description": "The key of the item on screen, big", + "defaultSize": { + "w": 2, + "h": 2 + }, + "minSize": { + "w": 1, + "h": 1 + }, + "instanceSchema": { + "keyColor": { + "type": "color", + "label": "Key", + "default": "#4ea1ff" + }, + "showSongName": { + "type": "boolean", + "label": "Song name", + "default": true + }, + "showNextKey": { + "type": "boolean", + "label": "Next key", + "default": true + } + } } ] } diff --git a/modules/pco-plan/style.css b/modules/pco-plan/style.css index 284f37c..952d4fd 100644 --- a/modules/pco-plan/style.css +++ b/modules/pco-plan/style.css @@ -635,25 +635,105 @@ [data-module="pco-plan"] .pp-abs-sec { flex: none; - padding: 0.1em 0.5em; - border-radius: 999px; - border: 1px solid var(--border); + padding: 0.14em 0.55em; + /* The same rounded square, tinted ground and transparent border the AbleSet + module gives its own section pills, so the two read as one family. */ + border-radius: 0.35em; + border: 1px solid transparent; + background: color-mix(in srgb, var(--text) 8%, var(--panel)); font-size: 0.72em; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; white-space: nowrap; color: var(--muted); - transition: color 0.2s ease, border-color 0.2s ease; + transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease; } [data-module="pco-plan"] .pp-abs-sec.is-done { opacity: 0.45; } /* The section now playing fills left to right as it goes, like the row. */ +/* The section playing keeps its own ground and fills across it, so the + progress reads against the chip rather than against the row behind it. */ [data-module="pco-plan"] .pp-abs-sec.is-now { color: var(--text); border-color: color-mix(in srgb, var(--pp-current) 60%, transparent); background: linear-gradient(90deg, - color-mix(in srgb, var(--pp-current) 40%, transparent) var(--pp-sec-fill, 0%), - transparent var(--pp-sec-fill, 0%)); + color-mix(in srgb, var(--pp-current) 45%, var(--panel)) var(--pp-sec-fill, 0%), + color-mix(in srgb, var(--text) 8%, var(--panel)) var(--pp-sec-fill, 0%)); } + +/* ── the Song Key tile ──────────────────────────────────────────────── + One value, as large as the tile allows. Sizes come from the tile itself + (container units), like the AbleSet module's single readouts, so a small + tile and a wall-sized one both read properly. */ + +[data-module="pco-plan"] .pk-wrap { + --pk-color: #4ea1ff; + position: absolute; + inset: 0; + container-type: size; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.2em; + padding: 4%; + text-align: center; + user-select: none; +} + +[data-module="pco-plan"] .pk-key { + font-size: clamp(18px, 46cqmin, 260px); + font-weight: 800; + line-height: 1; + letter-spacing: -0.01em; + color: var(--muted); + white-space: nowrap; +} +[data-module="pco-plan"] .pk-wrap.has-key .pk-key { color: var(--pk-color); } + +/* A key is a note, but it has to read as one word at a glance from the back + of a room: the accidental and the quality sit on the same line as the + letter, only smaller. Raising the accidental looked like two stacked + characters at tile sizes. */ +[data-module="pco-plan"] .pk-accidental { + font-size: 0.68em; + margin-left: 0.01em; +} +[data-module="pco-plan"] .pk-quality { + font-size: 0.5em; + font-weight: 700; + margin-left: 0.05em; + opacity: 0.85; +} + +[data-module="pco-plan"] .pk-name { + max-width: 100%; + font-size: clamp(9px, 9cqmin, 30px); + font-weight: 600; + line-height: 1.2; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +[data-module="pco-plan"] .pk-name[hidden] { display: none; } + +[data-module="pco-plan"] .pk-next { + max-width: 100%; + font-size: clamp(8px, 6.5cqmin, 20px); + font-weight: 600; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +[data-module="pco-plan"] .pk-next[hidden] { display: none; } + +[data-module="pco-plan"] .pk-wrap.is-offline { opacity: 0.6; } +/* The feed that picks the item is gone: the key stays readable, but faded, + so nobody trusts it as current without looking at the status dot. */ +[data-module="pco-plan"] .pk-wrap.is-stale .pk-key { opacity: 0.55; } +[data-module="pco-plan"] .pk-wrap.is-stale .pk-name, +[data-module="pco-plan"] .pk-wrap.is-stale .pk-next { opacity: 0.6; } From 06066529bcde6c5aab54b605cc1b7f4d35a7b9da Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 27 Sep 2026 11:27:31 -0400 Subject: [PATCH 03/12] Keys read as notes on the Plan tile's badge too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The item badge still showed Planning Center's raw "Bb" and "F#m" while the Song Key tile beside it showed B♭ and F♯m. One module should not spell the same thing two ways. The notation moves into key-notation.js and both tiles import it, rather than each keeping its own copy of the parse: the day one of them changes, they all should. Co-Authored-By: Claude Opus 5 --- README.md | 12 ++++++++---- modules/pco-plan/client.js | 4 +++- modules/pco-plan/key-client.js | 31 +++++++++++++------------------ modules/pco-plan/key-notation.js | 25 +++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 23 deletions(-) create mode 100644 modules/pco-plan/key-notation.js diff --git a/README.md b/README.md index 2d9f047..0efedf6 100644 --- a/README.md +++ b/README.md @@ -359,10 +359,14 @@ below. Nothing is installed without someone clicking. explain. **Song Key** is one value, as large as the tile allows: the key of the item - ProPresenter has live, set as a note (B♭, F♯m), with the song's name and the - next key under it — both switches, as is the colour. When the feed that - picks the item is gone the key stays up, faded, and the tile's dot says so - rather than pretending to be current. + ProPresenter has live, with the song's name and the next key under it — both + switches, as is the colour. When the feed that picks the item is gone the key + stays up, faded, and the tile's dot says so rather than pretending to be + current. + + Keys are written as notes wherever the module shows one: Planning Center's + `Bb` and `F#m` read as B♭ and F♯m, on the Plan tile's item badge and in the + Song Key tile alike. **Teams** shows the same plan's team and position assignments — each team as a band of its people, with their photo (fetched through ProdDash, never diff --git a/modules/pco-plan/client.js b/modules/pco-plan/client.js index ca2f29d..685bc75 100644 --- a/modules/pco-plan/client.js +++ b/modules/pco-plan/client.js @@ -14,6 +14,7 @@ import createTeams from "./teams-client.js"; import createKey from "./key-client.js"; +import { prettyKey } from "./key-notation.js"; const REFRESH_SVG = ''; @@ -369,7 +370,8 @@ function createPlan({ root, moduleApi }) { if (on('showKey') && item.keyName) { const key = document.createElement('span'); key.className = 'pp-key'; - key.textContent = item.keyName; + // B♭, F♯m — the same notation the Song Key tile uses. + key.textContent = prettyKey(item.keyName); titleLine.appendChild(key); } if (on('showNowIndicator')) { diff --git a/modules/pco-plan/key-client.js b/modules/pco-plan/key-client.js index eda8e5b..1297227 100644 --- a/modules/pco-plan/key-client.js +++ b/modules/pco-plan/key-client.js @@ -4,6 +4,8 @@ streamed state the Plan tile does, so it needs no connection of its own; which item is current comes from the ProPresenter module, as ever. */ +import { parseKey, prettyKey } from "./key-notation.js"; + export default function createKey({ root, moduleApi }) { let state = null; let stream = null; @@ -69,13 +71,7 @@ export default function createKey({ root, moduleApi }) { } if (!state.live.ppReachable) return moduleApi.setStatus('error', 'ProPresenter unreachable — showing the last item'); if (!item) return moduleApi.setStatus('ok', 'Waiting for an item to go live'); - moduleApi.setStatus('ok', `${titleOf(item)}${item.keyName ? ` — ${item.keyName}` : ' — no key in the plan'}`); - } - - /** "Bb" → "B♭", "F#m" → "F♯m" — for the plain-text line under the key. */ - function prettyKey(text) { - const match = /^([A-G])([#b])(.*)$/.exec(String(text || '').trim()); - return match ? `${match[1]}${match[2] === '#' ? '♯' : '♭'}${match[3]}` : String(text || ''); + moduleApi.setStatus('ok', `${titleOf(item)}${item.keyName ? ` — ${prettyKey(item.keyName)}` : ' — no key in the plan'}`); } /** @@ -84,23 +80,22 @@ export default function createKey({ root, moduleApi }) { */ function drawKey(text) { keyEl.innerHTML = ''; - const match = /^([A-G])([#b♯♭]?)(.*)$/.exec(String(text).trim()); - if (!match) { + const parts = parseKey(text); + if (!parts) { keyEl.textContent = text; return; } - const [, note, accidental, rest] = match; - keyEl.appendChild(document.createTextNode(note)); - if (accidental) { - const sup = document.createElement('span'); - sup.className = 'pk-accidental'; - sup.textContent = accidental === '#' ? '♯' : accidental === 'b' ? '♭' : accidental; - keyEl.appendChild(sup); + keyEl.appendChild(document.createTextNode(parts.note)); + if (parts.accidental) { + const acc = document.createElement('span'); + acc.className = 'pk-accidental'; + acc.textContent = parts.accidental; + keyEl.appendChild(acc); } - if (rest) { + if (parts.quality) { const tail = document.createElement('span'); tail.className = 'pk-quality'; - tail.textContent = rest; + tail.textContent = parts.quality; keyEl.appendChild(tail); } } diff --git a/modules/pco-plan/key-notation.js b/modules/pco-plan/key-notation.js new file mode 100644 index 0000000..f5b7dba --- /dev/null +++ b/modules/pco-plan/key-notation.js @@ -0,0 +1,25 @@ +/* PCO Plan — how a song key is written. + + Planning Center stores keys as typed: "Bb", "F#m", "Ab". Every tile in this + module shows them as notes instead — B♭, F♯m, A♭ — so the plan, the badge + on an item and the Song Key tile all agree. Shared rather than repeated + because the day one of them changes, they all should. */ + +/** "F#m" → { note: 'F', accidental: '♯', quality: 'm' }; anything odd → null. */ +export function parseKey(raw) { + const match = /^([A-G])\s*([#b♯♭]?)\s*(.*)$/.exec(String(raw || '').trim()); + if (!match) return null; + const [, note, accidental, quality] = match; + return { + note, + accidental: accidental === '#' ? '♯' : accidental === 'b' ? '♭' : accidental, + quality: quality.trim(), + }; +} + +/** The same thing as one string, for a badge or a caption. */ +export function prettyKey(raw) { + const parts = parseKey(raw); + if (!parts) return String(raw || '').trim(); + return `${parts.note}${parts.accidental}${parts.quality}`; +} From 32180bf2995eec0c602921f15284592c9d07e852 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 27 Sep 2026 11:32:10 -0400 Subject: [PATCH 04/12] Centre the text in the song-section chips The chips looked top-heavy, and measuring said so: the capitals sat 5.12px below the top of the chip and 4.22px above the bottom. Uppercase text has no descenders, so the font's descent allowance below the baseline is empty space the letters never use, while the gap between the ascender line and the cap height sits above them. The padding is now a touch shallower on top and deeper below, which puts the letters themselves in the middle: 4.67px above, 4.68px below. In em, so it holds wherever the tile's text size is set. Co-Authored-By: Claude Opus 5 --- modules/pco-plan/style.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/modules/pco-plan/style.css b/modules/pco-plan/style.css index 952d4fd..a4e037b 100644 --- a/modules/pco-plan/style.css +++ b/modules/pco-plan/style.css @@ -635,7 +635,12 @@ [data-module="pco-plan"] .pp-abs-sec { flex: none; - padding: 0.14em 0.55em; + /* Uppercase text has no descenders, so the font's descent allowance below + the baseline leaves the caps sitting high in the box — measured at 0.9px + too much headroom. The padding is a touch shallower on top and deeper + below to centre the letters themselves; in em, so it holds at any text + size. */ + padding: 0.095em 0.55em 0.185em; /* The same rounded square, tinted ground and transparent border the AbleSet module gives its own section pills, so the two read as one family. */ border-radius: 0.35em; From ece2c0a2c31309a97a40bc011a6bd8081c681417 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Tue, 29 Sep 2026 14:57:01 -0400 Subject: [PATCH 05/12] Centre the NOW tag; the type badge was already fine Re-measuring the badges properly changed the answer. The earlier reading that said the type badge sat 0.52px high was measuring the wrong glyphs: it fed the canvas the element's raw text ("Song", "Media") while the page renders text-transform: uppercase ("SONG", "MEDIA"), and a lowercase 'd' or 'g' has an ascender and a descender the capitals never draw. Measured against the text as actually rendered, the type badge is within 0.10px of centred and wants nothing. The NOW tag was the one off, by 0.59px, the same way the section chips were. So: the badge padding added in the last commit is reverted, and the NOW tag gets the correction instead. That line now reads, in px off centre: NOW 0.01, type badge 0.10, key badge 0.16, section chip -0.01. Co-Authored-By: Claude Opus 5 --- modules/pco-plan/style.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/pco-plan/style.css b/modules/pco-plan/style.css index a4e037b..035ee48 100644 --- a/modules/pco-plan/style.css +++ b/modules/pco-plan/style.css @@ -206,7 +206,9 @@ [data-module="pco-plan"] .pp-now-tag { display: none; - padding: 0.05em 0.45em; + /* Optically centred like the section chips: uppercase has no descenders, + so the capitals sat 0.59px low in the pill. */ + padding: 0.018em 0.45em 0.082em; border-radius: 999px; background: var(--pp-current); color: var(--bg); From 07e4c7841768a9ee999c8ebae36ca2ea27f454bc Mon Sep 17 00:00:00 2001 From: Waters Church Date: Tue, 29 Sep 2026 16:34:34 -0400 Subject: [PATCH 06/12] Module rules: a native helper ships prebuilt, for Apple silicon and Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The machine running ProdDash is a packaged app or the Windows bundle plus modules from the admin page's catalog. It has no compiler, and the catalog delivers exactly the folder that is in the repo — so a module's native program (audio capture, a decoder) ships already built, inside the module folder, one file per platform: bin/-macos-arm64 and bin/-win-x64.exe are required, Intel macOS optional, source and the script that rebuilds every binary under native/. The guide's rule 3 now says so, "The shape of a module" shows the layout, and a new "Native helpers" section gives the contract: pick the binary by platform at runtime, report the feature off when there is none — never build, never search the machine, never ask the operator to install anything — one command-line contract in any language per platform, bump the version when a binary changes, keep them lean, sign the macOS one. CLAUDE.md carries the one-line version. Rules only: no module changes here. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 7 ++++- docs/MODULE-GUIDE.md | 69 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 10308d3..ab20021 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,12 @@ title-bar controls). The rules that matter most: ship inside the module folder** (zero deps is the norm). **Everything the module does runs inside its server entry** — no helper scripts, companion programs or daemons the operator starts by hand; a listener or decoder is - started in `init()` and stopped in `stop()`. Declare `"proddash": ">=x.y.z"` + started in `init()` and stopped in `stop()`. **A native helper ships + prebuilt inside the module folder for Apple silicon macOS and 64-bit + Windows** (`bin/-macos-arm64`, `bin/-win-x64.exe`, source and + build script under `native/`); nothing is ever compiled on the machine + running ProdDash, and a missing binary means the feature reports itself + off, not a broken module. Declare `"proddash": ">=x.y.z"` and bump the module's `version` on every change. - Browsers never call upstream services directly; the module's server routes proxy everything (no CORS, secrets stay server-side). diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 4660e0e..7de6e4f 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -28,7 +28,10 @@ people running the booth depend on them. ProdDash. If your feature needs a listener, a decoder or a worker, your server entry owns it: start it in `init()`, stop it in `stop()`, report it in `health()`. A child process the module itself spawns and supervises - is fine; a program someone launches by hand is not. + is fine; a program someone launches by hand is not. **A native helper — + anything that is not JavaScript — ships prebuilt inside the module folder, + for Apple silicon macOS and 64-bit Windows; nothing is ever compiled on + the machine running ProdDash** (see "Native helpers" below). 4. **Declare the ProdDash version you need** (`"proddash": ">=1.1.0"` in the manifest) and **bump your own `version`** whenever you change the module. The admin page refuses to install a module whose requirement the running @@ -83,6 +86,10 @@ modules// ├── module.json manifest (required) ├── client.js ES module, client entry (required) ├── server.js server entry (optional — only if you need server code) +├── bin/ native helpers, prebuilt per platform (only if you need one) +│ ├── -macos-arm64 +│ └── -win-x64.exe +├── native/ their source and the script that builds every bin/ file └── ... assets (css, images…) served statically at /modules// ``` @@ -97,6 +104,66 @@ the newer version runs. **Uninstall** in the admin page removes an installed module's files (a bundled one is hidden instead, since its folder belongs to the app); its settings are kept for a later reinstall. +### Native helpers + +Some features need a program JavaScript cannot be: reading an audio input, +decoding timecode, measuring sound. Rule 3 says the module owns that program; +this section says how it ships: **already built, for every platform it runs +on, inside the module folder.** The machine running ProdDash is a packaged +app or the Windows bundle plus modules from the admin page's catalog. It has +no compiler and never will — the catalog delivers exactly the folder that is +in the repo, so the binaries live in git next to the code. + +- **Platforms.** Apple silicon macOS and 64-bit Windows are required. Intel + macOS is welcome but optional. One file per platform in `bin/`, named for + what it is: + + | Platform | File | + | --- | --- | + | macOS, Apple silicon | `bin/-macos-arm64` | + | Windows, 64-bit | `bin/-win-x64.exe` | + | macOS, Intel (optional) | `bin/-macos-x86_64` | + +- **Pick it at runtime, and cope without it.** Choose by `process.platform` + and `process.arch`; when there is no file for this machine, the feature + says so — in `health()` and in the tile's `setStatus` — and everything else + in the module keeps working. Never try to build it, never look for a copy + elsewhere on the machine, never tell the operator to install anything. + + ```js + const HELPERS = { + darwin: { arm64: 'ltc-capture-macos-arm64', x64: 'ltc-capture-macos-x86_64' }, + win32: { x64: 'ltc-capture-win-x64.exe' }, + }; + const name = HELPERS[process.platform]?.[process.arch]; + const helper = name ? path.join(__dirname, 'bin', name) : ''; + if (!helper || !fs.existsSync(helper)) { + // Timecode is off on this platform. Say it once, keep the clock running. + } + ``` + +- **One contract, any language.** Every platform's binary speaks the same + command line and output format, so `server.js` has one code path. What is + behind it is your choice per platform — Swift against CoreAudio on macOS, + C#, C or Rust on Windows — the module does not care. +- **Source and build beside it.** Keep the source under `native/` with one + script that produces every `bin/` file from a checkout, so any binary can be + rebuilt from what is committed. A helper that cannot be rebuilt does not + ship. +- **Bump the module version whenever a binary changes**, like any other + file — that is how installs learn about it. +- **Keep them lean.** Optimised builds, no debug symbols, a few megabytes at + most: the catalog fetches the whole repo as one archive, capped at 80 MB. +- **Signing.** Apple silicon refuses unsigned code, so sign the macOS binary + — Developer ID when you have it, ad-hoc at least. Windows needs nothing: + files the app writes carry no download mark, so SmartScreen never sees + them. Permissions come from the launcher (see "Permissions macOS has to + grant"), not from the binary. + +A helper that ends up existing for one platform only is an exception made +on purpose, not a default: say so in the module's README and in the health +line, so a Windows booth knows why the feature is off. + ## module.json ```json From ac1f17b9ed3e865c90256f665acf273116ccb770 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Wed, 30 Sep 2026 09:34:10 -0400 Subject: [PATCH 07/12] Shell 1.10.0: a packaged ProdDash carries no modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS app and the Windows bundle shipped every module in the repo, on by default, and a shell update silently added whatever new module had landed on main — Band Lineup appeared on AB that way. A module was also only as fresh as the last shell update. Now a packaged install (no .git — the same test the updater uses to choose the archive over git pull) carries no modules at all: every one arrives from the admin page's catalog into the data directory, and the shell's own update never writes modules/. A git checkout keeps running the modules in its tree. Existing installs move over by themselves: at the first start, the bundled modules that are switched on are copied into the data directory as installed copies (a compiled helper keeps its mode), an uninstalled one is dropped, and the app folder's modules/ is removed. Settings are keyed by module id, so nothing changes for the operator. Around it: the dashboard's empty state says "No modules yet — open Admin" when the registry is empty; the admin page says "Nothing installed yet", puts a checkbox on every compatible catalog entry and an Install selected button (POST /api/admin/modules/install {ids}) that fetches them in one go. The Mac build stops copying modules/ and no longer compiles or signs Swift helpers — those ship prebuilt inside a module, per the guide. tools/unbundled.test.js drives a packaged copy of the shell through the move, an archive update that must not bring modules back, and a multi-install. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 3 +- README.md | 18 ++-- docs/MODULE-GUIDE.md | 17 ++-- launcher/macos/README.md | 7 +- launcher/macos/build.sh | 27 +----- package.json | 2 +- public/admin/admin.css | 1 + public/admin/admin.js | 43 +++++++++ public/admin/index.html | 1 + public/shell.js | 10 ++ server.js | 79 +++++++++++++++ tools/unbundled.test.js | 203 +++++++++++++++++++++++++++++++++++++++ 12 files changed, 370 insertions(+), 41 deletions(-) create mode 100644 tools/unbundled.test.js diff --git a/CLAUDE.md b/CLAUDE.md index ab20021..572c2c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,8 @@ Modular production dashboard web app for Waters Church, developed in this repo. The app lives at the repo root and is built: zero-dependency Node server (`server.js`, Node 18+), dashboard shell client in `public/`, admin page at -`/admin`, modules in `modules/`, and a native macOS menu-bar launcher in +`/admin`, modules in `modules/` (the repo's copies — a packaged install ships +none and installs them from the admin page's catalog), and a native macOS menu-bar launcher in `launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries the server and its own Node runtime, supervises them, and owns the macOS permissions modules declare; see its README). Runtime state (module diff --git a/README.md b/README.md index 0efedf6..515c758 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,9 @@ and a clear degraded state, then recover on their own. Open the disk image and drag **ProdDash** to Applications. That's the whole installation: the app carries the dashboard and its own Node runtime, so -nothing else has to be installed and nothing can be missing. It lives in the +nothing else has to be installed. It carries no modules — open **Admin → +Available modules**, tick the ones this machine needs and press **Install +selected**; each updates on its own from then on. It lives in the menu bar, starts and stops the server, can open at login, and holds the permissions the modules need — the microphone the LTC listener reads, and local network access for ProdCom and ProPresenter. macOS grants those to the @@ -179,13 +181,15 @@ below. Nothing is installed without someone clicking. - **Available modules** lists modules in the repo that aren't installed here. **Install** downloads the module into the data directory's `modules/` and starts it right away — nothing is written into the app folder, and no - restart is needed. Modules that ship with your copy of ProdDash are - "bundled"; ones you installed are "installed"; if both exist, the newer - version runs. + restart is needed. Tick several and press **Install selected** to fetch + them in one go. A packaged ProdDash (the app, the Windows bundle) ships + with no modules at all: this list is how every one of them arrives, and a + ProdDash update never brings one along. A git checkout also runs the + modules in its own tree ("bundled"); if both exist, the newer version runs. - **Uninstall** on a module card removes an installed module's files. For a - bundled module (its folder belongs to the app) it hides the module from - every dashboard instead; it reappears under Available modules and can be - brought back without downloading. Settings are kept either way. + checkout's bundled module (its folder belongs to the repo) it hides the + module from every dashboard instead; it reappears under Available modules + and can be brought back without downloading. Settings are kept either way. - **Module updates**: when the repo has a newer version of an installed module, its card shows **Update to vX**. - **Version requirements**: every module declares the ProdDash versions it diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 7de6e4f..e41c2f5 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -96,13 +96,16 @@ modules// **Installation is exactly this:** in the admin page, open **Available modules** and press **Install** — the module is downloaded from the ProdDash repo into the server's data directory (`/modules//`) and starts -immediately. Developing locally? Drop the folder into `modules/` (bundled -with the checkout) or into the data directory's `modules/` and restart. No -shell code changes, ever. Modules bundled with a checkout and modules -installed from the admin page live side by side; when both have the same id -the newer version runs. **Uninstall** in the admin page removes an installed -module's files (a bundled one is hidden instead, since its folder belongs to -the app); its settings are kept for a later reinstall. +immediately. A packaged ProdDash — the macOS app, the Windows bundle — ships +with **no modules at all**: every one of them arrives this way, and the +shell's own update never brings one along, so what is in git is exactly what +runs on a booth. Developing locally? A git checkout runs whatever is in its +own `modules/` folder (the repo's copies, "bundled"), or drop a folder into +the data directory's `modules/` and restart. No shell code changes, ever. In +a checkout the two live side by side; when both have the same id the newer +version runs. **Uninstall** in the admin page removes an installed module's +files (in a checkout, the tree's copy is hidden instead, since that folder +belongs to the repo); its settings are kept for a later reinstall. ### Native helpers diff --git a/launcher/macos/README.md b/launcher/macos/README.md index 9ea0e6d..c287a19 100644 --- a/launcher/macos/README.md +++ b/launcher/macos/README.md @@ -68,9 +68,10 @@ Builds for **Apple Silicon**. To change that, or to move to a newer Node, edit against nodejs.org's published checksums either way. The app icon is built from `public/icons/icon-512.png`, so ProdDash's own icon -is the app's icon. A module's native helper is compiled during the build -(`.swift` beside a module's files becomes ``), so the Mac running -ProdDash never needs a compiler. +is the app's icon. The app carries no modules: they are installed from the +admin page's catalog into the data directory, and a module's native helper +ships prebuilt inside it (see the module guide), so the Mac running ProdDash +never needs a compiler. ## Signing diff --git a/launcher/macos/build.sh b/launcher/macos/build.sh index 5bb9b0b..43d3580 100755 --- a/launcher/macos/build.sh +++ b/launcher/macos/build.sh @@ -1,6 +1,8 @@ #!/bin/zsh # Build ProdDash.app — the whole application: launcher, server and its own -# Node runtime, with nothing to install first. +# Node runtime, with nothing to install first. It carries no modules: those +# come from the admin page's catalog into the data directory, and a module's +# native helper ships prebuilt inside it (docs/MODULE-GUIDE.md). # # ./build.sh build/ProdDash.app, self-contained # ./build.sh --dmg …and build/ProdDash--arm64.dmg @@ -127,23 +129,14 @@ if [[ $DEV -eq 0 ]]; then tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \ "node-$NODE_VERSION-darwin-$ARCH/bin/node" - echo " embedding ProdDash $VERSION" + echo " embedding ProdDash $VERSION (no modules — the catalog installs them)" mkdir -p "$PAYLOAD/config" cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$PAYLOAD/" - cp -R "$REPO_ROOT/public" "$REPO_ROOT/modules" "$PAYLOAD/" + cp -R "$REPO_ROOT/public" "$PAYLOAD/" # Only the checked-in defaults: config/ also holds this machine's own # modules.json and layouts when ProdDash has ever run from the checkout. cp "$REPO_ROOT/config/proddash.json" "$PAYLOAD/config/" find "$PAYLOAD" -name '.DS_Store' -delete - - # A module's native helper is built here, so the Mac running ProdDash needs - # no compiler: .swift beside a module's files becomes . - for source in "$PAYLOAD"/modules/*/*.swift; do - [[ -e "$source" ]] || continue - helper="${source%.swift}" - echo " compiling $(basename "$(dirname "$source")")/$(basename "$helper")" - swiftc -O -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$helper" "$source" - done fi # ── signing ────────────────────────────────────────────────────────────── @@ -165,16 +158,6 @@ fi if [[ -f "$CONTENTS/Resources/node/bin/node" ]]; then codesign "${SIGN[@]}" --entitlements Resources/node.entitlements "$CONTENTS/Resources/node/bin/node" fi -if [[ $DEV -eq 0 ]]; then - for helper in "$PAYLOAD"/modules/*/*; do - # A compiled helper is the extensionless executable beside a module's - # sources — test the name, not the path, which has dots all over it. - name=$(basename "$helper") - [[ -f "$helper" && -x "$helper" && "$name" != *.* ]] || continue - echo " signing $name" - codesign "${SIGN[@]}" --entitlements Resources/helper.entitlements "$helper" - done -fi codesign "${SIGN[@]}" --entitlements Resources/ProdDash.entitlements --identifier "$BUNDLE_ID" "$APP" codesign --verify --deep --strict "$APP" && echo " signature verifies" diff --git a/package.json b/package.json index c07ca1e..d70640d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proddash", - "version": "1.9.2", + "version": "1.10.0", "private": true, "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "license": "UNLICENSED", diff --git a/public/admin/admin.css b/public/admin/admin.css index 12f0018..0a7ae91 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -443,6 +443,7 @@ a.btn { text-decoration: none; } margin-bottom: 10px; } .catalog-main { flex: 1; min-width: 0; } +.catalog-pick { margin: 5px 10px 0 0; flex: none; } .catalog-title { display: flex; align-items: center; gap: 8px; } .catalog-row .module-desc { margin: 4px 0 2px; } .catalog-row .module-health { margin-top: 2px; } diff --git a/public/admin/admin.js b/public/admin/admin.js index f035683..18dff9b 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -962,6 +962,13 @@ async function loadState() { for (const mod of state.modules || []) { modulesList.appendChild(buildModuleCard(mod)); } + if (!(state.modules || []).length) { + // A packaged ProdDash starts like this: the catalog below is the way in. + const none = document.createElement('div'); + none.className = 'layouts-empty'; + none.textContent = 'Nothing installed yet — pick from Available modules below.'; + modulesList.appendChild(none); + } renderPin(state); loadTheme(); loadLayouts(); @@ -1163,6 +1170,7 @@ function renderAvailable(cat) { els.updateSlot.appendChild(btn); } const available = cat.available || []; + installSelectedBtn.hidden = available.filter((a) => a.compatible).length < 2; if (!available.length) { const none = document.createElement('div'); none.className = 'layouts-empty'; @@ -1205,11 +1213,46 @@ function renderAvailable(cat) { btn.disabled = !item.compatible; btn.title = item.compatible ? '' : item.reason; btn.addEventListener('click', () => installFromCatalog(item, btn)); + if (item.compatible) { + const pick = document.createElement('input'); + pick.type = 'checkbox'; + pick.className = 'catalog-pick'; + pick.dataset.id = item.id; + pick.dataset.name = item.name; + pick.title = 'Tick several, then Install selected'; + row.append(pick); + } row.append(main, btn); availableList.appendChild(row); } } +const installSelectedBtn = document.getElementById('install-selected'); + +/** The ticked modules, in one request; the answer says which ones landed. */ +async function installSelected() { + const picks = [...availableList.querySelectorAll('.catalog-pick:checked')]; + if (!picks.length) return toast('Tick the modules to install first', true); + installSelectedBtn.disabled = true; + installSelectedBtn.textContent = `Installing ${picks.length}…`; + try { + const r = await api('/api/admin/modules/install', { method: 'POST', body: JSON.stringify({ ids: picks.map((p) => p.dataset.id) }) }); + const done = (r.results || []).filter((x) => x.ok).length; + const failed = (r.results || []).filter((x) => !x.ok); + toast(failed.length + ? `${done} installed; ${failed.map((f) => `${f.id}: ${f.error}`).join(' · ')}` + : `${done} module${done === 1 ? '' : 's'} installed`, failed.length > 0); + await loadState(); + } catch (e) { + if (e.authRequired) showLogin(); + else toast(e.message, true); + } finally { + installSelectedBtn.disabled = false; + installSelectedBtn.textContent = 'Install selected'; + } +} +installSelectedBtn.addEventListener('click', installSelected); + async function installFromCatalog(item, btn) { btn.disabled = true; const label = btn.textContent; diff --git a/public/admin/index.html b/public/admin/index.html index ef9fe9c..fa819f0 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -58,6 +58,7 @@