Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Fable 5.1 601dc9e8b0 Update check: say what the forge answered, not zlib's "incorrect header check"
A Gitea that requires a sign-in does not refuse the branch archive: it
redirects the download to its login page, which arrives as a 200 of HTML,
and the admin page then reported zlib failing to unpack it — "incorrect
header check". Both booths showed exactly that on 29 Sept once the sign-in
requirement was switched on.

The updater now names the case: "… requires a sign-in to read
michael/ProdDash@main — turn off 'require sign-in to view' in Gitea, or
make the repo readable without one". Any other answer that is not a gzip
archive says what came back instead — a web page, or the content type —
and where to look.

tools/update-check.test.js drives the check against a mock forge: the login
redirect, a bare HTML answer, an HTTP error, and a real archive that must
still parse and be offered.

Shell 1.9.3.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 14:54:05 -04:00
24 changed files with 271 additions and 1092 deletions
-2
View File
@@ -14,8 +14,6 @@ modules/propresenter-timers/ltc-capture
# the launcher's build output, and its cache of the Node runtime it embeds
launcher/macos/build/
launcher/macos/.cache/
launcher/windows/build/
launcher/windows/.cache/
# Compiled per-machine, source sits next to it (see spl-capture.swift).
modules/spl-meter/spl-capture
+2 -8
View File
@@ -3,8 +3,7 @@
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/` (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
`/admin`, modules in `modules/`, 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
@@ -29,12 +28,7 @@ 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()`. **A native helper ships
prebuilt inside the module folder for Apple silicon macOS and 64-bit
Windows** (`bin/<name>-macos-arm64`, `bin/<name>-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"`
started in `init()` and stopped in `stop()`. 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).
+11 -50
View File
@@ -30,23 +30,11 @@ 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
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
Open the disk image and drag **ProdDash** to Applications. That's the whole
installation: the app carries the dashboard and its own Node runtime, so
nothing else has to be installed. 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
nothing else has to be installed and nothing can be missing. It lives in the
menu bar, starts and stops the server, can open at login, and holds the
permissions the modules need — the microphone the LTC listener reads, and
local network access for ProdCom and ProPresenter. macOS grants those to the
@@ -55,26 +43,12 @@ 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` (`ARCH=x86_64` in front of it
for Intel):
[launcher/macos](launcher/macos)/`build.sh`:
```bash
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
Requires [Node.js](https://nodejs.org) 18 or newer.
@@ -205,15 +179,13 @@ 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. 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.
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.
- **Uninstall** on a module card removes an installed module's files. For a
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.
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.
- **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
@@ -353,9 +325,8 @@ 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 three tiles in the + picker:
**Plan** (the order of service), **Teams** (who is scheduled) and **Song
Key** (the key of the item on screen).
- **PCO Plan** — Planning Center Services, as two tiles in the + picker:
**Plan** (the order of service) and **Teams** (who is scheduled).
**Plan** shows every
plan item with its scheduled start time, length, type, song key,
@@ -386,16 +357,6 @@ 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
+9 -80
View File
@@ -28,10 +28,7 @@ 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. **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).
is fine; a program someone launches by hand is not.
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
@@ -86,86 +83,19 @@ modules/<id>/
├── 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)
│ ├── <name>-macos-arm64
│ └── <name>-win-x64.exe
├── native/ their source and the script that builds every bin/ file
└── ... assets (css, images…) served statically at /modules/<id>/
```
**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 (`<data dir>/modules/<id>/`) and starts
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/<name>-macos-arm64` |
| Windows, 64-bit | `bin/<name>-win-x64.exe` |
| macOS, Intel (optional) | `bin/<name>-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.
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.
## module.json
@@ -461,8 +391,7 @@ 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
// positioned by the shell, hosted on the body under an element carrying
// your data-module, so your scoped CSS applies and nothing clips it)
// fixed-positioned by the shell, so the tile's overflow can't clip it)
},
});
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
+3 -5
View File
@@ -51,7 +51,6 @@ the cycle rather than spinning forever.
```bash
./build.sh # build/ProdDash.app — the whole application
./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 --run # …and open it
./build.sh --notarize # with --dmg: notarise and staple (Developer ID)
@@ -69,10 +68,9 @@ 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. 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.
is the app's icon. A module's native helper is compiled during the build
(`<name>.swift` beside a module's files becomes `<name>`), so the Mac running
ProdDash never needs a compiler.
## Signing
+25 -15
View File
@@ -1,8 +1,6 @@
#!/bin/zsh
# Build ProdDash.app — the whole application: launcher, server and its own
# 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).
# Node runtime, with nothing to install first.
#
# ./build.sh build/ProdDash.app, self-contained
# ./build.sh --dmg …and build/ProdDash-<version>-arm64.dmg
@@ -13,8 +11,6 @@
# ./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
@@ -31,13 +27,8 @@ 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="${ARCH:-arm64}" # arm64 (Apple silicon) or x86_64 (Intel)
ARCH="arm64"
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"
@@ -115,7 +106,7 @@ fi
# ── the server, and the Node that runs it ────────────────────────────────
if [[ $DEV -eq 0 ]]; then
TARBALL="node-$NODE_VERSION-darwin-$NODE_ARCH.tar.gz"
TARBALL="node-$NODE_VERSION-darwin-$ARCH.tar.gz"
mkdir -p "$CACHE_DIR"
if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then
echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)"
@@ -134,16 +125,25 @@ 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-$NODE_ARCH/bin/node"
"node-$NODE_VERSION-darwin-$ARCH/bin/node"
echo " embedding ProdDash $VERSION (no modules — the catalog installs them)"
echo " embedding ProdDash $VERSION"
mkdir -p "$PAYLOAD/config"
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$PAYLOAD/"
cp -R "$REPO_ROOT/public" "$PAYLOAD/"
cp -R "$REPO_ROOT/public" "$REPO_ROOT/modules" "$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: <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
# ── signing ──────────────────────────────────────────────────────────────
@@ -165,6 +165,16 @@ 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"
-121
View File
@@ -1,121 +0,0 @@
#!/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}'))"
+5 -9
View File
@@ -1,8 +1,8 @@
/* PCO Plan — client part.
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 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 server part reads Planning Center and (optionally) follows the
ProPresenter module's live item; this tile only renders the plan it is
@@ -13,8 +13,6 @@
the connection settings. */
import createTeams from "./teams-client.js";
import createKey from "./key-client.js";
import { prettyKey } from "./key-notation.js";
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
@@ -23,9 +21,8 @@ const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
export default function create({ root, moduleApi }) {
// Each picker entry carries its variant and its own settings.
// Tiles added as "Teams" carry that variant and have their own settings.
if (moduleApi.variant === "teams") return createTeams({ root, moduleApi });
if (moduleApi.variant === "key") return createKey({ root, moduleApi });
return createPlan({ root, moduleApi });
}
@@ -370,8 +367,7 @@ function createPlan({ root, moduleApi }) {
if (on('showKey') && item.keyName) {
const key = document.createElement('span');
key.className = 'pp-key';
// B♭, F♯m — the same notation the Song Key tile uses.
key.textContent = prettyKey(item.keyName);
key.textContent = item.keyName;
titleLine.appendChild(key);
}
if (on('showNowIndicator')) {
-181
View File
@@ -1,181 +0,0 @@
/* PCO Plan — the Song Key tile.
One thing, big: the key of the plan item on screen. It reads the same
streamed state the Plan tile does, so it needs no connection of its own;
which item is current comes from the ProPresenter module, as ever. */
import { parseKey, prettyKey } from "./key-notation.js";
export default function createKey({ root, moduleApi }) {
let state = null;
let stream = null;
let feedOffline = false;
root.innerHTML = `
<div class="pk-wrap">
<div class="pk-key"></div>
<div class="pk-name"></div>
<div class="pk-next"></div>
</div>`;
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.
},
};
}
-25
View File
@@ -1,25 +0,0 @@
/* 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}`;
}
+2 -32
View File
@@ -1,9 +1,9 @@
{
"id": "pco-plan",
"name": "PCO Plan",
"version": "1.9.0",
"version": "1.8.0",
"proddash": ">=1.4.0",
"description": "Order of service, team assignments and the song key from Planning Center Services",
"description": "Order of service and team assignments from Planning Center Services",
"provides": [
"timers"
],
@@ -621,36 +621,6 @@
"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
}
}
}
]
}
+7 -94
View File
@@ -206,9 +206,7 @@
[data-module="pco-plan"] .pp-now-tag {
display: none;
/* 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;
padding: 0.05em 0.45em;
border-radius: 999px;
background: var(--pp-current);
color: var(--bg);
@@ -637,110 +635,25 @@
[data-module="pco-plan"] .pp-abs-sec {
flex: none;
/* 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));
padding: 0.1em 0.5em;
border-radius: 999px;
border: 1px solid var(--border);
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, background 0.2s ease;
transition: color 0.2s ease, border-color 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) 45%, var(--panel)) var(--pp-sec-fill, 0%),
color-mix(in srgb, var(--text) 8%, var(--panel)) var(--pp-sec-fill, 0%));
color-mix(in srgb, var(--pp-current) 40%, transparent) var(--pp-sec-fill, 0%),
transparent 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; }
+3 -81
View File
@@ -81,8 +81,8 @@ export default function create({ root, moduleApi }) {
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
applyInstanceSettings();
}
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) });
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
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 }) {
},
});
const latestBtn = moduleApi.header.addButton({
moduleApi.header.addButton({
icon: JUMP_SVG,
title: 'Latest',
onClick() {
@@ -112,82 +112,6 @@ 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 = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></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));
@@ -202,7 +126,6 @@ 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();
@@ -492,7 +415,6 @@ export default function create({ root, moduleApi }) {
},
stop() {
stopped = true;
sizeWatch.disconnect();
stream?.close();
root.innerHTML = ''; // header controls are removed by the shell
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "prodcom-transcript",
"name": "ProdCom",
"version": "1.3.3",
"version": "1.3.2",
"proddash": ">=1.1.0",
"description": "Live ProdCom transcript",
"permissions": [
+1 -5
View File
@@ -20,13 +20,9 @@
}
[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 hangs off the body under a [data-module] host, so scoping still applies. */
it lives inside the tile, so [data-module] scoping still applies. */
[data-module="prodcom-transcript"] .pt-channel-item {
display: flex;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proddash",
"version": "1.10.1",
"version": "1.9.3",
"private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED",
-1
View File
@@ -443,7 +443,6 @@ 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; }
-43
View File
@@ -962,13 +962,6 @@ 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();
@@ -1170,7 +1163,6 @@ 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';
@@ -1213,46 +1205,11 @@ 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;
-1
View File
@@ -58,7 +58,6 @@
<section id="available-section" hidden>
<h1 class="admin-h1">Available modules</h1>
<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>
<span class="admin-status" id="catalog-status"></span>
</div>
+10 -51
View File
@@ -510,7 +510,6 @@ function reconcileOfferedTiles() {
}
if (changed) rebuildDividers();
scheduleOfferedRecheck();
refreshEmptyState(); // the registry may have gone from nothing to something
}
/* A module's entry list can change without anyone saving admin config: PCO
@@ -640,10 +639,8 @@ function buildTile(tile) {
el.addEventListener('pointermove', (ev) => {
if (!headIsOff(tile)) return;
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
// — neither is a moment to take the bar away. Nor is an open header
// menu, which hangs below the bar and is about to be pointed at.
// — neither is a moment to take the bar away.
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
if (el.classList.contains('menu-open')) return;
const y = ev.clientY - el.getBoundingClientRect().top;
const shown = el.classList.contains('head-peek');
// It takes less to hold the bar open than to open it, so a bar that
@@ -654,7 +651,6 @@ function buildTile(tile) {
el.addEventListener('pointerleave', () => {
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
if (el.classList.contains('menu-open')) return; // the pointer is on its way to the menu
el.classList.remove('head-peek');
});
@@ -662,7 +658,7 @@ function buildTile(tile) {
applyHeadState(tile, el);
grid.appendChild(el);
const entry = { el, body, dot, titleEl: title, controls, instance: null, openMenus: new Set() };
const entry = { el, body, dot, titleEl: title, controls, instance: null };
tileEls.set(tile.id, entry);
applyRect(tile);
@@ -680,15 +676,6 @@ function buildTile(tile) {
function refreshEmptyState() {
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 ───────────────────────────────────────────────────── */
@@ -1429,60 +1416,34 @@ function buildModuleApi(tile, man, entry) {
menu.className = 'tile-menu';
menu.hidden = true;
/* The menu lives on the body, not in the bar. A folded-away bar
(auto-hide, fullscreen) carries a backdrop-filter and a transform,
and either one makes a fixed-positioned descendant position
itself against the bar instead of the viewport — the menu landed
a screen-height below its button, inside the tile's overflow, and
never showed. Its host carries the tile's data-module so the
module's scoped CSS still reaches the items. */
const host = document.createElement('div');
host.className = 'tile-menu-host';
if (man) host.dataset.module = man.id;
host.appendChild(menu);
const setOpen = (on) => {
if (on === !menu.hidden) return;
menu.hidden = !on;
btn.classList.toggle('is-open', on);
if (on) entry.openMenus.add(menu);
else entry.openMenus.delete(menu);
// an open menu holds a folded-away bar out; when the last one
// closes with the pointer elsewhere, the bar goes back
entry.el.classList.toggle('menu-open', entry.openMenus.size > 0);
if (!on && !entry.openMenus.size && !entry.el.matches(':hover')) entry.el.classList.remove('head-peek');
};
const open = () => {
if (typeof build === 'function') {
menu.innerHTML = '';
build(menu);
}
setOpen(true);
// under the button, right-aligned, kept whole inside the viewport
menu.hidden = false;
// fixed positioning (escapes the tile's overflow): under the
// button, right-aligned, clamped to the viewport
const r = btn.getBoundingClientRect();
menu.style.top = Math.min(r.bottom + 6, window.innerHeight - 80) + 'px';
menu.style.left = '0px';
menu.style.top = '0px';
const w = menu.offsetWidth;
const h = menu.offsetHeight;
menu.style.top = Math.max(8, Math.min(r.bottom + 6, window.innerHeight - h - 8)) + 'px';
menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
};
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (menu.hidden) open();
else setOpen(false);
else menu.hidden = true;
});
const onDocClick = (e) => {
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) setOpen(false);
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) menu.hidden = true;
};
document.addEventListener('click', onDocClick);
docListeners.add(onDocClick);
wrap.append(btn);
wrap.append(btn, menu);
entry.controls.appendChild(wrap);
document.body.appendChild(host);
headerEls.add(wrap);
headerEls.add(host);
return {
button: btn,
menu,
@@ -1490,7 +1451,7 @@ function buildModuleApi(tile, man, entry) {
btn.textContent = text;
},
close() {
setOpen(false);
menu.hidden = true;
},
};
},
@@ -1502,8 +1463,6 @@ function buildModuleApi(tile, man, entry) {
for (const handle of [...sseHandles]) handle.close();
for (const el of headerEls) el.remove();
headerEls.clear();
entry.openMenus.clear();
entry.el.classList.remove('menu-open');
for (const fn of docListeners) document.removeEventListener('click', fn);
docListeners.clear();
},
+2 -3
View File
@@ -445,9 +445,8 @@ body.menu-hidden #topbar {
}
.tile-dd { position: relative; display: inline-flex; }
/* a module header menu — hosted on the body (shell.js addMenu) so neither
the tile's overflow nor a folded-away bar's backdrop-filter can touch it */
.tile-menu-host { display: contents; }
/* a module header menu — fixed-positioned by the shell so the tile's
overflow:hidden can't clip it */
.tile-menu {
position: fixed;
z-index: 70;
+13 -80
View File
@@ -277,52 +277,6 @@ function isUninstalled(id) {
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) {
const s = modulesConfig[id];
return s && typeof s === 'object' ? s : {};
@@ -956,9 +910,21 @@ async function fetchArchive() {
signal: AbortSignal.timeout(30000),
headers: { 'User-Agent': `ProdDash/${SHELL_VERSION}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${REPO.owner}/${REPO.repo}@${REPO.branch}`);
const where = `${REPO.owner}/${REPO.repo}@${REPO.branch}`;
// A forge that wants a sign-in does not refuse: it redirects the download
// to its login page, which arrives as a 200 of HTML. Say that, not
// zlib's "incorrect header check".
if (res.redirected && /\/user\/login\b|\/login\b/.test(new URL(res.url).pathname)) {
throw new Error(`${FORGE.base} requires a sign-in to read ${where} — turn off "require sign-in to view" in Gitea, or make the repo readable without one`);
}
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${where}`);
const gz = Buffer.from(await res.arrayBuffer());
if (gz.length > MAX_ARCHIVE_BYTES) throw new Error('archive is unexpectedly large');
if (gz.length < 2 || gz[0] !== 0x1f || gz[1] !== 0x8b) {
const type = String(res.headers.get('content-type') || 'unknown type').split(';')[0].trim();
const looksHtml = /^\s*<(!doctype|html)/i.test(gz.subarray(0, 64).toString('utf8'));
throw new Error(`${FORGE.base} answered with ${looksHtml ? 'a web page' : type} instead of the ${where} archive — check the forge address and that the repo is readable without a sign-in`);
}
const { files, comment } = parseTar(zlib.gunzipSync(gz));
const stripped = new Map(); // drop the archive's single top-level folder
for (const [p, data] of files) {
@@ -1281,18 +1247,9 @@ const DATA_DIR_INSIDE_ROOT = (() => {
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) {
if (rel.startsWith('.git/')) 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);
}
@@ -1635,29 +1592,6 @@ async function handleAdminApi(req, res, urlPath) {
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$/);
if (install && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
@@ -2152,7 +2086,6 @@ server.requestTimeout = 0;
/* ── boot ───────────────────────────────────────────────────────────── */
unbundleModules();
discoverModules();
mountAllModules();
-203
View File
@@ -1,203 +0,0 @@
'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);
})();
+176
View File
@@ -0,0 +1,176 @@
'use strict';
/**
* The update check against a forge that misbehaves: the admin page must say
* what went wrong in words, never zlib's "incorrect header check".
*
* node tools/update-check.test.js
*
* Plain Node, no dependencies. Starts a mock forge whose answer to the
* branch-archive download can be switched at runtime, starts server.js on a
* spare port pointed at it, and reads the shell status the admin page shows
* after each kind of answer:
*
* - a Gitea that requires a sign-in redirects the download to its login
* page (a 200 of HTML) — the case that hit both booths on 2026-09-29;
* - a captive portal or a wrong address answers HTML outright;
* - a plain HTTP error;
* - and a real archive, which must still parse and offer the update.
*
* Exits non-zero on the first failure and always stops what it started.
*/
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 ROOT = 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);
}
/** A branch archive the way a forge would build one: one top-level folder. */
function buildArchive(dir) {
const top = path.join(dir, 'ProdDash-main');
fs.mkdirSync(path.join(top, 'modules', 'demo'), { recursive: true });
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
fs.writeFileSync(path.join(top, 'modules', 'demo', 'module.json'), JSON.stringify({ id: 'demo', name: 'Demo', version: '1.0.0', client: 'client.js' }));
fs.writeFileSync(path.join(top, 'modules', 'demo', 'client.js'), 'export default function () {}\n');
const out = path.join(dir, 'main.tar.gz');
execFileSync('tar', ['-czf', out, '-C', dir, 'ProdDash-main']);
return fs.readFileSync(out);
}
(async () => {
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-update-test-'));
const dataDir = path.join(work, 'data');
fs.mkdirSync(dataDir);
const archive = buildArchive(path.join(work, 'forge'));
// ── the mock forge ──
let mode = 'ok';
const LOGIN_PAGE = '<!DOCTYPE html>\n<html><head><title>Sign in</title></head><body>Sign in to continue</body></html>';
const forge = http.createServer((req, res) => {
const url = new URL(req.url, 'http://forge.invalid');
if (url.pathname === '/user/login') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(LOGIN_PAGE);
}
if (url.pathname !== '/michael/ProdDash/archive/main.tar.gz') {
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('not here');
}
switch (mode) {
case 'login-redirect':
res.writeHead(303, { Location: '/user/login?redirect_to=' + encodeURIComponent(url.pathname) });
return res.end();
case 'html':
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end('<html><body>You must log in to this network first</body></html>');
case 'notfound':
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('no such branch');
default:
res.writeHead(200, { 'Content-Type': 'application/gzip' });
return res.end(archive);
}
});
const forgePort = await freePort();
await new Promise((r) => forge.listen(forgePort, '127.0.0.1', r));
const forgeBase = `http://127.0.0.1:${forgePort}`;
// ── ProdDash, pointed at it ──
const port = await freePort();
const child = spawn(process.execPath, ['server.js'], {
cwd: ROOT,
env: {
...process.env,
PRODDASH_DATA_DIR: dataDir,
PORT: String(port),
PRODDASH_FORGE: forgeBase,
PRODDASH_REPO: 'michael/ProdDash',
PRODDASH_NO_REMOTE_CHECK: '1',
PRODDASH_LAUNCHER: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let log = '';
child.stdout.on('data', (d) => { log += d; });
child.stderr.on('data', (d) => { log += d; });
const base = `http://127.0.0.1:${port}`;
/** What the admin page shows after a forced check. Admin is open: a fresh data dir has no PIN. */
const check = async (m) => {
mode = m;
const r = await fetch(base + '/api/admin/catalog?refresh=1');
assert.equal(r.status, 200, `catalog: HTTP ${r.status}`);
const body = await r.json();
return body.shell;
};
let failed = false;
try {
await waitFor(base + '/api/theme');
let shell = await check('login-redirect');
assert.match(shell.remote.error, /requires a sign-in/, 'a login redirect is named as such: ' + shell.remote.error);
assert.match(shell.remote.error, /require sign-in to view/, 'and the Gitea setting to look at is named');
assert.doesNotMatch(shell.remote.error, /header check/, 'never zlib\'s words');
assert.equal(shell.updateAvailable, false);
shell = await check('html');
assert.match(shell.remote.error, /answered with a web page instead of the michael\/ProdDash@main archive/, shell.remote.error);
shell = await check('notfound');
assert.match(shell.remote.error, /HTTP 404 fetching michael\/ProdDash@main/, shell.remote.error);
shell = await check('ok');
assert.equal(shell.remote.error, '', 'a real archive clears the error: ' + shell.remote.error);
assert.equal(shell.remote.version, '9.9.9', 'and is read');
assert.equal(shell.updateAvailable, true, 'and offered');
// Back to a broken forge: the last good read is not mistaken for a current one.
shell = await check('login-redirect');
assert.match(shell.remote.error, /requires a sign-in/);
console.log('update check: every check passed');
} catch (err) {
failed = true;
console.error('FAIL:', err.message);
console.error('--- last of the server log ---\n' + log.slice(-2000));
} finally {
child.kill('SIGTERM');
forge.close();
await new Promise((r) => setTimeout(r, 400));
fs.rmSync(work, { recursive: true, force: true });
}
process.exit(failed ? 1 : 0);
})();