diff --git a/.gitignore b/.gitignore index b553dd6..7acc9b2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,6 @@ dist/ config/modules.json 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 launcher/macos/build/ launcher/macos/.cache/ diff --git a/modules/propresenter-timers/bin/ltc-capture-macos-arm64 b/modules/propresenter-timers/bin/ltc-capture-macos-arm64 new file mode 100755 index 0000000..55b3877 Binary files /dev/null and b/modules/propresenter-timers/bin/ltc-capture-macos-arm64 differ diff --git a/modules/propresenter-timers/bin/ltc-capture-macos-x86_64 b/modules/propresenter-timers/bin/ltc-capture-macos-x86_64 new file mode 100755 index 0000000..a30ffd6 Binary files /dev/null and b/modules/propresenter-timers/bin/ltc-capture-macos-x86_64 differ diff --git a/modules/propresenter-timers/ltc-listener.js b/modules/propresenter-timers/ltc-listener.js index d98a1b8..2274aaa 100644 --- a/modules/propresenter-timers/ltc-listener.js +++ b/modules/propresenter-timers/ltc-listener.js @@ -3,8 +3,8 @@ /** * Built-in LTC listener — capture child + in-process decode. * - * Spawns the module's native capture tool (ltc-capture, compiled from - * ltc-capture.swift beside it) for the admin-configured audio device, reads + * Spawns the module's native capture tool (bin/ltc-capture-macos-, + * shipped prebuilt; source in native/) for the admin-configured audio device, reads * the interleaved s16le stream, extracts the configured channel, and decodes * SMPTE LTC with ltc.js — all inside the module server. Reports upward via * onUpdate roughly 4×/s: @@ -16,17 +16,13 @@ * 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. + * The capture tool ships prebuilt inside the module, one binary per + * platform under bin/ (module guide, "Native helpers"): nothing is ever + * built on the machine running ProdDash. captureTool() picks the binary for + * this platform; when there is none, timecode reports itself off and the + * rest of the module carries on. 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 @@ -51,107 +47,43 @@ 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 ──────── */ +/* ── the capture tool: prebuilt, one binary per platform ───────────────── */ /** 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; +/** What the module ships in bin/, by platform and architecture. */ +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 - * 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. + * The capture tool for this machine. It is never built here and never + * looked for anywhere else: the module ships it, or timecode is off on this + * platform and says so. `ready` means a usable binary at `bin`; otherwise + * `error` is the short tile text and `detail` names the platform. * - * 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. + * @returns {{ready:boolean, bin:string, error:string, detail:string}} */ -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 : { +function captureTool(moduleDir, platform = process.platform, arch = process.arch) { + const name = HELPERS[platform]?.[arch] || ''; + const bin = name ? path.join(moduleDir, 'bin', name) : ''; + if (bin && fs.existsSync(bin)) return { ready: true, bin, error: '', detail: '' }; + const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`; + return { ready: false, - error: building || !buildFailed ? TOOL_BUILDING : TOOL_MISSING, - detail: building || !buildFailed ? 'Building the LTC capture tool — ready in a moment' : BUILD_HELP, + bin: '', + 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, * onUpdate:(u:object)=>void}} opts * @returns {{stop:()=>void}} */ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) { - const bin = path.join(moduleDir, 'ltc-capture'); let child = null; let stopped = false; let restartTimer = null; @@ -181,22 +113,11 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) { function start() { if (stopped) return; - const tool = ensureCaptureBinary({ - moduleDir, - log, - onReady() { - // The build this start() was waiting on landed — go now. - if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); } - }, - }); + const tool = captureTool(moduleDir); 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 }); - // 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); return; } @@ -206,7 +127,7 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) { let decoder = null; 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.on('data', (text) => { @@ -298,4 +219,4 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) { }; } -module.exports = { createLtcListener, ensureCaptureBinary }; +module.exports = { createLtcListener, captureTool }; diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index a2b9d43..f85f541 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -1,7 +1,7 @@ { "id": "propresenter-timers", "name": "Timers", - "version": "1.6.0", + "version": "1.7.0", "proddash": ">=1.4.0", "description": "Timers, Clocks, and LTC From configured Modules", "provides": [ diff --git a/modules/propresenter-timers/native/build.sh b/modules/propresenter-timers/native/build.sh new file mode 100755 index 0000000..f957c96 --- /dev/null +++ b/modules/propresenter-timers/native/build.sh @@ -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 diff --git a/modules/propresenter-timers/native/helper.entitlements b/modules/propresenter-timers/native/helper.entitlements new file mode 100644 index 0000000..6d11e71 --- /dev/null +++ b/modules/propresenter-timers/native/helper.entitlements @@ -0,0 +1,10 @@ + + + + + + com.apple.security.device.audio-input + + + diff --git a/modules/propresenter-timers/ltc-capture.swift b/modules/propresenter-timers/native/ltc-capture.swift similarity index 96% rename from modules/propresenter-timers/ltc-capture.swift rename to modules/propresenter-timers/native/ltc-capture.swift index ff1f2d6..824d447 100644 --- a/modules/propresenter-timers/ltc-capture.swift +++ b/modules/propresenter-timers/native/ltc-capture.swift @@ -4,12 +4,12 @@ // 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 // 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 -// first use when swiftc is available; otherwise build it once on any -// same-architecture Mac and copy the binary next to this source: +// overkill — this is the whole job, natively. 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/propresenter-timers/ltc-capture \ -// modules/propresenter-timers/ltc-capture.swift (once, ~5 s) +// modules/propresenter-timers/native/build.sh (both architectures, ~10 s) // // ltc-capture --list // every input-capable device: name, input channels, sample rate diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index 4235b14..8822544 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, ensureCaptureBinary } = require('./ltc-listener'); +const { createLtcListener, captureTool } = require('./ltc-listener'); const { createTimerPhase } = require('./phase'); const manifest = require('./module.json'); @@ -660,11 +660,8 @@ 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 }); + // The capture tool ships prebuilt in the module; the listener picks + // it for this platform when it starts and says so when there is none. if (!device) { word = { error: 'Not configured — see Admin', @@ -1359,23 +1356,21 @@ 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. + // options shape matches the admin select's optionsRoute contract. The + // capture tool ships prebuilt in the module (bin/); with none for this + // platform the list is empty and the error says so. 'GET /ltc/devices': (req, res) => { - const tool = ensureCaptureBinary({ moduleDir: __dirname, log }); + const tool = captureTool(__dirname); if (!tool.ready) { - sendJson(res, 200, { options: [], error: tool.detail || tool.error }); + sendJson(res, 200, { options: [], error: tool.detail }); return; } - const bin = path.join(__dirname, 'ltc-capture'); - const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 }); + const child = spawn(tool.bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 }); let err = ''; child.stderr.setEncoding('utf8'); child.stderr.on('data', (t) => { err += t; }); - child.on('error', () => { - sendJson(res, 200, { options: [], error: 'LTC capture tool missing — see modules/propresenter-timers/ltc-capture.swift' }); + child.on('error', (e) => { + sendJson(res, 200, { options: [], error: `LTC capture tool could not be run: ${e.message}` }); }); child.on('exit', () => { if (res.writableEnded) return;