Compare commits

..
Author SHA1 Message Date
Waters Church 409f2c258e Merge remote-tracking branch 'origin/main' into claude/update-error-message
# Conflicts:
#	package.json
2026-09-30 14:22:25 -04:00
michael 7f6e5bd151 Merge pull request 'Shell 1.10.1: header menus open where their button is, folded bar or not; ProdCom 1.3.3 keeps its filter on a narrow tile' (#39) from claude/prodcom-filter-button-visibility-8a1d2d into main 2026-09-30 14:21:52 -04:00
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 41811ead1c Shell 1.10.1: header menus open where their button is, folded bar or not; ProdCom 1.3.3 keeps its filter on a narrow tile
A tile's dropdown menus (ProdCom's Channels, the Timers menu) opened but
never showed under a folded-away title bar — in fullscreen, or with
auto-hide on. The bar carries a backdrop-filter and a transform while
folded, 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. A tile near
the top of the screen showed a misplaced menu; anything lower showed
nothing at all.

The shell now hosts every header menu on the body, under an element
carrying the tile's data-module so a module's scoped CSS still reaches
its items; places it whole inside the viewport; holds a folded bar open
while one of its menus is open (the pointer has to travel down into the
menu); and marks the open menu's button the way the gear is marked.

ProdCom: seven controls need ~175 px of bar, and the shell clips what a
narrow tile cannot fit — the Channels button went with the rest. Below
380 px the six secondary controls fold into a ⋯ menu offering the same
actions, so the filter is the last thing to go.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 14:08:02 -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 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
18 changed files with 431 additions and 135 deletions
-3
View File
@@ -13,6 +13,3 @@ 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 -1
View File
@@ -461,7 +461,8 @@ const menu = moduleApi.header.addMenu({
build(menuEl) {
// called on every open with an emptied menu element — fill it with
// buttons (give them your scoped classes; the container is styled and
// fixed-positioned by the shell, so the tile's overflow can't clip it)
// positioned by the shell, hosted on the body under an element carrying
// your data-module, so your scoped CSS applies and nothing clips it)
},
});
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
+81 -3
View File
@@ -81,8 +81,8 @@ export default function create({ root, moduleApi }) {
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
applyInstanceSettings();
}
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
const minusBtn = moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
const plusBtn = moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
// icon and tooltip track the current direction — applyInstanceSettings sets both
const flowBtn = moduleApi.header.addButton({
@@ -95,7 +95,7 @@ export default function create({ root, moduleApi }) {
},
});
moduleApi.header.addButton({
const latestBtn = moduleApi.header.addButton({
icon: JUMP_SVG,
title: 'Latest',
onClick() {
@@ -112,6 +112,82 @@ export default function create({ root, moduleApi }) {
});
clearBtn.classList.add('pt-clear');
/* ── a narrow tile: the bar keeps Channels, the rest fold into ⋯ ──
Seven controls need ~175 px, and the shell clips whatever the bar
cannot fit — on a 3-column tile among many, or one squeezed by the
fullscreen fit, that used to take the channel filter with it. Below
COMPACT_PX the six secondary controls hide and a single ⋯ menu offers
the same actions, so the filter button is the last thing to go. */
const MORE_SVG = '<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));
@@ -126,6 +202,7 @@ export default function create({ root, moduleApi }) {
flowBtn.classList.toggle('active', up);
flowBtn.title = up ? 'Newest first' : 'Oldest first';
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
rebuildMoreMenu(); // its checkmarks mirror the buttons
}
applyInstanceSettings();
@@ -415,6 +492,7 @@ export default function create({ root, moduleApi }) {
},
stop() {
stopped = true;
sizeWatch.disconnect();
stream?.close();
root.innerHTML = ''; // header controls are removed by the shell
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "prodcom-transcript",
"name": "ProdCom",
"version": "1.3.2",
"version": "1.3.3",
"proddash": ">=1.1.0",
"description": "Live ProdCom transcript",
"permissions": [
+5 -1
View File
@@ -20,9 +20,13 @@
}
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
/* narrow tile: the secondary controls fold into the ⋯ menu (client.js) */
[data-module="prodcom-transcript"] .tile-btn[hidden],
[data-module="prodcom-transcript"] .tile-dd[hidden] { display: none; }
/* ── channels menu ──
The menu container is created and positioned by the shell (.tile-menu);
it lives inside the tile, so [data-module] scoping still applies. */
it hangs off the body under a [data-module] host, so scoping still applies. */
[data-module="prodcom-transcript"] .pt-channel-item {
display: flex;
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "spl-meter",
"name": "SPL Meter",
"version": "0.4.3",
"version": "0.5.0",
"proddash": ">=1.5.0",
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
"permissions": [
+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.
//
// Everything below uses Accelerate, which ships with macOS. No third-party
// code, no package manager, ~2 s to build:
// code, no package manager. It ships prebuilt in the module's bin/ (one
// binary per platform, committed — a booth never builds anything). To
// rebuild after a change here, on any Mac with the Xcode command line tools:
//
// swiftc -O -o modules/spl-meter/spl-capture \
// modules/spl-meter/spl-capture.swift
// modules/spl-meter/native/build.sh (both architectures)
//
// spl-capture --list
// every input-capable device: name, input channels, sample rate
+10 -20
View File
@@ -20,7 +20,7 @@
*/
const path = require('path');
const { createSplListener, ensureCaptureBinary } = require('./spl-listener');
const { createSplListener, captureTool } = require('./spl-listener');
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
const CALIBRATOR_DB = 94;
@@ -120,12 +120,9 @@ module.exports = {
pinger: null,
};
// Before anything else: the device picker shells out to this tool, so a
// machine that installed the module from the catalog (source only — the
// binary is gitignored) needs the build kicked off here. Waiting for a
// listener would never happen: no tool means no device list, and no
// device means no listener.
state.toolMissing = ensureCaptureBinary({ moduleDir, log });
// The capture tool ships prebuilt in the module (bin/); with none for
// this platform every reading is off and the health line says so.
state.toolMissing = captureTool(moduleDir).missing;
if (!config.enabled) {
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
@@ -185,9 +182,7 @@ module.exports = {
return {
stop() { teardown(); },
health() {
if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) {
return { status: 'error', message: state.toolMissing };
}
if (state.toolMissing) return { status: 'error', message: state.toolMissing };
if (state.error) return { status: 'error', message: state.error };
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
const quiet = silentMs();
@@ -311,11 +306,10 @@ module.exports = {
/** Device list for the admin page. Runs the capture tool's --list,
which is the only thing that knows what CoreAudio can see. */
'GET /devices': async (req, res) => {
const missing = ensureCaptureBinary({ moduleDir: __dirname, log });
if (missing) {
if (state) state.toolMissing = missing;
log(`device list unavailable — ${missing}`);
json(res, 200, { options: [] });
const tool = captureTool(__dirname);
if (tool.missing) {
log(`device list unavailable — ${tool.missing}`);
json(res, 200, { options: [], error: tool.missing });
return;
}
try {
@@ -447,7 +441,7 @@ function teardown() {
function listDevices() {
return new Promise((resolve, reject) => {
const { execFile } = require('child_process');
execFile(path.join(__dirname, 'spl-capture'), ['--list'], { timeout: 10000 },
execFile(captureTool(__dirname).bin, ['--list'], { timeout: 10000 },
(err, stdout) => {
if (err && !stdout) { reject(err); return; }
try { resolve(JSON.parse(stdout).devices || []); }
@@ -456,10 +450,6 @@ function listDevices() {
});
}
function fsExists(p) {
try { return require('fs').existsSync(p); } catch { return false; }
}
function json(res, code, body) {
const text = JSON.stringify(body);
res.writeHead(code, {
+35 -88
View File
@@ -3,7 +3,8 @@
/**
* Sound level listener — owns the native capture child.
*
* Spawns spl-capture (compiled from spl-capture.swift beside it) for the
* Spawns spl-capture (bin/spl-capture-macos-<arch>, shipped prebuilt;
* source in native/) for the
* admin-configured device and channels, reads its JSON lines, and hands the
* latest frame upward. All measurement happens in the child; this file is
* plumbing and supervision, nothing more.
@@ -13,12 +14,11 @@
* { frame: {...}, meta: {...} } measuring
* { error: '<what is wrong and how to fix it>' } not measuring
*
* The capture binary is per-machine and gitignored, exactly like the Timers
* module's ltc-capture. It normally arrives already built — compiled beside
* the app, or copied from any Mac of the same architecture. When it is
* missing this will build it, but in the background and never on the event
* loop, and from module scope so the device picker can trigger it too — see
* ensureCaptureBinary for what both of those cost the first time round.
* The capture tool ships prebuilt inside the module, one binary per
* platform under bin/ (module guide, "Native helpers"), exactly like the
* Timers module's ltc-capture: nothing is ever built on the machine running
* ProdDash. captureTool() picks the binary for this platform; when there is
* none, measurement reports itself off and the rest of the module carries on.
*
* The same macOS gotcha applies as for LTC, and is worth repeating because
* it costs an afternoon every time: a ProdDash launched over SSH or from a
@@ -31,72 +31,31 @@ const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
+ 'and copy it next to spl-capture.swift';
const BUILDING = 'Building the capture tool — this takes a minute or two';
/* ── the capture tool: prebuilt, one binary per platform ───────────────── */
/** What the module ships in bin/, by platform and architecture. */
const HELPERS = {
darwin: { arm64: 'spl-capture-macos-arm64', x64: 'spl-capture-macos-x86_64' },
win32: { x64: 'spl-capture-win-x64.exe' },
};
/**
* Build the capture tool if it is missing, WITHOUT blocking, and shared by
* everything that needs it.
* The capture tool for this machine. It is never built here and never
* looked for anywhere else: the module ships it, or measurement is off on
* this platform and says so. `bin` is the usable binary; otherwise
* `missing` is what to tell the admin page and the tiles.
*
* This lives at module scope rather than inside a listener because of a trap
* the first version walked into: the admin page's device picker runs the tool
* with --list, but the build only ran when a listener started, and a listener
* only starts once a device has been picked. On a machine that got the module
* from the catalog — source only, the binary being gitignored — the picker
* was empty, and nothing would ever fill it.
*
* It also must never block. It was a spawnSync once, copied from the LTC
* listener where the source is a third the size and builds in seconds. Here
* it took nearly two minutes on the production Mac and held the Node event
* loop for all of it, freezing every other module, then did it again on the
* next retry.
*
* The binary should normally already be there: built beside the app, or
* copied from any Mac of the same architecture. This is the fallback.
*
* @returns {string} '' when the tool is ready, otherwise what to say.
* @returns {{bin:string, missing:string}}
*/
let building = false;
let buildFailed = false;
function ensureCaptureBinary({ moduleDir, log, onReady }) {
const bin = path.join(moduleDir, 'spl-capture');
if (fs.existsSync(bin)) return '';
const src = bin + '.swift';
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
if (building) return BUILDING;
if (buildFailed) return BUILD_HELP;
building = true;
log('spl-capture binary missing — building it in the background');
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
let buildErr = '';
build.stderr.setEncoding('utf8');
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
const giveUp = setTimeout(() => {
try { build.kill('SIGKILL'); } catch { /* already gone */ }
}, 300000);
build.on('error', (err) => {
clearTimeout(giveUp);
building = false; buildFailed = true;
log(`swiftc could not be run: ${err.message}`);
});
build.on('exit', (code) => {
clearTimeout(giveUp);
building = false;
if (code === 0 && fs.existsSync(bin)) {
log('spl-capture built');
buildFailed = false;
if (typeof onReady === 'function') onReady();
return;
}
buildFailed = true;
log(`spl-capture build failed (exit ${code})`
+ (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
});
return BUILDING;
function captureTool(moduleDir, platform = process.platform, arch = process.arch) {
const name = HELPERS[platform]?.[arch] || '';
const bin = name ? path.join(moduleDir, 'bin', name) : '';
if (bin && fs.existsSync(bin)) return { bin, missing: '' };
const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`;
return {
bin: '',
missing: `The module ships no sound level capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — measurement is off on this machine`,
};
}
const RESTART_MS = 5000;
@@ -114,7 +73,6 @@ const STALL_MS = 3000;
*/
function createSplListener(opts) {
const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts;
const bin = path.join(moduleDir, 'spl-capture');
let child = null;
let stopped = false;
@@ -124,13 +82,6 @@ function createSplListener(opts) {
let meta = null;
let lastFrameWall = 0;
/** '' when the tool is ready, otherwise what to say about it. */
function ensureBinary() {
return ensureCaptureBinary({ moduleDir, log, onReady() {
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
} });
}
function scheduleRestart(ms) {
if (stopped || restartTimer) return;
restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms);
@@ -150,15 +101,11 @@ function createSplListener(opts) {
function start() {
if (stopped) return;
const missing = ensureBinary();
if (missing) {
onUpdate({ error: missing });
// onReady is only the FAST path (a successful build restarts us at
// once). The slow retry must stay armed regardless: a failed build
// never calls back, and a binary can also arrive from outside — scp'd
// in on a machine whose toolchain can't build (AB's broken CLT).
// Waiting on onReady alone deadlocked exactly that way in the Timers
// module: "building…" forever, with a working binary on disk.
const tool = captureTool(moduleDir);
if (tool.missing) {
// No binary for this platform: say so and look again later — a module
// update remounts us anyway, this is only belt and braces.
onUpdate({ error: tool.missing });
scheduleRestart(BAD_CONFIG_RETRY_MS);
return;
}
@@ -175,7 +122,7 @@ function createSplListener(opts) {
let stderrTail = '';
let buf = '';
child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
child = spawn(tool.bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
child.stderr.setEncoding('utf8');
child.stderr.on('data', (text) => {
@@ -258,4 +205,4 @@ function createSplListener(opts) {
};
}
module.exports = { createSplListener, ensureCaptureBinary };
module.exports = { createSplListener, captureTool };
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proddash",
"version": "1.10.0",
"version": "1.10.2",
"private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED",
+41 -10
View File
@@ -640,8 +640,10 @@ 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.
// — 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.
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
@@ -652,6 +654,7 @@ 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');
});
@@ -659,7 +662,7 @@ function buildTile(tile) {
applyHeadState(tile, el);
grid.appendChild(el);
const entry = { el, body, dot, titleEl: title, controls, instance: null };
const entry = { el, body, dot, titleEl: title, controls, instance: null, openMenus: new Set() };
tileEls.set(tile.id, entry);
applyRect(tile);
@@ -1426,34 +1429,60 @@ 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);
}
menu.hidden = false;
// fixed positioning (escapes the tile's overflow): under the
// button, right-aligned, clamped to the viewport
setOpen(true);
// under the button, right-aligned, kept whole inside 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 menu.hidden = true;
else setOpen(false);
});
const onDocClick = (e) => {
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) menu.hidden = true;
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) setOpen(false);
};
document.addEventListener('click', onDocClick);
docListeners.add(onDocClick);
wrap.append(btn, menu);
wrap.append(btn);
entry.controls.appendChild(wrap);
document.body.appendChild(host);
headerEls.add(wrap);
headerEls.add(host);
return {
button: btn,
menu,
@@ -1461,7 +1490,7 @@ function buildModuleApi(tile, man, entry) {
btn.textContent = text;
},
close() {
menu.hidden = true;
setOpen(false);
},
};
},
@@ -1473,6 +1502,8 @@ 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();
},
+3 -2
View File
@@ -445,8 +445,9 @@ body.menu-hidden #topbar {
}
.tile-dd { position: relative; display: inline-flex; }
/* a module header menu — fixed-positioned by the shell so the tile's
overflow:hidden can't clip it */
/* 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; }
.tile-menu {
position: fixed;
z-index: 70;
+13 -1
View File
@@ -956,9 +956,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) {
+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);
})();