Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26e47c2fdd | ||
|
|
485482531d | ||
|
|
7bb852b1c8 | ||
|
|
f800c1867e | ||
|
|
6fffefc6c0 | ||
|
|
227e3bb846 | ||
|
|
459d8b9c38 | ||
|
|
6b51044be6 |
@@ -53,6 +53,7 @@ title-bar controls). The rules that matter most:
|
||||
`node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`).
|
||||
`node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`).
|
||||
`node tools/vista-midi-mock.js 5104` (a network MIDI / RTP-MIDI session for Vista Cues; point its first sender at `127.0.0.1:5104` and paste the cue sheet it prints; `--invite 127.0.0.1:5040` joins from its side, `--name`, `--channel`, `--every`, `--notes 2,23,8`).
|
||||
`node tools/overflow-tap-mock.js` (ProPresenter slide notes on `127.0.0.1:1602` + the Overflow Dashboard API on `127.0.0.1:24720`; run ProdDash with `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720`, any email/password signs in; `--fail-switch`, `--expire=20`, `--login=<email>:<password>`).
|
||||
|
||||
## Reference code (read-only forks — don't develop these here)
|
||||
|
||||
|
||||
@@ -358,7 +358,13 @@ below. Nothing is installed without someone clicking.
|
||||
at ProdCom (the tile's bottom edge lights up in the accent color), and the
|
||||
channel the text goes out *as* is picked from their Send as menu — only
|
||||
channels ProdCom lets text be typed onto are offered.
|
||||
Admin config: ProdCom server (host : port), API key.
|
||||
Admin config: ProdCom server (host : port), API key, and two switches for
|
||||
a ProdCom on this Mac (macOS only): **Start ProdCom when it is offline**
|
||||
opens it when it isn't running (up to three tries, five seconds apart), and
|
||||
**Restart ProdCom to turn its API on** turns the Application API on and
|
||||
restarts ProdCom when it was running with the API off — ProdCom reads that
|
||||
switch only at launch, so the restart costs about ten seconds without
|
||||
intercom. Neither does anything for a ProdCom on another machine.
|
||||
Tile settings: channel icons, direction and timestamps.
|
||||
- **ProPresenter** — what's live and what's next, with slide progress,
|
||||
driven by the shared `propresenter-core` client (ProPresenter 7/20).
|
||||
@@ -515,6 +521,29 @@ below. Nothing is installed without someone clicking.
|
||||
read from the account), and a folded **Timing** group for the poll
|
||||
intervals and a position trim.
|
||||
Tile settings: cue name, previous cue, playhead, cue list.
|
||||
- **Overflow Tap** — what Overflow's own "ProPresenter Integration" helper
|
||||
app does, inside ProdDash, speaking the same Dashboard API it does: the
|
||||
live slide's notes are read from ProPresenter (the presentation layer and
|
||||
the announcement layer), and when they carry one of the keywords mapped in
|
||||
the Overflow Dashboard, Overflow is told — and points that keyword's Tap
|
||||
groups at its destination, so a tap on a disc follows what is on screen.
|
||||
A slide with no keyword changes nothing. Keywords match as the helper
|
||||
matches them: as a whole word of the notes, any case, punctuation around
|
||||
it ignored (`give` fires on "Give!" and not on "forgiveness"). The tile is
|
||||
one keyword, big: what Tap is on, its destination and the groups moved, a
|
||||
chip saying whether the slide on screen carries a keyword, and the last
|
||||
four switches with their times. How a switch went is the corner dot's
|
||||
job: a switch Overflow refuses is retried three times with the dot left
|
||||
alone, and only when those are spent does the dot go red (hover it for
|
||||
the reason) and, if switched on in admin, an alert channel flash. Admin
|
||||
config: the ProPresenter endpoint and an
|
||||
**Overflow Dashboard login** (email and password; Google sign-in has no
|
||||
password, so set one with "Forgot Your Password?"). The keywords, their
|
||||
destinations and their Tap groups stay where Overflow keeps them —
|
||||
Integrations › ProPresenter › Keyword Mappings in the Dashboard — and are
|
||||
read from there on start, every few minutes, and on the tile's refresh
|
||||
button. Tile settings are four switches: destination, groups, slide
|
||||
match, history.
|
||||
- **Band Lineup** — the backstage stage board: everyone on stage in their mic
|
||||
slot with photo, name and mic/pack label, on one shared board that every
|
||||
screen sees the same way — whoever edits it changes it everywhere, over SSE.
|
||||
@@ -615,8 +644,15 @@ board — a plan, its team members, birthdays and photos — run ProdDash with
|
||||
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`) and
|
||||
`node tools/vista-midi-mock.js 5104` (a network MIDI session playing a cue
|
||||
sheet's notes, as ProPresenter does for Vista — point Vista Cues' first
|
||||
sender at `127.0.0.1:5104` and paste the sheet it prints). See the testing section of
|
||||
the [module guide](docs/MODULE-GUIDE.md).
|
||||
sender at `127.0.0.1:5104` and paste the sheet it prints) and
|
||||
`node tools/overflow-tap-mock.js` (a ProPresenter whose slide notes carry
|
||||
keywords on `127.0.0.1:1602`, announcement layer included, plus the Overflow
|
||||
Dashboard API on `127.0.0.1:24720` — run ProdDash with
|
||||
`OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720` and sign the module in with
|
||||
any email and password; `--fail-switch` refuses every switch,
|
||||
`--expire=20` makes sessions expire, `--login=<email>:<password>` accepts
|
||||
one login only). See the testing section of the
|
||||
[module guide](docs/MODULE-GUIDE.md).
|
||||
|
||||
## Install as a desktop web app
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
verbatim, so they say what ProdDash does with each permission. -->
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>ProdDash reads an audio input on this Mac to decode SMPTE LTC timecode for the Timers module.</string>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>ProdDash asks ProdCom to quit and relaunches it when its Application API has been switched off.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>ProdDash connects to production gear on this network — ProdCom, ProPresenter and anything else its modules are pointed at.</string>
|
||||
<key>NSBonjourServices</key>
|
||||
|
||||
@@ -6,5 +6,10 @@
|
||||
the hardened runtime that needs saying out loud. -->
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<!-- The ProdCom module asks ProdCom to quit (an Apple Event) before it
|
||||
relaunches it with its API on; without this the hardened runtime
|
||||
refuses the event outright. -->
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/* Overflow Tap — the tile.
|
||||
*
|
||||
* One fact matters across a booth: which keyword Tap is on right now. That
|
||||
* is the big line. Under it, the destination it maps to and the groups it
|
||||
* moved; a small line says whether the slide on screen carries a keyword at
|
||||
* all; and the last four switches sit at the bottom with their times, so a
|
||||
* tech can see the service's trail. Whether the last switch landed is the
|
||||
* corner dot's to say — red when Overflow refused it and the module's own
|
||||
* retries did not fix that — never a line of text in the tile.
|
||||
*
|
||||
* Everything comes from the server's `state` stream. The tile never talks to
|
||||
* ProPresenter or Overflow itself.
|
||||
*/
|
||||
|
||||
const HISTORY_SHOWN = 4;
|
||||
|
||||
const ICON_REFRESH = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9"/><path d="M13.5 2.5v3h-3"/></svg>';
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
let state = null;
|
||||
let stream = null;
|
||||
let refreshBtn = null;
|
||||
let streamDown = false; // the shared ProdDash stream dropped; cleared by the next state
|
||||
|
||||
const settings = () => moduleApi.instanceSettings || {};
|
||||
const on = (key) => settings()[key] !== false;
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined && text !== null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function clock(ms) {
|
||||
if (!ms) return '';
|
||||
try {
|
||||
return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** The one-line reason the tile cannot do its job, or '' when it can. */
|
||||
function blocker() {
|
||||
if (!state) return 'Waiting for ProdDash';
|
||||
const pp = state.propresenter || {};
|
||||
const of = state.overflow || {};
|
||||
if (!pp.configured) return 'No ProPresenter host — set one in /admin';
|
||||
if (!of.configured) return 'No Overflow login — add it in /admin';
|
||||
if (of.reachable && !(state.keywords || []).length) return 'No keyword mappings in the Overflow Dashboard yet';
|
||||
return '';
|
||||
}
|
||||
|
||||
function render() {
|
||||
root.textContent = '';
|
||||
const box = el('div', 'ot-root');
|
||||
root.append(box);
|
||||
|
||||
const stop = blocker();
|
||||
if (stop) {
|
||||
box.append(el('div', 'ot-empty', stop));
|
||||
return;
|
||||
}
|
||||
|
||||
const pp = state.propresenter || {};
|
||||
const of = state.overflow || {};
|
||||
const cur = state.current;
|
||||
const offline = streamDown || !pp.reachable || !of.reachable;
|
||||
|
||||
if (offline) {
|
||||
box.classList.add('is-offline');
|
||||
const why = streamDown ? 'ProdDash connection lost'
|
||||
: !of.reachable ? (of.error || 'Overflow unreachable')
|
||||
: (pp.error ? `ProPresenter unreachable — ${pp.error}` : 'ProPresenter unreachable');
|
||||
box.append(el('div', 'ot-offline', why));
|
||||
}
|
||||
|
||||
// The keyword Tap is on, its destination and the groups moved. No status
|
||||
// words here: how the switch went is the corner dot's job (its hover
|
||||
// carries the reason), and the trail's ✓ / ✕ keeps the record. The
|
||||
// keyword's colour follows along — dimmed while a switch is in flight,
|
||||
// red once Overflow has refused it for good.
|
||||
const main = el('div', 'ot-main');
|
||||
if (cur) {
|
||||
main.dataset.tone = cur.pending ? 'muted' : (cur.ok ? 'ok' : 'alert');
|
||||
main.append(el('div', 'ot-keyword', cur.keyword));
|
||||
if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination || ''));
|
||||
if (on('showGroups') && cur.groups && cur.groups.length) main.append(el('div', 'ot-groups', cur.groups.join(' · ')));
|
||||
} else {
|
||||
main.dataset.tone = 'muted';
|
||||
main.append(el('div', 'ot-keyword is-none', '—'));
|
||||
main.append(el('div', 'ot-dest', 'No keyword sent yet'));
|
||||
}
|
||||
box.append(main);
|
||||
|
||||
if (on('showSlide')) {
|
||||
const slide = state.slide || {};
|
||||
const ann = state.announcement || {};
|
||||
const slideKeys = Array.isArray(slide.keywords) ? slide.keywords : [];
|
||||
const annKeys = Array.isArray(ann.keywords) ? ann.keywords : [];
|
||||
let text;
|
||||
let cls = 'ot-slide';
|
||||
if (!pp.reachable) { text = 'ProPresenter: not answering'; cls += ' is-muted'; }
|
||||
else if (!slide.live && !ann.live) { text = 'Nothing live in ProPresenter'; cls += ' is-muted'; }
|
||||
else if (slideKeys.length || annKeys.length) {
|
||||
const parts = [];
|
||||
if (slideKeys.length) parts.push(`This slide: ${slideKeys.join(' + ')}`);
|
||||
if (annKeys.length) parts.push(`Announcement: ${annKeys.join(' + ')}`);
|
||||
text = parts.join(' · ');
|
||||
cls += ' is-hit';
|
||||
} else if (slide.live) { text = slide.hasNotes ? 'This slide: no keyword' : 'This slide: no notes'; cls += ' is-muted'; }
|
||||
else { text = 'Announcement: no keyword'; cls += ' is-muted'; }
|
||||
box.append(el('div', cls, text));
|
||||
}
|
||||
|
||||
if (on('showHistory')) {
|
||||
const rows = (state.history || []).slice(0, HISTORY_SHOWN);
|
||||
const list = el('ol', 'ot-history');
|
||||
if (!rows.length) {
|
||||
list.append(el('li', 'ot-h-empty', cur ? 'No earlier switches' : 'No switches yet'));
|
||||
}
|
||||
for (const h of rows) {
|
||||
const row = el('li', `ot-h-row${h.ok ? '' : ' is-failed'}`);
|
||||
row.title = h.ok
|
||||
? `${h.keyword} → ${h.destination || ''}${h.groups && h.groups.length ? ` on ${h.groups.join(', ')}` : ''}`
|
||||
: `${h.keyword}: ${h.error || 'failed'}`;
|
||||
row.append(el('span', 'ot-h-time', clock(h.at)));
|
||||
row.append(el('span', 'ot-h-kw', h.keyword));
|
||||
row.append(el('span', 'ot-h-dest', h.destination || ''));
|
||||
row.append(el('span', 'ot-h-mark', h.ok ? '✓' : '✕'));
|
||||
list.append(row);
|
||||
}
|
||||
box.append(list);
|
||||
}
|
||||
}
|
||||
|
||||
function applyStatus() {
|
||||
if (!state) return moduleApi.setStatus('connecting', 'Waiting for ProdDash');
|
||||
const s = state.status === 'ok' || state.status === 'connecting' || state.status === 'error' ? state.status : 'connecting';
|
||||
moduleApi.setStatus(s, state.message || '');
|
||||
}
|
||||
|
||||
function handleState(e) {
|
||||
try {
|
||||
state = JSON.parse(e.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
streamDown = false;
|
||||
applyStatus();
|
||||
render();
|
||||
}
|
||||
|
||||
async function backfill() {
|
||||
try {
|
||||
const res = await moduleApi.fetch('/state');
|
||||
if (!res.ok) return;
|
||||
const body = await res.json();
|
||||
if (!body || !body.state || !stream) return;
|
||||
state = body.state;
|
||||
streamDown = false;
|
||||
applyStatus();
|
||||
render();
|
||||
} catch {
|
||||
/* the stream will bring the next state */
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!refreshBtn) return;
|
||||
refreshBtn.classList.add('active');
|
||||
try {
|
||||
const res = await moduleApi.fetch('/refresh', { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
let why = `Keyword refresh failed (${res.status})`;
|
||||
try { why = (await res.json()).error || why; } catch { /* keep the status */ }
|
||||
moduleApi.setStatus('error', why);
|
||||
}
|
||||
} catch (err) {
|
||||
moduleApi.setStatus('error', `Keyword refresh failed: ${err && err.message ? err.message : err}`);
|
||||
} finally {
|
||||
refreshBtn.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
moduleApi.setStatus('connecting', 'Connecting');
|
||||
refreshBtn = moduleApi.header.addButton({
|
||||
icon: ICON_REFRESH,
|
||||
title: 'Refresh keywords',
|
||||
onClick: refresh,
|
||||
});
|
||||
stream = moduleApi.sse('/stream', {
|
||||
// A fresh connection replays the state; a tile joining a stream the
|
||||
// page already carries gets only `open`, and the server sends state
|
||||
// on change alone — so backfill, or a second tile waits for the next
|
||||
// switch to draw anything.
|
||||
open() { backfill(); },
|
||||
error() {
|
||||
moduleApi.setStatus('error', 'Lost the connection to ProdDash');
|
||||
streamDown = true;
|
||||
render();
|
||||
},
|
||||
events: { state: handleState },
|
||||
});
|
||||
render();
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (stream) stream.close();
|
||||
stream = null;
|
||||
refreshBtn = null;
|
||||
root.textContent = '';
|
||||
},
|
||||
|
||||
onConfigChange() {
|
||||
render();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Keyword matching — which of Overflow's keywords a slide's notes carry.
|
||||
*
|
||||
* Pure functions, no I/O, so keywords.selftest.js can pin every rule down:
|
||||
*
|
||||
* cleanNotes(raw) → the notes with ProPresenter's invisible padding removed
|
||||
* keywordMatches(notes, keyword) → boolean
|
||||
* matchKeywords(notes, keywords) → every keyword (in the order given) the notes carry
|
||||
* sameSet(a, b) → the two keyword lists hold the same keywords
|
||||
*
|
||||
* The rule is Overflow's own helper's: the notes are split into whitespace-
|
||||
* separated words, each stripped of surrounding punctuation and lower-cased,
|
||||
* and a keyword fires when it is one of those words. So `give` fires on
|
||||
* "give", "Give!", "#give" and "(give)", and not on "forgiveness", "giveaway"
|
||||
* or "give_now". Phrases work too, bounded the same way on both ends —
|
||||
* something the helper cannot do, but nothing it would have matched is lost.
|
||||
*/
|
||||
|
||||
const ZERO_WIDTH = /[-]/g;
|
||||
|
||||
function cleanNotes(raw) {
|
||||
return String(raw ?? '').replace(ZERO_WIDTH, '').replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
function norm(s) {
|
||||
return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function escapeRe(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function keywordMatches(notes, keyword) {
|
||||
const n = cleanNotes(notes);
|
||||
const k = norm(keyword);
|
||||
if (!k || !n.trim()) return false;
|
||||
const flat = n.toLowerCase().replace(/\s+/g, ' ');
|
||||
const re = new RegExp(`(?:^|[^\\p{L}\\p{N}_])${escapeRe(k)}(?=$|[^\\p{L}\\p{N}_])`, 'u');
|
||||
return re.test(flat);
|
||||
}
|
||||
|
||||
/** Every keyword the notes carry, in the order the list gives them, each once. Entries may be strings or objects with a `keyword`. */
|
||||
function matchKeywords(notes, keywords) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const raw of keywords || []) {
|
||||
const k = typeof raw === 'string' ? raw : raw && raw.keyword;
|
||||
const key = norm(k);
|
||||
if (!key || seen.has(key)) continue;
|
||||
if (keywordMatches(notes, k)) {
|
||||
seen.add(key);
|
||||
out.push(String(k).trim());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sameSet(a, b) {
|
||||
const x = [...(a || [])].map(norm).sort();
|
||||
const y = [...(b || [])].map(norm).sort();
|
||||
return x.length === y.length && x.every((v, i) => v === y[i]);
|
||||
}
|
||||
|
||||
module.exports = { cleanNotes, norm, keywordMatches, matchKeywords, sameSet };
|
||||
@@ -0,0 +1,76 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Self-test for keywords.js — the matching rules, pinned.
|
||||
*
|
||||
* node modules/overflow-tap/keywords.selftest.js
|
||||
*/
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { keywordMatches, matchKeywords, sameSet, cleanNotes } = require('./keywords');
|
||||
|
||||
let n = 0;
|
||||
function test(name, fn) {
|
||||
n += 1;
|
||||
try {
|
||||
fn();
|
||||
console.log(`ok ${name}`);
|
||||
} catch (err) {
|
||||
console.log(`FAIL ${name}\n ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
test('a keyword fires as a word of the notes, whatever the case', () => {
|
||||
assert.equal(keywordMatches('give', 'give'), true);
|
||||
assert.equal(keywordMatches('Give', 'give'), true);
|
||||
assert.equal(keywordMatches('please GIVE today', 'give'), true);
|
||||
});
|
||||
|
||||
test('surrounding punctuation is ignored, as the helper strips it', () => {
|
||||
assert.equal(keywordMatches('give!', 'give'), true);
|
||||
assert.equal(keywordMatches('#give now', 'give'), true);
|
||||
assert.equal(keywordMatches('go to (give) now', 'give'), true);
|
||||
assert.equal(keywordMatches('"give"', 'give'), true);
|
||||
assert.equal(keywordMatches('give.', 'give'), true);
|
||||
});
|
||||
|
||||
test('a keyword inside another word does not fire', () => {
|
||||
assert.equal(keywordMatches('a sermon on forgiveness', 'give'), false);
|
||||
assert.equal(keywordMatches('giveaway', 'give'), false);
|
||||
assert.equal(keywordMatches('give_now', 'give'), false);
|
||||
});
|
||||
|
||||
test('a phrase keyword is bounded the same way', () => {
|
||||
assert.equal(keywordMatches('Please fill out your Next Steps card', 'next steps'), true);
|
||||
assert.equal(keywordMatches('nextsteps', 'next steps'), false);
|
||||
});
|
||||
|
||||
test('regex characters in a keyword are literal', () => {
|
||||
assert.equal(keywordMatches('give.now', 'give.now'), true);
|
||||
assert.equal(keywordMatches('giveXnow', 'give.now'), false);
|
||||
});
|
||||
|
||||
test('empty notes never match; zero-width padding is ignored', () => {
|
||||
assert.equal(keywordMatches('', 'give'), false);
|
||||
assert.equal(keywordMatches('', 'give'), false);
|
||||
assert.equal(cleanNotes('give'), 'give');
|
||||
assert.equal(keywordMatches('give', 'give'), true);
|
||||
});
|
||||
|
||||
test('matchKeywords returns every hit, in list order, once each', () => {
|
||||
const list = ['connect', 'give', 'prayer', { keyword: 'Events' }];
|
||||
assert.deepEqual(matchKeywords('give and connect, then events', list), ['connect', 'give', 'Events']);
|
||||
assert.deepEqual(matchKeywords('just worship', list), []);
|
||||
assert.deepEqual(matchKeywords('give', []), []);
|
||||
assert.deepEqual(matchKeywords('give give', ['give', 'GIVE']), ['give']);
|
||||
});
|
||||
|
||||
test('sameSet compares keyword lists regardless of order and case', () => {
|
||||
assert.equal(sameSet(['give', 'connect'], ['Connect', 'give']), true);
|
||||
assert.equal(sameSet(['give'], ['give', 'connect']), false);
|
||||
assert.equal(sameSet([], []), true);
|
||||
assert.equal(sameSet(['give'], []), false);
|
||||
});
|
||||
|
||||
console.log(`${n} tests, exit ${process.exitCode || 0}`);
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"id": "overflow-tap",
|
||||
"name": "Overflow Tap",
|
||||
"version": "1.0.0",
|
||||
"proddash": ">=1.11.0",
|
||||
"description": "Switches Overflow Tap destinations from keywords in ProPresenter slide notes",
|
||||
"permissions": [
|
||||
{
|
||||
"kind": "local-network",
|
||||
"reason": "Reaching ProPresenter on this network"
|
||||
}
|
||||
],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": {
|
||||
"w": 2,
|
||||
"h": 2
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 4,
|
||||
"h": 3
|
||||
},
|
||||
"configGroups": {
|
||||
"ProPresenter": {
|
||||
"help": "The ProPresenter whose slide notes carry the keywords. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Only the live slide's notes (and the announcement layer's) are read — nothing on the stage screens changes."
|
||||
},
|
||||
"Overflow": {
|
||||
"collapsed": "whenSet",
|
||||
"help": "An Overflow Dashboard login with Tap access — what Overflow's own ProPresenter helper app signs in with. Google sign-in does not work here: the account needs a password (Forgot Your Password? on the Dashboard sets one). The keywords, their destinations and their Tap groups are managed in the Dashboard under Integrations › ProPresenter › Keyword Mappings and read from there; after changing them, press the tile's refresh button or wait a few minutes."
|
||||
},
|
||||
"Alerts": {
|
||||
"toggle": "alertEnabled",
|
||||
"collapsed": true,
|
||||
"help": "Flash the dashboards listening on this channel when a switch fails — Overflow unreachable or refusing the change — so someone can fix Tap by hand before the moment passes."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"propresenter": {
|
||||
"type": "endpoint",
|
||||
"label": "ProPresenter",
|
||||
"group": "ProPresenter",
|
||||
"default": {
|
||||
"host": "",
|
||||
"port": 1025
|
||||
}
|
||||
},
|
||||
"password": {
|
||||
"type": "password",
|
||||
"label": "API password",
|
||||
"group": "ProPresenter",
|
||||
"default": "",
|
||||
"help": "Only if a password is set in ProPresenter's network preferences."
|
||||
},
|
||||
"overflowEmail": {
|
||||
"type": "string",
|
||||
"label": "Email",
|
||||
"group": "Overflow",
|
||||
"default": ""
|
||||
},
|
||||
"overflowPassword": {
|
||||
"type": "password",
|
||||
"label": "Password",
|
||||
"group": "Overflow",
|
||||
"default": ""
|
||||
},
|
||||
"nonprofitId": {
|
||||
"type": "string",
|
||||
"label": "Nonprofit ID",
|
||||
"group": "Overflow",
|
||||
"default": "",
|
||||
"help": "Leave blank: a church login carries its own. Only an @overflow.co staff login needs one."
|
||||
},
|
||||
"alertEnabled": {
|
||||
"type": "switch",
|
||||
"label": "Alert when a switch fails",
|
||||
"group": "Alerts",
|
||||
"default": false
|
||||
},
|
||||
"alertChannel": {
|
||||
"type": "number",
|
||||
"label": "Channel",
|
||||
"group": "Alerts",
|
||||
"default": 1,
|
||||
"min": 1,
|
||||
"max": 6,
|
||||
"help": "1–6; each dashboard chooses the channels it listens to in its Layouts menu."
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"showDestination": {
|
||||
"type": "switch",
|
||||
"label": "Destination",
|
||||
"default": true
|
||||
},
|
||||
"showGroups": {
|
||||
"type": "switch",
|
||||
"label": "Groups",
|
||||
"default": true
|
||||
},
|
||||
"showSlide": {
|
||||
"type": "switch",
|
||||
"label": "Slide match",
|
||||
"default": true
|
||||
},
|
||||
"showHistory": {
|
||||
"type": "switch",
|
||||
"label": "History",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Overflow Dashboard API — the calls Overflow's own "ProPresenter Integration"
|
||||
* helper app (1.2.3) makes, read from its bundle on 2026-10-11. None of this is
|
||||
* in Overflow's public API reference (which documents only reads for Tap): it
|
||||
* is the Dashboard's own backend, used with a Dashboard login — the same
|
||||
* email and password the helper asks for. Google sign-in does not work here;
|
||||
* the account needs a password (Overflow's article says the same).
|
||||
*
|
||||
* POST /nonprofit-dashboard/authentication/login/jwt
|
||||
* { email, password } → { token } a JWT; its payload carries
|
||||
* email, nonprofitId, permissions [{ action, subject }] (subject TAP
|
||||
* or all is required), nonprofits [...], exp
|
||||
* GET /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/keywords
|
||||
* → [ { keyword, destinationName, tapGroups: [{ id, tapGroupName }], … } ]
|
||||
* the Dashboard's Integrations › ProPresenter › Keyword Mappings
|
||||
* POST /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/keywords?syncNonprofit=true
|
||||
* { keywords: ["give"] } → the same list, updated THE SWITCH:
|
||||
* Overflow points the keyword's groups at its destination
|
||||
* GET /nonprofit-dashboard/propresenter/nonprofits?search=&limit=
|
||||
* POST /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/select
|
||||
* only for an @overflow.co staff login, which has no nonprofit of its
|
||||
* own and must pick one first
|
||||
*
|
||||
* Bearer auth on everything after login. A 401 means the session is gone —
|
||||
* sign in again. The helper's own wording for a refused login is kept here
|
||||
* (401 invalid email or password, 429 too many attempts, 403 locked).
|
||||
*
|
||||
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the client at
|
||||
* tools/overflow-tap-mock.js.
|
||||
*/
|
||||
|
||||
const DEFAULT_API_BASE = 'https://server.overflow.co';
|
||||
const API_BASE = String(process.env.OVERFLOW_TAP_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||||
const DASH = '/nonprofit-dashboard';
|
||||
const TIMEOUT_MS = 10000;
|
||||
const TRIGGER_TIMEOUT_MS = 15000;
|
||||
|
||||
class OverflowError extends Error {
|
||||
constructor(message, { status = 0, retryable = false, auth = false } = {}) {
|
||||
super(message);
|
||||
this.name = 'OverflowError';
|
||||
this.status = status;
|
||||
this.retryable = retryable;
|
||||
this.auth = auth; // the session was refused: sign in again before retrying
|
||||
}
|
||||
}
|
||||
|
||||
/** The JWT's payload, or null when the token is not one. No signature check — we only read our own session. */
|
||||
function decodeJwt(token) {
|
||||
const parts = String(token || '').split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
try {
|
||||
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
return JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** What the module needs to know about a session, from its token. */
|
||||
function describeSession(token) {
|
||||
const p = decodeJwt(token) || {};
|
||||
const email = String(p.email || '');
|
||||
const perms = Array.isArray(p.permissions) ? p.permissions : [];
|
||||
const nonprofits = Array.isArray(p.nonprofits)
|
||||
? p.nonprofits.map((n) => ({ id: String((n && n.id) || ''), name: String((n && n.name) || '') })).filter((n) => n.id)
|
||||
: [];
|
||||
return {
|
||||
email,
|
||||
staff: email.toLowerCase().endsWith('@overflow.co'),
|
||||
nonprofitId: p.nonprofitId ? String(p.nonprofitId) : '',
|
||||
nonprofits,
|
||||
hasTap: perms.some((x) => x && (x.subject === 'TAP' || x.subject === 'all')),
|
||||
expiresAt: Number.isFinite(Number(p.exp)) ? Number(p.exp) * 1000 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createOverflowClient({ fetchImpl = globalThis.fetch } = {}) {
|
||||
async function request(method, path, { query, body, token, timeout = TIMEOUT_MS } = {}) {
|
||||
const url = new URL(API_BASE + path);
|
||||
for (const [k, v] of Object.entries(query || {})) {
|
||||
if (v === undefined || v === null || v === '') continue;
|
||||
url.searchParams.set(k, String(v));
|
||||
}
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), timeout);
|
||||
let res;
|
||||
try {
|
||||
res = await fetchImpl(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: ctl.signal });
|
||||
} catch (err) {
|
||||
const aborted = err && err.name === 'AbortError';
|
||||
const cause = err && err.cause && (err.cause.code || err.cause.message);
|
||||
throw new OverflowError(
|
||||
aborted ? `Overflow did not answer within ${timeout / 1000} s` : `Overflow unreachable: ${err.message}${cause ? ` (${cause})` : ''}`,
|
||||
{ retryable: true },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
if (text) {
|
||||
try { data = JSON.parse(text); } catch { data = null; }
|
||||
}
|
||||
if (!res.ok) {
|
||||
const detail = data && (data.message || data.error || data.detail);
|
||||
const what = Array.isArray(detail) ? detail.join('; ') : (detail ? String(detail) : '');
|
||||
throw new OverflowError(what ? `Overflow answered ${res.status}: ${what}` : `Overflow answered ${res.status} ${res.statusText}`.trim(), {
|
||||
status: res.status,
|
||||
auth: res.status === 401,
|
||||
retryable: res.status === 429 || res.status >= 500,
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
return {
|
||||
base: API_BASE,
|
||||
|
||||
/** Sign in with a Dashboard login; resolves to the JWT. */
|
||||
async login(email, password) {
|
||||
try {
|
||||
const data = await request('POST', `${DASH}/authentication/login/jwt`, { body: { email, password } });
|
||||
const token = data && data.token;
|
||||
if (!token || typeof token !== 'string') throw new OverflowError('Overflow sign-in answered without a token');
|
||||
return token;
|
||||
} catch (err) {
|
||||
if (!(err instanceof OverflowError) || !err.status) throw err;
|
||||
if (err.status === 401) throw new OverflowError('Overflow refused the sign-in: invalid email or password', { status: 401 });
|
||||
if (err.status === 429) throw new OverflowError('Overflow refused the sign-in: too many attempts — wait a while', { status: 429, retryable: true });
|
||||
if (err.status === 403) throw new OverflowError('Overflow refused the sign-in: account locked', { status: 403 });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/** The Keyword Mappings for a nonprofit, as the Dashboard holds them. */
|
||||
async listKeywords(token, nonprofitId) {
|
||||
const data = await request('GET', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/keywords`, { token });
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
|
||||
/** Tell Overflow these keywords are on screen; it moves their groups. Resolves to the updated list when one comes back. */
|
||||
async trigger(token, nonprofitId, keywords) {
|
||||
const data = await request('POST', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/keywords`, {
|
||||
query: { syncNonprofit: 'true' },
|
||||
body: { keywords },
|
||||
token,
|
||||
timeout: TRIGGER_TIMEOUT_MS,
|
||||
});
|
||||
return Array.isArray(data) ? data : null;
|
||||
},
|
||||
|
||||
/** Staff logins only: the nonprofits this login may act for. */
|
||||
async searchNonprofits(token, search = '', limit = 10) {
|
||||
const data = await request('GET', `${DASH}/propresenter/nonprofits`, { token, query: { search, limit } });
|
||||
const list = (data && data.data && data.data.nonprofits) || (data && data.nonprofits) || (data && data.data) || data;
|
||||
return Array.isArray(list)
|
||||
? list.map((n) => ({ id: String((n && n.id) || ''), name: String((n && n.name) || ''), slug: String((n && n.slug) || '') }))
|
||||
: [];
|
||||
},
|
||||
|
||||
/** Staff logins only: bind the session to one nonprofit before reading its keywords. */
|
||||
selectNonprofit(token, nonprofitId) {
|
||||
return request('POST', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/select`, { token, body: {} });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createOverflowClient, OverflowError, decodeJwt, describeSession, API_BASE, DEFAULT_API_BASE };
|
||||
@@ -0,0 +1,678 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Overflow Tap — server entry.
|
||||
*
|
||||
* Does what Overflow's "ProPresenter Integration" helper app does, inside
|
||||
* ProdDash: watches the live slide in ProPresenter (the presentation layer
|
||||
* and the announcement layer), and when its notes carry one of the keywords
|
||||
* mapped in the Overflow Dashboard, tells Overflow — which points that
|
||||
* keyword's Tap groups at its destination, so what people reach when they
|
||||
* tap a disc follows what is on screen. A slide with no keyword changes
|
||||
* nothing; the last destination stays until the next keyworded slide.
|
||||
*
|
||||
* A switch Overflow refuses is retried three times, ten seconds apart, with
|
||||
* the tile's dot left alone meanwhile; only when those are spent does the
|
||||
* dot go red (and the alert fire, if switched on) — the booth hears about a
|
||||
* problem the module could not fix itself, not about every hiccup.
|
||||
*
|
||||
* GET /state → the view-model every tile draws
|
||||
* GET /stream → SSE, `state` events on every change (heartbeated)
|
||||
* POST /refresh → re-read the keyword mappings from the Dashboard now
|
||||
* GET /keywords → the mappings as read, for a look while setting up
|
||||
*
|
||||
* ProPresenter is polled (GET /v1/status/slide for the presentation layer,
|
||||
* GET /v1/announcement/slide_index for announcements, once a second each —
|
||||
* small requests, nothing heavier) because its chunked update stream is the
|
||||
* thing that goes quiet without saying so. Overflow is the Dashboard's own
|
||||
* API, signed in with a Dashboard login — the calls, and where they come
|
||||
* from, are at the top of overflow.js. Keyword mappings are read on start,
|
||||
* every few minutes after that, on /refresh, and from every trigger's answer.
|
||||
*
|
||||
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the module at
|
||||
* tools/overflow-tap-mock.js, which also plays a ProPresenter on :1602.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { createOverflowClient, OverflowError, describeSession } = require('./overflow');
|
||||
const { matchKeywords, sameSet, cleanNotes, norm } = require('./keywords');
|
||||
|
||||
const POLL_MS = 1000; // ProPresenter, while it answers
|
||||
const RETRY_MS = 3000; // ProPresenter, while it does not
|
||||
const PP_TIMEOUT_MS = 4000;
|
||||
const HEARTBEAT_MS = 15000;
|
||||
const KEYWORDS_REFRESH_MS = 5 * 60 * 1000;
|
||||
const OVERFLOW_RETRY_MS = 30 * 1000;
|
||||
const LOGIN_MIN_GAP_MS = 20 * 1000; // a refused sign-in is not retried sooner (429 = "too many attempts")
|
||||
const SESSION_MARGIN_MS = 60 * 1000; // sign in again this long before the token expires
|
||||
const SWITCH_RETRY_MS = 10 * 1000;
|
||||
const SWITCH_RETRIES = 3;
|
||||
const HISTORY_MAX = 8; // the tile shows four; a few more for the hover
|
||||
const ALERT_SECONDS = 30;
|
||||
|
||||
/** Set by init(), read by routes() — both are rebuilt together on a config save. */
|
||||
let current = null;
|
||||
|
||||
/**
|
||||
* The signed-in session, kept across re-inits for the same login: an admin
|
||||
* saving config five times in a minute must not become five sign-ins
|
||||
* (Overflow refuses that with 429). Keyed on a hash of the login, so a
|
||||
* changed email or password starts fresh; the token itself never leaves the
|
||||
* server process.
|
||||
*/
|
||||
let sessionCache = null; // { key, token, expiresAt, email, nonprofitId }
|
||||
|
||||
function json(res, code, body) {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
/** One line for a log or a tile; a fetch failure names its cause (ECONNREFUSED, ENOTFOUND…). */
|
||||
function describe(err) {
|
||||
if (err instanceof OverflowError) return err.message;
|
||||
if (err && err.name === 'AbortError') return 'timed out';
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
const cause = err && err.cause && (err.cause.code || err.cause.message);
|
||||
return cause && !msg.includes(String(cause)) ? `${msg} (${cause})` : msg;
|
||||
}
|
||||
|
||||
function unique(list) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const v of list) {
|
||||
const k = norm(v);
|
||||
if (!k || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The destination and group names Overflow attached to a keyword mapping, whatever the field names turn out to be. */
|
||||
function destinationOf(entry) {
|
||||
return String((entry && (entry.destinationName || (entry.destination && entry.destination.name))) || '');
|
||||
}
|
||||
function groupsOf(entry) {
|
||||
const list = entry && Array.isArray(entry.tapGroups) ? entry.tapGroups : [];
|
||||
return list.map((g) => String((g && (g.tapGroupName || g.name || g.id)) || '')).filter(Boolean);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init({ config, log, shell }) {
|
||||
/* ── config ────────────────────────────────────────────────────── */
|
||||
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
|
||||
const host = String(ep.host || '').trim();
|
||||
const port = Number(ep.port) || 1025;
|
||||
const ppPassword = String(config.password || '');
|
||||
const email = String(config.overflowEmail || '').trim();
|
||||
const ofPassword = String(config.overflowPassword || '');
|
||||
const staffNonprofit = String(config.nonprofitId || '').trim();
|
||||
const configured = Boolean(email && ofPassword);
|
||||
const alertEnabled = Boolean(config.alertEnabled);
|
||||
const alertChannel = Math.min(6, Math.max(1, Math.round(Number(config.alertChannel) || 1)));
|
||||
const sessionKey = crypto.createHash('sha256').update(`${email}\n${ofPassword}`).digest('hex');
|
||||
|
||||
const overflow = createOverflowClient();
|
||||
|
||||
/* ── state ─────────────────────────────────────────────────────── */
|
||||
const streams = new Set();
|
||||
const timers = new Set();
|
||||
let stopped = false;
|
||||
let lastFrame = '';
|
||||
|
||||
let keywordCache = new Map(); // norm(keyword) → the Dashboard's entry
|
||||
let session = null; // { token, expiresAt, email, nonprofitId }
|
||||
let lastLoginAt = 0;
|
||||
let lastLoginError = null;
|
||||
let wanted = null; // the keywords Tap should be on: { keywords, keyword, destination, groups }
|
||||
let inFlight = false;
|
||||
let retriesLeft = 0;
|
||||
let retryTimer = null;
|
||||
let alertRaised = false;
|
||||
/**
|
||||
* Refusals in a row, across keywords. The retry budget is per keyword, but
|
||||
* a service keeps bringing new keywords: if Overflow refuses every one of
|
||||
* them, each would start a fresh budget and the dot would never go red.
|
||||
* So the budget is also spent by refusals as a whole, and a success
|
||||
* clears it.
|
||||
*/
|
||||
let consecutiveFailures = 0;
|
||||
|
||||
// Presentation layer: the slide /v1/status/slide reports.
|
||||
let lastSlideKey = null;
|
||||
let lastSlide = null;
|
||||
let lastPresKeywords = [];
|
||||
// Announcement layer: the slide /v1/announcement/slide_index points at, read from /v1/announcement/active.
|
||||
let annSupported = true;
|
||||
let lastAnnKey = null;
|
||||
let annNotes = '';
|
||||
let annLive = false;
|
||||
let lastAnnKeywords = [];
|
||||
|
||||
const state = {
|
||||
status: 'connecting',
|
||||
message: '',
|
||||
propresenter: { configured: Boolean(host), reachable: false, error: '', host, port },
|
||||
overflow: { configured, reachable: false, error: '', keywords: 0, loadedAt: 0 },
|
||||
keywords: [],
|
||||
slide: { live: false, keywords: [], hasNotes: false },
|
||||
announcement: { live: false, keywords: [] },
|
||||
current: null,
|
||||
history: [],
|
||||
updatedAt: 0,
|
||||
};
|
||||
|
||||
/* ── timers that die with the handle ───────────────────────────── */
|
||||
|
||||
function later(fn, ms) {
|
||||
const t = setTimeout(() => {
|
||||
timers.delete(t);
|
||||
if (!stopped) fn();
|
||||
}, ms);
|
||||
t.unref?.();
|
||||
timers.add(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
/* ── health, shared by the admin line and the tiles ────────────── */
|
||||
|
||||
function assess(forAdmin = false) {
|
||||
const pp = state.propresenter;
|
||||
const of = state.overflow;
|
||||
if (!pp.configured) return ['error', 'No ProPresenter host configured — set one in /admin'];
|
||||
if (!of.configured) return ['error', 'Overflow login not set — add the email and password in /admin'];
|
||||
if (!of.reachable) return ['error', of.error || 'Signing in to Overflow…'];
|
||||
if (!pp.reachable) return ['error', pp.error ? `ProPresenter unreachable: ${pp.error}` : 'Reaching ProPresenter…'];
|
||||
if (!state.keywords.length) return ['error', 'No keyword mappings in the Overflow Dashboard (Integrations › ProPresenter)'];
|
||||
// A refused switch the module is still retrying on its own is amber —
|
||||
// being worked on; one it has given up on is red — the booth's problem.
|
||||
const cur = state.current;
|
||||
if (cur && !cur.ok && !cur.pending && !cur.retrying) {
|
||||
return ['error', `Tap not switched to ${cur.keyword} — ${cur.error}`];
|
||||
}
|
||||
if (cur && !cur.ok && (cur.retrying || consecutiveFailures > 0)) {
|
||||
return ['connecting', `Tap not switched to ${cur.keyword} yet — retrying (${consecutiveFailures} of ${SWITCH_RETRIES + 1} attempts failed): ${cur.error || 'in flight'}`];
|
||||
}
|
||||
const n = state.keywords.length;
|
||||
const who = forAdmin && session && session.email ? `${session.email}, ` : '';
|
||||
return ['ok', `Following ${host}:${port} · Overflow: ${who}${n} keyword${n === 1 ? '' : 's'}`];
|
||||
}
|
||||
|
||||
function publish() {
|
||||
if (stopped) return;
|
||||
[state.status, state.message] = assess(false);
|
||||
const { updatedAt, ...rest } = state;
|
||||
let frame;
|
||||
try {
|
||||
frame = JSON.stringify(rest);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (frame === lastFrame) return;
|
||||
lastFrame = frame;
|
||||
state.updatedAt = Date.now();
|
||||
const payload = `event: state\ndata: ${JSON.stringify(state)}\n\n`;
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(payload);
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── the Overflow session ──────────────────────────────────────── */
|
||||
|
||||
async function ensureSession() {
|
||||
const now = Date.now();
|
||||
if (session && now < session.expiresAt - SESSION_MARGIN_MS) return session;
|
||||
if (sessionCache && sessionCache.key === sessionKey && now < sessionCache.expiresAt - SESSION_MARGIN_MS) {
|
||||
session = sessionCache;
|
||||
return session;
|
||||
}
|
||||
if (lastLoginError && now - lastLoginAt < LOGIN_MIN_GAP_MS) throw lastLoginError;
|
||||
lastLoginAt = now;
|
||||
let token;
|
||||
try {
|
||||
token = await overflow.login(email, ofPassword);
|
||||
} catch (err) {
|
||||
lastLoginError = err;
|
||||
throw err;
|
||||
}
|
||||
lastLoginError = null;
|
||||
const info = describeSession(token);
|
||||
if (!info.hasTap) throw new OverflowError('This Overflow login has no Tap permission — use one with the Organization Admin or Tap Only role');
|
||||
let nonprofitId = info.nonprofitId || (info.nonprofits.length === 1 ? info.nonprofits[0].id : '');
|
||||
if (info.staff) {
|
||||
if (!staffNonprofit) throw new OverflowError('An @overflow.co login has no nonprofit of its own — enter the Nonprofit ID in /admin');
|
||||
await overflow.selectNonprofit(token, staffNonprofit);
|
||||
nonprofitId = staffNonprofit;
|
||||
} else if (!nonprofitId) {
|
||||
nonprofitId = staffNonprofit;
|
||||
}
|
||||
if (!nonprofitId) throw new OverflowError('This Overflow login carries no nonprofit — enter the Nonprofit ID in /admin');
|
||||
session = { token, expiresAt: info.expiresAt || now + 12 * 3600 * 1000, email: info.email || email, nonprofitId };
|
||||
sessionCache = { key: sessionKey, ...session };
|
||||
log(`signed in to Overflow as ${session.email} (nonprofit ${nonprofitId})`);
|
||||
return session;
|
||||
}
|
||||
|
||||
function dropSession() {
|
||||
session = null;
|
||||
if (sessionCache && sessionCache.key === sessionKey) sessionCache = null;
|
||||
}
|
||||
|
||||
/** Run a signed-in call; when the session is refused, sign in again once and retry. */
|
||||
async function withSession(fn) {
|
||||
const s = await ensureSession();
|
||||
try {
|
||||
return await fn(s);
|
||||
} catch (err) {
|
||||
if (!(err instanceof OverflowError) || !err.auth) throw err;
|
||||
dropSession();
|
||||
const again = await ensureSession();
|
||||
return fn(again);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── the keyword mappings ──────────────────────────────────────── */
|
||||
|
||||
function rebuildKeywordView() {
|
||||
state.keywords = [...keywordCache.values()].map((e) => ({
|
||||
keyword: String(e.keyword).trim(),
|
||||
destination: destinationOf(e),
|
||||
groups: groupsOf(e),
|
||||
}));
|
||||
state.overflow.keywords = state.keywords.length;
|
||||
}
|
||||
|
||||
/** Replace the cache with the Dashboard's list (`merge` folds a partial answer into it instead). */
|
||||
function applyKeywordList(list, merge = false) {
|
||||
const next = merge ? new Map(keywordCache) : new Map();
|
||||
for (const entry of list || []) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const k = norm(entry.keyword);
|
||||
if (!k) continue;
|
||||
if (entry.active === false || entry.enabled === false || entry.isActive === false) {
|
||||
next.delete(k);
|
||||
continue;
|
||||
}
|
||||
next.set(k, entry);
|
||||
}
|
||||
keywordCache = next;
|
||||
rebuildKeywordView();
|
||||
}
|
||||
|
||||
let loading = null;
|
||||
let refreshTimer = null;
|
||||
|
||||
async function loadKeywords() {
|
||||
if (stopped || !configured) return;
|
||||
if (loading) return loading;
|
||||
loading = (async () => {
|
||||
try {
|
||||
const list = await withSession((s) => overflow.listKeywords(s.token, s.nonprofitId));
|
||||
applyKeywordList(list);
|
||||
state.overflow.reachable = true;
|
||||
state.overflow.error = '';
|
||||
state.overflow.loadedAt = Date.now();
|
||||
reconsider(); // a slide already up when the mappings arrive takes effect now
|
||||
} catch (err) {
|
||||
state.overflow.reachable = false;
|
||||
state.overflow.error = describe(err);
|
||||
log(`Overflow: ${state.overflow.error}`);
|
||||
}
|
||||
publish();
|
||||
})();
|
||||
try {
|
||||
await loading;
|
||||
} finally {
|
||||
loading = null;
|
||||
}
|
||||
if (stopped) return;
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
timers.delete(refreshTimer);
|
||||
}
|
||||
refreshTimer = later(loadKeywords, state.overflow.reachable ? KEYWORDS_REFRESH_MS : OVERFLOW_RETRY_MS);
|
||||
}
|
||||
|
||||
/* ── the switch ────────────────────────────────────────────────── */
|
||||
|
||||
function describeTarget(keys) {
|
||||
const entries = keys.map((k) => keywordCache.get(norm(k))).filter(Boolean);
|
||||
return {
|
||||
keywords: keys,
|
||||
keyword: keys.join(' + '),
|
||||
destination: unique(entries.map(destinationOf).filter(Boolean)).join(' + '),
|
||||
groups: unique(entries.flatMap(groupsOf)),
|
||||
};
|
||||
}
|
||||
|
||||
function pushHistory(entry) {
|
||||
const { pending, ...rest } = entry;
|
||||
state.history.unshift(rest);
|
||||
if (state.history.length > HISTORY_MAX) state.history.length = HISTORY_MAX;
|
||||
}
|
||||
|
||||
function raiseAlert(keyword) {
|
||||
if (!alertEnabled || !shell || typeof shell.alert !== 'function') return;
|
||||
alertRaised = true;
|
||||
Promise.resolve(shell.alert({ channel: alertChannel, level: 'medium', text: `Tap not switched: ${keyword}`, duration: ALERT_SECONDS }))
|
||||
.catch((err) => log(`alert refused: ${describe(err)}`));
|
||||
}
|
||||
|
||||
function clearAlert() {
|
||||
if (!alertRaised || !shell || typeof shell.clearAlert !== 'function') return;
|
||||
alertRaised = false;
|
||||
Promise.resolve(shell.clearAlert(alertChannel)).catch(() => {});
|
||||
}
|
||||
|
||||
function requestSwitch(target) {
|
||||
wanted = target;
|
||||
retriesLeft = SWITCH_RETRIES;
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
timers.delete(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
runSwitch();
|
||||
}
|
||||
|
||||
async function runSwitch() {
|
||||
if (stopped || inFlight || !wanted) return;
|
||||
const target = wanted;
|
||||
const cur = state.current;
|
||||
const sameTarget = cur && sameSet(cur.keywords, target.keywords);
|
||||
if (sameTarget && cur.ok) return; // already there
|
||||
inFlight = true;
|
||||
let entry;
|
||||
if (sameTarget) {
|
||||
entry = cur; // another go at the same switch
|
||||
} else {
|
||||
if (cur) pushHistory(cur);
|
||||
entry = {
|
||||
keyword: target.keyword,
|
||||
keywords: target.keywords,
|
||||
destination: target.destination,
|
||||
groups: target.groups,
|
||||
at: Date.now(),
|
||||
ok: false,
|
||||
pending: true,
|
||||
retrying: false, // a retry is scheduled: the module is still trying to fix this itself
|
||||
error: '',
|
||||
attempts: 0,
|
||||
};
|
||||
state.current = entry;
|
||||
}
|
||||
entry.pending = true;
|
||||
entry.attempts += 1;
|
||||
publish();
|
||||
try {
|
||||
const answer = await withSession((s) => overflow.trigger(s.token, s.nonprofitId, target.keywords));
|
||||
if (answer) {
|
||||
applyKeywordList(answer, true);
|
||||
const fresh = describeTarget(target.keywords);
|
||||
entry.destination = fresh.destination || entry.destination;
|
||||
entry.groups = fresh.groups.length ? fresh.groups : entry.groups;
|
||||
}
|
||||
entry.ok = true;
|
||||
entry.error = '';
|
||||
entry.retrying = false;
|
||||
entry.at = Date.now();
|
||||
consecutiveFailures = 0;
|
||||
clearAlert();
|
||||
log(`Tap → ${entry.destination || '?'} (${target.keyword})${entry.groups.length ? ` on ${entry.groups.join(', ')}` : ''}`);
|
||||
} catch (err) {
|
||||
if (stopped) return;
|
||||
entry.ok = false;
|
||||
entry.error = describe(err);
|
||||
consecutiveFailures += 1;
|
||||
if (retriesLeft > 0 && consecutiveFailures <= SWITCH_RETRIES) {
|
||||
// Still the module's problem: try again shortly; the dot goes amber.
|
||||
retriesLeft -= 1;
|
||||
entry.retrying = true;
|
||||
log(`Tap not switched to ${target.keyword}: ${entry.error} — retrying in ${SWITCH_RETRY_MS / 1000} s`);
|
||||
retryTimer = later(() => {
|
||||
retryTimer = null;
|
||||
runSwitch();
|
||||
}, SWITCH_RETRY_MS);
|
||||
} else {
|
||||
// Out of retries: now it is the booth's problem — red dot, and the
|
||||
// alert if the operator asked for one (raised once per outage, not
|
||||
// again for every further keyword Overflow refuses).
|
||||
entry.retrying = false;
|
||||
log(`Tap not switched to ${target.keyword}: ${entry.error} — giving up after ${consecutiveFailures} refusals`);
|
||||
if (!alertRaised) raiseAlert(target.keyword);
|
||||
}
|
||||
} finally {
|
||||
entry.pending = false;
|
||||
inFlight = false;
|
||||
publish();
|
||||
if (!stopped && wanted !== target) runSwitch(); // a newer keyword arrived meanwhile
|
||||
}
|
||||
}
|
||||
|
||||
/* ── what is on screen, and what to do about it ────────────────── */
|
||||
|
||||
/**
|
||||
* The helper's rule, per layer: when the set of keywords the layer's
|
||||
* slide carries changes to a non-empty set, send it. Returning to a
|
||||
* keyword after a slide without one sends it again; staying on the same
|
||||
* keyword across slides sends nothing.
|
||||
*/
|
||||
function reconsider() {
|
||||
const list = [...keywordCache.values()];
|
||||
const presNotes = lastSlide ? cleanNotes(lastSlide.notes) : '';
|
||||
const presKeys = lastSlide ? matchKeywords(presNotes, list) : [];
|
||||
state.slide = { live: Boolean(lastSlide), keywords: presKeys, hasNotes: Boolean(presNotes.trim()) };
|
||||
const presChanged = !sameSet(presKeys, lastPresKeywords);
|
||||
if (presChanged) lastPresKeywords = presKeys;
|
||||
|
||||
const annKeys = annLive ? matchKeywords(annNotes, list) : [];
|
||||
state.announcement = { live: annLive, keywords: annKeys };
|
||||
const annChanged = !sameSet(annKeys, lastAnnKeywords);
|
||||
if (annChanged) lastAnnKeywords = annKeys;
|
||||
|
||||
const batch = [];
|
||||
if (presChanged && presKeys.length) batch.push(...presKeys);
|
||||
if (annChanged && annKeys.length) batch.push(...annKeys);
|
||||
if (batch.length) requestSwitch(describeTarget(unique(batch)));
|
||||
}
|
||||
|
||||
/* ── ProPresenter ──────────────────────────────────────────────── */
|
||||
|
||||
function ppUrl(target) {
|
||||
const u = new URL(`http://${host}:${port}${target}`);
|
||||
if (ppPassword) u.searchParams.set('password', ppPassword);
|
||||
return u;
|
||||
}
|
||||
|
||||
async function ppJson(target) {
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), PP_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(ppUrl(target), { headers: { Accept: 'application/json' }, signal: ctl.signal });
|
||||
if (!res.ok) {
|
||||
const err = new Error(`ProPresenter ${res.status} ${res.statusText}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
const text = await res.text();
|
||||
return text ? JSON.parse(text) : null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** The presentation layer: /v1/status/slide names the live slide and carries its notes. */
|
||||
async function pollSlide() {
|
||||
const data = await ppJson('/v1/status/slide');
|
||||
const cur = data && data.current && typeof data.current === 'object' ? data.current : null;
|
||||
const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : '';
|
||||
if (key === lastSlideKey) return false;
|
||||
lastSlideKey = key;
|
||||
lastSlide = cur;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The announcement layer: its slide index names the document and the
|
||||
* slide; the document's slides (every group's, in order, disabled ones
|
||||
* included — that is the index's space) carry the notes. An older
|
||||
* ProPresenter without this API answers 404 once and is left alone.
|
||||
*/
|
||||
async function pollAnnouncement() {
|
||||
if (!annSupported) return false;
|
||||
let idx;
|
||||
try {
|
||||
idx = await ppJson('/v1/announcement/slide_index');
|
||||
} catch (err) {
|
||||
if (err && err.status === 404) {
|
||||
annSupported = false;
|
||||
log('this ProPresenter has no announcement layer API — following the presentation layer only');
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const node = idx && idx.announcement_index;
|
||||
if (!node || !Number.isInteger(node.index)) {
|
||||
if (lastAnnKey === '') return false;
|
||||
lastAnnKey = '';
|
||||
annLive = false;
|
||||
annNotes = '';
|
||||
return true;
|
||||
}
|
||||
const id = node.presentation_id || {};
|
||||
const key = `${id.uuid || ''}\u0000${id.name || ''}\u0000${node.index}`;
|
||||
if (key === lastAnnKey) return false;
|
||||
const doc = await ppJson('/v1/announcement/active');
|
||||
const ann = doc && doc.announcement;
|
||||
const slides = [];
|
||||
for (const g of (ann && Array.isArray(ann.groups) ? ann.groups : [])) {
|
||||
for (const s of (g && Array.isArray(g.slides) ? g.slides : [])) slides.push(s);
|
||||
}
|
||||
const slide = slides[node.index];
|
||||
annNotes = slide ? cleanNotes(slide.notes) : '';
|
||||
annLive = true;
|
||||
lastAnnKey = key;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function pollTick() {
|
||||
if (stopped) return;
|
||||
let ok = false;
|
||||
let changed = false;
|
||||
try {
|
||||
changed = await pollSlide();
|
||||
state.propresenter.reachable = true;
|
||||
state.propresenter.error = '';
|
||||
ok = true;
|
||||
try {
|
||||
if (await pollAnnouncement()) changed = true;
|
||||
} catch {
|
||||
/* the presentation poll already said ProPresenter answers; a hiccup here waits for the next tick */
|
||||
}
|
||||
} catch (err) {
|
||||
state.propresenter.reachable = false;
|
||||
state.propresenter.error = err && err.name === 'AbortError' ? 'timed out' : describe(err);
|
||||
}
|
||||
if (changed) reconsider();
|
||||
publish();
|
||||
later(pollTick, ok ? POLL_MS : RETRY_MS);
|
||||
}
|
||||
|
||||
/* ── go ────────────────────────────────────────────────────────── */
|
||||
|
||||
if (host) {
|
||||
pollTick();
|
||||
log(`following ProPresenter at ${host}:${port}`);
|
||||
} else {
|
||||
log('no ProPresenter host configured — set one in /admin');
|
||||
}
|
||||
if (configured) {
|
||||
loadKeywords();
|
||||
log(`Overflow at ${overflow.base}`);
|
||||
} else {
|
||||
log('Overflow login not set — add the email and password in /admin');
|
||||
}
|
||||
publish();
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(': ping\n\n');
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_MS);
|
||||
heartbeat.unref?.();
|
||||
|
||||
const handle = {
|
||||
streams,
|
||||
configured,
|
||||
getState: () => state,
|
||||
refresh: () => loadKeywords(),
|
||||
};
|
||||
current = handle;
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearInterval(heartbeat);
|
||||
for (const t of timers) clearTimeout(t);
|
||||
timers.clear();
|
||||
clearAlert();
|
||||
for (const res of streams) {
|
||||
try { res.end(); } catch { /* already gone */ }
|
||||
}
|
||||
streams.clear();
|
||||
if (current === handle) current = null;
|
||||
},
|
||||
health() {
|
||||
const [status, message] = assess(true);
|
||||
return { status, message };
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
routes() {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
|
||||
json(res, 200, { state: current.getState() });
|
||||
},
|
||||
|
||||
'GET /stream': (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.write('retry: 3000\n\n');
|
||||
if (!current) return void res.end();
|
||||
res.write(`event: state\ndata: ${JSON.stringify(current.getState())}\n\n`);
|
||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||
current.streams.add(res);
|
||||
req.on('close', () => current && current.streams.delete(res));
|
||||
},
|
||||
|
||||
'POST /refresh': async (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
|
||||
if (!current.configured) return json(res, 400, { error: 'Overflow login is not set.' });
|
||||
await current.refresh();
|
||||
const st = current.getState();
|
||||
if (!st.overflow.reachable) return json(res, 502, { error: st.overflow.error || 'Overflow unreachable.' });
|
||||
json(res, 200, { ok: true, keywords: st.overflow.keywords });
|
||||
},
|
||||
|
||||
'GET /keywords': (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
|
||||
const st = current.getState();
|
||||
json(res, 200, { keywords: st.keywords, loadedAt: st.overflow.loadedAt, error: st.overflow.error });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
/* Overflow Tap — scoped under its tile via [data-module="overflow-tap"] and
|
||||
styled with the shell's variables only. Sizes hang off the tile (container
|
||||
units) so the keyword reads from across a booth on a big tile and still
|
||||
fits a 2×2. */
|
||||
|
||||
[data-module="overflow-tap"] {
|
||||
container-type: size;
|
||||
}
|
||||
|
||||
[data-module="overflow-tap"] .ot-root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: clamp(4px, 4cqmin, 18px);
|
||||
gap: clamp(2px, 1.8cqmin, 10px);
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
[data-module="overflow-tap"] .ot-offline {
|
||||
flex-shrink: 0;
|
||||
font-size: clamp(0.6rem, 4.6cqmin, 0.9rem);
|
||||
font-weight: 700;
|
||||
color: var(--danger);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="overflow-tap"] .ot-root.is-offline .ot-main,
|
||||
[data-module="overflow-tap"] .ot-root.is-offline .ot-slide,
|
||||
[data-module="overflow-tap"] .ot-root.is-offline .ot-history {
|
||||
opacity: 0.45;
|
||||
filter: grayscale(0.6);
|
||||
}
|
||||
|
||||
/* ── the live keyword ───────────────────────────────────────────── */
|
||||
|
||||
[data-module="overflow-tap"] .ot-main {
|
||||
--tone: var(--accent);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
container-type: size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-module="overflow-tap"] .ot-main[data-tone="ok"] { --tone: var(--accent); }
|
||||
[data-module="overflow-tap"] .ot-main[data-tone="warn"] { --tone: var(--warn); }
|
||||
[data-module="overflow-tap"] .ot-main[data-tone="alert"] { --tone: var(--danger); }
|
||||
[data-module="overflow-tap"] .ot-main[data-tone="muted"] { --tone: var(--muted); }
|
||||
|
||||
[data-module="overflow-tap"] .ot-keyword {
|
||||
color: var(--tone);
|
||||
font-size: min(clamp(1rem, 44cqh, 7rem), 18cqw);
|
||||
font-weight: 750;
|
||||
line-height: 1.15;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="overflow-tap"] .ot-keyword.is-none { color: var(--muted); }
|
||||
[data-module="overflow-tap"] .ot-main[data-tone="muted"] .ot-keyword {
|
||||
animation: ot-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ot-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
[data-module="overflow-tap"] .ot-dest {
|
||||
color: var(--text);
|
||||
font-size: min(clamp(0.7rem, 16cqh, 1.9rem), 8cqw);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="overflow-tap"] .ot-main[data-tone="muted"] .ot-dest { color: var(--muted); font-weight: 500; }
|
||||
|
||||
[data-module="overflow-tap"] .ot-groups {
|
||||
color: var(--muted);
|
||||
font-size: clamp(0.5rem, 9cqh, 0.95rem);
|
||||
line-height: 1.3;
|
||||
flex-shrink: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── this slide ─────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="overflow-tap"] .ot-slide {
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
font-size: clamp(0.55rem, 4.8cqmin, 0.95rem);
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0.25em 0.6em;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--text) 7%, transparent);
|
||||
align-self: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
[data-module="overflow-tap"] .ot-slide.is-muted { color: var(--muted); }
|
||||
[data-module="overflow-tap"] .ot-slide.is-hit {
|
||||
color: var(--accent-text);
|
||||
background: color-mix(in srgb, var(--accent) 80%, transparent);
|
||||
font-weight: 650;
|
||||
}
|
||||
[data-module="overflow-tap"] .ot-slide.is-warn {
|
||||
color: var(--warn);
|
||||
background: color-mix(in srgb, var(--warn) 16%, transparent);
|
||||
}
|
||||
|
||||
/* ── the trail ──────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="overflow-tap"] .ot-history {
|
||||
flex-shrink: 0;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.3em 0 0;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: clamp(0.55rem, 4.6cqmin, 0.95rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1em;
|
||||
}
|
||||
|
||||
[data-module="overflow-tap"] .ot-h-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1.4fr) auto;
|
||||
align-items: baseline;
|
||||
gap: 0 0.7em;
|
||||
color: var(--text);
|
||||
}
|
||||
[data-module="overflow-tap"] .ot-h-row.is-failed .ot-h-kw,
|
||||
[data-module="overflow-tap"] .ot-h-row.is-failed .ot-h-mark { color: var(--danger); }
|
||||
[data-module="overflow-tap"] .ot-h-time {
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
[data-module="overflow-tap"] .ot-h-kw { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
[data-module="overflow-tap"] .ot-h-dest { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
[data-module="overflow-tap"] .ot-h-mark { color: var(--accent); font-weight: 700; }
|
||||
[data-module="overflow-tap"] .ot-h-empty { color: var(--muted); text-align: center; }
|
||||
|
||||
/* On a short tile the trail would crowd the keyword out: keep the
|
||||
keyword, drop the trail. Inside the keyword block the same rule: when
|
||||
it is short, the small lines go before the keyword does. */
|
||||
@container (max-height: 120px) {
|
||||
[data-module="overflow-tap"] .ot-history { display: none; }
|
||||
}
|
||||
@container (max-height: 70px) {
|
||||
[data-module="overflow-tap"] .ot-groups { display: none; }
|
||||
}
|
||||
@container (max-height: 46px) {
|
||||
[data-module="overflow-tap"] .ot-dest { display: none; }
|
||||
}
|
||||
|
||||
[data-module="overflow-tap"] .ot-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6%;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
font-size: clamp(0.65rem, 5.5cqmin, 1rem);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "prodcom-transcript",
|
||||
"name": "ProdCom",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Live ProdCom transcript; typed text to a ProdCom channel through the Send Message module",
|
||||
"permissions": [
|
||||
@@ -38,6 +38,19 @@
|
||||
"type": "password",
|
||||
"label": "API key (only if PSK auth is enabled in ProdCom)",
|
||||
"default": ""
|
||||
},
|
||||
"autoStart": {
|
||||
"type": "switch",
|
||||
"label": "Start ProdCom when it is offline",
|
||||
"default": true,
|
||||
"help": "Only when ProdCom runs on this machine (the server above is 127.0.0.1 or this Mac’s own address). If ProdCom is not running, ProdDash opens it — up to three tries, five seconds apart. macOS only."
|
||||
},
|
||||
"apiRestart": {
|
||||
"type": "switch",
|
||||
"label": "Restart ProdCom to turn its API on",
|
||||
"default": true,
|
||||
"showWhen": "autoStart",
|
||||
"help": "When ProdCom is running but its Application API is switched off, ProdDash turns the API on and restarts ProdCom to apply it (about ten seconds without intercom). Off: the API is turned on for ProdCom’s next start and the tile says to restart it."
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
|
||||
@@ -32,9 +32,15 @@
|
||||
* extractEntries). POST /api/v1/channels/{id}/send with { text } puts typed
|
||||
* text on a channel (it comes back on the stream with source "typed"). A
|
||||
* bearer API key is sent when one is configured.
|
||||
*
|
||||
* When ProdCom runs on this very Mac and stops answering, supervisor.js opens
|
||||
* it again — or turns its Application API on and restarts it, the API being
|
||||
* read only at launch. Every failed connection is reported to it (fail()) and
|
||||
* every good one (connect()); it does nothing for a ProdCom on another machine.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const { createSupervisor } = require('./supervisor');
|
||||
|
||||
/** The module that owns the PIN and the unlock tokens — see verifyUnlock(). */
|
||||
const SENDER_ID = 'send-message';
|
||||
@@ -60,6 +66,19 @@ function resolveTarget(config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Codes that mean ProdCom could not be reached at all — not that it answered badly. */
|
||||
const CONNECTION_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EHOSTUNREACH', 'ENOTFOUND']);
|
||||
|
||||
/** The code of an error that is a failed (or timed-out) connection to ProdCom, else ''.
|
||||
An HTTP status from ProdCom is not one: its API is up, so there is nothing to start. */
|
||||
function connectionCode(err) {
|
||||
const code = err && (err.cause?.code || err.code);
|
||||
if (CONNECTION_CODES.has(code)) return code;
|
||||
if (code === 'UND_ERR_SOCKET') return 'ECONNRESET'; // fetch's spelling of a connection dropped under it
|
||||
if (err && (err.name === 'TimeoutError' || err.name === 'AbortError')) return 'ETIMEDOUT';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Entries inside whatever ProdCom sent: a bare entry, or one wrapped in data / payload / entry / entries / transcript. */
|
||||
function extractEntries(obj) {
|
||||
if (!obj || typeof obj !== 'object') return [];
|
||||
@@ -181,6 +200,7 @@ module.exports = {
|
||||
let upstream = null;
|
||||
let generation = 0; // callbacks from a superseded connection are ignored
|
||||
let retryTimer = null;
|
||||
let retryPending = false; // waiting out the backoff, with no connect() under way
|
||||
let retryMs = RETRY_MIN_MS;
|
||||
|
||||
const authHeaders = () => (apiKey ? { authorization: `Bearer ${apiKey}` } : {});
|
||||
@@ -190,7 +210,11 @@ module.exports = {
|
||||
headers: { accept: 'application/json', ...authHeaders() },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${pathname}`);
|
||||
if (!res.ok) {
|
||||
const err = new Error(`HTTP ${res.status} for ${pathname}`);
|
||||
err.httpStatus = res.status; // ProdCom answered, so it is up
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -220,6 +244,22 @@ module.exports = {
|
||||
broadcast('status', status);
|
||||
}
|
||||
|
||||
// Keeps ProdCom itself up when it is on this machine (supervisor.js); a
|
||||
// no-op everywhere else. Its words reach the tile through setStatus. This
|
||||
// module is re-init'ed whenever its config is saved, so `config` is current.
|
||||
const supervisor = createSupervisor({
|
||||
settings: () => ({ autoStart: config.autoStart !== false, apiRestart: config.apiRestart !== false }),
|
||||
target: () => ({ host: target ? target.hostname : '', port: target ? Number(target.port) || 80 : 0 }),
|
||||
log,
|
||||
status: setStatus,
|
||||
// ProdCom answers again: connect now rather than when the backoff runs out — unless a connect is already under way.
|
||||
onUp: () => {
|
||||
if (stopped || !retryPending) return;
|
||||
clearTimeout(retryTimer);
|
||||
connect();
|
||||
},
|
||||
});
|
||||
|
||||
function trim() {
|
||||
while (entries.size > ENTRIES_MAX) entries.delete(entries.keys().next().value);
|
||||
}
|
||||
@@ -281,22 +321,29 @@ module.exports = {
|
||||
function scheduleRetry() {
|
||||
if (stopped) return;
|
||||
clearTimeout(retryTimer);
|
||||
retryPending = true;
|
||||
retryTimer = setTimeout(connect, retryMs);
|
||||
retryMs = Math.min(RETRY_MAX_MS, retryMs * 2);
|
||||
}
|
||||
|
||||
function fail(gen, why) {
|
||||
/** `code` is set when the connection itself failed (see connectionCode), not when ProdCom answered badly. */
|
||||
function fail(gen, why, code = '') {
|
||||
if (stopped || gen !== generation) return;
|
||||
generation += 1; // whatever else this connection reports is stale now
|
||||
try { upstream?.destroy(); } catch { /* already gone */ }
|
||||
upstream = null;
|
||||
setStatus('error', `ProdCom unreachable (${why}) — retrying`);
|
||||
log(`ProdCom ${target.origin}: ${why} — retrying in ${retryMs / 1000} s`);
|
||||
if (code) supervisor.kick(code);
|
||||
// While the supervisor is starting ProdCom — or has given up and says why —
|
||||
// its words are the ones worth reading, not "unreachable" on every retry.
|
||||
const shown = code || why; // the normalised code where there is one (a timeout reads ETIMEDOUT, not 23)
|
||||
if (!(code && supervisor.message())) setStatus('error', `ProdCom unreachable (${shown}) — retrying`);
|
||||
log(`ProdCom ${target.origin}: ${shown} — retrying in ${retryMs / 1000} s`);
|
||||
scheduleRetry();
|
||||
}
|
||||
|
||||
/** Lists and history first (a fresh snapshot for the tiles), then the live stream. */
|
||||
async function connect() {
|
||||
retryPending = false;
|
||||
if (stopped || !target) return;
|
||||
const gen = ++generation;
|
||||
try {
|
||||
@@ -304,7 +351,8 @@ module.exports = {
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
if (gen !== generation || stopped) return;
|
||||
return fail(gen, err.cause?.code || err.code || err.message);
|
||||
if (err.httpStatus) supervisor.noteReachable(); // 401, 5xx…: ProdCom is up, whatever it said
|
||||
return fail(gen, err.cause?.code || err.code || err.message, connectionCode(err));
|
||||
}
|
||||
if (gen !== generation || stopped) return;
|
||||
broadcast('state', snapshot());
|
||||
@@ -319,9 +367,11 @@ module.exports = {
|
||||
if (gen !== generation) return void res.destroy();
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
supervisor.noteReachable(); // ProdCom answered
|
||||
return fail(gen, `HTTP ${res.statusCode} from the transcript stream`);
|
||||
}
|
||||
retryMs = RETRY_MIN_MS;
|
||||
supervisor.noteReachable();
|
||||
setStatus('ok', 'Live');
|
||||
log(`following ${target.origin} — ${channels.length} channels, ${entries.size} entries`);
|
||||
let buf = '';
|
||||
@@ -337,9 +387,9 @@ module.exports = {
|
||||
if (buf.length > 1e6) buf = ''; // a frame that never ends is dropped
|
||||
});
|
||||
res.on('end', () => fail(gen, 'stream ended'));
|
||||
res.on('error', (err) => fail(gen, err.code || err.message));
|
||||
res.on('error', (err) => fail(gen, err.code || err.message, connectionCode(err)));
|
||||
});
|
||||
req.on('error', (err) => fail(gen, err.code || err.message));
|
||||
req.on('error', (err) => fail(gen, err.code || err.message, connectionCode(err)));
|
||||
req.end();
|
||||
upstream = req;
|
||||
}
|
||||
@@ -449,6 +499,7 @@ module.exports = {
|
||||
stop() {
|
||||
stopped = true;
|
||||
generation += 1;
|
||||
supervisor.stop();
|
||||
clearTimeout(retryTimer);
|
||||
clearInterval(listsTimer);
|
||||
clearInterval(heartbeat);
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
'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 };
|
||||
@@ -0,0 +1,832 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Tests for the ProdCom supervisor (supervisor.js).
|
||||
*
|
||||
* node --test modules/prodcom-transcript/supervisor.test.js
|
||||
*
|
||||
* Fakes only. The decisions run on a fake machine layer (is ProdCom running,
|
||||
* open it, its preference, quit it), a fake ping, a fake sleep, log and tile
|
||||
* status; the real machine layer (defaultOs) runs only on a fake execFile, a
|
||||
* fake sleep and a fake clock. Nothing here starts, quits or configures a real
|
||||
* ProdCom, runs a command or waits on a timer. (One test aborts the real
|
||||
* abortableSleep with an hour on it: the timer is cleared at once.)
|
||||
*/
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
createSupervisor, defaultOs, isLocalHostName, parseApiPref, abortableSleep,
|
||||
} = require('./supervisor');
|
||||
|
||||
const START = (n) => ['connecting', `ProdCom is not running — starting it (${n}/3)`];
|
||||
const BACK = ['connecting', 'ProdCom is back — reconnecting'];
|
||||
const NO_START = ['error', 'ProdCom would not start — open it by hand'];
|
||||
const API_OFF_RESTART = ['connecting', "ProdCom's API is off — turning it on and restarting ProdCom"];
|
||||
const API_OFF_STAY = ['error', "ProdCom's API is off — restart ProdCom to turn it on"];
|
||||
const API_PORT = ['error', 'ProdCom is running with its API on, but port 24480 does not answer — check its API port'];
|
||||
const NOT_STUCK = (what) => ['error', `Could not turn on ProdCom's API — it still reads ${what}`];
|
||||
|
||||
/**
|
||||
* A supervisor on fakes. `calls` is every machine action in order, `sleeps` the
|
||||
* waits asked for, `statuses` what the tile was told, `logs` what the log got,
|
||||
* `ups` how often onUp fired. `hold` makes the Nth call of a fake hold until
|
||||
* the test lets it go — `hold: { 'launch#1': true }`, then `r.release('launch#1')`
|
||||
* — so a run can be looked at, or ended, in the middle of a machine call.
|
||||
*/
|
||||
function rig(o = {}) {
|
||||
const settings = { autoStart: true, apiRestart: true, ...(o.settings || {}) };
|
||||
const machine = { running: false, pref: null, local: true, ...(o.machine || {}) };
|
||||
const calls = [];
|
||||
const sleeps = [];
|
||||
const statuses = [];
|
||||
const logs = [];
|
||||
const order = []; // statuses and onUp, in the order they happened
|
||||
const probes = [...(o.probes || [])];
|
||||
const counts = {};
|
||||
const gates = {};
|
||||
let probed = 0;
|
||||
let ups = 0;
|
||||
|
||||
const held = (name) => {
|
||||
counts[name] = (counts[name] || 0) + 1;
|
||||
const key = `${name}#${counts[name]}`;
|
||||
if (!(o.hold && o.hold[key])) return null;
|
||||
return new Promise((resolve) => { gates[key] = resolve; });
|
||||
};
|
||||
|
||||
const os = {
|
||||
platform: o.platform || 'darwin',
|
||||
isLocalHost: (host) => {
|
||||
calls.push(`isLocalHost:${host}`);
|
||||
return machine.local;
|
||||
},
|
||||
isRunning: async () => {
|
||||
calls.push('isRunning');
|
||||
const g = held('isRunning');
|
||||
if (g) await g;
|
||||
if (o.runningError) throw new Error(o.runningError);
|
||||
// `runningSeq` scripts it per attempt (the last answer repeats); otherwise it is whatever `machine.running` says.
|
||||
if (o.runningSeq) return o.runningSeq.length > 1 ? o.runningSeq.shift() : o.runningSeq[0];
|
||||
return machine.running;
|
||||
},
|
||||
launch: async () => {
|
||||
calls.push('launch');
|
||||
const g = held('launch');
|
||||
if (g) await g;
|
||||
if (o.launchError) throw new Error(o.launchError);
|
||||
},
|
||||
readApiPref: async () => {
|
||||
calls.push('readApiPref');
|
||||
const g = held('readApiPref');
|
||||
if (g) await g;
|
||||
if (o.readError) throw new Error(o.readError);
|
||||
return machine.pref;
|
||||
},
|
||||
writeApiPref: async () => {
|
||||
calls.push('writeApiPref');
|
||||
const g = held('writeApiPref');
|
||||
if (g) await g;
|
||||
if (o.writeError) throw new Error(o.writeError);
|
||||
if (o.writeSticks !== false) machine.pref = true; // the preference now reads on, as `defaults write` makes it
|
||||
},
|
||||
quit: async () => {
|
||||
calls.push('quit');
|
||||
const g = held('quit');
|
||||
if (g) await g;
|
||||
if (o.quitError) throw new Error(o.quitError);
|
||||
},
|
||||
};
|
||||
|
||||
const sup = createSupervisor({
|
||||
settings: () => settings,
|
||||
target: () => ({ host: o.host ?? '127.0.0.1', port: 24480 }),
|
||||
probe: async () => {
|
||||
probed += 1;
|
||||
const g = held('probe');
|
||||
if (g) await g;
|
||||
return probes.length ? probes.shift() : false;
|
||||
},
|
||||
os,
|
||||
sleep: o.sleep || ((ms) => {
|
||||
sleeps.push(ms);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
log: (line) => logs.push(line),
|
||||
status: (state, message) => {
|
||||
statuses.push([state, message]);
|
||||
order.push(`status:${message}`);
|
||||
},
|
||||
onUp: () => {
|
||||
ups += 1;
|
||||
order.push('onUp');
|
||||
if (o.upError) throw new Error(o.upError);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
sup, settings, machine, calls, sleeps, statuses, logs, order,
|
||||
probed: () => probed,
|
||||
ups: () => ups,
|
||||
isHeld: (key) => typeof gates[key] === 'function',
|
||||
release: (key) => gates[key](),
|
||||
/** The machine actions, without the host check every kick starts with. */
|
||||
actions: () => calls.filter((c) => !c.startsWith('isLocalHost')),
|
||||
};
|
||||
}
|
||||
|
||||
/** A sleep that waits until the test lets it go — to look at a run mid-flight. */
|
||||
function heldSleep() {
|
||||
const held = [];
|
||||
const sleep = (ms, signal) => new Promise((resolve) => held.push({ ms, signal, release: resolve }));
|
||||
return { sleep, held, releaseAll: () => held.splice(0).forEach((h) => h.release()) };
|
||||
}
|
||||
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
/* ── a run, start to finish ─────────────────────────────────────────── */
|
||||
|
||||
test('not running: opens ProdCom, and the first probe answers', async () => {
|
||||
const r = rig({ probes: [true] });
|
||||
assert.equal(r.sup.kick('ECONNREFUSED'), null);
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'launch']);
|
||||
assert.deepEqual(r.statuses, [START(1), BACK]);
|
||||
assert.deepEqual(r.sleeps, [5000]);
|
||||
assert.equal(r.probed(), 1);
|
||||
assert.ok(r.logs.includes('ProdCom is up after 1 attempt(s)'));
|
||||
assert.equal(r.ups(), 1, 'the module is told to reconnect now');
|
||||
assert.equal(r.order[r.order.length - 1], 'onUp', '…after the tile has been told');
|
||||
// The run ended well: the module's own reconnect does the rest, and fail() may speak for itself again.
|
||||
assert.equal(r.sup.message(), null);
|
||||
});
|
||||
|
||||
test('not running: three tries that never answer, then it gives up and stays quiet', async () => {
|
||||
const r = rig({ probes: [false, false, false] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'launch', 'isRunning', 'launch', 'isRunning', 'launch']);
|
||||
assert.deepEqual(r.sleeps, [5000, 5000, 5000]);
|
||||
assert.equal(r.probed(), 3);
|
||||
assert.deepEqual(r.statuses, [START(1), START(2), START(3), NO_START]);
|
||||
assert.ok(r.logs.includes(NO_START[1]));
|
||||
assert.equal(r.sup.message(), NO_START[1]); // fail() keeps this instead of "unreachable"
|
||||
assert.equal(r.ups(), 0, 'no reconnect nudge for a run that failed');
|
||||
|
||||
const before = { statuses: r.statuses.length, sleeps: r.sleeps.length, probed: r.probed() };
|
||||
for (let i = 0; i < 5; i += 1) assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
|
||||
await r.sup.idle();
|
||||
assert.equal(r.actions().length, 6, 'no machine action once given up');
|
||||
assert.equal(r.statuses.length, before.statuses);
|
||||
assert.equal(r.sleeps.length, before.sleeps);
|
||||
assert.equal(r.probed(), before.probed);
|
||||
});
|
||||
|
||||
test('a reconnect nudge that throws does not spoil the run', async () => {
|
||||
const r = rig({ probes: [true], upError: 'boom' });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.statuses, [START(1), BACK]);
|
||||
assert.ok(r.logs.some((l) => l.includes('reconnect nudge failed (boom)')));
|
||||
assert.equal(r.sup.message(), null);
|
||||
});
|
||||
|
||||
/* ── the API is off ─────────────────────────────────────────────────── */
|
||||
|
||||
test('running with the API off: writes the preference, checks it took, restarts ProdCom once, then only looks and probes', async () => {
|
||||
const r = rig({ machine: { running: true, pref: null }, probes: [false, false, true] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), [
|
||||
'isRunning', 'readApiPref', 'writeApiPref', 'readApiPref', 'quit', 'launch',
|
||||
'isRunning', 'isRunning', // attempts 2 and 3: still there, so nothing but the probe
|
||||
]);
|
||||
assert.deepEqual(r.statuses, [API_OFF_RESTART, BACK]);
|
||||
assert.deepEqual(r.sleeps, [5000, 5000, 5000]);
|
||||
assert.equal(r.probed(), 3);
|
||||
assert.equal(r.calls.filter((c) => c === 'quit').length, 1);
|
||||
assert.ok(r.logs.includes('ProdCom is up after 3 attempt(s)'));
|
||||
});
|
||||
|
||||
test('the restart waits for the preference to read on: when it does not stick, nothing is quit', async () => {
|
||||
for (const [pref, what] of [[null, 'unset'], [false, 'off']]) {
|
||||
for (const apiRestart of [true, false]) {
|
||||
const r = rig({ machine: { running: true, pref }, writeSticks: false, settings: { apiRestart } });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'writeApiPref', 'readApiPref'], `pref ${pref}, apiRestart ${apiRestart}`);
|
||||
assert.deepEqual(r.statuses, [NOT_STUCK(what)]);
|
||||
assert.equal(r.sleeps.length, 0);
|
||||
assert.equal(r.probed(), 0);
|
||||
assert.equal(r.sup.message(), NOT_STUCK(what)[1]);
|
||||
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
|
||||
assert.equal(r.actions().length, 4);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a preference that cannot be written: says so, restarts nothing', async () => {
|
||||
const r = rig({ machine: { running: true, pref: false }, writeError: "Could not turn on ProdCom's API (exit 1: no)" });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'writeApiPref']);
|
||||
assert.deepEqual(r.statuses, [['error', "Could not turn on ProdCom's API (exit 1: no)"]]);
|
||||
});
|
||||
|
||||
test('running with the API off and restarting not allowed: writes the preference and says so', async () => {
|
||||
const r = rig({ machine: { running: true, pref: false }, settings: { apiRestart: false } });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'writeApiPref', 'readApiPref']);
|
||||
assert.deepEqual(r.statuses, [API_OFF_STAY]);
|
||||
assert.equal(r.sleeps.length, 0);
|
||||
assert.equal(r.probed(), 0);
|
||||
assert.equal(r.sup.message(), API_OFF_STAY[1]);
|
||||
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
|
||||
assert.equal(r.actions().length, 4);
|
||||
});
|
||||
|
||||
test('after the one restart, a ProdCom that is not running is opened again — never quit a second time', async () => {
|
||||
const r = rig({ runningSeq: [true, false, true], machine: { pref: null }, probes: [false, false, true] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), [
|
||||
'isRunning', 'readApiPref', 'writeApiPref', 'readApiPref', 'quit', 'launch',
|
||||
'isRunning', 'launch', // attempt 2: gone again — a launch
|
||||
'isRunning', // attempt 3: there; just the probe
|
||||
]);
|
||||
assert.deepEqual(r.statuses, [API_OFF_RESTART, START(2), BACK]);
|
||||
assert.equal(r.calls.filter((c) => c === 'quit').length, 1);
|
||||
assert.ok(r.logs.includes('ProdCom is up after 3 attempt(s)'));
|
||||
});
|
||||
|
||||
/* ── the API is on, and the port is silent ──────────────────────────── */
|
||||
|
||||
test('running with the API on: it is looked at again each attempt, and only the last blames the port', async () => {
|
||||
const r = rig({ machine: { running: true, pref: true } });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'isRunning', 'readApiPref', 'isRunning', 'readApiPref']);
|
||||
assert.deepEqual(r.statuses, [API_PORT], 'no verdict before the third look');
|
||||
assert.deepEqual(r.sleeps, [5000, 5000, 5000]);
|
||||
assert.equal(r.probed(), 3);
|
||||
assert.equal(r.sup.message(), API_PORT[1]);
|
||||
assert.equal(r.calls.includes('writeApiPref'), false);
|
||||
assert.equal(r.calls.includes('quit'), false);
|
||||
assert.equal(r.calls.includes('launch'), false);
|
||||
});
|
||||
|
||||
test('running with the API on, and it starts answering on the second look: no blame at all', async () => {
|
||||
const r = rig({ machine: { running: true, pref: true }, probes: [false, true] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'isRunning', 'readApiPref']);
|
||||
assert.deepEqual(r.statuses, [BACK]);
|
||||
assert.ok(r.logs.includes('ProdCom is up after 2 attempt(s)'));
|
||||
});
|
||||
|
||||
test('running with the API on, then gone on the second look (it was quitting): it is opened', async () => {
|
||||
const r = rig({ runningSeq: [true, false], machine: { pref: true }, probes: [false, true] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'isRunning', 'launch']);
|
||||
assert.deepEqual(r.statuses, [START(2), BACK]);
|
||||
assert.ok(r.logs.includes('ProdCom is up after 2 attempt(s)'));
|
||||
});
|
||||
|
||||
test('gone only on the last look: opened, and if it still will not answer that is "would not start"', async () => {
|
||||
const r = rig({ runningSeq: [true, true, false], machine: { pref: true } });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'isRunning', 'readApiPref', 'isRunning', 'launch']);
|
||||
assert.deepEqual(r.statuses, [START(3), NO_START]);
|
||||
});
|
||||
|
||||
test('a ProdCom this run just opened, still starting up with its API on, is waited for — not blamed on the port', async () => {
|
||||
// Attempt 1: not running → opened; the 5 s probe is false. Attempt 2: it is running now, its pref is on, the port is
|
||||
// still silent — that is a slow start, not a wrong port.
|
||||
const r = rig({ runningSeq: [false, true], machine: { pref: true }, probes: [false, true] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'launch', 'isRunning', 'readApiPref']);
|
||||
assert.deepEqual(r.statuses, [START(1), BACK], 'no error status on the way');
|
||||
assert.deepEqual(r.sleeps, [5000, 5000]);
|
||||
assert.equal(r.probed(), 2);
|
||||
assert.ok(r.logs.includes('ProdCom is up after 2 attempt(s)'));
|
||||
assert.equal(r.calls.includes('quit'), false);
|
||||
assert.equal(r.calls.includes('writeApiPref'), false);
|
||||
});
|
||||
|
||||
test('a ProdCom this run just opened that turns out to have its API off still gets the one restart', async () => {
|
||||
const r = rig({ runningSeq: [false, true], machine: { pref: null }, probes: [false, false, true] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), [
|
||||
'isRunning', 'launch',
|
||||
'isRunning', 'readApiPref', 'writeApiPref', 'readApiPref', 'quit', 'launch',
|
||||
'isRunning',
|
||||
]);
|
||||
assert.deepEqual(r.statuses, [START(1), API_OFF_RESTART, BACK]);
|
||||
assert.equal(r.calls.filter((c) => c === 'quit').length, 1);
|
||||
assert.ok(r.logs.includes('ProdCom is up after 3 attempt(s)'));
|
||||
});
|
||||
|
||||
test('a ProdCom this run opened that never answers, API on: three tries, then "would not start"', async () => {
|
||||
const r = rig({ runningSeq: [false, true, true], machine: { pref: true }, probes: [false, false, false] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'launch', 'isRunning', 'readApiPref', 'isRunning', 'readApiPref']);
|
||||
assert.deepEqual(r.statuses, [START(1), NO_START]);
|
||||
});
|
||||
|
||||
/* ── when there is nothing to do ────────────────────────────────────── */
|
||||
|
||||
test('nothing to do for a ProdCom on another machine', async () => {
|
||||
const r = rig({ host: '10.1.1.50', machine: { local: false } });
|
||||
const why = r.sup.kick('ECONNREFUSED');
|
||||
assert.equal(why, 'ProdCom at 10.1.1.50 is not on this machine');
|
||||
for (let i = 0; i < 4; i += 1) r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), []);
|
||||
assert.equal(r.statuses.length, 0);
|
||||
assert.equal(r.logs.filter((l) => l === why).length, 1, 'the reason is logged once, not on every tick');
|
||||
assert.equal(r.sup.message(), null);
|
||||
});
|
||||
|
||||
test('nothing to do when switched off', async () => {
|
||||
const r = rig({ settings: { autoStart: false } });
|
||||
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.calls, []);
|
||||
assert.equal(r.statuses.length, 0);
|
||||
assert.equal(r.logs.length, 1);
|
||||
});
|
||||
|
||||
test('the setting is read live: switching it on later takes effect without a new supervisor', async () => {
|
||||
const r = rig({ settings: { autoStart: false }, probes: [true] });
|
||||
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
|
||||
r.settings.autoStart = true;
|
||||
assert.equal(r.sup.kick('ECONNREFUSED'), null);
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.statuses, [START(1), BACK]);
|
||||
});
|
||||
|
||||
test('nothing to do off macOS', async () => {
|
||||
const r = rig({ platform: 'win32' });
|
||||
assert.equal(r.sup.kick('ECONNREFUSED'), 'ProdCom auto-start is macOS only');
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), []);
|
||||
assert.equal(r.statuses.length, 0);
|
||||
assert.equal(r.logs.filter((l) => l === 'ProdCom auto-start is macOS only').length, 1);
|
||||
});
|
||||
|
||||
test('a kick while a run is in progress does nothing', async () => {
|
||||
const h = heldSleep();
|
||||
const r = rig({ sleep: h.sleep, probes: [true] });
|
||||
assert.equal(r.sup.kick('ECONNREFUSED'), null);
|
||||
await tick();
|
||||
assert.equal(h.held.length, 1, 'the run is waiting out its five seconds');
|
||||
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
|
||||
assert.equal(typeof r.sup.kick('ETIMEDOUT'), 'string');
|
||||
assert.equal(r.sup.message(), START(1)[1], 'while it works, its words are the tile\'s');
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'launch'], 'no second run started');
|
||||
h.releaseAll();
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.statuses, [START(1), BACK]);
|
||||
});
|
||||
|
||||
test('kick() never throws into the reconnect loop, whatever the machine layer does', async () => {
|
||||
const r = rig();
|
||||
const sup = createSupervisor({
|
||||
settings: () => r.settings,
|
||||
target: () => ({ host: '127.0.0.1', port: 24480 }),
|
||||
os: { platform: 'darwin', isLocalHost: () => { throw new Error('no network interfaces'); } },
|
||||
probe: async () => true,
|
||||
sleep: () => Promise.resolve(),
|
||||
log: (line) => r.logs.push(line),
|
||||
status: (s, m) => r.statuses.push([s, m]),
|
||||
});
|
||||
assert.equal(sup.kick('ECONNREFUSED'), 'ProdCom auto-start could not decide (no network interfaces)');
|
||||
assert.equal(r.statuses.length, 0);
|
||||
});
|
||||
|
||||
/* ── an outage ends, a new one begins ───────────────────────────────── */
|
||||
|
||||
test('after giving up, a successful connection re-arms it for the next outage', async () => {
|
||||
const r = rig({ machine: { running: true, pref: true } });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
const first = r.actions().length;
|
||||
assert.equal(first, 6);
|
||||
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
|
||||
assert.equal(r.actions().length, first);
|
||||
|
||||
r.sup.noteReachable();
|
||||
assert.equal(r.sup.message(), null);
|
||||
assert.equal(r.sup.kick('ECONNREFUSED'), null);
|
||||
await r.sup.idle();
|
||||
assert.equal(r.actions().length, first * 2);
|
||||
assert.deepEqual(r.statuses, [API_PORT, API_PORT]);
|
||||
});
|
||||
|
||||
test('a second outage logs its reasons again', async () => {
|
||||
const r = rig({ host: '10.1.1.50', machine: { local: false } });
|
||||
const why = 'ProdCom at 10.1.1.50 is not on this machine';
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
assert.equal(r.logs.filter((l) => l === why).length, 1);
|
||||
r.sup.noteReachable(); // the first outage is over
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
assert.equal(r.logs.filter((l) => l === why).length, 2);
|
||||
|
||||
// And a run that starts clears them too: "has given up" is said once per outage, not once ever.
|
||||
const g = rig({ machine: { running: true, pref: true } });
|
||||
const gaveUp = 'ProdCom auto-start has given up until ProdCom answers';
|
||||
g.sup.kick('ECONNREFUSED');
|
||||
await g.sup.idle();
|
||||
g.sup.kick('ECONNREFUSED');
|
||||
g.sup.kick('ECONNREFUSED');
|
||||
assert.equal(g.logs.filter((l) => l === gaveUp).length, 1);
|
||||
g.sup.noteReachable();
|
||||
g.sup.kick('ECONNREFUSED');
|
||||
await g.sup.idle();
|
||||
g.sup.kick('ECONNREFUSED');
|
||||
assert.equal(g.logs.filter((l) => l === gaveUp).length, 2);
|
||||
});
|
||||
|
||||
test('a run that starts clears the reasons already logged, even with no connection in between', async () => {
|
||||
const h = heldSleep();
|
||||
const r = rig({ sleep: h.sleep, probes: [true, true] });
|
||||
const busy = 'ProdCom auto-start is already working on it';
|
||||
const said = () => r.logs.filter((l) => l === busy).length;
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await tick();
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
assert.equal(said(), 1);
|
||||
h.releaseAll();
|
||||
await r.sup.idle(); // the run ended well; the module has not connected (noteReachable) yet
|
||||
assert.equal(r.sup.kick('ECONNREFUSED'), null, 'a new run');
|
||||
await tick();
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
assert.equal(said(), 2, 'its reason is logged again');
|
||||
h.releaseAll();
|
||||
await r.sup.idle();
|
||||
});
|
||||
|
||||
test('a connection that succeeds mid-run ends the run without another word', async () => {
|
||||
const h = heldSleep();
|
||||
const r = rig({ sleep: h.sleep, probes: [true] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await tick();
|
||||
assert.deepEqual(r.statuses, [START(1)]);
|
||||
r.sup.noteReachable(); // the module got through on its own while we waited
|
||||
h.releaseAll();
|
||||
await r.sup.idle();
|
||||
assert.equal(r.probed(), 0, 'no probe after the outage was over');
|
||||
assert.deepEqual(r.statuses, [START(1)], 'nothing overwrites the module\'s own "Live"');
|
||||
assert.equal(r.ups(), 0);
|
||||
assert.equal(r.sup.message(), null);
|
||||
assert.equal(r.sup.kick('ECONNREFUSED'), null, 'and the next outage is a fresh run');
|
||||
await tick();
|
||||
assert.deepEqual(r.statuses, [START(1), START(1)]);
|
||||
r.sup.stop();
|
||||
await r.sup.idle();
|
||||
});
|
||||
|
||||
/* ── stop() and noteReachable() land mid-run ────────────────────────── */
|
||||
|
||||
test('stop() during the sleep ends the run: no more launches, no probes', async () => {
|
||||
const h = heldSleep();
|
||||
const r = rig({ sleep: h.sleep, probes: [true] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await tick();
|
||||
assert.equal(h.held.length, 1);
|
||||
assert.equal(h.held[0].signal.aborted, false);
|
||||
r.sup.stop();
|
||||
assert.equal(h.held[0].signal.aborted, true, 'the sleep was told to cut itself short');
|
||||
await r.sup.idle(); // settles even though the fake sleep never does
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'launch']);
|
||||
assert.equal(r.probed(), 0);
|
||||
assert.deepEqual(r.statuses, [START(1)]);
|
||||
assert.equal(r.sup.message(), null);
|
||||
assert.equal(r.sup.kick('ECONNREFUSED'), 'stopped');
|
||||
h.releaseAll();
|
||||
await tick();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'launch']);
|
||||
assert.equal(r.probed(), 0);
|
||||
assert.equal(r.ups(), 0);
|
||||
});
|
||||
|
||||
// Each machine call a run makes, held in flight; the run is ended (by the module
|
||||
// connecting, or by stop()) while the call is out, and nothing may follow it:
|
||||
// no status, no log, no further call, no reconnect nudge.
|
||||
const MID_CALL = [
|
||||
['isRunning', 'isRunning#1', { machine: { running: false } }],
|
||||
['launch', 'launch#1', { machine: { running: false } }],
|
||||
['readApiPref', 'readApiPref#1', { machine: { running: true, pref: null } }],
|
||||
['writeApiPref', 'writeApiPref#1', { machine: { running: true, pref: null } }],
|
||||
['readApiPref (the check that it took)', 'readApiPref#2', { machine: { running: true, pref: null } }],
|
||||
['quit', 'quit#1', { machine: { running: true, pref: null } }],
|
||||
['launch (after the quit)', 'launch#1', { machine: { running: true, pref: null } }],
|
||||
['probe', 'probe#1', { machine: { running: false }, probes: [true] }],
|
||||
];
|
||||
for (const [label, key, setup] of MID_CALL) {
|
||||
for (const how of ['noteReachable', 'stop']) {
|
||||
test(`${how}() while ${label} is in flight: nothing follows`, async () => {
|
||||
const r = rig({ ...setup, hold: { [key]: true } });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await tick();
|
||||
assert.ok(r.isHeld(key), `${key} is in flight`);
|
||||
r.sup[how]();
|
||||
const snapshot = { calls: [...r.calls], statuses: [...r.statuses], logs: [...r.logs], order: [...r.order], sleeps: [...r.sleeps] };
|
||||
r.release(key);
|
||||
await r.sup.idle();
|
||||
await tick();
|
||||
assert.deepEqual(r.calls, snapshot.calls, 'no further machine call');
|
||||
assert.deepEqual(r.statuses, snapshot.statuses, 'no status');
|
||||
assert.deepEqual(r.logs, snapshot.logs, 'no log line');
|
||||
assert.deepEqual(r.order, snapshot.order, 'no nudge');
|
||||
assert.deepEqual(r.sleeps, snapshot.sleeps, 'no further wait');
|
||||
assert.equal(r.probed(), key === 'probe#1' ? 1 : 0, 'no further probe');
|
||||
assert.equal(r.ups(), 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// …and the same when the call that was in flight ends in an error: the catch
|
||||
// must not report a failure of a run that is already over.
|
||||
const MID_CALL_ERROR = [
|
||||
['isRunning', 'isRunning#1', { machine: { running: false }, runningError: 'pgrep failed' }],
|
||||
['launch', 'launch#1', { machine: { running: false }, launchError: 'ProdCom is not installed on this machine' }],
|
||||
['readApiPref', 'readApiPref#1', { machine: { running: true, pref: null }, readError: 'defaults failed' }],
|
||||
['writeApiPref', 'writeApiPref#1', { machine: { running: true, pref: null }, writeError: 'write failed' }],
|
||||
['quit', 'quit#1', { machine: { running: true, pref: null }, quitError: 'ProdCom would not quit' }],
|
||||
['launch (after the quit)', 'launch#1', { machine: { running: true, pref: null }, launchError: 'launch failed' }],
|
||||
];
|
||||
for (const [label, key, setup] of MID_CALL_ERROR) {
|
||||
for (const how of ['noteReachable', 'stop']) {
|
||||
test(`${how}() while ${label} is in flight and then fails: no error is reported`, async () => {
|
||||
const r = rig({ ...setup, hold: { [key]: true } });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await tick();
|
||||
assert.ok(r.isHeld(key), `${key} is in flight`);
|
||||
r.sup[how]();
|
||||
const snapshot = { calls: [...r.calls], statuses: [...r.statuses], logs: [...r.logs] };
|
||||
r.release(key);
|
||||
await r.sup.idle();
|
||||
await tick();
|
||||
assert.deepEqual(r.calls, snapshot.calls);
|
||||
assert.deepEqual(r.statuses, snapshot.statuses, 'no error status');
|
||||
assert.deepEqual(r.logs, snapshot.logs, 'no error log');
|
||||
assert.equal(r.sup.message(), null, 'and nothing latched as given up');
|
||||
if (how === 'noteReachable') assert.equal(r.sup.kick('ECONNREFUSED'), null, 'the next outage still gets a run');
|
||||
r.sup.stop();
|
||||
await r.sup.idle();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('ProdCom is not installed: says so and gives up', async () => {
|
||||
const r = rig({ launchError: 'ProdCom is not installed on this machine' });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'launch']);
|
||||
assert.deepEqual(r.statuses, [['error', 'ProdCom is not installed on this machine']]);
|
||||
assert.equal(r.sleeps.length, 0);
|
||||
assert.equal(r.probed(), 0);
|
||||
assert.equal(r.sup.message(), 'ProdCom is not installed on this machine');
|
||||
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
|
||||
assert.equal(r.actions().length, 2);
|
||||
});
|
||||
|
||||
test('ProdCom will not quit: says so, does not open it again, gives up', async () => {
|
||||
const r = rig({ machine: { running: true, pref: null }, quitError: 'ProdCom would not quit' });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'writeApiPref', 'readApiPref', 'quit']);
|
||||
assert.deepEqual(r.statuses, [API_OFF_RESTART, ['error', 'ProdCom would not quit']]);
|
||||
assert.equal(r.sleeps.length, 0);
|
||||
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
|
||||
});
|
||||
|
||||
test('a machine call that fails for another reason gives up with its message', async () => {
|
||||
const r = rig({ machine: { running: true }, readError: "Could not read ProdCom's settings (timed out)" });
|
||||
r.sup.kick('ETIMEDOUT');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref'], 'not knowing the setting is not a reason to restart ProdCom');
|
||||
assert.deepEqual(r.statuses, [['error', "Could not read ProdCom's settings (timed out)"]]);
|
||||
});
|
||||
|
||||
/* ── the real machine layer, on a fake execFile ─────────────────────── */
|
||||
|
||||
const ok = (stdout = '') => ({ stdout });
|
||||
const exited = (code, stderr = '') => ({ error: Object.assign(new Error('Command failed'), { code }), stderr });
|
||||
const timedOut = () => ({ error: Object.assign(new Error('timed out'), { killed: true, signal: 'SIGTERM', code: null }) });
|
||||
const BUNDLE = 'com.prodcom.prodcom';
|
||||
|
||||
/** A fake child_process.execFile answering from `script(file, args, n)` (a result, or a promise of one). */
|
||||
function fakeExecFile(script) {
|
||||
const calls = [];
|
||||
const execFile = (file, args, opts, cb) => {
|
||||
calls.push({ file, args, opts, line: [file, ...args].join(' ') });
|
||||
Promise.resolve(script(file, args, calls.length)).then((res) => {
|
||||
const r = res || {};
|
||||
cb(r.error || null, r.stdout || '', r.stderr || '');
|
||||
});
|
||||
};
|
||||
return { execFile, calls, lines: () => calls.map((c) => c.line) };
|
||||
}
|
||||
|
||||
/** A fake clock: sleeping advances it, so a wait that would last ten seconds takes no time. */
|
||||
function fakeClock() {
|
||||
let t = 0;
|
||||
const slept = [];
|
||||
return {
|
||||
now: () => t,
|
||||
sleep: (ms) => {
|
||||
slept.push(ms);
|
||||
t += ms;
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
slept,
|
||||
};
|
||||
}
|
||||
|
||||
test('default os: reading the preference — 1, 0, a missing key, a timeout', async () => {
|
||||
const answers = [ok('1\n'), ok('0\n'), exited(1, 'The domain/default pair of (com.prodcom.prodcom, applicationAPIEnabled) does not exist'), timedOut()];
|
||||
const x = fakeExecFile(() => answers.shift());
|
||||
const os = defaultOs({ execFile: x.execFile });
|
||||
assert.equal(await os.readApiPref(), true);
|
||||
assert.equal(await os.readApiPref(), false);
|
||||
assert.equal(await os.readApiPref(), null, 'a missing key is "not set", not an error');
|
||||
await assert.rejects(os.readApiPref(), /^Error: Could not read ProdCom's settings \(timed out\)$/);
|
||||
assert.equal(x.lines()[0], `/usr/bin/defaults read ${BUNDLE} applicationAPIEnabled`);
|
||||
assert.equal(x.calls[0].opts.timeout, 5000);
|
||||
});
|
||||
|
||||
test('default os: writing the preference, and what a failure says', async () => {
|
||||
const x = fakeExecFile((file, args, n) => (n === 1 ? ok() : exited(1, '\n Could not write domain com.prodcom.prodcom; exiting\nmore')));
|
||||
const os = defaultOs({ execFile: x.execFile });
|
||||
await os.writeApiPref();
|
||||
assert.equal(x.lines()[0], `/usr/bin/defaults write ${BUNDLE} applicationAPIEnabled -bool true`);
|
||||
await assert.rejects(os.writeApiPref(), /^Error: Could not turn on ProdCom's API \(exit 1: Could not write domain com\.prodcom\.prodcom; exiting\)$/);
|
||||
});
|
||||
|
||||
test('default os: opening ProdCom is done in the background (-g), and a failed open reads as not installed', async () => {
|
||||
const answers = [ok(), exited(1, 'Unable to find application'), timedOut()];
|
||||
const x = fakeExecFile(() => answers.shift());
|
||||
const os = defaultOs({ execFile: x.execFile });
|
||||
await os.launch();
|
||||
assert.equal(x.lines()[0], `/usr/bin/open -g -b ${BUNDLE}`);
|
||||
assert.ok(x.calls[0].args.includes('-g'));
|
||||
await assert.rejects(os.launch(), /^Error: ProdCom is not installed on this machine$/);
|
||||
await assert.rejects(os.launch(), /^Error: Could not open ProdCom \(timed out\)$/);
|
||||
});
|
||||
|
||||
test('default os: is ProdCom running — pgrep 0 / 1, anything else is an error', async () => {
|
||||
const answers = [ok('123\n'), exited(1), exited(2, 'pgrep: bad'), timedOut()];
|
||||
const x = fakeExecFile(() => answers.shift());
|
||||
const os = defaultOs({ execFile: x.execFile });
|
||||
assert.equal(await os.isRunning(), true);
|
||||
assert.equal(await os.isRunning(), false);
|
||||
await assert.rejects(os.isRunning(), /Could not check whether ProdCom is running \(exit 2: pgrep: bad\)/);
|
||||
await assert.rejects(os.isRunning(), /Could not check whether ProdCom is running \(timed out\)/);
|
||||
assert.equal(x.lines()[0], '/usr/bin/pgrep -x ProdCom');
|
||||
});
|
||||
|
||||
test('default os: quit, the polite way — asked by Apple Event, then waited for', async () => {
|
||||
let pgreps = 0;
|
||||
const x = fakeExecFile((file) => {
|
||||
if (file === '/usr/bin/osascript') return ok();
|
||||
pgreps += 1;
|
||||
return pgreps < 3 ? ok('123\n') : exited(1); // still there twice, then gone
|
||||
});
|
||||
const clock = fakeClock();
|
||||
const os = defaultOs({ execFile: x.execFile, sleep: clock.sleep, now: clock.now });
|
||||
await os.quit(new AbortController().signal);
|
||||
assert.deepEqual(x.lines(), [
|
||||
`/usr/bin/osascript -e tell application id "${BUNDLE}" to quit`,
|
||||
'/usr/bin/pgrep -x ProdCom', '/usr/bin/pgrep -x ProdCom', '/usr/bin/pgrep -x ProdCom',
|
||||
]);
|
||||
assert.equal(x.calls[0].opts.timeout, 5000, 'the Apple Event gets five seconds');
|
||||
assert.deepEqual(clock.slept, [500, 500]);
|
||||
});
|
||||
|
||||
test('default os: quit, when it will not be asked — SIGTERM, then waited for', async () => {
|
||||
let pgreps = 0;
|
||||
const x = fakeExecFile((file) => {
|
||||
if (file === '/usr/bin/osascript') return exited(1, 'execution error: Not authorized to send Apple events (-1743)');
|
||||
if (file === '/usr/bin/pkill') return ok();
|
||||
pgreps += 1;
|
||||
return pgreps < 2 ? ok('123\n') : exited(1);
|
||||
});
|
||||
const clock = fakeClock();
|
||||
const os = defaultOs({ execFile: x.execFile, sleep: clock.sleep, now: clock.now });
|
||||
await os.quit();
|
||||
assert.deepEqual(x.lines().map((l) => l.split(' ').slice(0, 3).join(' ')), [
|
||||
'/usr/bin/osascript -e tell',
|
||||
'/usr/bin/pkill -x -TERM',
|
||||
'/usr/bin/pgrep -x ProdCom',
|
||||
'/usr/bin/pgrep -x ProdCom',
|
||||
]);
|
||||
assert.deepEqual(x.calls[1].args, ['-x', '-TERM', 'ProdCom']);
|
||||
});
|
||||
|
||||
test('default os: quit, and it will not go — "would not quit" after ten seconds', async () => {
|
||||
const x = fakeExecFile((file) => (file === '/usr/bin/pgrep' ? ok('123\n') : ok()));
|
||||
const clock = fakeClock();
|
||||
const os = defaultOs({ execFile: x.execFile, sleep: clock.sleep, now: clock.now });
|
||||
await assert.rejects(os.quit(), /^Error: ProdCom would not quit$/);
|
||||
assert.equal(clock.now(), 10000);
|
||||
assert.equal(x.calls.filter((c) => c.file === '/usr/bin/pkill').length, 0, 'the Apple Event worked, so no SIGTERM');
|
||||
});
|
||||
|
||||
test('default os: quit, ended while the Apple Event is out: no SIGTERM, no look at the process list', async () => {
|
||||
const ctl = new AbortController();
|
||||
let finish;
|
||||
const x = fakeExecFile((file) => {
|
||||
if (file === '/usr/bin/osascript') return new Promise((resolve) => { finish = () => resolve(exited(1, 'denied')); });
|
||||
return ok('123\n');
|
||||
});
|
||||
const os = defaultOs({ execFile: x.execFile, sleep: fakeClock().sleep });
|
||||
const quitting = os.quit(ctl.signal);
|
||||
await tick();
|
||||
ctl.abort();
|
||||
finish();
|
||||
await assert.rejects(quitting, /^Error: cancelled$/);
|
||||
assert.equal(x.calls.length, 1, 'only the osascript was ever run');
|
||||
});
|
||||
|
||||
test('default os: quit, ended while SIGTERM is out: no look at the process list', async () => {
|
||||
const ctl = new AbortController();
|
||||
let finish;
|
||||
const x = fakeExecFile((file) => {
|
||||
if (file === '/usr/bin/osascript') return exited(1, 'denied');
|
||||
if (file === '/usr/bin/pkill') return new Promise((resolve) => { finish = () => resolve(ok()); });
|
||||
return ok('123\n');
|
||||
});
|
||||
const os = defaultOs({ execFile: x.execFile, sleep: fakeClock().sleep });
|
||||
const quitting = os.quit(ctl.signal);
|
||||
await tick();
|
||||
ctl.abort();
|
||||
finish();
|
||||
await assert.rejects(quitting, /^Error: cancelled$/);
|
||||
assert.deepEqual(x.calls.map((c) => c.file), ['/usr/bin/osascript', '/usr/bin/pkill']);
|
||||
});
|
||||
|
||||
test('default os: quit, ended while it waits for the process to go: no further look', async () => {
|
||||
const ctl = new AbortController();
|
||||
const x = fakeExecFile((file) => (file === '/usr/bin/pgrep' ? ok('123\n') : ok()));
|
||||
const os = defaultOs({
|
||||
execFile: x.execFile,
|
||||
sleep: () => { ctl.abort(); return Promise.resolve(false); }, // the run ends during the first wait
|
||||
});
|
||||
await assert.rejects(os.quit(ctl.signal), /^Error: cancelled$/);
|
||||
assert.equal(x.calls.filter((c) => c.file === '/usr/bin/pgrep').length, 1);
|
||||
});
|
||||
|
||||
test('default os: quit, already ended before it starts: it does nothing at all', async () => {
|
||||
const ctl = new AbortController();
|
||||
ctl.abort();
|
||||
const x = fakeExecFile(() => ok());
|
||||
const os = defaultOs({ execFile: x.execFile });
|
||||
await assert.rejects(os.quit(ctl.signal), /^Error: cancelled$/);
|
||||
assert.equal(x.calls.length, 0);
|
||||
});
|
||||
|
||||
/* ── the pieces that need no machine ────────────────────────────────── */
|
||||
|
||||
test('isLocalHostName: this machine by every name it has, and no other', () => {
|
||||
const addrs = ['127.0.0.1', '::1', 'fe80::1', '10.3.11.152', 'fe80::aede:48ff:fe00:1122'];
|
||||
const here = 'AB-ProPresenter.local';
|
||||
for (const host of ['', 'localhost', 'LocalHost', '127.0.0.1', '::1', '[::1]', '0.0.0.0',
|
||||
'10.3.11.152', 'FE80::1', 'fe80:0:0:0:0:0:0:1', 'fe80::1%lo0',
|
||||
'ab-propresenter.local', 'AB-PROPRESENTER', 'ab-propresenter', 'ab-propresenter.local.']) {
|
||||
assert.equal(isLocalHostName(host, here, addrs), true, `"${host}" is this machine`);
|
||||
}
|
||||
for (const host of ['10.1.1.50', 'prodcom.local', 'na-ingest', 'example.com', '10.3.11.153']) {
|
||||
assert.equal(isLocalHostName(host, here, addrs), false, `"${host}" is not`);
|
||||
}
|
||||
// A hostname without .local matches the same machine reached as .local.
|
||||
assert.equal(isLocalHostName('studio.local', 'Studio', []), true);
|
||||
});
|
||||
|
||||
test('parseApiPref: defaults prints a bool as 1 or 0', () => {
|
||||
assert.equal(parseApiPref('1\n'), true);
|
||||
assert.equal(parseApiPref('0\n'), false);
|
||||
assert.equal(parseApiPref(' true '), true);
|
||||
assert.equal(parseApiPref(''), null);
|
||||
assert.equal(parseApiPref('banana'), null);
|
||||
});
|
||||
|
||||
test('the default sleep is cut short by its signal and leaves no timer behind', async () => {
|
||||
const ctl = new AbortController();
|
||||
const slept = abortableSleep(60 * 60 * 1000, ctl.signal); // an hour — would hold the test open if it were left running
|
||||
ctl.abort();
|
||||
assert.equal(await slept, false);
|
||||
assert.equal(await abortableSleep(60 * 60 * 1000, ctl.signal), false, 'already aborted: returns at once');
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Mock ProPresenter slide status + mock Overflow Dashboard API, for developing
|
||||
* the overflow-tap module without a live ProPresenter or an Overflow account.
|
||||
*
|
||||
* node tools/overflow-tap-mock.js [--pp=1602] [--overflow=24720] [--step=4000]
|
||||
* [--login=<email>:<password>] [--expire=<seconds>]
|
||||
* [--fail-switch] [--slow] [--no-announcements]
|
||||
*
|
||||
* ProPresenter side (127.0.0.1:1602 by default) — the routes the module uses,
|
||||
* with a service that advances every --step ms:
|
||||
* GET /version reachability probe
|
||||
* GET /v1/status/slide { current: { uuid, text, notes }, next }, cycling
|
||||
* through slides whose notes carry keywords
|
||||
* ("give", "Next steps: connect", "prayer"),
|
||||
* slides with other notes ("forgiveness" —
|
||||
* must NOT fire "give"), a slide whose
|
||||
* keyword is not mapped on the account, and
|
||||
* a stretch with nothing live.
|
||||
* GET /v1/announcement/slide_index the announcement layer: a three-slide
|
||||
* document whose index advances every third
|
||||
* step; its middle slide carries "events".
|
||||
* GET /v1/announcement/active that document, groups → slides → notes.
|
||||
* --no-announcements answers 404 to both,
|
||||
* like an older ProPresenter.
|
||||
*
|
||||
* Overflow side (127.0.0.1:24720 by default; run ProdDash with
|
||||
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720) — the Dashboard API the
|
||||
* module speaks (see modules/overflow-tap/overflow.js):
|
||||
* POST /nonprofit-dashboard/authentication/login/jwt any non-empty email +
|
||||
* password signs in (--login=<email>:<password> demands that pair,
|
||||
* answering 401 otherwise); the token expires after --expire seconds
|
||||
* (3600 by default — set 20 to exercise the sign-in-again path). An
|
||||
* @overflow.co email gets a staff token with no nonprofit of its own.
|
||||
* GET /nonprofit-dashboard/propresenter/nonprofits staff: the nonprofits
|
||||
* POST /nonprofit-dashboard/propresenter/nonprofits/{id}/select
|
||||
* GET /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords four mappings:
|
||||
* give → Give Online (every group), connect → Connect Card (Lobby),
|
||||
* prayer → Prayer Request (every group), events → Events (Lobby, Kids
|
||||
* Wing). "baptism" is deliberately unmapped.
|
||||
* POST /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords?syncNonprofit=true
|
||||
* { keywords } — moves the groups and answers the updated list.
|
||||
* --fail-switch answers 500 instead, to exercise the failure path.
|
||||
* Every signed-in call needs a Bearer token this mock issued and has not
|
||||
* expired, or it answers 401. --slow adds a two-second delay to every Overflow
|
||||
* answer.
|
||||
*
|
||||
* Every incoming request is logged so we can confirm what the module does.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (name, dflt) => {
|
||||
const a = args.find((x) => x.startsWith(`--${name}=`));
|
||||
return a ? a.slice(name.length + 3) : dflt;
|
||||
};
|
||||
const PP_PORT = Number.parseInt(opt('pp', '1602'), 10);
|
||||
const OF_PORT = Number.parseInt(opt('overflow', '24720'), 10);
|
||||
const STEP_MS = Number.parseInt(opt('step', '4000'), 10);
|
||||
const LOGIN = opt('login', '');
|
||||
const EXPIRE_S = Number.parseInt(opt('expire', '3600'), 10);
|
||||
const FAIL_SWITCH = args.includes('--fail-switch');
|
||||
const SLOW = args.includes('--slow');
|
||||
const NO_ANN = args.includes('--no-announcements');
|
||||
|
||||
function sendJson(res, code, body, extra = {}) {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json', ...extra });
|
||||
res.end(body === undefined ? '' : JSON.stringify(body));
|
||||
}
|
||||
|
||||
/* ── ProPresenter ────────────────────────────────────────────────── */
|
||||
|
||||
const SLIDES = [
|
||||
{ uuid: 'S1', text: 'Welcome to Waters Church', notes: '' },
|
||||
{ uuid: 'S2', text: 'Give online', notes: 'give' },
|
||||
{ uuid: 'S3', text: 'Amazing grace, how sweet the sound', notes: 'Chorus x2' },
|
||||
{ uuid: 'S4', text: 'Fill out a Connect Card', notes: 'Next steps: connect' },
|
||||
{ uuid: 'S5', text: 'A sermon on forgiveness', notes: 'forgiveness — do not switch on this' },
|
||||
{ uuid: 'S6', text: 'Pray with us', notes: 'prayer' },
|
||||
{ uuid: 'S7', text: 'Baptism Sunday', notes: 'baptism' },
|
||||
null, // nothing live
|
||||
{ uuid: 'S8', text: 'Give online (again)', notes: 'give' },
|
||||
{ uuid: 'S9', text: 'See you next week', notes: '' },
|
||||
];
|
||||
const ANNOUNCEMENT = {
|
||||
id: { uuid: 'ANN-1', name: 'Announcements', index: 0 },
|
||||
presentation_path: 'Announcements',
|
||||
groups: [
|
||||
{ name: 'Loop', slides: [
|
||||
{ enabled: true, notes: '', text: 'Welcome', label: '' },
|
||||
{ enabled: true, notes: 'events', text: 'Upcoming events', label: '' },
|
||||
{ enabled: true, notes: '', text: 'Thanks for coming', label: '' },
|
||||
] },
|
||||
],
|
||||
};
|
||||
let step = 0;
|
||||
setInterval(() => { step += 1; }, STEP_MS).unref();
|
||||
const slideIdx = () => step % SLIDES.length;
|
||||
const annIdx = () => Math.floor(step / 3) % ANNOUNCEMENT.groups[0].slides.length;
|
||||
|
||||
function slidePayload() {
|
||||
const cur = SLIDES[slideIdx()];
|
||||
const nxt = SLIDES[(slideIdx() + 1) % SLIDES.length];
|
||||
return {
|
||||
current: cur ? { uuid: cur.uuid, text: cur.text, notes: cur.notes } : null,
|
||||
next: nxt ? { uuid: nxt.uuid, text: nxt.text, notes: nxt.notes } : null,
|
||||
};
|
||||
}
|
||||
|
||||
http.createServer((req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const p = url.pathname;
|
||||
console.log(`[pp] ${req.method} ${p}`);
|
||||
if (p === '/version') return sendJson(res, 200, { name: 'Overflow mock', platform: 'mac', host_description: 'ProPresenter 21 (mock)', api_version: 'v1' });
|
||||
if (p === '/v1/status/slide') return sendJson(res, 200, slidePayload());
|
||||
if (p === '/v1/status/layers') return sendJson(res, 200, { slide: Boolean(SLIDES[slideIdx()]), media: false, announcements: !NO_ANN });
|
||||
if (p === '/v1/announcement/slide_index') {
|
||||
if (NO_ANN) return sendJson(res, 404, { error: 'not found' });
|
||||
return sendJson(res, 200, { announcement_index: { index: annIdx(), presentation_id: ANNOUNCEMENT.id } });
|
||||
}
|
||||
if (p === '/v1/announcement/active') {
|
||||
if (NO_ANN) return sendJson(res, 404, { error: 'not found' });
|
||||
return sendJson(res, 200, { announcement: ANNOUNCEMENT });
|
||||
}
|
||||
sendJson(res, 404, { error: 'not found' });
|
||||
}).listen(PP_PORT, '127.0.0.1', () => {
|
||||
console.log(`[pp] mock ProPresenter on http://127.0.0.1:${PP_PORT} (slide every ${STEP_MS} ms${NO_ANN ? ', no announcement layer' : ''})`);
|
||||
});
|
||||
|
||||
/* ── Overflow Dashboard ──────────────────────────────────────────── */
|
||||
|
||||
const NONPROFIT = { id: '6710f34fd5061afeec3eab00', name: 'Waters Church (mock)', slug: 'waters-church' };
|
||||
const DESTINATIONS = {
|
||||
give: { id: '6710f34fd5061afeec3eab01', name: 'Give Online' },
|
||||
connect: { id: '6710f34fd5061afeec3eab02', name: 'Connect Card' },
|
||||
prayer: { id: '6710f34fd5061afeec3eab03', name: 'Prayer Request' },
|
||||
events: { id: '6710f34fd5061afeec3eab04', name: 'Events' },
|
||||
};
|
||||
const GROUPS = [
|
||||
{ id: '6710f34fd5061afeec3eab11', tapGroupName: 'Auditorium', destinationId: DESTINATIONS.events.id },
|
||||
{ id: '6710f34fd5061afeec3eab12', tapGroupName: 'Lobby', destinationId: DESTINATIONS.events.id },
|
||||
{ id: '6710f34fd5061afeec3eab13', tapGroupName: 'Kids Wing', destinationId: DESTINATIONS.connect.id },
|
||||
];
|
||||
const groupRef = (g) => ({ id: g.id, tapGroupName: g.tapGroupName });
|
||||
const KEYWORDS = [
|
||||
{ id: 'kw1', keyword: 'give', destinationId: DESTINATIONS.give.id, destinationName: DESTINATIONS.give.name, tapGroups: GROUPS.map(groupRef), active: true },
|
||||
{ id: 'kw2', keyword: 'connect', destinationId: DESTINATIONS.connect.id, destinationName: DESTINATIONS.connect.name, tapGroups: [groupRef(GROUPS[1])], active: true },
|
||||
{ id: 'kw3', keyword: 'prayer', destinationId: DESTINATIONS.prayer.id, destinationName: DESTINATIONS.prayer.name, tapGroups: GROUPS.map(groupRef), active: true },
|
||||
{ id: 'kw4', keyword: 'events', destinationId: DESTINATIONS.events.id, destinationName: DESTINATIONS.events.name, tapGroups: [groupRef(GROUPS[1]), groupRef(GROUPS[2])], active: true },
|
||||
];
|
||||
const stampedKeywords = () => KEYWORDS.map((k) => ({ ...k, nonprofitId: NONPROFIT.id, updatedAt: new Date().toISOString() }));
|
||||
|
||||
const tokens = new Map(); // token → { email, exp, nonprofitId }
|
||||
|
||||
function issueToken(email) {
|
||||
const staff = email.toLowerCase().endsWith('@overflow.co');
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
id: 'user-1',
|
||||
email,
|
||||
fullName: 'Mock User',
|
||||
role: staff ? 'overflowSupport' : 'orgAdmin',
|
||||
permissions: [{ action: 'manage', subject: 'TAP' }],
|
||||
nonprofits: staff ? [] : [{ id: NONPROFIT.id, name: NONPROFIT.name, slug: NONPROFIT.slug }],
|
||||
iat: now,
|
||||
exp: now + EXPIRE_S,
|
||||
};
|
||||
if (!staff) payload.nonprofitId = NONPROFIT.id;
|
||||
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
||||
const token = `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}.${crypto.randomBytes(16).toString('base64url')}`;
|
||||
tokens.set(token, { email, exp: payload.exp, nonprofitId: staff ? '' : NONPROFIT.id });
|
||||
return token;
|
||||
}
|
||||
|
||||
function bearer(req) {
|
||||
const m = /^Bearer\s+(.+)$/.exec(String(req.headers.authorization || ''));
|
||||
if (!m) return null;
|
||||
const t = tokens.get(m[1]);
|
||||
if (!t) return null;
|
||||
if (Date.now() / 1000 >= t.exp) return null;
|
||||
return t;
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => (body += c));
|
||||
req.on('end', () => {
|
||||
try { resolve(JSON.parse(body || '{}')); } catch { resolve(null); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const p = url.pathname;
|
||||
const answer = (code, body) => {
|
||||
const go = () => sendJson(res, code, body);
|
||||
if (SLOW) setTimeout(go, 2000);
|
||||
else go();
|
||||
};
|
||||
console.log(`[overflow] ${req.method} ${p}${url.search}`);
|
||||
|
||||
if (req.method === 'POST' && p === '/nonprofit-dashboard/authentication/login/jwt') {
|
||||
const body = await readBody(req);
|
||||
const email = String((body && body.email) || '').trim();
|
||||
const password = String((body && body.password) || '');
|
||||
if (!email || !password) return answer(400, { statusCode: 400, message: 'email and password are required' });
|
||||
if (LOGIN && `${email}:${password}` !== LOGIN) return answer(401, { statusCode: 401, message: 'Invalid email or password' });
|
||||
console.log(`[overflow] signed in ${email} (token expires in ${EXPIRE_S} s)`);
|
||||
return answer(200, { token: issueToken(email) });
|
||||
}
|
||||
|
||||
const session = bearer(req);
|
||||
if (!session) return answer(401, { statusCode: 401, message: 'Unauthorized' });
|
||||
|
||||
if (req.method === 'GET' && p === '/nonprofit-dashboard/propresenter/nonprofits') {
|
||||
return answer(200, { data: { nonprofits: [NONPROFIT] } });
|
||||
}
|
||||
const sel = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/select$/.exec(p);
|
||||
if (req.method === 'POST' && sel) {
|
||||
if (sel[1] !== NONPROFIT.id) return answer(404, { statusCode: 404, message: 'Nonprofit not found' });
|
||||
session.nonprofitId = NONPROFIT.id;
|
||||
return answer(204);
|
||||
}
|
||||
const kw = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/keywords$/.exec(p);
|
||||
if (kw) {
|
||||
if (kw[1] !== NONPROFIT.id || session.nonprofitId !== NONPROFIT.id) return answer(403, { statusCode: 403, message: 'Forbidden' });
|
||||
if (req.method === 'GET') return answer(200, stampedKeywords());
|
||||
if (req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const keys = body && Array.isArray(body.keywords) ? body.keywords.map((k) => String(k).toLowerCase().trim()) : [];
|
||||
if (!keys.length) return answer(400, { statusCode: 400, message: 'keywords is required' });
|
||||
if (FAIL_SWITCH) return answer(500, { statusCode: 500, message: 'Internal server error' });
|
||||
for (const k of keys) {
|
||||
const entry = KEYWORDS.find((x) => x.keyword === k);
|
||||
if (!entry) {
|
||||
console.log(`[overflow] "${k}" is not a mapped keyword — ignored`);
|
||||
continue;
|
||||
}
|
||||
for (const g of entry.tapGroups) {
|
||||
const group = GROUPS.find((x) => x.id === g.id);
|
||||
if (group) group.destinationId = entry.destinationId;
|
||||
}
|
||||
console.log(`[overflow] ${entry.tapGroups.map((g) => g.tapGroupName).join(', ')} → ${entry.destinationName} (${k})`);
|
||||
}
|
||||
return answer(200, stampedKeywords());
|
||||
}
|
||||
}
|
||||
answer(404, { statusCode: 404, message: `Cannot ${req.method} ${p}` });
|
||||
}).listen(OF_PORT, '127.0.0.1', () => {
|
||||
console.log(`[overflow] mock Overflow Dashboard API on http://127.0.0.1:${OF_PORT}${FAIL_SWITCH ? ' (every switch answers 500)' : ''}${LOGIN ? ' (one login accepted)' : ''}${EXPIRE_S < 3600 ? ` (tokens expire after ${EXPIRE_S} s)` : ''}`);
|
||||
});
|
||||
Reference in New Issue
Block a user