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 };
|