Compare commits

..
Author SHA1 Message Date
michael 2307770ce9 Merge pull request 'SPL Meter 0.5.0: the capture tool ships prebuilt, for Apple silicon and Intel' (#38) from claude/spl-prebuilt-helper into main 2026-09-30 14:20:46 -04:00
michael fe3811b03e Merge pull request 'Timers 1.7.0: the LTC capture tool ships prebuilt, for Apple silicon and Intel' (#37) from claude/timers-prebuilt-helper into main 2026-09-30 14:19:52 -04:00
Waters ChurchandClaude Fable 5.1 0929af46ca SPL Meter 0.5.0: the capture tool ships prebuilt, for Apple silicon and Intel
Same move as Timers 1.7.0. The capture tool existed only where the macOS
app build had compiled it, with the module trying swiftc on the booth as a
fallback — NA has been logging "spawn spl-capture ENOENT" since its
catalog install. Under the module guide's rule it now ships built:
bin/spl-capture-macos-arm64 and bin/spl-capture-macos-x86_64, signed with
the Developer ID (hardened runtime, audio-input entitlement), from
native/spl-capture.swift via native/build.sh. captureTool() picks the
binary for this platform; where there is none — Windows, until a Windows
implementation exists — measurement reports itself off in the health line
and the device picker while the module stays mounted. The runtime build
and its "building…" state are gone, and the old top-level binary is no
longer gitignored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 10:12:42 -04:00
Waters ChurchandClaude Fable 5.1 2817a444c3 Timers 1.7.0: the LTC capture tool ships prebuilt, for Apple silicon and Intel
The tool used to be compiled by the macOS app build, or by the module
itself on the booth with swiftc — and a catalog install got source only,
which the unbundled 1.10.0 shell made every fresh install. Under the module
guide's rule it now ships built: bin/ltc-capture-macos-arm64 and
bin/ltc-capture-macos-x86_64, signed with the Developer ID (hardened
runtime, audio-input entitlement), from native/ltc-capture.swift via
native/build.sh. Nothing is built or looked for on the machine any more:
captureTool() picks the binary for this platform, and where there is none
— Windows, until a Windows implementation exists — timecode reports itself
off in the tile and the health line while the rest of the module runs.
The runtime build, its "building…" states and the device picker's build
kick are gone with it. The old top-level binary is no longer gitignored
(it is no longer written).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 10:11:08 -04:00
michael c11389473f Merge pull request 'Release builds: Intel disk images, a Windows bundle, and where to download them' (#24) from claude/release-builds into main 2026-09-30 09:58:11 -04:00
Waters ChurchandClaude Fable 5.1 c6a44e6ddc Windows build script: executable, like the Mac one
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 09:54:51 -04:00
Waters ChurchandClaude Fable 5.1 2f14b30d68 Windows bundle: no modules either, and the read-me says where they come from
The same change the Mac build got in 1.10.0: the bundle carries the shell
alone; the admin page's catalog installs modules into %APPDATA%\ProdDash.
README.txt gains the step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 09:46:51 -04:00
Waters Church b5d39c60dc Merge remote-tracking branch 'origin/main' into claude/release-builds 2026-09-30 09:46:12 -04:00
michael 407eaecc23 Merge pull request 'Shell 1.10.0: a packaged ProdDash carries no modules' (#36) from claude/unbundled-shell into main 2026-09-30 09:42:23 -04:00
Waters ChurchandClaude Fable 5.1 ac1f17b9ed Shell 1.10.0: a packaged ProdDash carries no modules
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 <noreply@anthropic.com>
2026-09-30 09:34:10 -04:00
michael c37575f662 Merge pull request 'Module rules: a native helper ships prebuilt, for Apple silicon and Windows' (#35) from claude/module-helper-rules into main 2026-09-29 16:45:35 -04:00
Waters ChurchandClaude Fable 5.1 b5c539ba05 Release builds: Intel disk images, a Windows bundle, and where to download them
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 <noreply@anthropic.com>
2026-09-24 17:53:19 -04:00
30 changed files with 747 additions and 299 deletions
+2 -6
View File
@@ -8,12 +8,8 @@ dist/
config/modules.json config/modules.json
config/layouts/ 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 # the launcher's build output, and its cache of the Node runtime it embeds
launcher/macos/build/ launcher/macos/build/
launcher/macos/.cache/ launcher/macos/.cache/
launcher/windows/build/
# Compiled per-machine, source sits next to it (see spl-capture.swift). launcher/windows/.cache/
modules/spl-meter/spl-capture
+2 -1
View File
@@ -3,7 +3,8 @@
Modular production dashboard web app for Waters Church, developed in this repo. 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 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 (`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 `launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
the server and its own Node runtime, supervises them, and owns the macOS the server and its own Node runtime, supervises them, and owns the macOS
permissions modules declare; see its README). Runtime state (module permissions modules declare; see its README). Runtime state (module
+37 -9
View File
@@ -30,11 +30,23 @@ and a clear degraded state, then recover on their own.
## Run it ## 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 ### macOS — the app
Open the disk image and drag **ProdDash** to Applications. That's the whole Download the disk image for your Mac — `ProdDash-<version>-arm64.dmg` for
Apple silicon, `ProdDash-<version>-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 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 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 permissions the modules need — the microphone the LTC listener reads, and
local network access for ProdCom and ProPresenter. macOS grants those to the 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. zeroes.
Build the disk image from a checkout with 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 ```bash
cd launcher/macos && ./build.sh --dmg cd launcher/macos && ./build.sh --dmg
``` ```
### Windows — the bundle
Download `ProdDash-<version>-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 ### Anywhere else, or from a checkout
Requires [Node.js](https://nodejs.org) 18 or newer. 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 - **Available modules** lists modules in the repo that aren't installed
here. **Install** downloads the module into the data directory's `modules/` 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 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 restart is needed. Tick several and press **Install selected** to fetch
"bundled"; ones you installed are "installed"; if both exist, the newer them in one go. A packaged ProdDash (the app, the Windows bundle) ships
version runs. 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 - **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 checkout's bundled module (its folder belongs to the repo) it hides the
every dashboard instead; it reappears under Available modules and can be module from every dashboard instead; it reappears under Available modules
brought back without downloading. Settings are kept either way. 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 updates**: when the repo has a newer version of an installed
module, its card shows **Update to vX**. module, its card shows **Update to vX**.
- **Version requirements**: every module declares the ProdDash versions it - **Version requirements**: every module declares the ProdDash versions it
+10 -7
View File
@@ -96,13 +96,16 @@ modules/<id>/
**Installation is exactly this:** in the admin page, open **Available **Installation is exactly this:** in the admin page, open **Available
modules** and press **Install** — the module is downloaded from the ProdDash modules** and press **Install** — the module is downloaded from the ProdDash
repo into the server's data directory (`<data dir>/modules/<id>/`) and starts repo into the server's data directory (`<data dir>/modules/<id>/`) and starts
immediately. Developing locally? Drop the folder into `modules/` (bundled immediately. A packaged ProdDash — the macOS app, the Windows bundle — ships
with the checkout) or into the data directory's `modules/` and restart. No with **no modules at all**: every one of them arrives this way, and the
shell code changes, ever. Modules bundled with a checkout and modules shell's own update never brings one along, so what is in git is exactly what
installed from the admin page live side by side; when both have the same id runs on a booth. Developing locally? A git checkout runs whatever is in its
the newer version runs. **Uninstall** in the admin page removes an installed own `modules/` folder (the repo's copies, "bundled"), or drop a folder into
module's files (a bundled one is hidden instead, since its folder belongs to the data directory's `modules/` and restart. No shell code changes, ever. In
the app); its settings are kept for a later reinstall. 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 ### Native helpers
+5 -3
View File
@@ -51,6 +51,7 @@ the cycle rather than spinning forever.
```bash ```bash
./build.sh # build/ProdDash.app — the whole application ./build.sh # build/ProdDash.app — the whole application
./build.sh --dmg # …and build/ProdDash-<version>-arm64.dmg ./build.sh --dmg # …and build/ProdDash-<version>-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 --install # …and copy it to /Applications
./build.sh --run # …and open it ./build.sh --run # …and open it
./build.sh --notarize # with --dmg: notarise and staple (Developer ID) ./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. against nodejs.org's published checksums either way.
The app icon is built from `public/icons/icon-512.png`, so ProdDash's own icon 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 is the app's icon. The app carries no modules: they are installed from the
(`<name>.swift` beside a module's files becomes `<name>`), so the Mac running admin page's catalog into the data directory, and a module's native helper
ProdDash never needs a compiler. ships prebuilt inside it (see the module guide), so the Mac running ProdDash
never needs a compiler.
## Signing ## Signing
+15 -25
View File
@@ -1,6 +1,8 @@
#!/bin/zsh #!/bin/zsh
# Build ProdDash.app — the whole application: launcher, server and its own # 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 build/ProdDash.app, self-contained
# ./build.sh --dmg …and build/ProdDash-<version>-arm64.dmg # ./build.sh --dmg …and build/ProdDash-<version>-arm64.dmg
@@ -11,6 +13,8 @@
# ./build.sh --dev launcher only — no Node, no server payload, so # ./build.sh --dev launcher only — no Node, no server payload, so
# it runs against the checkout. Seconds, not # it runs against the checkout. Seconds, not
# minutes, while working on the launcher itself. # 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 # Signing: a "Developer ID Application" certificate is used when one is
# installed (override with CODESIGN_IDENTITY). Without one the app is signed # 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 # The Node the app ships. Bump it here; the download is checksum-verified
# against nodejs.org's own SHASUMS256.txt and cached in .cache/. # against nodejs.org's own SHASUMS256.txt and cached in .cache/.
NODE_VERSION="v24.21.0" NODE_VERSION="v24.21.0"
ARCH="arm64" ARCH="${ARCH:-arm64}" # arm64 (Apple silicon) or x86_64 (Intel)
DEPLOYMENT_TARGET="13.0" 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="../.." REPO_ROOT="../.."
OUT_DIR="build" OUT_DIR="build"
@@ -106,7 +115,7 @@ fi
# ── the server, and the Node that runs it ──────────────────────────────── # ── the server, and the Node that runs it ────────────────────────────────
if [[ $DEV -eq 0 ]]; then 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" mkdir -p "$CACHE_DIR"
if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then
echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)" 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" echo " embedding Node $NODE_VERSION"
mkdir -p "$CONTENTS/Resources/node/bin" mkdir -p "$CONTENTS/Resources/node/bin"
tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \ 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" mkdir -p "$PAYLOAD/config"
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$PAYLOAD/" 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 # Only the checked-in defaults: config/ also holds this machine's own
# modules.json and layouts when ProdDash has ever run from the checkout. # modules.json and layouts when ProdDash has ever run from the checkout.
cp "$REPO_ROOT/config/proddash.json" "$PAYLOAD/config/" cp "$REPO_ROOT/config/proddash.json" "$PAYLOAD/config/"
find "$PAYLOAD" -name '.DS_Store' -delete find "$PAYLOAD" -name '.DS_Store' -delete
# A module's native helper is built here, so the Mac running ProdDash needs
# no compiler: <name>.swift beside a module's files becomes <name>.
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 fi
# ── signing ────────────────────────────────────────────────────────────── # ── signing ──────────────────────────────────────────────────────────────
@@ -165,16 +165,6 @@ fi
if [[ -f "$CONTENTS/Resources/node/bin/node" ]]; then if [[ -f "$CONTENTS/Resources/node/bin/node" ]]; then
codesign "${SIGN[@]}" --entitlements Resources/node.entitlements "$CONTENTS/Resources/node/bin/node" codesign "${SIGN[@]}" --entitlements Resources/node.entitlements "$CONTENTS/Resources/node/bin/node"
fi 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 "${SIGN[@]}" --entitlements Resources/ProdDash.entitlements --identifier "$BUNDLE_ID" "$APP"
codesign --verify --deep --strict "$APP" && echo " signature verifies" codesign --verify --deep --strict "$APP" && echo " signature verifies"
+121
View File
@@ -0,0 +1,121 @@
#!/bin/zsh
# Build the Windows bundle — ProdDash-<version>-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-<version>-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" <<README
ProdDash $VERSION for Windows (64-bit)
=======================================
Nothing to install. This folder holds the dashboard and its own copy of Node.
1. Move this folder somewhere you can write to, for example C:\ProdDash
(not Program Files: updates write into this folder).
2. Double-click ProdDash.bat. A window opens and stays open while the
dashboard runs; closing it stops the dashboard.
- If Windows warns about the file, choose More info, then Run anyway.
- When Windows Defender Firewall asks about node.exe, allow it on
private networks so other screens can reach the dashboard.
3. Open http://localhost:24500 on this PC. Other devices on the network use
http://<this PC's IP address>: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}'))"
Binary file not shown.
Binary file not shown.
+34 -113
View File
@@ -3,8 +3,8 @@
/** /**
* Built-in LTC listener — capture child + in-process decode. * Built-in LTC listener — capture child + in-process decode.
* *
* Spawns the module's native capture tool (ltc-capture, compiled from * Spawns the module's native capture tool (bin/ltc-capture-macos-<arch>,
* ltc-capture.swift beside it) for the admin-configured audio device, reads * shipped prebuilt; source in native/) for the admin-configured audio device, reads
* the interleaved s16le stream, extracts the configured channel, and decodes * the interleaved s16le stream, extracts the configured channel, and decodes
* SMPTE LTC with ltc.js — all inside the module server. Reports upward via * SMPTE LTC with ltc.js — all inside the module server. Reports upward via
* onUpdate roughly 4×/s: * onUpdate roughly 4×/s:
@@ -16,17 +16,13 @@
* shows (short — "Not configured — see Admin"), `detail` is the full story * shows (short — "Not configured — see Admin"), `detail` is the full story
* for the admin health line and the log. * for the admin health line and the log.
* *
* The capture binary is per-machine (gitignored), and a catalog install or * The capture tool ships prebuilt inside the module, one binary per
* update ships source only — the update even wipes a previously built * platform under bin/ (module guide, "Native helpers"): nothing is ever
* binary. ensureCaptureBinary() therefore rebuilds it with swiftc in the * built on the machine running ProdDash. captureTool() picks the binary for
* background as part of coming up: kicked when the LTC source starts and by * this platform; when there is none, timecode reports itself off and the
* the admin device picker, never on the event loop (a blocking build once * rest of the module carries on. A dead capture child is restarted with
* froze the whole server — see spl-meter's history), built to a temp name * backoff — fast for a crash, slow for config-shaped failures (unknown
* and renamed so a killed build can't leave a half-written "binary", and * device, ambiguity) that never fix themselves.
* 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.
* *
* macOS gotcha this file can't fix: microphone permission. A ProdDash * macOS gotcha this file can't fix: microphone permission. A ProdDash
* server launched over SSH or from a bare launchd job gets SILENT ZEROS * 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. */ /** stderr line the capture prints once the device is open. */
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/; 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. */ /** 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 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; /** What the module ships in bin/, by platform and architecture. */
let buildFailed = false; 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 * The capture tool for this machine. It is never built here and never
* does not — module scope, so the LTC source can kick it the moment it * looked for anywhere else: the module ships it, or timecode is off on this
* starts and the admin device picker can kick it before any device is * platform and says so. `ready` means a usable binary at `bin`; otherwise
* configured (a listener-only build would deadlock: no tool → no device * `error` is the short tile text and `detail` names the platform.
* 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.
* *
* Never blocks (a synchronous build once held the whole server's event loop * @returns {{ready:boolean, bin:string, error:string, detail:string}}
* — 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.
*/ */
function ensureCaptureBinary({ moduleDir, log, onReady }) { function captureTool(moduleDir, platform = process.platform, arch = process.arch) {
const bin = path.join(moduleDir, 'ltc-capture'); const name = HELPERS[platform]?.[arch] || '';
const src = bin + '.swift'; const bin = name ? path.join(moduleDir, 'bin', name) : '';
const ok = { ready: true, error: '', detail: '' }; if (bin && fs.existsSync(bin)) return { ready: true, bin, error: '', detail: '' };
let haveBin = false; const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`;
let stale = false; return {
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 : {
ready: false, ready: false,
error: building || !buildFailed ? TOOL_BUILDING : TOOL_MISSING, bin: '',
detail: building || !buildFailed ? 'Building the LTC capture tool — ready in a moment' : BUILD_HELP, 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, * @param {{device:string, channel:number, moduleDir:string, log:Function,
* onUpdate:(u:object)=>void}} opts * onUpdate:(u:object)=>void}} opts
* @returns {{stop:()=>void}} * @returns {{stop:()=>void}}
*/ */
function createLtcListener({ device, channel, moduleDir, log, onUpdate }) { function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
const bin = path.join(moduleDir, 'ltc-capture');
let child = null; let child = null;
let stopped = false; let stopped = false;
let restartTimer = null; let restartTimer = null;
@@ -181,22 +113,11 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
function start() { function start() {
if (stopped) return; if (stopped) return;
const tool = ensureCaptureBinary({ const tool = captureTool(moduleDir);
moduleDir,
log,
onReady() {
// The build this start() was waiting on landed — go now.
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
},
});
if (!tool.ready) { 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 }); 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); scheduleRestart(BAD_CONFIG_RETRY_MS);
return; return;
} }
@@ -206,7 +127,7 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
let decoder = null; let decoder = null;
let stderrTail = ''; 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.setEncoding('utf8');
child.stderr.on('data', (text) => { child.stderr.on('data', (text) => {
@@ -298,4 +219,4 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
}; };
} }
module.exports = { createLtcListener, ensureCaptureBinary }; module.exports = { createLtcListener, captureTool };
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "propresenter-timers", "id": "propresenter-timers",
"name": "Timers", "name": "Timers",
"version": "1.6.0", "version": "1.7.0",
"proddash": ">=1.4.0", "proddash": ">=1.4.0",
"description": "Timers, Clocks, and LTC From configured Modules", "description": "Timers, Clocks, and LTC From configured Modules",
"provides": [ "provides": [
+48
View File
@@ -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
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The helper reads an audio input; under the hardened runtime that
needs saying. The permission itself comes from the launcher. -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
@@ -4,12 +4,12 @@
// The module decodes LTC in-process (ltc.js) and spawns this to move // 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 // 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 // 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 // overkill — this is the whole job, natively. It ships prebuilt in the
// first use when swiftc is available; otherwise build it once on any // module's bin/ (one binary per platform, committed — a booth never
// same-architecture Mac and copy the binary next to this source: // 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/native/build.sh (both architectures, ~10 s)
// modules/propresenter-timers/ltc-capture.swift (once, ~5 s)
// //
// ltc-capture --list // ltc-capture --list
// every input-capable device: name, input channels, sample rate // every input-capable device: name, input channels, sample rate
+11 -16
View File
@@ -81,7 +81,7 @@
const http = require('http'); const http = require('http');
const path = require('path'); const path = require('path');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const { createLtcListener, ensureCaptureBinary } = require('./ltc-listener'); const { createLtcListener, captureTool } = require('./ltc-listener');
const { createTimerPhase } = require('./phase'); const { createTimerPhase } = require('./phase');
const manifest = require('./module.json'); const manifest = require('./module.json');
@@ -660,11 +660,8 @@ function createLtcSource({ config, log, moduleDir }) {
kind: 'ltc', kind: 'ltc',
start(e) { start(e) {
emit = e; emit = e;
// Kick the capture-tool build right away: a catalog install ships // The capture tool ships prebuilt in the module; the listener picks
// source only and a catalog update wipes any previously built binary, // it for this platform when it starts and says so when there is none.
// 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 });
if (!device) { if (!device) {
word = { word = {
error: 'Not configured — see Admin', error: 'Not configured — see Admin',
@@ -1359,23 +1356,21 @@ module.exports = {
}, },
// The admin device picker asks this machine what it can hear. The // The admin device picker asks this machine what it can hear. The
// options shape matches the admin select's optionsRoute contract. // options shape matches the admin select's optionsRoute contract. The
// Opening the picker also kicks the capture-tool build when a catalog // capture tool ships prebuilt in the module (bin/); with none for this
// install/update left only the source — without this, no tool meant // platform the list is empty and the error says so.
// no device list, and no device meant nothing would ever build it.
'GET /ltc/devices': (req, res) => { 'GET /ltc/devices': (req, res) => {
const tool = ensureCaptureBinary({ moduleDir: __dirname, log }); const tool = captureTool(__dirname);
if (!tool.ready) { if (!tool.ready) {
sendJson(res, 200, { options: [], error: tool.detail || tool.error }); sendJson(res, 200, { options: [], error: tool.detail });
return; return;
} }
const bin = path.join(__dirname, 'ltc-capture'); const child = spawn(tool.bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
let err = ''; let err = '';
child.stderr.setEncoding('utf8'); child.stderr.setEncoding('utf8');
child.stderr.on('data', (t) => { err += t; }); child.stderr.on('data', (t) => { err += t; });
child.on('error', () => { child.on('error', (e) => {
sendJson(res, 200, { options: [], error: 'LTC capture tool missing — see modules/propresenter-timers/ltc-capture.swift' }); sendJson(res, 200, { options: [], error: `LTC capture tool could not be run: ${e.message}` });
}); });
child.on('exit', () => { child.on('exit', () => {
if (res.writableEnded) return; if (res.writableEnded) return;
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "spl-meter", "id": "spl-meter",
"name": "SPL Meter", "name": "SPL Meter",
"version": "0.4.3", "version": "0.5.0",
"proddash": ">=1.5.0", "proddash": ">=1.5.0",
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine", "description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
"permissions": [ "permissions": [
+48
View File
@@ -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
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The helper reads an audio input; under the hardened runtime that
needs saying. The permission itself comes from the launcher. -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
@@ -8,10 +8,11 @@
// measurement is done here and only the answers cross the pipe. // measurement is done here and only the answers cross the pipe.
// //
// Everything below uses Accelerate, which ships with macOS. No third-party // 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/native/build.sh (both architectures)
// modules/spl-meter/spl-capture.swift
// //
// spl-capture --list // spl-capture --list
// every input-capable device: name, input channels, sample rate // every input-capable device: name, input channels, sample rate
+10 -20
View File
@@ -20,7 +20,7 @@
*/ */
const path = require('path'); 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. */ /** Reference level of a sound level calibrator. Effectively always 94 dB. */
const CALIBRATOR_DB = 94; const CALIBRATOR_DB = 94;
@@ -120,12 +120,9 @@ module.exports = {
pinger: null, pinger: null,
}; };
// Before anything else: the device picker shells out to this tool, so a // The capture tool ships prebuilt in the module (bin/); with none for
// machine that installed the module from the catalog (source only — the // this platform every reading is off and the health line says so.
// binary is gitignored) needs the build kicked off here. Waiting for a state.toolMissing = captureTool(moduleDir).missing;
// listener would never happen: no tool means no device list, and no
// device means no listener.
state.toolMissing = ensureCaptureBinary({ moduleDir, log });
if (!config.enabled) { if (!config.enabled) {
state.error = 'Sound level measurement is off — turn it on in ProdDash settings'; state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
@@ -185,9 +182,7 @@ module.exports = {
return { return {
stop() { teardown(); }, stop() { teardown(); },
health() { health() {
if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) { if (state.toolMissing) return { status: 'error', message: state.toolMissing };
return { status: 'error', message: state.toolMissing };
}
if (state.error) return { status: 'error', message: state.error }; if (state.error) return { status: 'error', message: state.error };
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' }; if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
const quiet = silentMs(); const quiet = silentMs();
@@ -311,11 +306,10 @@ module.exports = {
/** Device list for the admin page. Runs the capture tool's --list, /** Device list for the admin page. Runs the capture tool's --list,
which is the only thing that knows what CoreAudio can see. */ which is the only thing that knows what CoreAudio can see. */
'GET /devices': async (req, res) => { 'GET /devices': async (req, res) => {
const missing = ensureCaptureBinary({ moduleDir: __dirname, log }); const tool = captureTool(__dirname);
if (missing) { if (tool.missing) {
if (state) state.toolMissing = missing; log(`device list unavailable — ${tool.missing}`);
log(`device list unavailable — ${missing}`); json(res, 200, { options: [], error: tool.missing });
json(res, 200, { options: [] });
return; return;
} }
try { try {
@@ -447,7 +441,7 @@ function teardown() {
function listDevices() { function listDevices() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const { execFile } = require('child_process'); const { execFile } = require('child_process');
execFile(path.join(__dirname, 'spl-capture'), ['--list'], { timeout: 10000 }, execFile(captureTool(__dirname).bin, ['--list'], { timeout: 10000 },
(err, stdout) => { (err, stdout) => {
if (err && !stdout) { reject(err); return; } if (err && !stdout) { reject(err); return; }
try { resolve(JSON.parse(stdout).devices || []); } 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) { function json(res, code, body) {
const text = JSON.stringify(body); const text = JSON.stringify(body);
res.writeHead(code, { res.writeHead(code, {
+35 -88
View File
@@ -3,7 +3,8 @@
/** /**
* Sound level listener — owns the native capture child. * 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-<arch>, shipped prebuilt;
* source in native/) for the
* admin-configured device and channels, reads its JSON lines, and hands 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 * latest frame upward. All measurement happens in the child; this file is
* plumbing and supervision, nothing more. * plumbing and supervision, nothing more.
@@ -13,12 +14,11 @@
* { frame: {...}, meta: {...} } measuring * { frame: {...}, meta: {...} } measuring
* { error: '<what is wrong and how to fix it>' } not measuring * { error: '<what is wrong and how to fix it>' } not measuring
* *
* The capture binary is per-machine and gitignored, exactly like the Timers * The capture tool ships prebuilt inside the module, one binary per
* module's ltc-capture. It normally arrives already built — compiled beside * platform under bin/ (module guide, "Native helpers"), exactly like the
* the app, or copied from any Mac of the same architecture. When it is * Timers module's ltc-capture: nothing is ever built on the machine running
* missing this will build it, but in the background and never on the event * ProdDash. captureTool() picks the binary for this platform; when there is
* loop, and from module scope so the device picker can trigger it too — see * none, measurement reports itself off and the rest of the module carries on.
* ensureCaptureBinary for what both of those cost the first time round.
* *
* The same macOS gotcha applies as for LTC, and is worth repeating because * 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 * 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 fs = require('fs');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with ' /* ── the capture tool: prebuilt, one binary per platform ───────────────── */
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
+ 'and copy it next to spl-capture.swift'; /** What the module ships in bin/, by platform and architecture. */
const BUILDING = 'Building the capture tool — this takes a minute or two'; 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 * The capture tool for this machine. It is never built here and never
* everything that needs it. * 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 * @returns {{bin:string, missing:string}}
* 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.
*/ */
let building = false; function captureTool(moduleDir, platform = process.platform, arch = process.arch) {
let buildFailed = false; const name = HELPERS[platform]?.[arch] || '';
const bin = name ? path.join(moduleDir, 'bin', name) : '';
function ensureCaptureBinary({ moduleDir, log, onReady }) { if (bin && fs.existsSync(bin)) return { bin, missing: '' };
const bin = path.join(moduleDir, 'spl-capture'); const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`;
if (fs.existsSync(bin)) return ''; return {
const src = bin + '.swift'; bin: '',
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder'; missing: `The module ships no sound level capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — measurement is off on this machine`,
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;
} }
const RESTART_MS = 5000; const RESTART_MS = 5000;
@@ -114,7 +73,6 @@ const STALL_MS = 3000;
*/ */
function createSplListener(opts) { function createSplListener(opts) {
const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts; const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts;
const bin = path.join(moduleDir, 'spl-capture');
let child = null; let child = null;
let stopped = false; let stopped = false;
@@ -124,13 +82,6 @@ function createSplListener(opts) {
let meta = null; let meta = null;
let lastFrameWall = 0; 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) { function scheduleRestart(ms) {
if (stopped || restartTimer) return; if (stopped || restartTimer) return;
restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms); restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms);
@@ -150,15 +101,11 @@ function createSplListener(opts) {
function start() { function start() {
if (stopped) return; if (stopped) return;
const missing = ensureBinary(); const tool = captureTool(moduleDir);
if (missing) { if (tool.missing) {
onUpdate({ error: missing }); // No binary for this platform: say so and look again later — a module
// onReady is only the FAST path (a successful build restarts us at // update remounts us anyway, this is only belt and braces.
// once). The slow retry must stay armed regardless: a failed build onUpdate({ error: tool.missing });
// 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.
scheduleRestart(BAD_CONFIG_RETRY_MS); scheduleRestart(BAD_CONFIG_RETRY_MS);
return; return;
} }
@@ -175,7 +122,7 @@ function createSplListener(opts) {
let stderrTail = ''; let stderrTail = '';
let buf = ''; 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.setEncoding('utf8');
child.stderr.on('data', (text) => { child.stderr.on('data', (text) => {
@@ -258,4 +205,4 @@ function createSplListener(opts) {
}; };
} }
module.exports = { createSplListener, ensureCaptureBinary }; module.exports = { createSplListener, captureTool };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proddash", "name": "proddash",
"version": "1.9.2", "version": "1.10.0",
"private": true, "private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED", "license": "UNLICENSED",
+1
View File
@@ -443,6 +443,7 @@ a.btn { text-decoration: none; }
margin-bottom: 10px; margin-bottom: 10px;
} }
.catalog-main { flex: 1; min-width: 0; } .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-title { display: flex; align-items: center; gap: 8px; }
.catalog-row .module-desc { margin: 4px 0 2px; } .catalog-row .module-desc { margin: 4px 0 2px; }
.catalog-row .module-health { margin-top: 2px; } .catalog-row .module-health { margin-top: 2px; }
+43
View File
@@ -962,6 +962,13 @@ async function loadState() {
for (const mod of state.modules || []) { for (const mod of state.modules || []) {
modulesList.appendChild(buildModuleCard(mod)); 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); renderPin(state);
loadTheme(); loadTheme();
loadLayouts(); loadLayouts();
@@ -1163,6 +1170,7 @@ function renderAvailable(cat) {
els.updateSlot.appendChild(btn); els.updateSlot.appendChild(btn);
} }
const available = cat.available || []; const available = cat.available || [];
installSelectedBtn.hidden = available.filter((a) => a.compatible).length < 2;
if (!available.length) { if (!available.length) {
const none = document.createElement('div'); const none = document.createElement('div');
none.className = 'layouts-empty'; none.className = 'layouts-empty';
@@ -1205,11 +1213,46 @@ function renderAvailable(cat) {
btn.disabled = !item.compatible; btn.disabled = !item.compatible;
btn.title = item.compatible ? '' : item.reason; btn.title = item.compatible ? '' : item.reason;
btn.addEventListener('click', () => installFromCatalog(item, btn)); 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); row.append(main, btn);
availableList.appendChild(row); 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) { async function installFromCatalog(item, btn) {
btn.disabled = true; btn.disabled = true;
const label = btn.textContent; const label = btn.textContent;
+1
View File
@@ -58,6 +58,7 @@
<section id="available-section" hidden> <section id="available-section" hidden>
<h1 class="admin-h1">Available modules</h1> <h1 class="admin-h1">Available modules</h1>
<div class="catalog-toolbar"> <div class="catalog-toolbar">
<button class="btn small primary" id="install-selected" hidden>Install selected</button>
<button class="btn small" id="catalog-refresh">Check again</button> <button class="btn small" id="catalog-refresh">Check again</button>
<span class="admin-status" id="catalog-status"></span> <span class="admin-status" id="catalog-status"></span>
</div> </div>
+10
View File
@@ -510,6 +510,7 @@ function reconcileOfferedTiles() {
} }
if (changed) rebuildDividers(); if (changed) rebuildDividers();
scheduleOfferedRecheck(); scheduleOfferedRecheck();
refreshEmptyState(); // the registry may have gone from nothing to something
} }
/* A module's entry list can change without anyone saving admin config: PCO /* A module's entry list can change without anyone saving admin config: PCO
@@ -676,6 +677,15 @@ function buildTile(tile) {
function refreshEmptyState() { function refreshEmptyState() {
emptyEl.hidden = tiles.length > 0; emptyEl.hidden = tiles.length > 0;
if (emptyEl.hidden) return;
// Two different empties: a bare board, and a ProdDash with nothing to put
// on one — a packaged install starts without modules until Admin adds some.
const noModules = registry.size === 0;
if (noModules === emptyEl.classList.contains('no-modules')) return;
emptyEl.classList.toggle('no-modules', noModules);
emptyEl.innerHTML = noModules
? 'No modules yet — <a href="/admin">open Admin</a> and install some under Available modules.'
: 'No tiles yet — use “+ Add tile” to build this dashboard.<br>Every browser keeps its own layout.';
} }
/* ── drag to move ───────────────────────────────────────────────────── */ /* ── drag to move ───────────────────────────────────────────────────── */
+79
View File
@@ -277,6 +277,52 @@ function isUninstalled(id) {
return moduleState(id).uninstalled === true; return moduleState(id).uninstalled === true;
} }
/**
* A packaged install that still carries modules in its app folder — every
* install made before 1.10.0 does — moves them into the data directory
* once, as installed copies, and drops the app-folder copies. Nothing
* changes for the operator: the same modules run with the same settings
* (config is keyed by module id), and from now on each updates on its own
* from the catalog. A compiled helper sitting in a bundled folder travels
* with it. A module the operator had uninstalled is simply dropped — the
* catalog still offers it, and its settings stay in modules.json.
*/
function unbundleModules() {
if (!PACKAGED || !fs.existsSync(BUNDLED_MODULES_DIR)) return;
const installed = new Map(readManifests(INSTALLED_MODULES_DIR, 'installed').map((m) => [m.id, m]));
let moved = 0;
let failed = 0;
for (const man of readManifests(BUNDLED_MODULES_DIR, 'bundled')) {
if (isUninstalled(man.id)) continue;
const copy = installed.get(man.id);
if (copy && compareVersions(copy.version, man.version) >= 0) continue; // the data directory already has as good or better
const dest = path.join(INSTALLED_MODULES_DIR, man.id);
const staging = path.join(INSTALLED_MODULES_DIR, `.${man.id}.moving`);
try {
fs.mkdirSync(INSTALLED_MODULES_DIR, { recursive: true });
fs.rmSync(staging, { recursive: true, force: true });
fs.cpSync(man.dir, staging, { recursive: true }); // keeps modes: a compiled helper stays executable
fs.rmSync(dest, { recursive: true, force: true });
fs.renameSync(staging, dest);
moved += 1;
console.log(`[proddash] moved bundled module "${man.id}" v${man.version} into the data directory${copy ? ` (over the installed v${copy.version})` : ''}`);
} catch (err) {
failed += 1;
console.error(`[proddash] could not move bundled module "${man.id}" into the data directory:`, err?.message || err);
}
}
if (failed) {
console.warn(`[proddash] ${failed} bundled module${failed === 1 ? '' : 's'} stay in the app folder for now — tried again at the next start`);
return;
}
try {
fs.rmSync(BUNDLED_MODULES_DIR, { recursive: true, force: true });
console.log(`[proddash] the app folder carries no modules now${moved ? ` — ${moved} moved into the data directory` : ''}; the admin page's catalog installs and updates them`);
} catch (err) {
console.warn("[proddash] could not remove the app folder's modules:", err?.message || err);
}
}
function moduleState(id) { function moduleState(id) {
const s = modulesConfig[id]; const s = modulesConfig[id];
return s && typeof s === 'object' ? s : {}; return s && typeof s === 'object' ? s : {};
@@ -1235,9 +1281,18 @@ const DATA_DIR_INSIDE_ROOT = (() => {
return rel.split(path.sep).join('/') + '/'; return rel.split(path.sep).join('/') + '/';
})(); })();
/* A packaged install — the macOS app, the Windows bundle: anything that is
not a git checkout, the same test the updater uses to choose the archive
over git pull — carries no modules of its own. Every module comes from
the catalog into the data directory, and the shell's own update never
brings one along. A checkout keeps running the modules in its tree, which
is what development wants. */
const PACKAGED = !fs.existsSync(path.join(ROOT, '.git'));
function isKeptOnUpdate(rel) { function isKeptOnUpdate(rel) {
if (rel.startsWith('.git/')) return true; if (rel.startsWith('.git/')) return true;
if (rel === 'config/modules.json' || rel.startsWith('config/layouts/')) return true; if (rel === 'config/modules.json' || rel.startsWith('config/layouts/')) return true;
if (PACKAGED && (rel === 'modules' || rel.startsWith('modules/'))) return true;
return Boolean(DATA_DIR_INSIDE_ROOT) && `${rel}/`.startsWith(DATA_DIR_INSIDE_ROOT); return Boolean(DATA_DIR_INSIDE_ROOT) && `${rel}/`.startsWith(DATA_DIR_INSIDE_ROOT);
} }
@@ -1580,6 +1635,29 @@ async function handleAdminApi(req, res, urlPath) {
return; return;
} }
// Several at once — a fresh install picking its set. One result per id;
// a failure stops nothing else.
if (urlPath === '/api/admin/modules/install' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
const ids = Array.isArray(body.ids) ? [...new Set(body.ids.map((id) => String(id)))] : [];
if (!ids.length) return sendJson(res, 400, { error: 'Nothing to install.' });
const results = [];
for (const id of ids) {
try {
results.push({ ok: true, ...(await installModule(id)) });
} catch (err) {
results.push({ ok: false, id, error: err?.message || String(err) });
}
}
return sendJson(res, 200, { ok: results.every((r) => r.ok), results });
}
const install = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/install$/); const install = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/install$/);
if (install && req.method === 'POST') { if (install && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return; if (refuseAdminWrite(req, res)) return;
@@ -2074,6 +2152,7 @@ server.requestTimeout = 0;
/* ── boot ───────────────────────────────────────────────────────────── */ /* ── boot ───────────────────────────────────────────────────────────── */
unbundleModules();
discoverModules(); discoverModules();
mountAllModules(); mountAllModules();
+203
View File
@@ -0,0 +1,203 @@
'use strict';
/**
* A packaged ProdDash carries no modules. This drives a packaged copy of the
* shell — server.js and its client in a folder with no .git — through the
* three things that make that true:
*
* 1. an install made before 1.10.0 still has modules in its app folder:
* at boot they move into the data directory once, executables keeping
* their mode, an uninstalled one is dropped, and the app folder's
* modules/ is gone;
* 2. a shell update from an archive that carries modules/ never writes
* them into the app folder — they show up in the catalog instead;
* 3. a fresh install lists everything the repo offers, and installing
* several at once lands them in the data directory and mounts them.
*
* node tools/unbundled.test.js
*
* Plain Node, no dependencies: a mock forge, a temp app folder, a temp data
* directory. Exits non-zero on the first failure and cleans up after itself.
*/
const assert = require('node:assert/strict');
const { spawn, execFileSync } = require('node:child_process');
const fs = require('node:fs');
const http = require('node:http');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const REPO = path.join(__dirname, '..');
function freePort() {
return new Promise((resolve, reject) => {
const probe = net.createServer();
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address();
probe.close(() => resolve(port));
});
probe.on('error', reject);
});
}
async function waitFor(url, ms = 15000) {
const deadline = Date.now() + ms;
let last = '';
while (Date.now() < deadline) {
try {
const r = await fetch(url);
if (r.ok) return;
last = 'HTTP ' + r.status;
} catch (err) {
last = err.message;
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error('server did not come up: ' + last);
}
function writeModule(dir, id, version, extra = {}) {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({ id, name: id[0].toUpperCase() + id.slice(1), version, client: 'client.js', ...extra }));
fs.writeFileSync(path.join(dir, 'client.js'), 'export default function () {}\n');
}
/** The repo as a forge would archive it: one top-level folder, the shell files, and modules/. */
function buildArchive(dir) {
const top = path.join(dir, 'ProdDash-main');
fs.mkdirSync(top, { recursive: true });
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(top, 'server.js'));
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
writeModule(path.join(top, 'modules', 'demo'), 'demo', '2.0.0');
writeModule(path.join(top, 'modules', 'other'), 'other', '1.0.0');
writeModule(path.join(top, 'modules', 'third'), 'third', '1.0.0');
const out = path.join(dir, 'main.tar.gz');
execFileSync('tar', ['-czf', out, '-C', dir, 'ProdDash-main']);
return fs.readFileSync(out);
}
/** A packaged install as it was before 1.10.0: the shell plus bundled modules, no .git. */
function buildPackagedApp(app) {
fs.mkdirSync(path.join(app, 'config'), { recursive: true });
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(app, 'server.js'));
fs.copyFileSync(path.join(REPO, 'package.json'), path.join(app, 'package.json'));
fs.cpSync(path.join(REPO, 'public'), path.join(app, 'public'), { recursive: true });
fs.writeFileSync(path.join(app, 'config', 'proddash.json'), JSON.stringify({ port: 24500 }));
writeModule(path.join(app, 'modules', 'demo'), 'demo', '1.0.0');
fs.mkdirSync(path.join(app, 'modules', 'demo', 'bin'));
fs.writeFileSync(path.join(app, 'modules', 'demo', 'bin', 'demo-helper'), '#!/bin/sh\necho helper\n');
fs.chmodSync(path.join(app, 'modules', 'demo', 'bin', 'demo-helper'), 0o755);
writeModule(path.join(app, 'modules', 'gone'), 'gone', '1.0.0');
}
(async () => {
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-unbundled-'));
const app = path.join(work, 'app');
const dataDir = path.join(work, 'data');
fs.mkdirSync(dataDir);
buildPackagedApp(app);
// The operator had uninstalled "gone" before this update.
fs.writeFileSync(path.join(dataDir, 'modules.json'), JSON.stringify({ gone: { uninstalled: true } }));
const archive = buildArchive(path.join(work, 'forge'));
const forge = http.createServer((req, res) => {
if (req.url.startsWith('/michael/ProdDash/archive/main.tar.gz')) {
res.writeHead(200, { 'Content-Type': 'application/gzip' });
return res.end(archive);
}
res.writeHead(404);
res.end();
});
const forgePort = await freePort();
await new Promise((r) => forge.listen(forgePort, '127.0.0.1', r));
const port = await freePort();
const base = `http://127.0.0.1:${port}`;
const env = {
...process.env,
PRODDASH_DATA_DIR: dataDir,
PORT: String(port),
PRODDASH_FORGE: `http://127.0.0.1:${forgePort}`,
PRODDASH_REPO: 'michael/ProdDash',
PRODDASH_NO_REMOTE_CHECK: '1',
PRODDASH_LAUNCHER: '1', // a supervisor is watching: an update exits 75 instead of respawning
};
let log = '';
let child = null;
const start = () => {
child = spawn(process.execPath, ['server.js'], { cwd: app, env, stdio: ['ignore', 'pipe', 'pipe'] });
child.stdout.on('data', (d) => { log += d; });
child.stderr.on('data', (d) => { log += d; });
return child;
};
const exited = (proc) => new Promise((resolve) => proc.on('exit', resolve));
const api = async (p, init = {}) => {
const r = await fetch(base + p, { headers: { 'Content-Type': 'application/json' }, ...init });
return { status: r.status, body: await r.json().catch(() => ({})) };
};
const moduleIds = async () => (await api('/api/modules')).body.modules.map((m) => m.id).sort();
let failed = false;
try {
// ── 1. the move ──
start();
await waitFor(base + '/api/theme');
const moved = path.join(dataDir, 'modules', 'demo');
assert.ok(fs.existsSync(path.join(moved, 'module.json')), 'demo moved into the data directory');
const helper = path.join(moved, 'bin', 'demo-helper');
assert.ok(fs.existsSync(helper), 'its compiled helper came along');
assert.ok(fs.statSync(helper).mode & 0o111, 'and is still executable');
assert.equal(fs.existsSync(path.join(dataDir, 'modules', 'gone')), false, 'an uninstalled module is dropped, not moved');
assert.equal(fs.existsSync(path.join(app, 'modules')), false, "the app folder's modules/ is gone");
assert.deepEqual(await moduleIds(), ['demo'], 'demo runs, gone does not');
assert.match(log, /moved bundled module "demo" v1\.0\.0 into the data directory/);
assert.match(log, /the app folder carries no modules now/);
const state = (await api('/api/admin/state')).body;
assert.equal(state.modules.find((m) => m.id === 'demo')?.source, 'installed', 'demo is an installed copy now');
// The catalog sees the repo's modules/ — as offers, not as a bundle.
let cat = (await api('/api/admin/catalog?refresh=1')).body;
assert.equal(cat.shell.remote.error, '', 'the mock forge answers: ' + cat.shell.remote.error);
assert.deepEqual(cat.updates.map((u) => `${u.id} ${u.installedVersion}->${u.version}`), ['demo 1.0.0->2.0.0']);
assert.deepEqual(cat.available.map((a) => a.id).sort(), ['gone', 'other', 'third'].filter((id) => id !== 'gone'), 'other and third are on offer');
// ── 2. a shell update carries no modules ──
const running = child;
const up = await api('/api/admin/update', { method: 'POST', body: '{}' });
assert.equal(up.status, 200, 'update: ' + JSON.stringify(up.body));
assert.equal(await exited(running), 75, 'the server asks its supervisor for a restart');
assert.equal(fs.existsSync(path.join(app, 'modules')), false, 'the archive\'s modules/ was not written into the app folder');
assert.equal(JSON.parse(fs.readFileSync(path.join(app, 'package.json'), 'utf8')).version, '9.9.9', 'but the shell itself was updated');
log = '';
start();
await waitFor(base + '/api/theme');
assert.doesNotMatch(log, /moved bundled module/, 'nothing left to move on the second start');
assert.deepEqual(await moduleIds(), ['demo'], 'still just demo, from the data directory');
cat = (await api('/api/admin/catalog')).body;
assert.deepEqual(cat.available.map((a) => a.id).sort(), ['other', 'third'], 'the new modules are offers, not installs');
// ── 3. several at once ──
const many = await api('/api/admin/modules/install', { method: 'POST', body: JSON.stringify({ ids: ['other', 'third', 'nope'] }) });
assert.equal(many.status, 200);
assert.equal(many.body.ok, false, 'one of them does not exist');
assert.deepEqual(many.body.results.map((r) => `${r.id}:${r.ok}`), ['other:true', 'third:true', 'nope:false']);
assert.ok(fs.existsSync(path.join(dataDir, 'modules', 'other', 'module.json')), 'other landed in the data directory');
assert.deepEqual(await moduleIds(), ['demo', 'other', 'third'], 'and both are mounted');
const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' });
assert.equal(empty.status, 400, 'nothing to install is said');
console.log('unbundled shell: every check passed');
} catch (err) {
failed = true;
console.error('FAIL:', err.message);
console.error('--- last of the server log ---\n' + log.slice(-2500));
} finally {
try { child?.kill('SIGTERM'); } catch { /* gone */ }
forge.close();
await new Promise((r) => setTimeout(r, 400));
fs.rmSync(work, { recursive: true, force: true });
}
process.exit(failed ? 1 : 0);
})();