Compare commits

..
Author SHA1 Message Date
michael 3b81efcb51 Merge pull request 'Update check: say what the forge answered, not zlib's "incorrect header check"' (#34) from claude/update-error-message into main 2026-09-30 14:23:04 -04:00
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 2817a444c3 Timers 1.7.0: the LTC capture tool ships prebuilt, for Apple silicon and Intel
The tool used to be compiled by the macOS app build, or by the module
itself on the booth with swiftc — and a catalog install got source only,
which the unbundled 1.10.0 shell made every fresh install. Under the module
guide's rule it now ships built: bin/ltc-capture-macos-arm64 and
bin/ltc-capture-macos-x86_64, signed with the Developer ID (hardened
runtime, audio-input entitlement), from native/ltc-capture.swift via
native/build.sh. Nothing is built or looked for on the machine any more:
captureTool() picks the binary for this platform, and where there is none
— Windows, until a Windows implementation exists — timecode reports itself
off in the tile and the health line while the rest of the module runs.
The runtime build, its "building…" states and the device picker's build
kick are gone with it. The old top-level binary is no longer gitignored
(it is no longer written).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 10:11:08 -04:00
michael c11389473f Merge pull request 'Release builds: Intel disk images, a Windows bundle, and where to download them' (#24) from claude/release-builds into main 2026-09-30 09:58:11 -04:00
Waters ChurchandClaude Fable 5.1 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
26 changed files with 540 additions and 273 deletions
-6
View File
@@ -8,14 +8,8 @@ dist/
config/modules.json config/modules.json
config/layouts/ config/layouts/
# compiled per-machine, source sits next to it
modules/propresenter-timers/ltc-capture
# the launcher's build output, and its cache of the Node runtime it embeds # the launcher's build output, and its cache of the Node runtime it embeds
launcher/macos/build/ launcher/macos/build/
launcher/macos/.cache/ launcher/macos/.cache/
launcher/windows/build/ launcher/windows/build/
launcher/windows/.cache/ 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) { build(menuEl) {
// called on every open with an emptied menu element — fill it with // called on every open with an emptied menu element — fill it with
// buttons (give them your scoped classes; the container is styled and // 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 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)) }); moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
applyInstanceSettings(); applyInstanceSettings();
} }
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) }); const minusBtn = 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 plusBtn = moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
// icon and tooltip track the current direction — applyInstanceSettings sets both // icon and tooltip track the current direction — applyInstanceSettings sets both
const flowBtn = moduleApi.header.addButton({ 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, icon: JUMP_SVG,
title: 'Latest', title: 'Latest',
onClick() { onClick() {
@@ -112,6 +112,82 @@ export default function create({ root, moduleApi }) {
}); });
clearBtn.classList.add('pt-clear'); 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() { function applyInstanceSettings() {
const s = moduleApi.instanceSettings; const s = moduleApi.instanceSettings;
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15)); 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.classList.toggle('active', up);
flowBtn.title = up ? 'Newest first' : 'Oldest first'; flowBtn.title = up ? 'Newest first' : 'Oldest first';
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages'; jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
rebuildMoreMenu(); // its checkmarks mirror the buttons
} }
applyInstanceSettings(); applyInstanceSettings();
@@ -415,6 +492,7 @@ export default function create({ root, moduleApi }) {
}, },
stop() { stop() {
stopped = true; stopped = true;
sizeWatch.disconnect();
stream?.close(); stream?.close();
root.innerHTML = ''; // header controls are removed by the shell root.innerHTML = ''; // header controls are removed by the shell
}, },
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "prodcom-transcript", "id": "prodcom-transcript",
"name": "ProdCom", "name": "ProdCom",
"version": "1.3.2", "version": "1.3.3",
"proddash": ">=1.1.0", "proddash": ">=1.1.0",
"description": "Live ProdCom transcript", "description": "Live ProdCom transcript",
"permissions": [ "permissions": [
+5 -1
View File
@@ -20,9 +20,13 @@
} }
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; } [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 ── /* ── channels menu ──
The menu container is created and positioned by the shell (.tile-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 { [data-module="prodcom-transcript"] .pt-channel-item {
display: flex; display: flex;
Binary file not shown.
Binary file not shown.
+34 -113
View File
@@ -3,8 +3,8 @@
/** /**
* Built-in LTC listener — capture child + in-process decode. * Built-in LTC listener — capture child + in-process decode.
* *
* Spawns the module's native capture tool (ltc-capture, compiled from * Spawns the module's native capture tool (bin/ltc-capture-macos-<arch>,
* ltc-capture.swift beside it) for the admin-configured audio device, reads * shipped prebuilt; source in native/) for the admin-configured audio device, reads
* the interleaved s16le stream, extracts the configured channel, and decodes * the interleaved s16le stream, extracts the configured channel, and decodes
* SMPTE LTC with ltc.js — all inside the module server. Reports upward via * SMPTE LTC with ltc.js — all inside the module server. Reports upward via
* onUpdate roughly 4×/s: * onUpdate roughly 4×/s:
@@ -16,17 +16,13 @@
* shows (short — "Not configured — see Admin"), `detail` is the full story * shows (short — "Not configured — see Admin"), `detail` is the full story
* for the admin health line and the log. * for the admin health line and the log.
* *
* The capture binary is per-machine (gitignored), and a catalog install or * The capture tool ships prebuilt inside the module, one binary per
* update ships source only — the update even wipes a previously built * platform under bin/ (module guide, "Native helpers"): nothing is ever
* binary. ensureCaptureBinary() therefore rebuilds it with swiftc in the * built on the machine running ProdDash. captureTool() picks the binary for
* background as part of coming up: kicked when the LTC source starts and by * this platform; when there is none, timecode reports itself off and the
* the admin device picker, never on the event loop (a blocking build once * rest of the module carries on. A dead capture child is restarted with
* froze the whole server — see spl-meter's history), built to a temp name * backoff — fast for a crash, slow for config-shaped failures (unknown
* and renamed so a killed build can't leave a half-written "binary", and * device, ambiguity) that never fix themselves.
* rebuilt when a module update leaves the source newer than the binary.
* A dead capture child is restarted with backoff — fast for a crash, slow
* for config-shaped failures (unknown device, ambiguity) that never fix
* themselves.
* *
* macOS gotcha this file can't fix: microphone permission. A ProdDash * macOS gotcha this file can't fix: microphone permission. A ProdDash
* server launched over SSH or from a bare launchd job gets SILENT ZEROS * server launched over SSH or from a bare launchd job gets SILENT ZEROS
@@ -51,107 +47,43 @@ const RUNNING_WINDOW_MS = 400;
/** stderr line the capture prints once the device is open. */ /** stderr line the capture prints once the device is open. */
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/; const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
/* ── the capture tool: build it when the install didn't bring one ──────── */ /* ── the capture tool: prebuilt, one binary per platform ───────────────── */
/** Tile-facing words (short); the detail goes to admin health and the log. */ /** Tile-facing words (short); the detail goes to admin health and the log. */
const TOOL_BUILDING = 'Capture tool building…';
const TOOL_MISSING = 'Capture tool unavailable — see Admin'; const TOOL_MISSING = 'Capture tool unavailable — see Admin';
const BUILD_HELP = 'LTC capture tool could not be built here — build it on any Mac of the '
+ 'same architecture with `swiftc -O -o ltc-capture ltc-capture.swift` (both files are in '
+ 'the module folder) and copy the binary next to ltc-capture.swift';
let building = false; /** What the module ships in bin/, by platform and architecture. */
let buildFailed = false; const HELPERS = {
darwin: { arm64: 'ltc-capture-macos-arm64', x64: 'ltc-capture-macos-x86_64' },
win32: { x64: 'ltc-capture-win-x64.exe' },
};
/** /**
* Make sure the capture tool exists, building it in the background when it * The capture tool for this machine. It is never built here and never
* does not — module scope, so the LTC source can kick it the moment it * looked for anywhere else: the module ships it, or timecode is off on this
* starts and the admin device picker can kick it before any device is * platform and says so. `ready` means a usable binary at `bin`; otherwise
* configured (a listener-only build would deadlock: no tool → no device * `error` is the short tile text and `detail` names the platform.
* list → no device → no listener). A catalog install ships source only and
* a catalog update deletes the module folder (old binary included), so this
* IS the install step for the tool.
* *
* Never blocks (a synchronous build once held the whole server's event loop * @returns {{ready:boolean, bin:string, error:string, detail:string}}
* — every module — for the duration). Builds to a temp name and renames, so
* a killed or crashed build can't leave a truncated file that looks like a
* ready binary. A binary older than the source (a module update over an
* existing folder) keeps serving while a replacement builds behind it.
*
* @returns {{ready:boolean, error:string, detail:string}} `ready` = a binary
* is usable right now; otherwise `error` is the short tile text and
* `detail` the full one.
*/ */
function ensureCaptureBinary({ moduleDir, log, onReady }) { function captureTool(moduleDir, platform = process.platform, arch = process.arch) {
const bin = path.join(moduleDir, 'ltc-capture'); const name = HELPERS[platform]?.[arch] || '';
const src = bin + '.swift'; const bin = name ? path.join(moduleDir, 'bin', name) : '';
const ok = { ready: true, error: '', detail: '' }; if (bin && fs.existsSync(bin)) return { ready: true, bin, error: '', detail: '' };
let haveBin = false; const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`;
let stale = false; return {
try {
haveBin = fs.existsSync(bin);
if (haveBin && fs.existsSync(src)) stale = fs.statSync(src).mtimeMs > fs.statSync(bin).mtimeMs;
} catch { /* treat as missing */ }
if (haveBin && !stale) return ok;
if (!fs.existsSync(src)) {
return haveBin ? ok : { ready: false, error: TOOL_MISSING, detail: 'ltc-capture.swift is missing from the module folder' };
}
const notYet = haveBin ? ok : {
ready: false, ready: false,
error: building || !buildFailed ? TOOL_BUILDING : TOOL_MISSING, bin: '',
detail: building || !buildFailed ? 'Building the LTC capture tool — ready in a moment' : BUILD_HELP, error: TOOL_MISSING,
detail: `The module ships no LTC capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — timecode is off on this machine`,
}; };
if (building || buildFailed) return notYet;
building = true;
const tmp = bin + '.building';
log(stale ? 'ltc-capture is older than its source — rebuilding it in the background'
: 'ltc-capture binary missing — building it in the background');
const build = spawn('swiftc', ['-O', '-o', tmp, src], { stdio: ['ignore', 'ignore', 'pipe'] });
let buildErr = '';
build.stderr.setEncoding('utf8');
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
const giveUp = setTimeout(() => {
try { build.kill('SIGKILL'); } catch { /* already gone */ }
}, 300000);
giveUp.unref?.();
build.on('error', (err) => {
clearTimeout(giveUp);
building = false;
buildFailed = true;
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
log(`swiftc could not be run: ${err.message}`);
});
build.on('exit', (code) => {
clearTimeout(giveUp);
building = false;
if (code === 0 && fs.existsSync(tmp)) {
try {
fs.renameSync(tmp, bin);
} catch (err) {
buildFailed = true;
log(`ltc-capture built but could not be moved into place: ${err.message}`);
return;
} }
buildFailed = false;
log('ltc-capture built');
if (typeof onReady === 'function') onReady();
return;
}
buildFailed = true;
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
log(`ltc-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
});
return notYet;
}
/** /**
* @param {{device:string, channel:number, moduleDir:string, log:Function, * @param {{device:string, channel:number, moduleDir:string, log:Function,
* onUpdate:(u:object)=>void}} opts * onUpdate:(u:object)=>void}} opts
* @returns {{stop:()=>void}} * @returns {{stop:()=>void}}
*/ */
function createLtcListener({ device, channel, moduleDir, log, onUpdate }) { function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
const bin = path.join(moduleDir, 'ltc-capture');
let child = null; let child = null;
let stopped = false; let stopped = false;
let restartTimer = null; let restartTimer = null;
@@ -181,22 +113,11 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
function start() { function start() {
if (stopped) return; if (stopped) return;
const tool = ensureCaptureBinary({ const tool = captureTool(moduleDir);
moduleDir,
log,
onReady() {
// The build this start() was waiting on landed — go now.
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
},
});
if (!tool.ready) { if (!tool.ready) {
// No binary for this platform: say so and look again later — a module
// update remounts us anyway, this is only belt and braces.
report({ error: tool.error, detail: tool.detail }); report({ error: tool.error, detail: tool.detail });
// onReady is only the FAST path (a successful build restarts us at
// once). The slow retry must stay armed regardless: a failed build
// never calls back, and a binary can also arrive from outside — scp'd
// in on a machine whose toolchain can't build (AB's broken CLT).
// Waiting on onReady alone deadlocked exactly that way in production:
// "Capture tool building…" forever, with a working binary on disk.
scheduleRestart(BAD_CONFIG_RETRY_MS); scheduleRestart(BAD_CONFIG_RETRY_MS);
return; return;
} }
@@ -206,7 +127,7 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
let decoder = null; let decoder = null;
let stderrTail = ''; let stderrTail = '';
child = spawn(bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] }); child = spawn(tool.bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] });
child.stderr.setEncoding('utf8'); child.stderr.setEncoding('utf8');
child.stderr.on('data', (text) => { child.stderr.on('data', (text) => {
@@ -298,4 +219,4 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
}; };
} }
module.exports = { createLtcListener, ensureCaptureBinary }; module.exports = { createLtcListener, captureTool };
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "propresenter-timers", "id": "propresenter-timers",
"name": "Timers", "name": "Timers",
"version": "1.6.0", "version": "1.7.0",
"proddash": ">=1.4.0", "proddash": ">=1.4.0",
"description": "Timers, Clocks, and LTC From configured Modules", "description": "Timers, Clocks, and LTC From configured Modules",
"provides": [ "provides": [
+48
View File
@@ -0,0 +1,48 @@
#!/bin/zsh
# Build this module's native helper for every macOS architecture it ships —
# ../bin/ltc-capture-macos-arm64 and ../bin/ltc-capture-macos-x86_64 —
# from ltc-capture.swift beside this script. Run it on any Mac with the
# Xcode command line tools; both architectures cross-compile from either.
# The binaries are committed: a machine running ProdDash never builds
# anything (docs/MODULE-GUIDE.md, "Native helpers").
#
# Signed with a Developer ID Application certificate when one is installed
# (override with CODESIGN_IDENTITY) — hardened runtime plus the audio-input
# entitlement, which a helper that reads a microphone needs under the
# hardened runtime — and ad-hoc otherwise, which is fine on your own Macs.
#
# Windows is not built here: there is no Windows implementation of this
# tool yet, so on Windows the module reports timecode off.
set -e -u
cd "$(dirname "$0")"
NAME="ltc-capture"
DEPLOYMENT_TARGET="13.0"
command -v swiftc >/dev/null || {
echo "build.sh: swiftc not found — install the Xcode command line tools: xcode-select --install" >&2
exit 1
}
IDENTITY="${CODESIGN_IDENTITY:-}"
if [[ -z "$IDENTITY" ]]; then
IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
fi
if [[ -n "$IDENTITY" ]]; then
echo "signing as $IDENTITY"
SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp)
else
echo "signing ad-hoc (no Developer ID installed — fine on your own Macs)"
SIGN=(--force --sign - --options runtime)
fi
mkdir -p ../bin
for ARCH in arm64 x86_64; do
OUT="../bin/$NAME-macos-$ARCH"
echo " compiling $OUT"
swiftc -O -whole-module-optimization -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$OUT" "$NAME.swift"
codesign "${SIGN[@]}" --entitlements helper.entitlements "$OUT"
codesign --verify --strict "$OUT"
echo " built $OUT ($(du -h "$OUT" | awk '{print $1}'))"
done
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The helper reads an audio input; under the hardened runtime that
needs saying. The permission itself comes from the launcher. -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
@@ -4,12 +4,12 @@
// The module decodes LTC in-process (ltc.js) and spawns this to move // The module decodes LTC in-process (ltc.js) and spawns this to move
// samples from an input device to a pipe. Production machines rarely have // samples from an input device to a pipe. Production machines rarely have
// ffmpeg/sox, and shipping a third-party binary for that one job is // ffmpeg/sox, and shipping a third-party binary for that one job is
// overkill — this is the whole job, natively. The module compiles it on // overkill — this is the whole job, natively. It ships prebuilt in the
// first use when swiftc is available; otherwise build it once on any // module's bin/ (one binary per platform, committed — a booth never
// same-architecture Mac and copy the binary next to this source: // builds anything). To rebuild after a change here, on any Mac with the
// Xcode command line tools:
// //
// swiftc -O -o modules/propresenter-timers/ltc-capture \ // modules/propresenter-timers/native/build.sh (both architectures, ~10 s)
// modules/propresenter-timers/ltc-capture.swift (once, ~5 s)
// //
// ltc-capture --list // ltc-capture --list
// every input-capable device: name, input channels, sample rate // every input-capable device: name, input channels, sample rate
+11 -16
View File
@@ -81,7 +81,7 @@
const http = require('http'); const http = require('http');
const path = require('path'); const path = require('path');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const { createLtcListener, ensureCaptureBinary } = require('./ltc-listener'); const { createLtcListener, captureTool } = require('./ltc-listener');
const { createTimerPhase } = require('./phase'); const { createTimerPhase } = require('./phase');
const manifest = require('./module.json'); const manifest = require('./module.json');
@@ -660,11 +660,8 @@ function createLtcSource({ config, log, moduleDir }) {
kind: 'ltc', kind: 'ltc',
start(e) { start(e) {
emit = e; emit = e;
// Kick the capture-tool build right away: a catalog install ships // The capture tool ships prebuilt in the module; the listener picks
// source only and a catalog update wipes any previously built binary, // it for this platform when it starts and says so when there is none.
// so coming up after an install/update must rebuild it without
// waiting for a device to be picked or a listener to need it.
ensureCaptureBinary({ moduleDir, log });
if (!device) { if (!device) {
word = { word = {
error: 'Not configured — see Admin', error: 'Not configured — see Admin',
@@ -1359,23 +1356,21 @@ module.exports = {
}, },
// The admin device picker asks this machine what it can hear. The // The admin device picker asks this machine what it can hear. The
// options shape matches the admin select's optionsRoute contract. // options shape matches the admin select's optionsRoute contract. The
// Opening the picker also kicks the capture-tool build when a catalog // capture tool ships prebuilt in the module (bin/); with none for this
// install/update left only the source — without this, no tool meant // platform the list is empty and the error says so.
// no device list, and no device meant nothing would ever build it.
'GET /ltc/devices': (req, res) => { 'GET /ltc/devices': (req, res) => {
const tool = ensureCaptureBinary({ moduleDir: __dirname, log }); const tool = captureTool(__dirname);
if (!tool.ready) { if (!tool.ready) {
sendJson(res, 200, { options: [], error: tool.detail || tool.error }); sendJson(res, 200, { options: [], error: tool.detail });
return; return;
} }
const bin = path.join(__dirname, 'ltc-capture'); const child = spawn(tool.bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
let err = ''; let err = '';
child.stderr.setEncoding('utf8'); child.stderr.setEncoding('utf8');
child.stderr.on('data', (t) => { err += t; }); child.stderr.on('data', (t) => { err += t; });
child.on('error', () => { child.on('error', (e) => {
sendJson(res, 200, { options: [], error: 'LTC capture tool missing — see modules/propresenter-timers/ltc-capture.swift' }); sendJson(res, 200, { options: [], error: `LTC capture tool could not be run: ${e.message}` });
}); });
child.on('exit', () => { child.on('exit', () => {
if (res.writableEnded) return; if (res.writableEnded) return;
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "spl-meter", "id": "spl-meter",
"name": "SPL Meter", "name": "SPL Meter",
"version": "0.4.3", "version": "0.5.0",
"proddash": ">=1.5.0", "proddash": ">=1.5.0",
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine", "description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
"permissions": [ "permissions": [
+48
View File
@@ -0,0 +1,48 @@
#!/bin/zsh
# Build this module's native helper for every macOS architecture it ships —
# ../bin/spl-capture-macos-arm64 and ../bin/spl-capture-macos-x86_64 —
# from spl-capture.swift beside this script. Run it on any Mac with the
# Xcode command line tools; both architectures cross-compile from either.
# The binaries are committed: a machine running ProdDash never builds
# anything (docs/MODULE-GUIDE.md, "Native helpers").
#
# Signed with a Developer ID Application certificate when one is installed
# (override with CODESIGN_IDENTITY) — hardened runtime plus the audio-input
# entitlement, which a helper that reads a microphone needs under the
# hardened runtime — and ad-hoc otherwise, which is fine on your own Macs.
#
# Windows is not built here: there is no Windows implementation of this
# tool yet, so on Windows the module reports measurement off.
set -e -u
cd "$(dirname "$0")"
NAME="spl-capture"
DEPLOYMENT_TARGET="13.0"
command -v swiftc >/dev/null || {
echo "build.sh: swiftc not found — install the Xcode command line tools: xcode-select --install" >&2
exit 1
}
IDENTITY="${CODESIGN_IDENTITY:-}"
if [[ -z "$IDENTITY" ]]; then
IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
fi
if [[ -n "$IDENTITY" ]]; then
echo "signing as $IDENTITY"
SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp)
else
echo "signing ad-hoc (no Developer ID installed — fine on your own Macs)"
SIGN=(--force --sign - --options runtime)
fi
mkdir -p ../bin
for ARCH in arm64 x86_64; do
OUT="../bin/$NAME-macos-$ARCH"
echo " compiling $OUT"
swiftc -O -whole-module-optimization -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$OUT" "$NAME.swift"
codesign "${SIGN[@]}" --entitlements helper.entitlements "$OUT"
codesign --verify --strict "$OUT"
echo " built $OUT ($(du -h "$OUT" | awk '{print $1}'))"
done
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The helper reads an audio input; under the hardened runtime that
needs saying. The permission itself comes from the launcher. -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
@@ -8,10 +8,11 @@
// measurement is done here and only the answers cross the pipe. // measurement is done here and only the answers cross the pipe.
// //
// Everything below uses Accelerate, which ships with macOS. No third-party // Everything below uses Accelerate, which ships with macOS. No third-party
// code, no package manager, ~2 s to build: // code, no package manager. It ships prebuilt in the module's bin/ (one
// binary per platform, committed — a booth never builds anything). To
// rebuild after a change here, on any Mac with the Xcode command line tools:
// //
// swiftc -O -o modules/spl-meter/spl-capture \ // modules/spl-meter/native/build.sh (both architectures)
// modules/spl-meter/spl-capture.swift
// //
// spl-capture --list // spl-capture --list
// every input-capable device: name, input channels, sample rate // every input-capable device: name, input channels, sample rate
+10 -20
View File
@@ -20,7 +20,7 @@
*/ */
const path = require('path'); const path = require('path');
const { createSplListener, ensureCaptureBinary } = require('./spl-listener'); const { createSplListener, captureTool } = require('./spl-listener');
/** Reference level of a sound level calibrator. Effectively always 94 dB. */ /** Reference level of a sound level calibrator. Effectively always 94 dB. */
const CALIBRATOR_DB = 94; const CALIBRATOR_DB = 94;
@@ -120,12 +120,9 @@ module.exports = {
pinger: null, pinger: null,
}; };
// Before anything else: the device picker shells out to this tool, so a // The capture tool ships prebuilt in the module (bin/); with none for
// machine that installed the module from the catalog (source only — the // this platform every reading is off and the health line says so.
// binary is gitignored) needs the build kicked off here. Waiting for a state.toolMissing = captureTool(moduleDir).missing;
// listener would never happen: no tool means no device list, and no
// device means no listener.
state.toolMissing = ensureCaptureBinary({ moduleDir, log });
if (!config.enabled) { if (!config.enabled) {
state.error = 'Sound level measurement is off — turn it on in ProdDash settings'; state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
@@ -185,9 +182,7 @@ module.exports = {
return { return {
stop() { teardown(); }, stop() { teardown(); },
health() { health() {
if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) { if (state.toolMissing) return { status: 'error', message: state.toolMissing };
return { status: 'error', message: state.toolMissing };
}
if (state.error) return { status: 'error', message: state.error }; if (state.error) return { status: 'error', message: state.error };
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' }; if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
const quiet = silentMs(); const quiet = silentMs();
@@ -311,11 +306,10 @@ module.exports = {
/** Device list for the admin page. Runs the capture tool's --list, /** Device list for the admin page. Runs the capture tool's --list,
which is the only thing that knows what CoreAudio can see. */ which is the only thing that knows what CoreAudio can see. */
'GET /devices': async (req, res) => { 'GET /devices': async (req, res) => {
const missing = ensureCaptureBinary({ moduleDir: __dirname, log }); const tool = captureTool(__dirname);
if (missing) { if (tool.missing) {
if (state) state.toolMissing = missing; log(`device list unavailable — ${tool.missing}`);
log(`device list unavailable — ${missing}`); json(res, 200, { options: [], error: tool.missing });
json(res, 200, { options: [] });
return; return;
} }
try { try {
@@ -447,7 +441,7 @@ function teardown() {
function listDevices() { function listDevices() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const { execFile } = require('child_process'); const { execFile } = require('child_process');
execFile(path.join(__dirname, 'spl-capture'), ['--list'], { timeout: 10000 }, execFile(captureTool(__dirname).bin, ['--list'], { timeout: 10000 },
(err, stdout) => { (err, stdout) => {
if (err && !stdout) { reject(err); return; } if (err && !stdout) { reject(err); return; }
try { resolve(JSON.parse(stdout).devices || []); } try { resolve(JSON.parse(stdout).devices || []); }
@@ -456,10 +450,6 @@ function listDevices() {
}); });
} }
function fsExists(p) {
try { return require('fs').existsSync(p); } catch { return false; }
}
function json(res, code, body) { function json(res, code, body) {
const text = JSON.stringify(body); const text = JSON.stringify(body);
res.writeHead(code, { res.writeHead(code, {
+35 -88
View File
@@ -3,7 +3,8 @@
/** /**
* Sound level listener — owns the native capture child. * Sound level listener — owns the native capture child.
* *
* Spawns spl-capture (compiled from spl-capture.swift beside it) for the * Spawns spl-capture (bin/spl-capture-macos-<arch>, shipped prebuilt;
* source in native/) for the
* admin-configured device and channels, reads its JSON lines, and hands the * admin-configured device and channels, reads its JSON lines, and hands the
* latest frame upward. All measurement happens in the child; this file is * latest frame upward. All measurement happens in the child; this file is
* plumbing and supervision, nothing more. * plumbing and supervision, nothing more.
@@ -13,12 +14,11 @@
* { frame: {...}, meta: {...} } measuring * { frame: {...}, meta: {...} } measuring
* { error: '<what is wrong and how to fix it>' } not measuring * { error: '<what is wrong and how to fix it>' } not measuring
* *
* The capture binary is per-machine and gitignored, exactly like the Timers * The capture tool ships prebuilt inside the module, one binary per
* module's ltc-capture. It normally arrives already built — compiled beside * platform under bin/ (module guide, "Native helpers"), exactly like the
* the app, or copied from any Mac of the same architecture. When it is * Timers module's ltc-capture: nothing is ever built on the machine running
* missing this will build it, but in the background and never on the event * ProdDash. captureTool() picks the binary for this platform; when there is
* loop, and from module scope so the device picker can trigger it too — see * none, measurement reports itself off and the rest of the module carries on.
* ensureCaptureBinary for what both of those cost the first time round.
* *
* The same macOS gotcha applies as for LTC, and is worth repeating because * The same macOS gotcha applies as for LTC, and is worth repeating because
* it costs an afternoon every time: a ProdDash launched over SSH or from a * it costs an afternoon every time: a ProdDash launched over SSH or from a
@@ -31,72 +31,31 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with ' /* ── the capture tool: prebuilt, one binary per platform ───────────────── */
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
+ 'and copy it next to spl-capture.swift'; /** What the module ships in bin/, by platform and architecture. */
const BUILDING = 'Building the capture tool — this takes a minute or two'; const HELPERS = {
darwin: { arm64: 'spl-capture-macos-arm64', x64: 'spl-capture-macos-x86_64' },
win32: { x64: 'spl-capture-win-x64.exe' },
};
/** /**
* Build the capture tool if it is missing, WITHOUT blocking, and shared by * The capture tool for this machine. It is never built here and never
* everything that needs it. * looked for anywhere else: the module ships it, or measurement is off on
* this platform and says so. `bin` is the usable binary; otherwise
* `missing` is what to tell the admin page and the tiles.
* *
* This lives at module scope rather than inside a listener because of a trap * @returns {{bin:string, missing:string}}
* the first version walked into: the admin page's device picker runs the tool
* with --list, but the build only ran when a listener started, and a listener
* only starts once a device has been picked. On a machine that got the module
* from the catalog — source only, the binary being gitignored — the picker
* was empty, and nothing would ever fill it.
*
* It also must never block. It was a spawnSync once, copied from the LTC
* listener where the source is a third the size and builds in seconds. Here
* it took nearly two minutes on the production Mac and held the Node event
* loop for all of it, freezing every other module, then did it again on the
* next retry.
*
* The binary should normally already be there: built beside the app, or
* copied from any Mac of the same architecture. This is the fallback.
*
* @returns {string} '' when the tool is ready, otherwise what to say.
*/ */
let building = false; function captureTool(moduleDir, platform = process.platform, arch = process.arch) {
let buildFailed = false; const name = HELPERS[platform]?.[arch] || '';
const bin = name ? path.join(moduleDir, 'bin', name) : '';
function ensureCaptureBinary({ moduleDir, log, onReady }) { if (bin && fs.existsSync(bin)) return { bin, missing: '' };
const bin = path.join(moduleDir, 'spl-capture'); const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`;
if (fs.existsSync(bin)) return ''; return {
const src = bin + '.swift'; bin: '',
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder'; missing: `The module ships no sound level capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — measurement is off on this machine`,
if (building) return BUILDING; };
if (buildFailed) return BUILD_HELP;
building = true;
log('spl-capture binary missing — building it in the background');
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
let buildErr = '';
build.stderr.setEncoding('utf8');
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
const giveUp = setTimeout(() => {
try { build.kill('SIGKILL'); } catch { /* already gone */ }
}, 300000);
build.on('error', (err) => {
clearTimeout(giveUp);
building = false; buildFailed = true;
log(`swiftc could not be run: ${err.message}`);
});
build.on('exit', (code) => {
clearTimeout(giveUp);
building = false;
if (code === 0 && fs.existsSync(bin)) {
log('spl-capture built');
buildFailed = false;
if (typeof onReady === 'function') onReady();
return;
}
buildFailed = true;
log(`spl-capture build failed (exit ${code})`
+ (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
});
return BUILDING;
} }
const RESTART_MS = 5000; const RESTART_MS = 5000;
@@ -114,7 +73,6 @@ const STALL_MS = 3000;
*/ */
function createSplListener(opts) { function createSplListener(opts) {
const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts; const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts;
const bin = path.join(moduleDir, 'spl-capture');
let child = null; let child = null;
let stopped = false; let stopped = false;
@@ -124,13 +82,6 @@ function createSplListener(opts) {
let meta = null; let meta = null;
let lastFrameWall = 0; let lastFrameWall = 0;
/** '' when the tool is ready, otherwise what to say about it. */
function ensureBinary() {
return ensureCaptureBinary({ moduleDir, log, onReady() {
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
} });
}
function scheduleRestart(ms) { function scheduleRestart(ms) {
if (stopped || restartTimer) return; if (stopped || restartTimer) return;
restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms); restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms);
@@ -150,15 +101,11 @@ function createSplListener(opts) {
function start() { function start() {
if (stopped) return; if (stopped) return;
const missing = ensureBinary(); const tool = captureTool(moduleDir);
if (missing) { if (tool.missing) {
onUpdate({ error: missing }); // No binary for this platform: say so and look again later — a module
// onReady is only the FAST path (a successful build restarts us at // update remounts us anyway, this is only belt and braces.
// once). The slow retry must stay armed regardless: a failed build onUpdate({ error: tool.missing });
// never calls back, and a binary can also arrive from outside — scp'd
// in on a machine whose toolchain can't build (AB's broken CLT).
// Waiting on onReady alone deadlocked exactly that way in the Timers
// module: "building…" forever, with a working binary on disk.
scheduleRestart(BAD_CONFIG_RETRY_MS); scheduleRestart(BAD_CONFIG_RETRY_MS);
return; return;
} }
@@ -175,7 +122,7 @@ function createSplListener(opts) {
let stderrTail = ''; let stderrTail = '';
let buf = ''; let buf = '';
child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] }); child = spawn(tool.bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
child.stderr.setEncoding('utf8'); child.stderr.setEncoding('utf8');
child.stderr.on('data', (text) => { child.stderr.on('data', (text) => {
@@ -258,4 +205,4 @@ function createSplListener(opts) {
}; };
} }
module.exports = { createSplListener, ensureCaptureBinary }; module.exports = { createSplListener, captureTool };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proddash", "name": "proddash",
"version": "1.10.0", "version": "1.10.2",
"private": true, "private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED", "license": "UNLICENSED",
+41 -10
View File
@@ -640,8 +640,10 @@ function buildTile(tile) {
el.addEventListener('pointermove', (ev) => { el.addEventListener('pointermove', (ev) => {
if (!headIsOff(tile)) return; if (!headIsOff(tile)) return;
// Mid-drag the tile moves under the pointer, and mid-resize the edges do // 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('dragging') || el.classList.contains('resizing')) return;
if (el.classList.contains('menu-open')) return;
const y = ev.clientY - el.getBoundingClientRect().top; const y = ev.clientY - el.getBoundingClientRect().top;
const shown = el.classList.contains('head-peek'); const shown = el.classList.contains('head-peek');
// It takes less to hold the bar open than to open it, so a bar that // 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', () => { el.addEventListener('pointerleave', () => {
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return; 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'); el.classList.remove('head-peek');
}); });
@@ -659,7 +662,7 @@ function buildTile(tile) {
applyHeadState(tile, el); applyHeadState(tile, el);
grid.appendChild(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); tileEls.set(tile.id, entry);
applyRect(tile); applyRect(tile);
@@ -1426,34 +1429,60 @@ function buildModuleApi(tile, man, entry) {
menu.className = 'tile-menu'; menu.className = 'tile-menu';
menu.hidden = true; 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 = () => { const open = () => {
if (typeof build === 'function') { if (typeof build === 'function') {
menu.innerHTML = ''; menu.innerHTML = '';
build(menu); build(menu);
} }
menu.hidden = false; setOpen(true);
// fixed positioning (escapes the tile's overflow): under the // under the button, right-aligned, kept whole inside the viewport
// button, right-aligned, clamped to the viewport
const r = btn.getBoundingClientRect(); const r = btn.getBoundingClientRect();
menu.style.top = Math.min(r.bottom + 6, window.innerHeight - 80) + 'px';
menu.style.left = '0px'; menu.style.left = '0px';
menu.style.top = '0px';
const w = menu.offsetWidth; 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'; menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
}; };
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
if (menu.hidden) open(); if (menu.hidden) open();
else menu.hidden = true; else setOpen(false);
}); });
const onDocClick = (e) => { 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); document.addEventListener('click', onDocClick);
docListeners.add(onDocClick); docListeners.add(onDocClick);
wrap.append(btn, menu); wrap.append(btn);
entry.controls.appendChild(wrap); entry.controls.appendChild(wrap);
document.body.appendChild(host);
headerEls.add(wrap); headerEls.add(wrap);
headerEls.add(host);
return { return {
button: btn, button: btn,
menu, menu,
@@ -1461,7 +1490,7 @@ function buildModuleApi(tile, man, entry) {
btn.textContent = text; btn.textContent = text;
}, },
close() { close() {
menu.hidden = true; setOpen(false);
}, },
}; };
}, },
@@ -1473,6 +1502,8 @@ function buildModuleApi(tile, man, entry) {
for (const handle of [...sseHandles]) handle.close(); for (const handle of [...sseHandles]) handle.close();
for (const el of headerEls) el.remove(); for (const el of headerEls) el.remove();
headerEls.clear(); headerEls.clear();
entry.openMenus.clear();
entry.el.classList.remove('menu-open');
for (const fn of docListeners) document.removeEventListener('click', fn); for (const fn of docListeners) document.removeEventListener('click', fn);
docListeners.clear(); docListeners.clear();
}, },
+3 -2
View File
@@ -445,8 +445,9 @@ body.menu-hidden #topbar {
} }
.tile-dd { position: relative; display: inline-flex; } .tile-dd { position: relative; display: inline-flex; }
/* a module header menu — fixed-positioned by the shell so the tile's /* a module header menu — hosted on the body (shell.js addMenu) so neither
overflow:hidden can't clip it */ the tile's overflow nor a folded-away bar's backdrop-filter can touch it */
.tile-menu-host { display: contents; }
.tile-menu { .tile-menu {
position: fixed; position: fixed;
z-index: 70; z-index: 70;
+13 -1
View File
@@ -956,9 +956,21 @@ async function fetchArchive() {
signal: AbortSignal.timeout(30000), signal: AbortSignal.timeout(30000),
headers: { 'User-Agent': `ProdDash/${SHELL_VERSION}` }, 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()); const gz = Buffer.from(await res.arrayBuffer());
if (gz.length > MAX_ARCHIVE_BYTES) throw new Error('archive is unexpectedly large'); 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 { files, comment } = parseTar(zlib.gunzipSync(gz));
const stripped = new Map(); // drop the archive's single top-level folder const stripped = new Map(); // drop the archive's single top-level folder
for (const [p, data] of files) { 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);
})();