Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
601dc9e8b0 | ||
|
|
92b98b6579 | ||
|
|
58f3e5010a | ||
|
|
7cc19ef696 | ||
|
|
e9d6edc2c8 | ||
|
|
9313c9e42a |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "spl-meter",
|
||||
"name": "SPL Meter",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.3",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
||||
"permissions": [
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { createSplListener } = require('./spl-listener');
|
||||
const { createSplListener, ensureCaptureBinary } = require('./spl-listener');
|
||||
|
||||
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
|
||||
const CALIBRATOR_DB = 94;
|
||||
@@ -114,11 +114,19 @@ module.exports = {
|
||||
error: '',
|
||||
streams: new Set(),
|
||||
silentSince: null,
|
||||
toolMissing: '',
|
||||
listener: null,
|
||||
lastSent: '',
|
||||
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 });
|
||||
|
||||
if (!config.enabled) {
|
||||
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
|
||||
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||
@@ -177,6 +185,9 @@ module.exports = {
|
||||
return {
|
||||
stop() { teardown(); },
|
||||
health() {
|
||||
if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) {
|
||||
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();
|
||||
@@ -300,6 +311,13 @@ 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: [] });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const devices = await listDevices();
|
||||
json(res, 200, {
|
||||
@@ -438,6 +456,10 @@ 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, {
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* 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: see ensureBinary for what that cost the first time it was wrong.
|
||||
* 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 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
|
||||
@@ -30,6 +31,74 @@ 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';
|
||||
|
||||
/**
|
||||
* Build the capture tool if it is missing, WITHOUT blocking, and shared by
|
||||
* everything that needs it.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
const RESTART_MS = 5000;
|
||||
/** An immediate exit is config-shaped (bad device, channel out of range) —
|
||||
retrying every 5 s would only fill the log with the same sentence. */
|
||||
@@ -53,64 +122,13 @@ function createSplListener(opts) {
|
||||
let stallTimer = null;
|
||||
let startedAt = 0;
|
||||
let meta = null;
|
||||
let building = false;
|
||||
let buildFailed = false;
|
||||
let lastFrameWall = 0;
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
* Build the capture tool if it is missing, WITHOUT blocking.
|
||||
*
|
||||
* This used to be a spawnSync, copied from the LTC listener where the
|
||||
* source is a third the size and builds in a few seconds. Here it took
|
||||
* nearly two minutes on the production Mac and froze the entire ProdDash
|
||||
* server for every second of it — one module's optional helper took the
|
||||
* whole dashboard down, then did it again on every retry. Nothing that
|
||||
* runs while a service might be on is allowed to hold the event loop.
|
||||
*
|
||||
* The build is a fallback for a module installed from the catalog on a
|
||||
* machine with a Swift toolchain. The normal path is a binary built
|
||||
* alongside the app, or copied from any same-architecture Mac.
|
||||
*
|
||||
* @returns {string} '' when the binary is ready, otherwise what to say.
|
||||
*/
|
||||
/** '' when the tool is ready, otherwise what to say about it. */
|
||||
function ensureBinary() {
|
||||
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 the capture tool — this takes a minute or two';
|
||||
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 (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
||||
return;
|
||||
}
|
||||
buildFailed = true;
|
||||
log(`spl-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
||||
});
|
||||
return 'Building the capture tool — this takes a minute or two';
|
||||
return ensureCaptureBinary({ moduleDir, log, onReady() {
|
||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
||||
} });
|
||||
}
|
||||
|
||||
function scheduleRestart(ms) {
|
||||
@@ -135,9 +153,13 @@ function createSplListener(opts) {
|
||||
const missing = ensureBinary();
|
||||
if (missing) {
|
||||
onUpdate({ error: missing });
|
||||
// A build in flight calls start() itself when it lands; anything else
|
||||
// is config-shaped and should back off hard rather than spin.
|
||||
if (!building) scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
// 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.
|
||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -236,4 +258,4 @@ function createSplListener(opts) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createSplListener };
|
||||
module.exports = { createSplListener, ensureCaptureBinary };
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proddash",
|
||||
"version": "1.9.2",
|
||||
"version": "1.9.3",
|
||||
"private": true,
|
||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||
"license": "UNLICENSED",
|
||||
|
||||
@@ -910,9 +910,21 @@ async function fetchArchive() {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
headers: { 'User-Agent': `ProdDash/${SHELL_VERSION}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${REPO.owner}/${REPO.repo}@${REPO.branch}`);
|
||||
const where = `${REPO.owner}/${REPO.repo}@${REPO.branch}`;
|
||||
// A forge that wants a sign-in does not refuse: it redirects the download
|
||||
// to its login page, which arrives as a 200 of HTML. Say that, not
|
||||
// zlib's "incorrect header check".
|
||||
if (res.redirected && /\/user\/login\b|\/login\b/.test(new URL(res.url).pathname)) {
|
||||
throw new Error(`${FORGE.base} requires a sign-in to read ${where} — turn off "require sign-in to view" in Gitea, or make the repo readable without one`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${where}`);
|
||||
const gz = Buffer.from(await res.arrayBuffer());
|
||||
if (gz.length > MAX_ARCHIVE_BYTES) throw new Error('archive is unexpectedly large');
|
||||
if (gz.length < 2 || gz[0] !== 0x1f || gz[1] !== 0x8b) {
|
||||
const type = String(res.headers.get('content-type') || 'unknown type').split(';')[0].trim();
|
||||
const looksHtml = /^\s*<(!doctype|html)/i.test(gz.subarray(0, 64).toString('utf8'));
|
||||
throw new Error(`${FORGE.base} answered with ${looksHtml ? 'a web page' : type} instead of the ${where} archive — check the forge address and that the repo is readable without a sign-in`);
|
||||
}
|
||||
const { files, comment } = parseTar(zlib.gunzipSync(gz));
|
||||
const stripped = new Map(); // drop the archive's single top-level folder
|
||||
for (const [p, data] of files) {
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
Reference in New Issue
Block a user