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