diff --git a/.gitignore b/.gitignore index e3910f3..3b6c710 100644 --- a/.gitignore +++ b/.gitignore @@ -8,12 +8,8 @@ dist/ config/modules.json config/layouts/ -# compiled per-machine, source sits next to it -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/ - -# Compiled per-machine, source sits next to it (see spl-capture.swift). -modules/spl-meter/spl-capture +launcher/windows/build/ +launcher/windows/.cache/ diff --git a/CLAUDE.md b/CLAUDE.md index 10308d3..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 @@ -28,7 +29,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/README.md b/README.md index f425f4b..eda564d 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,23 @@ 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 +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 @@ -43,12 +55,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. @@ -179,13 +205,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 @@ -325,8 +353,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 +386,16 @@ 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, 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 by the browser; initials when Planning Center has none) and a dot for diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 4660e0e..1429df2 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,19 +86,86 @@ 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// ``` **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 + +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 @@ -391,7 +461,8 @@ const menu = moduleApi.header.addMenu({ build(menuEl) { // called on every open with an emptied menu element — fill it with // buttons (give them your scoped classes; the container is styled and - // fixed-positioned by the shell, so the tile's overflow can't clip it) + // positioned by the shell, hosted on the body under an element carrying + // your data-module, so your scoped CSS applies and nothing clips it) }, }); menu.setLabel('Channels (2/4) ▾'); // update the button text any time diff --git a/launcher/macos/README.md b/launcher/macos/README.md index 9ea0e6d..e97c9eb 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) @@ -68,9 +69,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..e339d73 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 @@ -11,6 +13,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 +31,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 +115,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,25 +134,16 @@ 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" + 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 +165,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/launcher/windows/build.sh b/launcher/windows/build.sh new file mode 100755 index 0000000..0bf4bbc --- /dev/null +++ b/launcher/windows/build.sh @@ -0,0 +1,121 @@ +#!/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. It carries no +# modules: those come from the admin page's catalog into %APPDATA%\ProdDash. +# +# ./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 (no modules — the catalog installs them)" +cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$STAGE/" +cp -R "$REPO_ROOT/public" "$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. +4. The bundle ships without modules. On the admin page, tick the ones this + PC needs under Available modules and press Install selected; each one + updates on its own from then on. + +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}'))" diff --git a/modules/pco-plan/client.js b/modules/pco-plan/client.js index 451bdda..685bc75 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,8 @@ the connection settings. */ import createTeams from "./teams-client.js"; +import createKey from "./key-client.js"; +import { prettyKey } from "./key-notation.js"; const REFRESH_SVG = ''; @@ -21,8 +23,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 ? ` — ${prettyKey(item.keyName)}` : ' — no key in the plan'}`); + } + + /** + * 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 parts = parseKey(text); + if (!parts) { + keyEl.textContent = text; + return; + } + 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 (parts.quality) { + const tail = document.createElement('span'); + tail.className = 'pk-quality'; + tail.textContent = parts.quality; + 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/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}`; +} 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..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); @@ -635,25 +637,110 @@ [data-module="pco-plan"] .pp-abs-sec { flex: none; - padding: 0.1em 0.5em; - border-radius: 999px; - border: 1px solid var(--border); + /* 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; + 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; } diff --git a/modules/prodcom-transcript/client.js b/modules/prodcom-transcript/client.js index 87a1980..95dadf8 100644 --- a/modules/prodcom-transcript/client.js +++ b/modules/prodcom-transcript/client.js @@ -81,8 +81,8 @@ export default function create({ root, moduleApi }) { moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) }); applyInstanceSettings(); } - moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) }); - moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) }); + const minusBtn = moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) }); + const plusBtn = moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) }); // icon and tooltip track the current direction — applyInstanceSettings sets both const flowBtn = moduleApi.header.addButton({ @@ -95,7 +95,7 @@ export default function create({ root, moduleApi }) { }, }); - moduleApi.header.addButton({ + const latestBtn = moduleApi.header.addButton({ icon: JUMP_SVG, title: 'Latest', onClick() { @@ -112,6 +112,82 @@ export default function create({ root, moduleApi }) { }); clearBtn.classList.add('pt-clear'); + /* ── a narrow tile: the bar keeps Channels, the rest fold into ⋯ ── + Seven controls need ~175 px, and the shell clips whatever the bar + cannot fit — on a 3-column tile among many, or one squeezed by the + fullscreen fit, that used to take the channel filter with it. Below + COMPACT_PX the six secondary controls hide and a single ⋯ menu offers + the same actions, so the filter button is the last thing to go. */ + const MORE_SVG = ''; + const COMPACT_PX = 380; // tile width below which the full bar no longer fits + + let moreMenuEl = null; + const moreMenu = moduleApi.header.addMenu({ + icon: MORE_SVG, + title: 'More', + build(menu) { + menu.classList.add('pt-menu'); + moreMenuEl = menu; + rebuildMoreMenu(); + }, + }); + const moreWrap = moreMenu.button.parentElement; // the shell's .tile-dd + const secondaryBtns = [timeBtn, minusBtn, plusBtn, flowBtn, latestBtn, clearBtn]; + + function addMoreItem(label, { on = null, onClick } = {}) { + const item = document.createElement('button'); + item.className = 'pt-channel-item' + (on ? ' on' : ''); + if (on !== null) { + item.setAttribute('role', 'menuitemcheckbox'); + item.setAttribute('aria-checked', String(on)); + } else { + item.setAttribute('role', 'menuitem'); + } + const check = document.createElement('span'); + check.className = 'check'; + check.textContent = '✓'; + const name = document.createElement('span'); + name.className = 'name'; + name.textContent = label; + item.append(check, name); + item.addEventListener('click', (e) => { + e.stopPropagation(); // the rebuild detaches this item — keep the menu open + onClick(); + }); + moreMenuEl.appendChild(item); + } + + function rebuildMoreMenu() { + if (!moreMenuEl) return; + moreMenuEl.innerHTML = ''; + const s = moduleApi.instanceSettings; + addMoreItem('Timestamps', { on: Boolean(s.showTimes), onClick: () => timeBtn.click() }); + addMoreItem('Newest first', { on: newestFirst(), onClick: () => flowBtn.click() }); + const divider = document.createElement('div'); + divider.className = 'pt-menu-divider'; + moreMenuEl.appendChild(divider); + addMoreItem('Smaller text', { onClick: () => bumpTextSize(-2) }); + addMoreItem('Larger text', { onClick: () => bumpTextSize(2) }); + addMoreItem('Latest', { onClick: () => { moreMenu.close(); latestBtn.click(); } }); + addMoreItem('Clear', { onClick: () => { moreMenu.close(); clearView(); } }); + } + + let compact = null; // unknown until the first measurement + function applyCompact(width) { + const next = width > 0 && width < COMPACT_PX; + if (next === compact) return; + compact = next; + for (const btn of secondaryBtns) btn.hidden = compact; + moreWrap.hidden = !compact; + if (!compact) moreMenu.close(); + } + // the tile's own body tells the width; onResize only knows grid cells + const sizeWatch = new ResizeObserver((entries) => { + applyCompact(entries[0]?.contentRect.width || root.clientWidth); + }); + sizeWatch.observe(root); + applyCompact(root.clientWidth); + function applyInstanceSettings() { const s = moduleApi.instanceSettings; const size = Math.min(48, Math.max(10, Number(s.textSize) || 15)); @@ -126,6 +202,7 @@ export default function create({ root, moduleApi }) { flowBtn.classList.toggle('active', up); flowBtn.title = up ? 'Newest first' : 'Oldest first'; jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages'; + rebuildMoreMenu(); // its checkmarks mirror the buttons } applyInstanceSettings(); @@ -415,6 +492,7 @@ export default function create({ root, moduleApi }) { }, stop() { stopped = true; + sizeWatch.disconnect(); stream?.close(); root.innerHTML = ''; // header controls are removed by the shell }, diff --git a/modules/prodcom-transcript/module.json b/modules/prodcom-transcript/module.json index 170610a..d5ee214 100644 --- a/modules/prodcom-transcript/module.json +++ b/modules/prodcom-transcript/module.json @@ -1,7 +1,7 @@ { "id": "prodcom-transcript", "name": "ProdCom", - "version": "1.3.2", + "version": "1.3.3", "proddash": ">=1.1.0", "description": "Live ProdCom transcript", "permissions": [ diff --git a/modules/prodcom-transcript/style.css b/modules/prodcom-transcript/style.css index 0153e8d..0867c40 100644 --- a/modules/prodcom-transcript/style.css +++ b/modules/prodcom-transcript/style.css @@ -20,9 +20,13 @@ } [data-module="prodcom-transcript"] .pt-count[hidden] { display: none; } +/* narrow tile: the secondary controls fold into the ⋯ menu (client.js) */ +[data-module="prodcom-transcript"] .tile-btn[hidden], +[data-module="prodcom-transcript"] .tile-dd[hidden] { display: none; } + /* ── channels menu ── The menu container is created and positioned by the shell (.tile-menu); - it lives inside the tile, so [data-module] scoping still applies. */ + it hangs off the body under a [data-module] host, so scoping still applies. */ [data-module="prodcom-transcript"] .pt-channel-item { display: flex; diff --git a/modules/propresenter-timers/bin/ltc-capture-macos-arm64 b/modules/propresenter-timers/bin/ltc-capture-macos-arm64 new file mode 100755 index 0000000..55b3877 Binary files /dev/null and b/modules/propresenter-timers/bin/ltc-capture-macos-arm64 differ diff --git a/modules/propresenter-timers/bin/ltc-capture-macos-x86_64 b/modules/propresenter-timers/bin/ltc-capture-macos-x86_64 new file mode 100755 index 0000000..a30ffd6 Binary files /dev/null and b/modules/propresenter-timers/bin/ltc-capture-macos-x86_64 differ diff --git a/modules/propresenter-timers/ltc-listener.js b/modules/propresenter-timers/ltc-listener.js index d98a1b8..2274aaa 100644 --- a/modules/propresenter-timers/ltc-listener.js +++ b/modules/propresenter-timers/ltc-listener.js @@ -3,8 +3,8 @@ /** * Built-in LTC listener — capture child + in-process decode. * - * Spawns the module's native capture tool (ltc-capture, compiled from - * ltc-capture.swift beside it) for the admin-configured audio device, reads + * Spawns the module's native capture tool (bin/ltc-capture-macos-, + * shipped prebuilt; source in native/) for the admin-configured audio device, reads * the interleaved s16le stream, extracts the configured channel, and decodes * SMPTE LTC with ltc.js — all inside the module server. Reports upward via * onUpdate roughly 4×/s: @@ -16,17 +16,13 @@ * shows (short — "Not configured — see Admin"), `detail` is the full story * for the admin health line and the log. * - * The capture binary is per-machine (gitignored), and a catalog install or - * update ships source only — the update even wipes a previously built - * binary. ensureCaptureBinary() therefore rebuilds it with swiftc in the - * background as part of coming up: kicked when the LTC source starts and by - * the admin device picker, never on the event loop (a blocking build once - * froze the whole server — see spl-meter's history), built to a temp name - * and renamed so a killed build can't leave a half-written "binary", and - * rebuilt when a module update leaves the source newer than the binary. - * A dead capture child is restarted with backoff — fast for a crash, slow - * for config-shaped failures (unknown device, ambiguity) that never fix - * themselves. + * The capture tool ships prebuilt inside the module, one binary per + * platform under bin/ (module guide, "Native helpers"): nothing is ever + * built on the machine running ProdDash. captureTool() picks the binary for + * this platform; when there is none, timecode reports itself off and the + * rest of the module carries on. A dead capture child is restarted with + * backoff — fast for a crash, slow for config-shaped failures (unknown + * device, ambiguity) that never fix themselves. * * macOS gotcha this file can't fix: microphone permission. A ProdDash * server launched over SSH or from a bare launchd job gets SILENT ZEROS @@ -51,107 +47,43 @@ const RUNNING_WINDOW_MS = 400; /** stderr line the capture prints once the device is open. */ const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/; -/* ── the capture tool: build it when the install didn't bring one ──────── */ +/* ── the capture tool: prebuilt, one binary per platform ───────────────── */ /** Tile-facing words (short); the detail goes to admin health and the log. */ -const TOOL_BUILDING = 'Capture tool building…'; const TOOL_MISSING = 'Capture tool unavailable — see Admin'; -const BUILD_HELP = 'LTC capture tool could not be built here — build it on any Mac of the ' - + 'same architecture with `swiftc -O -o ltc-capture ltc-capture.swift` (both files are in ' - + 'the module folder) and copy the binary next to ltc-capture.swift'; -let building = false; -let buildFailed = false; +/** What the module ships in bin/, by platform and architecture. */ +const HELPERS = { + darwin: { arm64: 'ltc-capture-macos-arm64', x64: 'ltc-capture-macos-x86_64' }, + win32: { x64: 'ltc-capture-win-x64.exe' }, +}; /** - * Make sure the capture tool exists, building it in the background when it - * does not — module scope, so the LTC source can kick it the moment it - * starts and the admin device picker can kick it before any device is - * configured (a listener-only build would deadlock: no tool → no device - * list → no device → no listener). A catalog install ships source only and - * a catalog update deletes the module folder (old binary included), so this - * IS the install step for the tool. + * The capture tool for this machine. It is never built here and never + * looked for anywhere else: the module ships it, or timecode is off on this + * platform and says so. `ready` means a usable binary at `bin`; otherwise + * `error` is the short tile text and `detail` names the platform. * - * Never blocks (a synchronous build once held the whole server's event loop - * — every module — for the duration). Builds to a temp name and renames, so - * a killed or crashed build can't leave a truncated file that looks like a - * ready binary. A binary older than the source (a module update over an - * existing folder) keeps serving while a replacement builds behind it. - * - * @returns {{ready:boolean, error:string, detail:string}} `ready` = a binary - * is usable right now; otherwise `error` is the short tile text and - * `detail` the full one. + * @returns {{ready:boolean, bin:string, error:string, detail:string}} */ -function ensureCaptureBinary({ moduleDir, log, onReady }) { - const bin = path.join(moduleDir, 'ltc-capture'); - const src = bin + '.swift'; - const ok = { ready: true, error: '', detail: '' }; - let haveBin = false; - let stale = false; - try { - haveBin = fs.existsSync(bin); - if (haveBin && fs.existsSync(src)) stale = fs.statSync(src).mtimeMs > fs.statSync(bin).mtimeMs; - } catch { /* treat as missing */ } - if (haveBin && !stale) return ok; - if (!fs.existsSync(src)) { - return haveBin ? ok : { ready: false, error: TOOL_MISSING, detail: 'ltc-capture.swift is missing from the module folder' }; - } - const notYet = haveBin ? ok : { +function captureTool(moduleDir, platform = process.platform, arch = process.arch) { + const name = HELPERS[platform]?.[arch] || ''; + const bin = name ? path.join(moduleDir, 'bin', name) : ''; + if (bin && fs.existsSync(bin)) return { ready: true, bin, error: '', detail: '' }; + const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`; + return { ready: false, - error: building || !buildFailed ? TOOL_BUILDING : TOOL_MISSING, - detail: building || !buildFailed ? 'Building the LTC capture tool — ready in a moment' : BUILD_HELP, + bin: '', + error: TOOL_MISSING, + detail: `The module ships no LTC capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — timecode is off on this machine`, }; - if (building || buildFailed) return notYet; - - building = true; - const tmp = bin + '.building'; - log(stale ? 'ltc-capture is older than its source — rebuilding it in the background' - : 'ltc-capture binary missing — building it in the background'); - const build = spawn('swiftc', ['-O', '-o', tmp, src], { stdio: ['ignore', 'ignore', 'pipe'] }); - let buildErr = ''; - build.stderr.setEncoding('utf8'); - build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); }); - const giveUp = setTimeout(() => { - try { build.kill('SIGKILL'); } catch { /* already gone */ } - }, 300000); - giveUp.unref?.(); - build.on('error', (err) => { - clearTimeout(giveUp); - building = false; - buildFailed = true; - try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ } - log(`swiftc could not be run: ${err.message}`); - }); - build.on('exit', (code) => { - clearTimeout(giveUp); - building = false; - if (code === 0 && fs.existsSync(tmp)) { - try { - fs.renameSync(tmp, bin); - } catch (err) { - buildFailed = true; - log(`ltc-capture built but could not be moved into place: ${err.message}`); - return; - } - buildFailed = false; - log('ltc-capture built'); - if (typeof onReady === 'function') onReady(); - return; - } - buildFailed = true; - try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ } - log(`ltc-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : '')); - }); - return notYet; } - /** * @param {{device:string, channel:number, moduleDir:string, log:Function, * onUpdate:(u:object)=>void}} opts * @returns {{stop:()=>void}} */ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) { - const bin = path.join(moduleDir, 'ltc-capture'); let child = null; let stopped = false; let restartTimer = null; @@ -181,22 +113,11 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) { function start() { if (stopped) return; - const tool = ensureCaptureBinary({ - moduleDir, - log, - onReady() { - // The build this start() was waiting on landed — go now. - if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); } - }, - }); + const tool = captureTool(moduleDir); if (!tool.ready) { + // No binary for this platform: say so and look again later — a module + // update remounts us anyway, this is only belt and braces. report({ error: tool.error, detail: tool.detail }); - // onReady is only the FAST path (a successful build restarts us at - // once). The slow retry must stay armed regardless: a failed build - // never calls back, and a binary can also arrive from outside — scp'd - // in on a machine whose toolchain can't build (AB's broken CLT). - // Waiting on onReady alone deadlocked exactly that way in production: - // "Capture tool building…" forever, with a working binary on disk. scheduleRestart(BAD_CONFIG_RETRY_MS); return; } @@ -206,7 +127,7 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) { let decoder = null; let stderrTail = ''; - child = spawn(bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] }); + child = spawn(tool.bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] }); child.stderr.setEncoding('utf8'); child.stderr.on('data', (text) => { @@ -298,4 +219,4 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) { }; } -module.exports = { createLtcListener, ensureCaptureBinary }; +module.exports = { createLtcListener, captureTool }; diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index a2b9d43..f85f541 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -1,7 +1,7 @@ { "id": "propresenter-timers", "name": "Timers", - "version": "1.6.0", + "version": "1.7.0", "proddash": ">=1.4.0", "description": "Timers, Clocks, and LTC From configured Modules", "provides": [ diff --git a/modules/propresenter-timers/native/build.sh b/modules/propresenter-timers/native/build.sh new file mode 100755 index 0000000..f957c96 --- /dev/null +++ b/modules/propresenter-timers/native/build.sh @@ -0,0 +1,48 @@ +#!/bin/zsh +# Build this module's native helper for every macOS architecture it ships — +# ../bin/ltc-capture-macos-arm64 and ../bin/ltc-capture-macos-x86_64 — +# from ltc-capture.swift beside this script. Run it on any Mac with the +# Xcode command line tools; both architectures cross-compile from either. +# The binaries are committed: a machine running ProdDash never builds +# anything (docs/MODULE-GUIDE.md, "Native helpers"). +# +# Signed with a Developer ID Application certificate when one is installed +# (override with CODESIGN_IDENTITY) — hardened runtime plus the audio-input +# entitlement, which a helper that reads a microphone needs under the +# hardened runtime — and ad-hoc otherwise, which is fine on your own Macs. +# +# Windows is not built here: there is no Windows implementation of this +# tool yet, so on Windows the module reports timecode off. +set -e -u +cd "$(dirname "$0")" + +NAME="ltc-capture" +DEPLOYMENT_TARGET="13.0" + +command -v swiftc >/dev/null || { + echo "build.sh: swiftc not found — install the Xcode command line tools: xcode-select --install" >&2 + exit 1 +} + +IDENTITY="${CODESIGN_IDENTITY:-}" +if [[ -z "$IDENTITY" ]]; then + IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \ + | awk -F'"' '/Developer ID Application/ {print $2; exit}') +fi +if [[ -n "$IDENTITY" ]]; then + echo "signing as $IDENTITY" + SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp) +else + echo "signing ad-hoc (no Developer ID installed — fine on your own Macs)" + SIGN=(--force --sign - --options runtime) +fi + +mkdir -p ../bin +for ARCH in arm64 x86_64; do + OUT="../bin/$NAME-macos-$ARCH" + echo " compiling $OUT" + swiftc -O -whole-module-optimization -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$OUT" "$NAME.swift" + codesign "${SIGN[@]}" --entitlements helper.entitlements "$OUT" + codesign --verify --strict "$OUT" + echo " built $OUT ($(du -h "$OUT" | awk '{print $1}'))" +done diff --git a/modules/propresenter-timers/native/helper.entitlements b/modules/propresenter-timers/native/helper.entitlements new file mode 100644 index 0000000..6d11e71 --- /dev/null +++ b/modules/propresenter-timers/native/helper.entitlements @@ -0,0 +1,10 @@ + + + + + + com.apple.security.device.audio-input + + + diff --git a/modules/propresenter-timers/ltc-capture.swift b/modules/propresenter-timers/native/ltc-capture.swift similarity index 96% rename from modules/propresenter-timers/ltc-capture.swift rename to modules/propresenter-timers/native/ltc-capture.swift index ff1f2d6..824d447 100644 --- a/modules/propresenter-timers/ltc-capture.swift +++ b/modules/propresenter-timers/native/ltc-capture.swift @@ -4,12 +4,12 @@ // The module decodes LTC in-process (ltc.js) and spawns this to move // samples from an input device to a pipe. Production machines rarely have // ffmpeg/sox, and shipping a third-party binary for that one job is -// overkill — this is the whole job, natively. The module compiles it on -// first use when swiftc is available; otherwise build it once on any -// same-architecture Mac and copy the binary next to this source: +// overkill — this is the whole job, natively. It ships prebuilt in the +// module's bin/ (one binary per platform, committed — a booth never +// builds anything). To rebuild after a change here, on any Mac with the +// Xcode command line tools: // -// swiftc -O -o modules/propresenter-timers/ltc-capture \ -// modules/propresenter-timers/ltc-capture.swift (once, ~5 s) +// modules/propresenter-timers/native/build.sh (both architectures, ~10 s) // // ltc-capture --list // every input-capable device: name, input channels, sample rate diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index 4235b14..8822544 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -81,7 +81,7 @@ const http = require('http'); const path = require('path'); const { spawn } = require('child_process'); -const { createLtcListener, ensureCaptureBinary } = require('./ltc-listener'); +const { createLtcListener, captureTool } = require('./ltc-listener'); const { createTimerPhase } = require('./phase'); const manifest = require('./module.json'); @@ -660,11 +660,8 @@ function createLtcSource({ config, log, moduleDir }) { kind: 'ltc', start(e) { emit = e; - // Kick the capture-tool build right away: a catalog install ships - // source only and a catalog update wipes any previously built binary, - // so coming up after an install/update must rebuild it without - // waiting for a device to be picked or a listener to need it. - ensureCaptureBinary({ moduleDir, log }); + // The capture tool ships prebuilt in the module; the listener picks + // it for this platform when it starts and says so when there is none. if (!device) { word = { error: 'Not configured — see Admin', @@ -1359,23 +1356,21 @@ module.exports = { }, // The admin device picker asks this machine what it can hear. The - // options shape matches the admin select's optionsRoute contract. - // Opening the picker also kicks the capture-tool build when a catalog - // install/update left only the source — without this, no tool meant - // no device list, and no device meant nothing would ever build it. + // options shape matches the admin select's optionsRoute contract. The + // capture tool ships prebuilt in the module (bin/); with none for this + // platform the list is empty and the error says so. 'GET /ltc/devices': (req, res) => { - const tool = ensureCaptureBinary({ moduleDir: __dirname, log }); + const tool = captureTool(__dirname); if (!tool.ready) { - sendJson(res, 200, { options: [], error: tool.detail || tool.error }); + sendJson(res, 200, { options: [], error: tool.detail }); return; } - const bin = path.join(__dirname, 'ltc-capture'); - const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 }); + const child = spawn(tool.bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 }); let err = ''; child.stderr.setEncoding('utf8'); child.stderr.on('data', (t) => { err += t; }); - child.on('error', () => { - sendJson(res, 200, { options: [], error: 'LTC capture tool missing — see modules/propresenter-timers/ltc-capture.swift' }); + child.on('error', (e) => { + sendJson(res, 200, { options: [], error: `LTC capture tool could not be run: ${e.message}` }); }); child.on('exit', () => { if (res.writableEnded) return; diff --git a/modules/spl-meter/bin/spl-capture-macos-arm64 b/modules/spl-meter/bin/spl-capture-macos-arm64 new file mode 100755 index 0000000..2b9c11b Binary files /dev/null and b/modules/spl-meter/bin/spl-capture-macos-arm64 differ diff --git a/modules/spl-meter/bin/spl-capture-macos-x86_64 b/modules/spl-meter/bin/spl-capture-macos-x86_64 new file mode 100755 index 0000000..0ed93de Binary files /dev/null and b/modules/spl-meter/bin/spl-capture-macos-x86_64 differ diff --git a/modules/spl-meter/module.json b/modules/spl-meter/module.json index 8eea9e5..0fb7c4d 100644 --- a/modules/spl-meter/module.json +++ b/modules/spl-meter/module.json @@ -1,7 +1,7 @@ { "id": "spl-meter", "name": "SPL Meter", - "version": "0.4.3", + "version": "0.5.0", "proddash": ">=1.5.0", "description": "Calibrated sound level, Leq and RTA from an audio input on this machine", "permissions": [ diff --git a/modules/spl-meter/native/build.sh b/modules/spl-meter/native/build.sh new file mode 100755 index 0000000..d6748b0 --- /dev/null +++ b/modules/spl-meter/native/build.sh @@ -0,0 +1,48 @@ +#!/bin/zsh +# Build this module's native helper for every macOS architecture it ships — +# ../bin/spl-capture-macos-arm64 and ../bin/spl-capture-macos-x86_64 — +# from spl-capture.swift beside this script. Run it on any Mac with the +# Xcode command line tools; both architectures cross-compile from either. +# The binaries are committed: a machine running ProdDash never builds +# anything (docs/MODULE-GUIDE.md, "Native helpers"). +# +# Signed with a Developer ID Application certificate when one is installed +# (override with CODESIGN_IDENTITY) — hardened runtime plus the audio-input +# entitlement, which a helper that reads a microphone needs under the +# hardened runtime — and ad-hoc otherwise, which is fine on your own Macs. +# +# Windows is not built here: there is no Windows implementation of this +# tool yet, so on Windows the module reports measurement off. +set -e -u +cd "$(dirname "$0")" + +NAME="spl-capture" +DEPLOYMENT_TARGET="13.0" + +command -v swiftc >/dev/null || { + echo "build.sh: swiftc not found — install the Xcode command line tools: xcode-select --install" >&2 + exit 1 +} + +IDENTITY="${CODESIGN_IDENTITY:-}" +if [[ -z "$IDENTITY" ]]; then + IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \ + | awk -F'"' '/Developer ID Application/ {print $2; exit}') +fi +if [[ -n "$IDENTITY" ]]; then + echo "signing as $IDENTITY" + SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp) +else + echo "signing ad-hoc (no Developer ID installed — fine on your own Macs)" + SIGN=(--force --sign - --options runtime) +fi + +mkdir -p ../bin +for ARCH in arm64 x86_64; do + OUT="../bin/$NAME-macos-$ARCH" + echo " compiling $OUT" + swiftc -O -whole-module-optimization -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$OUT" "$NAME.swift" + codesign "${SIGN[@]}" --entitlements helper.entitlements "$OUT" + codesign --verify --strict "$OUT" + echo " built $OUT ($(du -h "$OUT" | awk '{print $1}'))" +done diff --git a/modules/spl-meter/native/helper.entitlements b/modules/spl-meter/native/helper.entitlements new file mode 100644 index 0000000..6d11e71 --- /dev/null +++ b/modules/spl-meter/native/helper.entitlements @@ -0,0 +1,10 @@ + + + + + + com.apple.security.device.audio-input + + + diff --git a/modules/spl-meter/spl-capture.swift b/modules/spl-meter/native/spl-capture.swift similarity index 99% rename from modules/spl-meter/spl-capture.swift rename to modules/spl-meter/native/spl-capture.swift index 25a76dc..4b90d5b 100644 --- a/modules/spl-meter/spl-capture.swift +++ b/modules/spl-meter/native/spl-capture.swift @@ -8,10 +8,11 @@ // measurement is done here and only the answers cross the pipe. // // Everything below uses Accelerate, which ships with macOS. No third-party -// code, no package manager, ~2 s to build: +// code, no package manager. It ships prebuilt in the module's bin/ (one +// binary per platform, committed — a booth never builds anything). To +// rebuild after a change here, on any Mac with the Xcode command line tools: // -// swiftc -O -o modules/spl-meter/spl-capture \ -// modules/spl-meter/spl-capture.swift +// modules/spl-meter/native/build.sh (both architectures) // // spl-capture --list // every input-capable device: name, input channels, sample rate diff --git a/modules/spl-meter/server.js b/modules/spl-meter/server.js index 0ae966a..ded2dbc 100644 --- a/modules/spl-meter/server.js +++ b/modules/spl-meter/server.js @@ -20,7 +20,7 @@ */ const path = require('path'); -const { createSplListener, ensureCaptureBinary } = require('./spl-listener'); +const { createSplListener, captureTool } = require('./spl-listener'); /** Reference level of a sound level calibrator. Effectively always 94 dB. */ const CALIBRATOR_DB = 94; @@ -120,12 +120,9 @@ module.exports = { pinger: null, }; - // Before anything else: the device picker shells out to this tool, so a - // machine that installed the module from the catalog (source only — the - // binary is gitignored) needs the build kicked off here. Waiting for a - // listener would never happen: no tool means no device list, and no - // device means no listener. - state.toolMissing = ensureCaptureBinary({ moduleDir, log }); + // The capture tool ships prebuilt in the module (bin/); with none for + // this platform every reading is off and the health line says so. + state.toolMissing = captureTool(moduleDir).missing; if (!config.enabled) { state.error = 'Sound level measurement is off — turn it on in ProdDash settings'; @@ -185,9 +182,7 @@ module.exports = { return { stop() { teardown(); }, health() { - if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) { - return { status: 'error', message: state.toolMissing }; - } + if (state.toolMissing) return { status: 'error', message: state.toolMissing }; if (state.error) return { status: 'error', message: state.error }; if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' }; const quiet = silentMs(); @@ -311,11 +306,10 @@ module.exports = { /** Device list for the admin page. Runs the capture tool's --list, which is the only thing that knows what CoreAudio can see. */ 'GET /devices': async (req, res) => { - const missing = ensureCaptureBinary({ moduleDir: __dirname, log }); - if (missing) { - if (state) state.toolMissing = missing; - log(`device list unavailable — ${missing}`); - json(res, 200, { options: [] }); + const tool = captureTool(__dirname); + if (tool.missing) { + log(`device list unavailable — ${tool.missing}`); + json(res, 200, { options: [], error: tool.missing }); return; } try { @@ -447,7 +441,7 @@ function teardown() { function listDevices() { return new Promise((resolve, reject) => { const { execFile } = require('child_process'); - execFile(path.join(__dirname, 'spl-capture'), ['--list'], { timeout: 10000 }, + execFile(captureTool(__dirname).bin, ['--list'], { timeout: 10000 }, (err, stdout) => { if (err && !stdout) { reject(err); return; } try { resolve(JSON.parse(stdout).devices || []); } @@ -456,10 +450,6 @@ function listDevices() { }); } -function fsExists(p) { - try { return require('fs').existsSync(p); } catch { return false; } -} - function json(res, code, body) { const text = JSON.stringify(body); res.writeHead(code, { diff --git a/modules/spl-meter/spl-listener.js b/modules/spl-meter/spl-listener.js index ebe1276..6738762 100644 --- a/modules/spl-meter/spl-listener.js +++ b/modules/spl-meter/spl-listener.js @@ -3,7 +3,8 @@ /** * Sound level listener — owns the native capture child. * - * Spawns spl-capture (compiled from spl-capture.swift beside it) for the + * Spawns spl-capture (bin/spl-capture-macos-, shipped prebuilt; + * source in native/) for the * admin-configured device and channels, reads its JSON lines, and hands the * latest frame upward. All measurement happens in the child; this file is * plumbing and supervision, nothing more. @@ -13,12 +14,11 @@ * { frame: {...}, meta: {...} } measuring * { error: '' } not measuring * - * The capture binary is per-machine and gitignored, exactly like the Timers - * module's ltc-capture. It normally arrives already built — compiled beside - * the app, or copied from any Mac of the same architecture. When it is - * missing this will build it, but in the background and never on the event - * loop, and from module scope so the device picker can trigger it too — see - * ensureCaptureBinary for what both of those cost the first time round. + * The capture tool ships prebuilt inside the module, one binary per + * platform under bin/ (module guide, "Native helpers"), exactly like the + * Timers module's ltc-capture: nothing is ever built on the machine running + * ProdDash. captureTool() picks the binary for this platform; when there is + * none, measurement reports itself off and the rest of the module carries on. * * The same macOS gotcha applies as for LTC, and is worth repeating because * it costs an afternoon every time: a ProdDash launched over SSH or from a @@ -31,72 +31,31 @@ const path = require('path'); const fs = require('fs'); const { spawn } = require('child_process'); -const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with ' - + '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` ' - + 'and copy it next to spl-capture.swift'; -const BUILDING = 'Building the capture tool — this takes a minute or two'; +/* ── the capture tool: prebuilt, one binary per platform ───────────────── */ + +/** What the module ships in bin/, by platform and architecture. */ +const HELPERS = { + darwin: { arm64: 'spl-capture-macos-arm64', x64: 'spl-capture-macos-x86_64' }, + win32: { x64: 'spl-capture-win-x64.exe' }, +}; /** - * Build the capture tool if it is missing, WITHOUT blocking, and shared by - * everything that needs it. + * The capture tool for this machine. It is never built here and never + * looked for anywhere else: the module ships it, or measurement is off on + * this platform and says so. `bin` is the usable binary; otherwise + * `missing` is what to tell the admin page and the tiles. * - * This lives at module scope rather than inside a listener because of a trap - * the first version walked into: the admin page's device picker runs the tool - * with --list, but the build only ran when a listener started, and a listener - * only starts once a device has been picked. On a machine that got the module - * from the catalog — source only, the binary being gitignored — the picker - * was empty, and nothing would ever fill it. - * - * It also must never block. It was a spawnSync once, copied from the LTC - * listener where the source is a third the size and builds in seconds. Here - * it took nearly two minutes on the production Mac and held the Node event - * loop for all of it, freezing every other module, then did it again on the - * next retry. - * - * The binary should normally already be there: built beside the app, or - * copied from any Mac of the same architecture. This is the fallback. - * - * @returns {string} '' when the tool is ready, otherwise what to say. + * @returns {{bin:string, missing:string}} */ -let building = false; -let buildFailed = false; - -function ensureCaptureBinary({ moduleDir, log, onReady }) { - const bin = path.join(moduleDir, 'spl-capture'); - if (fs.existsSync(bin)) return ''; - const src = bin + '.swift'; - if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder'; - if (building) return BUILDING; - if (buildFailed) return BUILD_HELP; - - building = true; - log('spl-capture binary missing — building it in the background'); - const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] }); - let buildErr = ''; - build.stderr.setEncoding('utf8'); - build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); }); - const giveUp = setTimeout(() => { - try { build.kill('SIGKILL'); } catch { /* already gone */ } - }, 300000); - build.on('error', (err) => { - clearTimeout(giveUp); - building = false; buildFailed = true; - log(`swiftc could not be run: ${err.message}`); - }); - build.on('exit', (code) => { - clearTimeout(giveUp); - building = false; - if (code === 0 && fs.existsSync(bin)) { - log('spl-capture built'); - buildFailed = false; - if (typeof onReady === 'function') onReady(); - return; - } - buildFailed = true; - log(`spl-capture build failed (exit ${code})` - + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : '')); - }); - return BUILDING; +function captureTool(moduleDir, platform = process.platform, arch = process.arch) { + const name = HELPERS[platform]?.[arch] || ''; + const bin = name ? path.join(moduleDir, 'bin', name) : ''; + if (bin && fs.existsSync(bin)) return { bin, missing: '' }; + const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`; + return { + bin: '', + missing: `The module ships no sound level capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — measurement is off on this machine`, + }; } const RESTART_MS = 5000; @@ -114,7 +73,6 @@ const STALL_MS = 3000; */ function createSplListener(opts) { const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts; - const bin = path.join(moduleDir, 'spl-capture'); let child = null; let stopped = false; @@ -124,13 +82,6 @@ function createSplListener(opts) { let meta = null; let lastFrameWall = 0; - /** '' when the tool is ready, otherwise what to say about it. */ - function ensureBinary() { - return ensureCaptureBinary({ moduleDir, log, onReady() { - if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); } - } }); - } - function scheduleRestart(ms) { if (stopped || restartTimer) return; restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms); @@ -150,15 +101,11 @@ function createSplListener(opts) { function start() { if (stopped) return; - const missing = ensureBinary(); - if (missing) { - onUpdate({ error: missing }); - // onReady is only the FAST path (a successful build restarts us at - // once). The slow retry must stay armed regardless: a failed build - // never calls back, and a binary can also arrive from outside — scp'd - // in on a machine whose toolchain can't build (AB's broken CLT). - // Waiting on onReady alone deadlocked exactly that way in the Timers - // module: "building…" forever, with a working binary on disk. + const tool = captureTool(moduleDir); + if (tool.missing) { + // No binary for this platform: say so and look again later — a module + // update remounts us anyway, this is only belt and braces. + onUpdate({ error: tool.missing }); scheduleRestart(BAD_CONFIG_RETRY_MS); return; } @@ -175,7 +122,7 @@ function createSplListener(opts) { let stderrTail = ''; let buf = ''; - child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] }); + child = spawn(tool.bin, args, { stdio: ['pipe', 'pipe', 'pipe'] }); child.stderr.setEncoding('utf8'); child.stderr.on('data', (text) => { @@ -258,4 +205,4 @@ function createSplListener(opts) { }; } -module.exports = { createSplListener, ensureCaptureBinary }; +module.exports = { createSplListener, captureTool }; diff --git a/package.json b/package.json index a20c9d8..42a599b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proddash", - "version": "1.9.3", + "version": "1.10.2", "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 @@