Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2817a444c3 | ||
|
|
c11389473f | ||
|
|
c6a44e6ddc | ||
|
|
2f14b30d68 | ||
|
|
b5d39c60dc | ||
|
|
b5c539ba05 |
+2
-3
@@ -8,12 +8,11 @@ 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/.cache/
|
||||||
|
|
||||||
# Compiled per-machine, source sits next to it (see spl-capture.swift).
|
# Compiled per-machine, source sits next to it (see spl-capture.swift).
|
||||||
modules/spl-meter/spl-capture
|
modules/spl-meter/spl-capture
|
||||||
|
|||||||
@@ -30,9 +30,19 @@ and a clear degraded state, then recover on their own.
|
|||||||
|
|
||||||
## Run it
|
## Run it
|
||||||
|
|
||||||
|
Ready-made builds are on the
|
||||||
|
[Releases page](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash/releases);
|
||||||
|
the files themselves live in
|
||||||
|
[ProdDash-releases](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash-releases).
|
||||||
|
There is a disk image for Apple silicon Macs, one for Intel Macs, and a
|
||||||
|
bundle for 64-bit Windows. Every build updates itself afterwards from the
|
||||||
|
admin page, so the download only has to be recent, not current.
|
||||||
|
|
||||||
### macOS — the app
|
### macOS — the app
|
||||||
|
|
||||||
Open the disk image and drag **ProdDash** to Applications. That's the whole
|
Download the disk image for your Mac — `ProdDash-<version>-arm64.dmg` for
|
||||||
|
Apple silicon, `ProdDash-<version>-x86_64.dmg` for Intel — open it and drag
|
||||||
|
**ProdDash** to Applications. That's the whole
|
||||||
installation: the app carries the dashboard and its own Node runtime, so
|
installation: the app carries the dashboard and its own Node runtime, so
|
||||||
nothing else has to be installed. It carries no modules — open **Admin →
|
nothing else has to be installed. It carries no modules — open **Admin →
|
||||||
Available modules**, tick the ones this machine needs and press **Install
|
Available modules**, tick the ones this machine needs and press **Install
|
||||||
@@ -45,12 +55,26 @@ a launchd job) simply doesn't have them, and an LTC input goes silently to
|
|||||||
zeroes.
|
zeroes.
|
||||||
|
|
||||||
Build the disk image from a checkout with
|
Build the disk image from a checkout with
|
||||||
[launcher/macos](launcher/macos)/`build.sh`:
|
[launcher/macos](launcher/macos)/`build.sh` (`ARCH=x86_64` in front of it
|
||||||
|
for Intel):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd launcher/macos && ./build.sh --dmg
|
cd launcher/macos && ./build.sh --dmg
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Windows — the bundle
|
||||||
|
|
||||||
|
Download `ProdDash-<version>-win-x64.zip`, unzip it somewhere you can write
|
||||||
|
to — `C:\ProdDash`, say, not Program Files, because updates write into the
|
||||||
|
folder — and double-click **ProdDash.bat**. The window it opens is the
|
||||||
|
dashboard running: it stays open, and it starts the server again when the
|
||||||
|
admin page applies an update. Allow `node.exe` on private networks when the
|
||||||
|
firewall asks, so other screens can reach it. Settings and layouts live in
|
||||||
|
`%APPDATA%\ProdDash`, so a new bundle never loses them. To start with
|
||||||
|
Windows, put a shortcut to `ProdDash.bat` in `shell:startup`. There is no
|
||||||
|
menu-bar-style launcher for Windows yet; the bundle is built with
|
||||||
|
[launcher/windows](launcher/windows)/`build.sh`.
|
||||||
|
|
||||||
### Anywhere else, or from a checkout
|
### Anywhere else, or from a checkout
|
||||||
|
|
||||||
Requires [Node.js](https://nodejs.org) 18 or newer.
|
Requires [Node.js](https://nodejs.org) 18 or newer.
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ the cycle rather than spinning forever.
|
|||||||
```bash
|
```bash
|
||||||
./build.sh # build/ProdDash.app — the whole application
|
./build.sh # build/ProdDash.app — the whole application
|
||||||
./build.sh --dmg # …and build/ProdDash-<version>-arm64.dmg
|
./build.sh --dmg # …and build/ProdDash-<version>-arm64.dmg
|
||||||
|
ARCH=x86_64 ./build.sh --dmg # the same for Intel Macs (…-x86_64.dmg)
|
||||||
./build.sh --install # …and copy it to /Applications
|
./build.sh --install # …and copy it to /Applications
|
||||||
./build.sh --run # …and open it
|
./build.sh --run # …and open it
|
||||||
./build.sh --notarize # with --dmg: notarise and staple (Developer ID)
|
./build.sh --notarize # with --dmg: notarise and staple (Developer ID)
|
||||||
|
|||||||
+10
-3
@@ -13,6 +13,8 @@
|
|||||||
# ./build.sh --dev launcher only — no Node, no server payload, so
|
# ./build.sh --dev launcher only — no Node, no server payload, so
|
||||||
# it runs against the checkout. Seconds, not
|
# it runs against the checkout. Seconds, not
|
||||||
# minutes, while working on the launcher itself.
|
# minutes, while working on the launcher itself.
|
||||||
|
# ARCH=x86_64 ./build.sh the same for Intel Macs (default: arm64, Apple
|
||||||
|
# silicon); the Node it embeds follows.
|
||||||
#
|
#
|
||||||
# Signing: a "Developer ID Application" certificate is used when one is
|
# Signing: a "Developer ID Application" certificate is used when one is
|
||||||
# installed (override with CODESIGN_IDENTITY). Without one the app is signed
|
# installed (override with CODESIGN_IDENTITY). Without one the app is signed
|
||||||
@@ -29,8 +31,13 @@ LAUNCHER_VERSION="1.0.0"
|
|||||||
# The Node the app ships. Bump it here; the download is checksum-verified
|
# The Node the app ships. Bump it here; the download is checksum-verified
|
||||||
# against nodejs.org's own SHASUMS256.txt and cached in .cache/.
|
# against nodejs.org's own SHASUMS256.txt and cached in .cache/.
|
||||||
NODE_VERSION="v24.21.0"
|
NODE_VERSION="v24.21.0"
|
||||||
ARCH="arm64"
|
ARCH="${ARCH:-arm64}" # arm64 (Apple silicon) or x86_64 (Intel)
|
||||||
DEPLOYMENT_TARGET="13.0"
|
DEPLOYMENT_TARGET="13.0"
|
||||||
|
case "$ARCH" in
|
||||||
|
arm64) NODE_ARCH="arm64" ;;
|
||||||
|
x86_64) NODE_ARCH="x64" ;; # nodejs.org's name for it
|
||||||
|
*) echo "build.sh: ARCH must be arm64 or x86_64, not $ARCH" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
REPO_ROOT="../.."
|
REPO_ROOT="../.."
|
||||||
OUT_DIR="build"
|
OUT_DIR="build"
|
||||||
@@ -108,7 +115,7 @@ fi
|
|||||||
|
|
||||||
# ── the server, and the Node that runs it ────────────────────────────────
|
# ── the server, and the Node that runs it ────────────────────────────────
|
||||||
if [[ $DEV -eq 0 ]]; then
|
if [[ $DEV -eq 0 ]]; then
|
||||||
TARBALL="node-$NODE_VERSION-darwin-$ARCH.tar.gz"
|
TARBALL="node-$NODE_VERSION-darwin-$NODE_ARCH.tar.gz"
|
||||||
mkdir -p "$CACHE_DIR"
|
mkdir -p "$CACHE_DIR"
|
||||||
if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then
|
if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then
|
||||||
echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)"
|
echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)"
|
||||||
@@ -127,7 +134,7 @@ if [[ $DEV -eq 0 ]]; then
|
|||||||
echo " embedding Node $NODE_VERSION"
|
echo " embedding Node $NODE_VERSION"
|
||||||
mkdir -p "$CONTENTS/Resources/node/bin"
|
mkdir -p "$CONTENTS/Resources/node/bin"
|
||||||
tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \
|
tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \
|
||||||
"node-$NODE_VERSION-darwin-$ARCH/bin/node"
|
"node-$NODE_VERSION-darwin-$NODE_ARCH/bin/node"
|
||||||
|
|
||||||
echo " embedding ProdDash $VERSION (no modules — the catalog installs them)"
|
echo " embedding ProdDash $VERSION (no modules — the catalog installs them)"
|
||||||
mkdir -p "$PAYLOAD/config"
|
mkdir -p "$PAYLOAD/config"
|
||||||
|
|||||||
Executable
+121
@@ -0,0 +1,121 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
# Build the Windows bundle — ProdDash-<version>-win-x64.zip: the dashboard,
|
||||||
|
# its own Node runtime and a start script, so nothing has to be installed on
|
||||||
|
# the Windows machine. Unzip, double-click ProdDash.bat, done. It carries no
|
||||||
|
# modules: those come from the admin page's catalog into %APPDATA%\ProdDash.
|
||||||
|
#
|
||||||
|
# ./build.sh build/ProdDash-<version>-win-x64.zip
|
||||||
|
#
|
||||||
|
# Builds on macOS or Linux (zsh, curl, shasum, unzip, zip). Node for Windows
|
||||||
|
# comes from nodejs.org, checksum-verified against its SHASUMS256.txt and
|
||||||
|
# cached in .cache/. There is no Windows launcher app yet: ProdDash.bat keeps
|
||||||
|
# the server running and starts it again when the admin page applies an
|
||||||
|
# update (the server exits with code 75 to ask for that).
|
||||||
|
set -e -u
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
APP_NAME="ProdDash"
|
||||||
|
# The Node the bundle ships — keep in step with launcher/macos/build.sh.
|
||||||
|
NODE_VERSION="v24.21.0"
|
||||||
|
NODE_DIST="node-$NODE_VERSION-win-x64"
|
||||||
|
|
||||||
|
REPO_ROOT="../.."
|
||||||
|
OUT_DIR="build"
|
||||||
|
CACHE_DIR=".cache"
|
||||||
|
|
||||||
|
for tool in curl shasum unzip zip; do
|
||||||
|
command -v "$tool" >/dev/null || { echo "build.sh: $tool not found" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
VERSION=$(sed -n 's/.*"version" *: *"\([^"]*\)".*/\1/p' "$REPO_ROOT/package.json" | head -1)
|
||||||
|
[[ -n "$VERSION" ]] || { echo "build.sh: can't read the version from package.json" >&2; exit 1; }
|
||||||
|
NAME="$APP_NAME-$VERSION-win-x64"
|
||||||
|
STAGE="$OUT_DIR/$NAME"
|
||||||
|
|
||||||
|
echo "ProdDash $VERSION — building the Windows bundle"
|
||||||
|
rm -rf "$OUT_DIR"
|
||||||
|
mkdir -p "$STAGE/node" "$STAGE/config" "$CACHE_DIR"
|
||||||
|
|
||||||
|
# ── Node for Windows ─────────────────────────────────────────────────────
|
||||||
|
ZIPFILE="$NODE_DIST.zip"
|
||||||
|
if [[ ! -f "$CACHE_DIR/$ZIPFILE" ]]; then
|
||||||
|
echo " fetching Node $NODE_VERSION for Windows (~30 MB, cached for next time)"
|
||||||
|
curl -fsSL --retry 3 -o "$CACHE_DIR/$ZIPFILE.part" "https://nodejs.org/dist/$NODE_VERSION/$ZIPFILE"
|
||||||
|
mv "$CACHE_DIR/$ZIPFILE.part" "$CACHE_DIR/$ZIPFILE"
|
||||||
|
fi
|
||||||
|
SUMS="$CACHE_DIR/SHASUMS256-$NODE_VERSION.txt"
|
||||||
|
[[ -f "$SUMS" ]] || curl -fsSL --retry 3 -o "$SUMS" "https://nodejs.org/dist/$NODE_VERSION/SHASUMS256.txt"
|
||||||
|
# This binary ships under ProdDash's name — check it every build.
|
||||||
|
WANT=$(awk -v f="$ZIPFILE" '$2 == f {print $1}' "$SUMS")
|
||||||
|
GOT=$(shasum -a 256 "$CACHE_DIR/$ZIPFILE" | awk '{print $1}')
|
||||||
|
if [[ -z "$WANT" || "$WANT" != "$GOT" ]]; then
|
||||||
|
echo "build.sh: $ZIPFILE does not match nodejs.org's checksum — refusing to ship it" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " embedding Node $NODE_VERSION (node.exe and its licence)"
|
||||||
|
unzip -q -j "$CACHE_DIR/$ZIPFILE" "$NODE_DIST/node.exe" "$NODE_DIST/LICENSE" -d "$STAGE/node"
|
||||||
|
|
||||||
|
# ── ProdDash ─────────────────────────────────────────────────────────────
|
||||||
|
echo " embedding ProdDash $VERSION (no modules — the catalog installs them)"
|
||||||
|
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$STAGE/"
|
||||||
|
cp -R "$REPO_ROOT/public" "$STAGE/"
|
||||||
|
# Only the checked-in defaults: config/ also holds this machine's own
|
||||||
|
# modules.json and layouts when ProdDash has ever run from the checkout.
|
||||||
|
cp "$REPO_ROOT/config/proddash.json" "$STAGE/config/"
|
||||||
|
find "$STAGE" -name '.DS_Store' -delete
|
||||||
|
|
||||||
|
# ── the start script and the read-me, with Windows line endings ──────────
|
||||||
|
crlf() { awk '{ printf "%s\r\n", $0 }' > "$1"; }
|
||||||
|
|
||||||
|
crlf "$STAGE/ProdDash.bat" <<'BAT'
|
||||||
|
@echo off
|
||||||
|
rem ProdDash - double-click to start. Keep this window open: closing it stops the dashboard.
|
||||||
|
title ProdDash
|
||||||
|
cd /d "%~dp0"
|
||||||
|
rem The server exits with code 75 after applying an update from the admin page; start it again.
|
||||||
|
set PRODDASH_LAUNCHER=1
|
||||||
|
:run
|
||||||
|
"%~dp0node\node.exe" server.js
|
||||||
|
set CODE=%ERRORLEVEL%
|
||||||
|
if %CODE% EQU 75 goto run
|
||||||
|
echo.
|
||||||
|
echo ProdDash stopped (exit code %CODE%). Press any key to close this window.
|
||||||
|
pause >nul
|
||||||
|
BAT
|
||||||
|
|
||||||
|
crlf "$STAGE/README.txt" <<README
|
||||||
|
ProdDash $VERSION for Windows (64-bit)
|
||||||
|
=======================================
|
||||||
|
|
||||||
|
Nothing to install. This folder holds the dashboard and its own copy of Node.
|
||||||
|
|
||||||
|
1. Move this folder somewhere you can write to, for example C:\ProdDash
|
||||||
|
(not Program Files: updates write into this folder).
|
||||||
|
2. Double-click ProdDash.bat. A window opens and stays open while the
|
||||||
|
dashboard runs; closing it stops the dashboard.
|
||||||
|
- If Windows warns about the file, choose More info, then Run anyway.
|
||||||
|
- When Windows Defender Firewall asks about node.exe, allow it on
|
||||||
|
private networks so other screens can reach the dashboard.
|
||||||
|
3. Open http://localhost:24500 on this PC. Other devices on the network use
|
||||||
|
http://<this PC's IP address>:24500. Settings are on the admin page,
|
||||||
|
http://localhost:24500/admin.
|
||||||
|
4. The bundle ships without modules. On the admin page, tick the ones this
|
||||||
|
PC needs under Available modules and press Install selected; each one
|
||||||
|
updates on its own from then on.
|
||||||
|
|
||||||
|
Your settings and layouts live in %APPDATA%\ProdDash, not in this folder,
|
||||||
|
so unzipping a new version or updating never loses them.
|
||||||
|
|
||||||
|
Start with Windows: press Win+R, type shell:startup, press Enter, and put a
|
||||||
|
shortcut to ProdDash.bat in the folder that opens.
|
||||||
|
|
||||||
|
Updates: the admin page's Update now fetches the new version into this
|
||||||
|
folder and restarts the dashboard; the window stays open. A copy of the
|
||||||
|
version it replaced is kept for a short while in case the new one will not
|
||||||
|
start.
|
||||||
|
README
|
||||||
|
|
||||||
|
# ── zip ──────────────────────────────────────────────────────────────────
|
||||||
|
echo " zipping"
|
||||||
|
(cd "$OUT_DIR" && zip -q -r -X "$NAME.zip" "$NAME")
|
||||||
|
echo "built $OUT_DIR/$NAME.zip ($(du -sh "$OUT_DIR/$NAME.zip" | awk '{print $1}'))"
|
||||||
BIN
Binary file not shown.
Binary file not shown.
@@ -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,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": [
|
||||||
|
|||||||
Executable
+48
@@ -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>
|
||||||
+5
-5
@@ -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
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user