Timers 1.7.0: the LTC capture tool ships prebuilt, for Apple silicon and Intel #37

Merged
michael merged 1 commits from claude/timers-prebuilt-helper into main 2026-09-30 14:19:58 -04:00
9 changed files with 109 additions and 138 deletions
-3
View File
@@ -8,9 +8,6 @@ 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/
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;