ProdCom is a sandboxed Mac App Store app whose HTTP API lives inside it, behind the UserDefaults switch applicationAPIEnabled — absent means off, and a running ProdCom does not notice the switch changing; it reads it at launch. So when the tile says "unreachable" on a booth where ProdCom runs on the same Mac, there are two causes and two fixes: ProdCom is not running (open it), or it is running with the API off (set the switch, quit it, open it again). The module's server now does both, behind two Admin switches, both on: "Start ProdCom when it is offline" and "Restart ProdCom to turn its API on". On a connection-level failure (never an HTTP answer) the supervisor runs up to three attempts five seconds apart: not running → open -g -b; running with the switch off → write it, read it back, ask ProdCom to quit (Apple Event, SIGTERM after 5 s), open it again — at most once per outage; running with the switch on → keep probing, and only blame the port if that is still true on the third look, because a quitting ProdCom still shows in pgrep. A successful run nudges the module to reconnect at once. It gives up with a tile message until ProdCom answers again, and does nothing at all for a ProdCom on another machine or off macOS. The launcher gains the Apple Events entitlement and usage string so the polite quit can be granted; if it is denied, SIGTERM does the job. Verified live on the dev Mac against ProdCom 2.3.4: not running + switch absent → opened, switch written, restarted, Live after two attempts; an operator's Cmd-Q → reopened. 71 fake-driven tests (node --test), the OS layer included through an injectable execFile. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
438 lines
18 KiB
JavaScript
438 lines
18 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* ProdCom Transcript — keeping ProdCom itself running.
|
|
*
|
|
* When ProdCom runs on the same Mac as ProdDash (the booth Mac) and its API
|
|
* stops answering, the usual reason is that nobody has opened ProdCom, or that
|
|
* its Application API is switched off — ProdCom reads that switch only at
|
|
* launch. This watches for that and puts it right: open ProdCom if it is not
|
|
* running; if it is running with the API off, turn the API on and restart it.
|
|
* Nothing here does anything on another OS, or when ProdCom is on another
|
|
* machine — a remote ProdCom is somebody else's to look after.
|
|
*
|
|
* The module calls kick(code) every time a connection to ProdCom fails and
|
|
* noteReachable() when one succeeds; the rest is decided here. A "run" is up to
|
|
* three attempts, each: act, wait five seconds, ask the API whether it is up.
|
|
* When a run cannot fix it, the supervisor gives up for that outage — kick()
|
|
* then returns at once, so the module's reconnect loop can call it every few
|
|
* seconds without anything happening — until a connection succeeds again.
|
|
*
|
|
* Every touch of the machine (is ProdCom running, open it, read/write its
|
|
* preference, quit it) and of the network (the ping) is injected, so
|
|
* supervisor.test.js drives the decisions with fakes; the real ones are
|
|
* defaultOs() and httpProbe() below, all child_process.execFile with an
|
|
* argument list — never a shell string — and a timeout.
|
|
*/
|
|
|
|
const http = require('http');
|
|
const nodeOs = require('os');
|
|
const { execFile } = require('child_process');
|
|
|
|
const BUNDLE_ID = 'com.prodcom.prodcom';
|
|
const PROCESS_NAME = 'ProdCom';
|
|
const API_PREF = 'applicationAPIEnabled'; // a UserDefaults bool in the app's own domain
|
|
|
|
const MAX_ATTEMPTS = 3;
|
|
const ATTEMPT_WAIT_MS = 5000; // after acting, before asking whether the API answers
|
|
const PROBE_TIMEOUT_MS = 3000;
|
|
const QUICK_TOOL_MS = 5000; // pgrep, defaults, pkill
|
|
const SLOW_TOOL_MS = 10000; // open
|
|
const QUIT_ASK_MS = 5000; // osascript's patience: a ProdCom that will not answer an Apple Event is told with SIGTERM
|
|
const QUIT_WAIT_MS = 10000; // for the process to be gone after asking it to quit
|
|
const QUIT_POLL_MS = 500;
|
|
|
|
/* ── the real machine ───────────────────────────────────────────────── */
|
|
|
|
/** Run a program with an argument list (`execFileImpl` is child_process.execFile, or a fake in tests).
|
|
Never rejects: { ok, exitCode, error, stdout, stderr } —
|
|
exitCode is the program's own exit status, or null when it never got to exit
|
|
normally (not found, timed out, killed). */
|
|
function runProgram(execFileImpl, file, args, timeout) {
|
|
return new Promise((resolve) => {
|
|
execFileImpl(file, args, { timeout, windowsHide: true }, (error, stdout, stderr) => {
|
|
resolve({
|
|
ok: !error,
|
|
exitCode: error ? (typeof error.code === 'number' ? error.code : null) : 0,
|
|
error,
|
|
stdout: String(stdout || ''),
|
|
stderr: String(stderr || ''),
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
/** What went wrong with an exec() result, for a message: how it failed, then the first line the program said about it. */
|
|
function why(r) {
|
|
let how = 'unknown';
|
|
if (r.exitCode !== null) how = `exit ${r.exitCode}`;
|
|
else if (r.error) how = r.error.killed ? 'timed out' : (r.error.code || r.error.message);
|
|
const said = r.stderr.split('\n').map((l) => l.trim()).find(Boolean);
|
|
if (!said) return how;
|
|
return `${how}: ${said.length > 120 ? `${said.slice(0, 120)}…` : said}`;
|
|
}
|
|
|
|
/** A sleep the supervisor can cut short: resolves true after `ms`, false at once
|
|
when `signal` aborts (its timer is cleared, so nothing is left behind). */
|
|
function abortableSleep(ms, signal) {
|
|
return new Promise((resolve) => {
|
|
if (signal && signal.aborted) return resolve(false);
|
|
let timer = null;
|
|
const onAbort = () => {
|
|
clearTimeout(timer);
|
|
resolve(false);
|
|
};
|
|
timer = setTimeout(() => {
|
|
if (signal) signal.removeEventListener('abort', onAbort);
|
|
resolve(true);
|
|
}, ms);
|
|
timer.unref?.();
|
|
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
|
});
|
|
}
|
|
|
|
/** `defaults read` prints a bool as 1 / 0; anything else is not an answer. */
|
|
function parseApiPref(stdout) {
|
|
const s = String(stdout || '').trim().toLowerCase();
|
|
if (s === '1' || s === 'true' || s === 'yes') return true;
|
|
if (s === '0' || s === 'false' || s === 'no') return false;
|
|
return null;
|
|
}
|
|
|
|
/** The address as one canonical spelling, so a configured "::1" matches the interface's. */
|
|
function canonicalAddress(a) {
|
|
let s = String(a ?? '').trim().toLowerCase();
|
|
if (s.startsWith('[') && s.endsWith(']')) s = s.slice(1, -1);
|
|
s = s.replace(/%.*$/, ''); // a scope id (fe80::1%en0)
|
|
if (s.includes(':')) {
|
|
try { s = new URL(`http://[${s}]`).hostname.slice(1, -1); } catch { /* not an address */ }
|
|
}
|
|
return s;
|
|
}
|
|
|
|
/** Is `host` this machine? `hostname` and `addresses` are this machine's own
|
|
(os.hostname(), every address of os.networkInterfaces()) — passed in so this
|
|
is a pure function. */
|
|
function isLocalHostName(host, hostname, addresses) {
|
|
const h = canonicalAddress(host).replace(/\.$/, '');
|
|
if (h === '' || h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0.0.0.0') return true;
|
|
const bare = (s) => String(s).toLowerCase().replace(/\.$/, '').replace(/\.local$/, '');
|
|
if (hostname && bare(hostname) === bare(h)) return true;
|
|
return (addresses || []).some((a) => canonicalAddress(a) === h);
|
|
}
|
|
|
|
/** Is anything answering HTTP at host:port? Any response counts, whatever its
|
|
status — the API's ping needs no key, and a 401 is still a live server. */
|
|
function httpProbe(host, port) {
|
|
return new Promise((resolve) => {
|
|
let done = false;
|
|
const finish = (up) => {
|
|
if (done) return;
|
|
done = true;
|
|
resolve(up);
|
|
};
|
|
let h = canonicalAddress(host);
|
|
if (!h || h === '0.0.0.0') h = '127.0.0.1';
|
|
try {
|
|
const req = http.get({
|
|
host: h,
|
|
port,
|
|
path: '/api/v1/ping',
|
|
agent: false,
|
|
timeout: PROBE_TIMEOUT_MS,
|
|
headers: { accept: 'application/json' },
|
|
}, (res) => {
|
|
res.resume();
|
|
finish(true);
|
|
});
|
|
req.on('timeout', () => {
|
|
req.destroy();
|
|
finish(false);
|
|
});
|
|
req.on('error', () => finish(false));
|
|
} catch {
|
|
finish(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The operating-system layer: macOS commands, each run with execFile.
|
|
* `deps` is for tests only — { execFile, sleep, now } replace the real
|
|
* child_process.execFile, abortableSleep and Date.now.
|
|
*/
|
|
function defaultOs(deps = {}) {
|
|
const exec = (file, args, timeout) => runProgram(deps.execFile || execFile, file, args, timeout);
|
|
const wait = deps.sleep || abortableSleep;
|
|
const now = deps.now || Date.now;
|
|
|
|
async function isRunning() {
|
|
const r = await exec('/usr/bin/pgrep', ['-x', PROCESS_NAME], QUICK_TOOL_MS);
|
|
if (r.ok) return true;
|
|
if (r.exitCode === 1) return false; // pgrep's "nothing matched"
|
|
throw new Error(`Could not check whether ProdCom is running (${why(r)})`);
|
|
}
|
|
|
|
/** Open ProdCom in the background (-g): a booth Mac must not lose the keyboard from ProPresenter or OBS mid-service. */
|
|
async function launch() {
|
|
const r = await exec('/usr/bin/open', ['-g', '-b', BUNDLE_ID], SLOW_TOOL_MS);
|
|
if (r.ok) return;
|
|
if (r.exitCode !== null) throw new Error('ProdCom is not installed on this machine');
|
|
throw new Error(`Could not open ProdCom (${why(r)})`);
|
|
}
|
|
|
|
async function readApiPref() {
|
|
const r = await exec('/usr/bin/defaults', ['read', BUNDLE_ID, API_PREF], QUICK_TOOL_MS);
|
|
if (r.ok) return parseApiPref(r.stdout);
|
|
if (r.exitCode !== null) return null; // the key does not exist yet
|
|
// Not knowing is not the same as "off": it would restart a ProdCom that is fine.
|
|
throw new Error(`Could not read ProdCom's settings (${why(r)})`);
|
|
}
|
|
|
|
async function writeApiPref() {
|
|
const r = await exec('/usr/bin/defaults', ['write', BUNDLE_ID, API_PREF, '-bool', 'true'], QUICK_TOOL_MS);
|
|
if (!r.ok) throw new Error(`Could not turn on ProdCom's API (${why(r)})`);
|
|
}
|
|
|
|
/** Ask ProdCom to quit; if it will not be asked, terminate it. Resolves when it is gone.
|
|
Once `signal` aborts it does nothing more — no SIGTERM, no further look at the process list. */
|
|
async function quit(signal) {
|
|
const cancelled = () => Boolean(signal && signal.aborted);
|
|
if (cancelled()) throw new Error('cancelled');
|
|
const asked = await exec('/usr/bin/osascript', ['-e', `tell application id "${BUNDLE_ID}" to quit`], QUIT_ASK_MS);
|
|
if (cancelled()) throw new Error('cancelled');
|
|
if (!asked.ok) await exec('/usr/bin/pkill', ['-x', '-TERM', PROCESS_NAME], QUICK_TOOL_MS);
|
|
const until = now() + QUIT_WAIT_MS;
|
|
for (;;) {
|
|
if (cancelled()) throw new Error('cancelled');
|
|
if (!(await isRunning())) return;
|
|
if (now() >= until) throw new Error('ProdCom would not quit');
|
|
await wait(QUIT_POLL_MS, signal);
|
|
}
|
|
}
|
|
|
|
return {
|
|
platform: process.platform,
|
|
isLocalHost(host) {
|
|
const addresses = [];
|
|
for (const list of Object.values(nodeOs.networkInterfaces())) {
|
|
for (const a of list || []) addresses.push(a.address);
|
|
}
|
|
return isLocalHostName(host, nodeOs.hostname(), addresses);
|
|
},
|
|
isRunning,
|
|
launch,
|
|
readApiPref,
|
|
writeApiPref,
|
|
quit,
|
|
};
|
|
}
|
|
|
|
/* ── the decisions ──────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* @param opts.settings () => { autoStart, apiRestart } read live, every time
|
|
* @param opts.target () => { host, port } where the module points ProdCom
|
|
* @param opts.probe () => Promise<boolean> optional; the API answers (default: GET /api/v1/ping)
|
|
* @param opts.os the machine layer (see defaultOs); optional, defaults to the real one
|
|
* @param opts.sleep (ms, signal) => Promise optional; the default is cut short by `signal`
|
|
* @param opts.log (line) => void
|
|
* @param opts.status (state, message) => void the tile's status; state is 'connecting' | 'error'
|
|
* @param opts.onUp () => void optional; ProdCom answers again — the module may reconnect now
|
|
*/
|
|
function createSupervisor(opts) {
|
|
const { settings, target, log, status } = opts;
|
|
const onUp = opts.onUp || (() => {});
|
|
const os = opts.os || defaultOs();
|
|
const probe = opts.probe || (() => { const t = target(); return httpProbe(t.host, t.port); });
|
|
const sleep = opts.sleep || abortableSleep;
|
|
|
|
let stopped = false;
|
|
let busy = false; // a run is in progress
|
|
let gaveUp = false; // this outage is beyond the supervisor until a connection succeeds
|
|
let msg = null; // the supervisor's words for the tile
|
|
let epoch = 0; // a run belongs to the epoch it started in; a new one ends it
|
|
let abort = null; // cuts the running run's sleep (and quit) short
|
|
let current = Promise.resolve();
|
|
const said = new Set(); // no-op reasons already logged
|
|
|
|
/** End the run in progress, if any: it unwinds without another word. */
|
|
function cancelRun() {
|
|
epoch += 1;
|
|
if (abort) {
|
|
abort.abort();
|
|
abort = null;
|
|
}
|
|
}
|
|
|
|
/** Nothing to do, and why — logged once per distinct reason, not on every reconnect tick. */
|
|
function skip(reason) {
|
|
if (!said.has(reason)) {
|
|
said.add(reason);
|
|
log(reason);
|
|
}
|
|
return reason;
|
|
}
|
|
|
|
/** Wait `ms`, but never past stop() or the end of the run, whatever `sleep` does with the signal. */
|
|
function pause(ms, signal) {
|
|
return new Promise((resolve) => {
|
|
if (signal.aborted) return resolve();
|
|
const done = () => {
|
|
signal.removeEventListener('abort', done);
|
|
resolve();
|
|
};
|
|
signal.addEventListener('abort', done, { once: true });
|
|
Promise.resolve().then(() => sleep(ms, signal)).then(done, done);
|
|
});
|
|
}
|
|
|
|
async function run(myEpoch, signal) {
|
|
const live = () => !stopped && epoch === myEpoch;
|
|
const say = (state, text) => {
|
|
msg = text;
|
|
status(state, text);
|
|
};
|
|
const giveUp = (text) => {
|
|
gaveUp = true;
|
|
say('error', text);
|
|
log(text);
|
|
};
|
|
|
|
let restarted = false; // ProdCom was quit and opened again in this run — once is all it gets
|
|
let opened = false; // this run opened ProdCom itself: if it is running now, it may simply still be starting up
|
|
let apiOnSilent = false; // the latest look found ProdCom running, API on, port silent, and not of our opening
|
|
try {
|
|
for (let n = 1; n <= MAX_ATTEMPTS; n += 1) {
|
|
const s = settings();
|
|
apiOnSilent = false;
|
|
// Look afresh every attempt: one sample proves little (a ProdCom that is quitting still shows as running).
|
|
const running = await os.isRunning();
|
|
if (!live()) return;
|
|
if (!running) {
|
|
// Not there — never opened, quit by hand, or gone since the restart. A launch, never a second quit.
|
|
await os.launch();
|
|
if (!live()) return;
|
|
opened = true;
|
|
say('connecting', `ProdCom is not running — starting it (${n}/${MAX_ATTEMPTS})`);
|
|
} else if (!restarted) {
|
|
const pref = await os.readApiPref();
|
|
if (!live()) return;
|
|
if (pref === true) {
|
|
// Running with its API on and the port still silent. Slow to start, quitting, or the wrong port —
|
|
// one look cannot tell, so wait and look again; only the last attempt blames the port.
|
|
apiOnSilent = !opened;
|
|
} else {
|
|
// The API is off (or the key was never written): switch it on for ProdCom's next start…
|
|
await os.writeApiPref();
|
|
if (!live()) return;
|
|
// …and restart ProdCom only if the setting really holds: that restart cuts its intercom.
|
|
const now = await os.readApiPref();
|
|
if (!live()) return;
|
|
if (now !== true) {
|
|
return giveUp(`Could not turn on ProdCom's API — it still reads ${now === false ? 'off' : 'unset'}`);
|
|
}
|
|
if (!s.apiRestart) {
|
|
return giveUp("ProdCom's API is off — restart ProdCom to turn it on");
|
|
}
|
|
say('connecting', "ProdCom's API is off — turning it on and restarting ProdCom");
|
|
await os.quit(signal);
|
|
if (!live()) return;
|
|
await os.launch();
|
|
if (!live()) return;
|
|
restarted = true;
|
|
}
|
|
}
|
|
await pause(ATTEMPT_WAIT_MS, signal);
|
|
if (!live()) return;
|
|
const up = await Promise.resolve().then(probe).catch(() => false);
|
|
if (!live()) return;
|
|
if (up) {
|
|
log(`ProdCom is up after ${n} attempt(s)`);
|
|
say('connecting', 'ProdCom is back — reconnecting');
|
|
try {
|
|
onUp();
|
|
} catch (err) {
|
|
log(`ProdCom auto-start: reconnect nudge failed (${err && err.message ? err.message : err})`);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
giveUp(apiOnSilent
|
|
? `ProdCom is running with its API on, but port ${target().port} does not answer — check its API port`
|
|
: 'ProdCom would not start — open it by hand');
|
|
} catch (err) {
|
|
if (!live()) return;
|
|
giveUp(err && err.message ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
/** Decide whether a run is called for, and start it: the reason it did not, or null. */
|
|
function start(code) {
|
|
if (stopped) return 'stopped';
|
|
if (!settings().autoStart) return skip('ProdCom auto-start is off');
|
|
if (os.platform !== 'darwin') return skip('ProdCom auto-start is macOS only');
|
|
const { host } = target();
|
|
if (!os.isLocalHost(host)) return skip(`ProdCom at ${host} is not on this machine`);
|
|
if (busy) return skip('ProdCom auto-start is already working on it');
|
|
if (gaveUp) return skip('ProdCom auto-start has given up until ProdCom answers');
|
|
|
|
busy = true;
|
|
said.clear(); // a new outage says its reasons again
|
|
const myEpoch = epoch;
|
|
const controller = new AbortController();
|
|
abort = controller;
|
|
log(`ProdCom did not answer (${code}) — checking whether to start it`);
|
|
current = run(myEpoch, controller.signal)
|
|
.catch((err) => log(`ProdCom auto-start failed: ${err && err.message ? err.message : err}`))
|
|
.then(() => {
|
|
busy = false;
|
|
if (abort === controller) abort = null;
|
|
});
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
/**
|
|
* A connection to ProdCom failed (`code` is the error code). Starts a run
|
|
* unless there is nothing to do; answers the reason when it does nothing,
|
|
* null when it started one.
|
|
*/
|
|
kick(code) {
|
|
try {
|
|
return start(code);
|
|
} catch (err) {
|
|
// Called from the module's reconnect path: whatever goes wrong here must not stop it reconnecting.
|
|
return skip(`ProdCom auto-start could not decide (${err && err.message ? err.message : err})`);
|
|
}
|
|
},
|
|
|
|
/** A connection to ProdCom succeeded: this outage is over, the next gets a fresh run. */
|
|
noteReachable() {
|
|
if (busy) log('ProdCom answered on its own — ending the start-up run');
|
|
gaveUp = false;
|
|
msg = null;
|
|
said.clear();
|
|
cancelRun();
|
|
},
|
|
|
|
/** The supervisor's words for the tile while it is working or has given up, else null. */
|
|
message() {
|
|
return busy || gaveUp ? msg : null;
|
|
},
|
|
|
|
/** Resolves when no run is in progress. */
|
|
idle() {
|
|
return current;
|
|
},
|
|
|
|
stop() {
|
|
stopped = true;
|
|
msg = null;
|
|
cancelRun();
|
|
},
|
|
};
|
|
}
|
|
|
|
module.exports = { createSupervisor, defaultOs, isLocalHostName, parseApiPref, abortableSleep };
|