diff --git a/modules/propresenter-timers/ltc-listener.js b/modules/propresenter-timers/ltc-listener.js index 6cba673..cc43b93 100644 --- a/modules/propresenter-timers/ltc-listener.js +++ b/modules/propresenter-timers/ltc-listener.js @@ -10,13 +10,23 @@ * onUpdate roughly 4×/s: * * { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy - * { error: '' } not capturing + * { error: '', detail: '' } not capturing * - * The capture binary is per-machine (gitignored): if it is missing this - * tries one swiftc compile of the source next to it, and otherwise says to - * build it on any same-architecture Mac and copy it over. A dead child is - * restarted with backoff — fast for a crash, slow for config-shaped - * failures (unknown device, ambiguous name) that never fix themselves. + * Error strings are two-tier on purpose: `error` is what a dashboard tile + * shows (short — "Not configured — see Admin"), `detail` is the full story + * for the admin health line and the log. + * + * The capture binary is per-machine (gitignored), and a catalog install or + * update ships source only — the update even wipes a previously built + * binary. ensureCaptureBinary() therefore rebuilds it with swiftc in the + * background as part of coming up: kicked when the LTC source starts and by + * the admin device picker, never on the event loop (a blocking build once + * froze the whole server — see spl-meter's history), built to a temp name + * and renamed so a killed build can't leave a half-written "binary", and + * 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 * server launched over SSH or from a bare launchd job gets SILENT ZEROS @@ -27,7 +37,7 @@ const path = require('path'); const fs = require('fs'); -const { spawn, spawnSync } = require('child_process'); +const { spawn } = require('child_process'); const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc'); const RESTART_MS = 5000; @@ -41,6 +51,100 @@ const RUNNING_WINDOW_MS = 400; /** stderr line the capture prints once the device is open. */ const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/; +/* ── the capture tool: build it when the install didn't bring one ──────── */ + +/** 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 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; +let buildFailed = false; + +/** + * Make sure the capture tool exists, building it in the background when it + * does not — module scope, so the LTC source can kick it the moment it + * starts and the admin device picker can kick it before any device is + * configured (a listener-only build would deadlock: no tool → no device + * 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 + * — 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 }) { + const bin = path.join(moduleDir, 'ltc-capture'); + const src = bin + '.swift'; + const ok = { ready: true, error: '', detail: '' }; + let haveBin = false; + let stale = false; + 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, + error: building || !buildFailed ? TOOL_BUILDING : TOOL_MISSING, + detail: building || !buildFailed ? 'Building the LTC capture tool — ready in a moment' : BUILD_HELP, + }; + 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, * onUpdate:(u:object)=>void}} opts @@ -75,26 +179,21 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) { return 'nosignal'; } - function ensureBinary() { - if (fs.existsSync(bin)) return ''; - const src = bin + '.swift'; - log('ltc-capture binary missing — trying a one-time swiftc build'); - const built = spawnSync('swiftc', ['-O', '-o', bin, src], { timeout: 120000 }); - if (built.status === 0 && fs.existsSync(bin)) { - log('ltc-capture compiled'); - return ''; - } - return 'LTC capture tool missing — build it on any Apple Silicon Mac with ' - + '`swiftc -O -o modules/propresenter-timers/ltc-capture modules/propresenter-timers/ltc-capture.swift` ' - + 'and copy it here'; - } - function start() { if (stopped) return; - const missing = ensureBinary(); - if (missing) { - report({ error: missing }); - scheduleRestart(BAD_CONFIG_RETRY_MS); + const tool = ensureCaptureBinary({ + moduleDir, + log, + onReady() { + // The build this start() was waiting on landed — go now. + if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); } + }, + }); + if (!tool.ready) { + report({ error: tool.error, detail: tool.detail }); + // A build in flight restarts us itself when it lands; anything else is + // config-shaped and should back off hard rather than spin. + if (tool.error !== TOOL_BUILDING) scheduleRestart(BAD_CONFIG_RETRY_MS); return; } @@ -113,7 +212,10 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) { const channels = Number(m[2]); const rate = Number(m[3]); if (channel > channels) { - report({ error: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.` }); + report({ + error: 'Channel not on this device — see Admin', + detail: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.`, + }); try { child.kill(); } catch { /* exiting anyway */ } return; } @@ -135,17 +237,21 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) { }); child.on('error', (err) => { - report({ error: `LTC capture failed to start: ${err.message}` }); + report({ error: TOOL_MISSING, detail: `LTC capture failed to start: ${err.message}` }); }); child.on('exit', (code) => { child = null; if (stopped) return; // The capture's own failure text (unknown device, ambiguous name, - // permission hint) is the most useful thing we can show. + // permission hint) is the most useful DETAIL we have; the tile just + // needs to know the input is down and where to fix it. const said = stderrTail.split('\n').reverse().find((l) => l.includes('ltc-capture:') && !CAPTURING_RE.test(l)); const quickDeath = Date.now() - startedAt < 3000; - report({ error: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting') }); + report({ + error: quickDeath ? 'Audio device unavailable — see Admin' : 'Audio capture restarting…', + detail: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting'), + }); scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS); }); } @@ -188,4 +294,4 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) { }; } -module.exports = { createLtcListener }; +module.exports = { createLtcListener, ensureCaptureBinary }; diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index 2feb0d7..a825788 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -1,7 +1,7 @@ { "id": "propresenter-timers", "name": "Timers", - "version": "1.4.2", + "version": "1.5.0", "proddash": ">=1.4.0", "description": "Timers, Clocks, and LTC From configured Modules", "provides": [ diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index b41eb88..f282ddc 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -81,7 +81,7 @@ const http = require('http'); const path = require('path'); const { spawn } = require('child_process'); -const { createLtcListener } = require('./ltc-listener'); +const { createLtcListener, ensureCaptureBinary } = require('./ltc-listener'); const { createTimerPhase } = require('./phase'); const manifest = require('./module.json'); @@ -396,7 +396,16 @@ function createProPresenterSource({ config, log }) { return lastError ? 'error' : 'connecting'; } - function message() { + /** Short, for the tiles — a dashboard warns, the admin page explains. + The client prefixes the source label, so no "ProPresenter" here. */ + function tileMessage() { + if (!configured) return 'Not configured — see Admin'; + if (reachable) return `${timers.length} timer${timers.length === 1 ? '' : 's'}`; + return 'Unreachable'; + } + + /** The full story, for the admin health line. */ + function adminMessage() { if (!configured) return 'No ProPresenter host configured — set one in Admin, or switch ProPresenter timers off'; if (reachable) return `Polling ${host}:${port}, ${timers.length} timer${timers.length === 1 ? '' : 's'}`; return lastError ? `Unreachable at ${host}:${port} — ${lastError}` : `Reaching ${host}:${port}…`; @@ -404,7 +413,7 @@ function createProPresenterSource({ config, log }) { function publish() { if (stopped) return; - emit({ status: status(), message: message(), timers, updatedAt }); + emit({ status: status(), message: tileMessage(), timers, updatedAt }); } async function pollTick() { @@ -450,7 +459,7 @@ function createProPresenterSource({ config, log }) { clearTimeout(pollTimer); }, health() { - return { status: status(), message: message() }; + return { status: status(), message: adminMessage() }; }, }; } @@ -464,7 +473,9 @@ function createLtcSource({ config, log, moduleDir }) { let emit = () => {}; let listener = null; let stopped = false; - /** The listener's latest word: { state, time, fps, df, ageMs } | { error } | null while starting. */ + /** The listener's latest word: { state, time, fps, df, ageMs } | + { error, detail } (error = short tile text, detail = the admin/log + story) | null while starting. */ let word = null; function rateText() { @@ -516,8 +527,16 @@ function createLtcSource({ config, log, moduleDir }) { kind: 'ltc', start(e) { emit = e; + // Kick the capture-tool build right away: a catalog install ships + // source only and a catalog update wipes any previously built binary, + // 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) { - word = { error: 'LTC timecode is on but no audio device is selected — pick one in Admin' }; + word = { + error: 'Not configured — see Admin', + detail: 'LTC timecode is on but no audio device is selected — pick one in Admin', + }; publish(); return; } @@ -539,7 +558,8 @@ function createLtcSource({ config, log, moduleDir }) { listener = null; }, health() { - return { status: status(), message: message() }; + // The admin page gets the full story; the tiles got the short one. + return { status: status(), message: (word && word.error && word.detail) || message() }; }, }; } @@ -588,7 +608,15 @@ function createModuleSource({ moduleId, name, shellPort, log }) { let silenceTimer = null; let backoff = BACKOFF_MIN_MS; - function message() { + /** Short, for the tiles; the lastError detail belongs to the admin line. + The client prefixes the source label, so it isn't repeated here. */ + function tileMessage() { + if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}`; + if (state === 'connecting') return 'Connecting…'; + return 'Unavailable'; + } + + function adminMessage() { if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}${via ? ` (${via})` : ''}`; if (state === 'connecting') return lastError ? `reconnecting — ${lastError}` : 'connecting…'; return `unreachable — ${lastError}`; @@ -596,7 +624,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) { function publish() { if (stopped) return; - emit({ label, status: state, message: message(), timers, updatedAt }); + emit({ label, status: state, message: tileMessage(), timers, updatedAt }); } function accept(raw) { @@ -774,7 +802,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) { if (!stopped && !stream && via === 'poll') connectStream(); }, health() { - return { status: state, message: message() }; + return { status: state, message: adminMessage() }; }, }; } @@ -1172,7 +1200,7 @@ module.exports = { return out; }, - routes() { + routes({ log }) { return { 'GET /state': (req, res) => { sendJson(res, 200, { state: current ? current.getState() : emptyState() }); @@ -1195,7 +1223,15 @@ module.exports = { // The admin device picker asks this machine what it can hear. The // options shape matches the admin select's optionsRoute contract. + // Opening the picker also kicks the capture-tool build when a catalog + // install/update left only the source — without this, no tool meant + // no device list, and no device meant nothing would ever build it. 'GET /ltc/devices': (req, res) => { + const tool = ensureCaptureBinary({ moduleDir: __dirname, log }); + if (!tool.ready) { + sendJson(res, 200, { options: [], error: tool.detail || tool.error }); + return; + } const bin = path.join(__dirname, 'ltc-capture'); const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 }); let err = '';