671 lines
34 KiB
JavaScript
671 lines
34 KiB
JavaScript
/*
|
|||
|
|
* propresenter-link.js — the app's ONE connection to ProPresenter, owned by the embedded server.
|
||
|
|
*
|
||
|
|
* The renderer never talks to the ProPresenter machine itself. This module connects, follows the live
|
||
|
|
* slide, and publishes a small state snapshot; server.js fans that out to the app over SSE
|
||
|
|
* (/api/propresenter/events) and answers the settings panel (config / test). One connection no matter
|
||
|
|
* how many windows are open (stage TV, admin copy), and the link survives a page reload.
|
||
|
|
*
|
||
|
|
* How ProPresenter actually streams (this is NOT what the first draft of the PDR assumed):
|
||
|
|
* Pro 7 — the network API is REST over HTTP. Real-time updates are the SAME GET endpoints with
|
||
|
|
* ?chunked=true: the response never ends and every change arrives as another JSON document
|
||
|
|
* on the open connection. There is no ws://…/v1/… endpoint.
|
||
|
|
* GET /version detect + build info (no auth on the API)
|
||
|
|
* GET /v1/status/slide?chunked=true current slide text + notes, live
|
||
|
|
* GET /v1/presentation/slide_index?chunked=true which slide of which presentation, live
|
||
|
|
* GET /v1/presentation/{uuid} the presentation: groups[].slides[] (label!)
|
||
|
|
* GET /v1/announcement/slide_index?chunked=true same for the announcement layer
|
||
|
|
* GET /v1/announcement/active the active announcement presentation
|
||
|
|
* If an endpoint answers once and closes instead of streaming, it is polled at pollMs.
|
||
|
|
* Pro 6 / fallback — the Stage Display WebSocket, ws://host:port/stagedisplay, protocol 610 (Pro 7
|
||
|
|
* still serves it). It pushes the current slide text ("cs") and notes ("csn") on every
|
||
|
|
* advance. This is the only path that uses the network password.
|
||
|
|
*
|
||
|
|
* States: idle (not configured / disabled) -> connecting -> live <-> disconnected (reconnect loop).
|
||
|
|
* The last slide is never cleared on a drop: the tab keeps its verse until a new one arrives.
|
||
|
|
*/
|
||
|
|
import http from 'http';
|
||
|
|
import fs from 'fs';
|
||
|
|
import { EventEmitter } from 'events';
|
||
|
|
|
||
|
|
const DEFAULTS = Object.freeze({
|
||
|
|
enabled: false,
|
||
|
|
host: '192.168.1.100',
|
||
|
|
port: 1025,
|
||
|
|
reconnectMs: 3000,
|
||
|
|
pollMs: 500,
|
||
|
|
version: 'auto', // auto | pro7 | pro6
|
||
|
|
watchAnnouncements: true
|
||
|
|
});
|
||
|
|
|
||
|
|
const VERSION_PROBE_MS = 3000;
|
||
|
|
const STREAM_OPEN_MS = 5000; // headers must arrive within this
|
||
|
|
const HEALTH_EVERY_MS = 20000; // probe /version while streaming so a dead TCP path is noticed
|
||
|
|
const STAGE_PROTOCOL = 610;
|
||
|
|
|
||
|
|
function clampInt(v, lo, hi, dflt) {
|
||
|
|
const n = parseInt(v, 10);
|
||
|
|
if (!Number.isFinite(n)) return dflt;
|
||
|
|
return Math.max(lo, Math.min(hi, n));
|
||
|
|
}
|
||
|
|
|
||
|
|
function sanitizeConfig(raw) {
|
||
|
|
const cfg = { ...DEFAULTS };
|
||
|
|
if (raw && typeof raw === 'object') {
|
||
|
|
if (typeof raw.enabled === 'boolean') cfg.enabled = raw.enabled;
|
||
|
|
if (typeof raw.host === 'string') cfg.host = raw.host.trim().replace(/^https?:\/\//i, '').replace(/\/.*$/, '').replace(/:\d+$/, '');
|
||
|
|
cfg.port = clampInt(raw.port, 1, 65535, DEFAULTS.port);
|
||
|
|
cfg.reconnectMs = clampInt(raw.reconnectMs, 500, 30000, DEFAULTS.reconnectMs);
|
||
|
|
cfg.pollMs = clampInt(raw.pollMs, 200, 10000, DEFAULTS.pollMs);
|
||
|
|
if (raw.version === 'pro7' || raw.version === 'pro6' || raw.version === 'auto') cfg.version = raw.version;
|
||
|
|
if (typeof raw.watchAnnouncements === 'boolean') cfg.watchAnnouncements = raw.watchAnnouncements;
|
||
|
|
}
|
||
|
|
return cfg;
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
* Pulls complete top-level JSON documents out of an arbitrary byte stream. ProPresenter's chunked
|
||
|
|
* responses put one JSON document per update, separated by CR/LF pairs, but TCP chunk boundaries fall
|
||
|
|
* anywhere — so this tracks brace depth (string-aware) instead of trusting any delimiter.
|
||
|
|
*/
|
||
|
|
class JsonFrameParser {
|
||
|
|
constructor() { this.buf = ''; this.depth = 0; this.inStr = false; this.esc = false; this.start = -1; }
|
||
|
|
push(text) {
|
||
|
|
const out = [];
|
||
|
|
this.buf += text;
|
||
|
|
let i = (this.start >= 0) ? Math.max(this.start, this.buf.length - text.length) : 0;
|
||
|
|
// Resume scanning from where we left off. Positions before `start` (outside any document)
|
||
|
|
// are separators/noise and can be dropped once a document closes.
|
||
|
|
for (; i < this.buf.length; i++) {
|
||
|
|
const ch = this.buf[i];
|
||
|
|
if (this.start < 0) {
|
||
|
|
if (ch === '{' || ch === '[') { this.start = i; this.depth = 1; this.inStr = false; this.esc = false; }
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (this.inStr) {
|
||
|
|
if (this.esc) this.esc = false;
|
||
|
|
else if (ch === '\\') this.esc = true;
|
||
|
|
else if (ch === '"') this.inStr = false;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (ch === '"') { this.inStr = true; continue; }
|
||
|
|
if (ch === '{' || ch === '[') { this.depth++; continue; }
|
||
|
|
if (ch === '}' || ch === ']') {
|
||
|
|
this.depth--;
|
||
|
|
if (this.depth === 0) {
|
||
|
|
const doc = this.buf.slice(this.start, i + 1);
|
||
|
|
try { out.push(JSON.parse(doc)); } catch (_) { /* not JSON after all — skip it */ }
|
||
|
|
this.buf = this.buf.slice(i + 1);
|
||
|
|
i = -1;
|
||
|
|
this.start = -1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Nothing open: everything buffered is separator noise.
|
||
|
|
if (this.start < 0) this.buf = '';
|
||
|
|
else if (this.start > 0) { this.buf = this.buf.slice(this.start); this.start = 0; }
|
||
|
|
// A single document can't legitimately be this large; protect memory against a non-JSON firehose.
|
||
|
|
if (this.buf.length > 4 * 1024 * 1024) { this.buf = ''; this.start = -1; this.depth = 0; this.inStr = false; }
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function httpGet(host, port, pathname, timeoutMs) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
let done = false;
|
||
|
|
const finish = (fn, v) => { if (done) return; done = true; fn(v); };
|
||
|
|
const req = http.request({ host, port, path: pathname, method: 'GET', headers: { 'Accept': 'application/json' } }, (res) => {
|
||
|
|
let body = '';
|
||
|
|
res.setEncoding('utf8');
|
||
|
|
res.on('data', (c) => { body += c; if (body.length > 8 * 1024 * 1024) { try { req.destroy(); } catch (_) {} } });
|
||
|
|
res.on('end', () => {
|
||
|
|
let json = null;
|
||
|
|
try { json = body ? JSON.parse(body) : null; } catch (_) { json = null; }
|
||
|
|
finish(resolve, { status: res.statusCode || 0, json, text: body });
|
||
|
|
});
|
||
|
|
res.on('error', (e) => finish(reject, e));
|
||
|
|
});
|
||
|
|
req.setTimeout(timeoutMs, () => { try { req.destroy(new Error('timeout')); } catch (_) {} });
|
||
|
|
req.on('error', (e) => finish(reject, e));
|
||
|
|
req.end();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
* Flatten a Pro7 presentation object into one slide list in cue order. `presentation_index.index`
|
||
|
|
* counts across every group, which is exactly this order.
|
||
|
|
*/
|
||
|
|
function flattenSlides(pres) {
|
||
|
|
const out = [];
|
||
|
|
try {
|
||
|
|
const groups = Array.isArray(pres && pres.groups) ? pres.groups : [];
|
||
|
|
groups.forEach((g) => {
|
||
|
|
const slides = Array.isArray(g && g.slides) ? g.slides : [];
|
||
|
|
slides.forEach((s) => {
|
||
|
|
let text = '';
|
||
|
|
if (s && typeof s.text === 'string') text = s.text;
|
||
|
|
else if (s && Array.isArray(s.elements)) text = s.elements.map((e) => (e && typeof e.text === 'string') ? e.text : '').filter(Boolean).join('\n');
|
||
|
|
out.push({
|
||
|
|
label: (s && typeof s.label === 'string') ? s.label : '',
|
||
|
|
notes: (s && typeof s.notes === 'string') ? s.notes : '',
|
||
|
|
text,
|
||
|
|
group: (g && typeof g.name === 'string') ? g.name : ''
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
} catch (_) {}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
function describeError(e) {
|
||
|
|
if (!e) return 'unknown error';
|
||
|
|
const code = e.code || '';
|
||
|
|
if (code === 'ECONNREFUSED') return 'connection refused — is "Enable Network" on in ProPresenter and is the port right?';
|
||
|
|
if (code === 'EHOSTUNREACH' || code === 'ENETUNREACH') return 'host unreachable — check the IP address and that both machines share the network';
|
||
|
|
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return 'host name not found';
|
||
|
|
if (code === 'ETIMEDOUT' || /timeout/i.test(e.message || '')) return 'timed out — no answer from that address';
|
||
|
|
if (code === 'ECONNRESET') return 'connection reset by ProPresenter';
|
||
|
|
return e.message || String(e);
|
||
|
|
}
|
||
|
|
|
||
|
|
export class ProPresenterLink extends EventEmitter {
|
||
|
|
constructor(opts) {
|
||
|
|
super();
|
||
|
|
this.configPath = opts && opts.configPath;
|
||
|
|
this.secrets = (opts && opts.secrets) || null; // { available(), encrypt(str), decrypt(str) } — registered late by Electron main
|
||
|
|
this.log = (opts && opts.log) || ((...a) => console.log('[PPSYNC]', ...a));
|
||
|
|
this.warn = (opts && opts.warn) || ((...a) => console.warn('[PPSYNC]', ...a));
|
||
|
|
this.cfg = { ...DEFAULTS };
|
||
|
|
this.passwordEnc = null; // encrypted at rest (base64), decrypted only when the stage protocol needs it
|
||
|
|
this.passwordMem = null; // dev fallback (no OS encryption available): kept in memory only
|
||
|
|
this.status = 'idle';
|
||
|
|
this.transport = null; // pro7-stream | pro7-poll | stage-ws
|
||
|
|
this.error = null;
|
||
|
|
this.info = null; // /version answer
|
||
|
|
this.slide = null; // last slide we published (see composeSlide)
|
||
|
|
this.seq = 0;
|
||
|
|
this.connectedAt = null;
|
||
|
|
this.lastEventAt = null;
|
||
|
|
this.attempts = 0;
|
||
|
|
this._gen = 0; // connection generation: anything from an older generation is ignored
|
||
|
|
this._timers = new Set();
|
||
|
|
this._streams = new Map(); // key -> { req, res }
|
||
|
|
this._ws = null;
|
||
|
|
this._layers = {}; // per layer: { uuid, name, index, slides: [] , at }
|
||
|
|
this._statusSlide = null; // { text, notes, at }
|
||
|
|
this._presCache = new Map();
|
||
|
|
this._composeTimer = null;
|
||
|
|
this._stopped = true;
|
||
|
|
this.loadConfig();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------- config
|
||
|
|
loadConfig() {
|
||
|
|
try {
|
||
|
|
if (this.configPath && fs.existsSync(this.configPath)) {
|
||
|
|
const data = JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
|
||
|
|
this.cfg = sanitizeConfig(data);
|
||
|
|
this.passwordEnc = (data && typeof data.passwordEnc === 'string' && data.passwordEnc) ? data.passwordEnc : null;
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
this.warn('config unreadable, using defaults:', e && e.message || e);
|
||
|
|
this.cfg = { ...DEFAULTS };
|
||
|
|
}
|
||
|
|
return this.cfg;
|
||
|
|
}
|
||
|
|
|
||
|
|
_persist() {
|
||
|
|
if (!this.configPath) return;
|
||
|
|
const out = { ...this.cfg, passwordEnc: this.passwordEnc || null };
|
||
|
|
try { fs.mkdirSync(require_dirname(this.configPath), { recursive: true }); } catch (_) {}
|
||
|
|
fs.writeFileSync(this.configPath, JSON.stringify(out, null, 2) + '\n');
|
||
|
|
}
|
||
|
|
|
||
|
|
hasPassword() { return !!(this.passwordEnc || this.passwordMem); }
|
||
|
|
|
||
|
|
_secretsAvailable() {
|
||
|
|
try { return !!(this.secrets && typeof this.secrets.available === 'function' && this.secrets.available()); } catch (_) { return false; }
|
||
|
|
}
|
||
|
|
|
||
|
|
_password() {
|
||
|
|
if (this.passwordMem) return this.passwordMem;
|
||
|
|
if (this.passwordEnc && this.secrets && typeof this.secrets.decrypt === 'function') {
|
||
|
|
try { const p = this.secrets.decrypt(this.passwordEnc); if (typeof p === 'string') return p; } catch (_) {}
|
||
|
|
this.warn('stored password could not be decrypted on this machine (it is tied to the Windows user that saved it)');
|
||
|
|
}
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
|
||
|
|
publicConfig() {
|
||
|
|
return { ...this.cfg, hasPassword: this.hasPassword(), passwordStorage: this.passwordEnc ? 'encrypted' : (this.passwordMem ? 'memory' : 'none') };
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
* Save + apply. `password`: undefined = keep, '' = clear, string = replace (encrypted with the OS
|
||
|
|
* store when Electron gives us one; in a bare `node server.js` dev run it stays in memory only and
|
||
|
|
* is gone at restart — it is never written to disk in the clear).
|
||
|
|
*/
|
||
|
|
saveConfig(partial) {
|
||
|
|
const next = sanitizeConfig({ ...this.cfg, ...(partial || {}) });
|
||
|
|
let secretNote = null;
|
||
|
|
if (partial && typeof partial.password === 'string') {
|
||
|
|
const p = partial.password;
|
||
|
|
if (!p) { this.passwordEnc = null; this.passwordMem = null; }
|
||
|
|
else if (this._secretsAvailable()) {
|
||
|
|
let enc = null;
|
||
|
|
try { enc = this.secrets.encrypt(p); } catch (_) { enc = null; }
|
||
|
|
if (enc) { this.passwordEnc = enc; this.passwordMem = null; }
|
||
|
|
else { this.passwordMem = p; this.passwordEnc = null; secretNote = 'OS encryption failed; password kept in memory only'; }
|
||
|
|
} else {
|
||
|
|
this.passwordMem = p; this.passwordEnc = null;
|
||
|
|
secretNote = 'no OS credential store in this run; password kept in memory only (not saved to disk)';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
this.cfg = next;
|
||
|
|
try { this._persist(); } catch (e) { this.warn('could not save config:', e && e.message || e); }
|
||
|
|
this.restart();
|
||
|
|
return { config: this.publicConfig(), note: secretNote };
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------- snapshot
|
||
|
|
snapshot() {
|
||
|
|
return {
|
||
|
|
status: this.status,
|
||
|
|
transport: this.transport,
|
||
|
|
error: this.error,
|
||
|
|
info: this.info,
|
||
|
|
config: this.publicConfig(),
|
||
|
|
slide: this.slide,
|
||
|
|
seq: this.seq,
|
||
|
|
connectedAt: this.connectedAt,
|
||
|
|
lastEventAt: this.lastEventAt,
|
||
|
|
attempts: this.attempts,
|
||
|
|
ts: Date.now()
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
_emit() { try { this.emit('state', this.snapshot()); } catch (_) {} }
|
||
|
|
|
||
|
|
_setStatus(status, extra) {
|
||
|
|
const prev = this.status;
|
||
|
|
this.status = status;
|
||
|
|
if (extra && 'error' in extra) this.error = extra.error;
|
||
|
|
if (extra && 'transport' in extra) this.transport = extra.transport;
|
||
|
|
if (status === 'live' && prev !== 'live') { this.connectedAt = Date.now(); this.attempts = 0; this.error = null; }
|
||
|
|
if (status !== 'live' && status !== 'connecting') this.transport = status === 'idle' ? null : this.transport;
|
||
|
|
if (prev !== status) this.log(`state: ${prev} -> ${status}${this.transport ? ' (' + this.transport + ')' : ''}${this.error ? ' — ' + this.error : ''}`);
|
||
|
|
this._emit();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------- lifecycle
|
||
|
|
start() {
|
||
|
|
this._stopped = false;
|
||
|
|
if (!this.cfg.enabled || !this.cfg.host) { this._teardown(); this._setStatus('idle', { error: null, transport: null }); return; }
|
||
|
|
this._connect();
|
||
|
|
}
|
||
|
|
|
||
|
|
stop() {
|
||
|
|
this._stopped = true;
|
||
|
|
this._teardown();
|
||
|
|
this._setStatus('idle', { error: null, transport: null });
|
||
|
|
}
|
||
|
|
|
||
|
|
restart() {
|
||
|
|
this._teardown();
|
||
|
|
this.attempts = 0;
|
||
|
|
this.start();
|
||
|
|
}
|
||
|
|
|
||
|
|
_timer(fn, ms) {
|
||
|
|
const gen = this._gen;
|
||
|
|
const t = setTimeout(() => { this._timers.delete(t); if (gen !== this._gen) return; try { fn(); } catch (e) { this.warn('timer error:', e && e.message || e); } }, ms);
|
||
|
|
this._timers.add(t);
|
||
|
|
return t;
|
||
|
|
}
|
||
|
|
|
||
|
|
_teardown() {
|
||
|
|
this._gen++;
|
||
|
|
for (const t of this._timers) clearTimeout(t);
|
||
|
|
this._timers.clear();
|
||
|
|
for (const [, s] of this._streams) { try { s.req.destroy(); } catch (_) {} }
|
||
|
|
this._streams.clear();
|
||
|
|
if (this._ws) { try { this._ws.close(); } catch (_) {} this._ws = null; }
|
||
|
|
if (this._composeTimer) { clearTimeout(this._composeTimer); this._composeTimer = null; }
|
||
|
|
this._layers = {};
|
||
|
|
this._statusSlide = null;
|
||
|
|
this._streamOpenedAt = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
_scheduleReconnect(reason) {
|
||
|
|
if (this._stopped) return;
|
||
|
|
this.attempts++;
|
||
|
|
this._teardown();
|
||
|
|
this._setStatus('disconnected', { error: reason || this.error || 'disconnected' });
|
||
|
|
const wait = this.cfg.reconnectMs;
|
||
|
|
this._timer(() => this._connect(), wait);
|
||
|
|
}
|
||
|
|
|
||
|
|
async _connect() {
|
||
|
|
if (this._stopped) return;
|
||
|
|
const gen = ++this._gen;
|
||
|
|
this._setStatus('connecting');
|
||
|
|
const { host, port, version } = this.cfg;
|
||
|
|
let restOk = false;
|
||
|
|
this._restError = null;
|
||
|
|
if (version !== 'pro6') {
|
||
|
|
try {
|
||
|
|
const r = await httpGet(host, port, '/version', VERSION_PROBE_MS);
|
||
|
|
if (gen !== this._gen) return;
|
||
|
|
if (r.status === 200 && r.json && typeof r.json === 'object') { restOk = true; this.info = r.json; }
|
||
|
|
else if (version === 'pro7') { this._scheduleReconnect(`ProPresenter answered HTTP ${r.status} on /version`); return; }
|
||
|
|
else this._restError = `HTTP ${r.status} on /version`;
|
||
|
|
} catch (e) {
|
||
|
|
if (gen !== this._gen) return;
|
||
|
|
if (version === 'pro7') { this._scheduleReconnect(describeError(e)); return; }
|
||
|
|
this._restError = describeError(e);
|
||
|
|
this.log('REST probe failed (' + this._restError + '); trying the stage display protocol');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (restOk) { this._startPro7(gen); return; }
|
||
|
|
this._startStageDisplay(gen);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------- Pro 7 REST / chunked
|
||
|
|
_startPro7(gen) {
|
||
|
|
this._streamOpenedAt = null;
|
||
|
|
this._setStatus('live', { transport: 'pro7-poll', error: null }); // upgraded to pro7-stream once a stream proves itself
|
||
|
|
this._openStream('status', '/v1/status/slide?chunked=true', (doc) => this._onStatusSlide(doc), gen);
|
||
|
|
this._openStream('pres', '/v1/presentation/slide_index?chunked=true', (doc) => this._onSlideIndex('presentation', doc), gen);
|
||
|
|
if (this.cfg.watchAnnouncements) {
|
||
|
|
this._openStream('ann', '/v1/announcement/slide_index?chunked=true', (doc) => this._onSlideIndex('announcement', doc), gen);
|
||
|
|
}
|
||
|
|
this._healthLoop(gen);
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
* One chunked endpoint. A stream that stays open is the live feed; one that answers and closes is
|
||
|
|
* re-requested every pollMs (that IS the polling fallback — same code, same parser). Connection-level
|
||
|
|
* failures (refused/reset/timeout) mean the whole link is down: reconnect from the top.
|
||
|
|
*/
|
||
|
|
_openStream(key, pathname, onDoc, gen) {
|
||
|
|
if (gen !== this._gen) return;
|
||
|
|
const { host, port } = this.cfg;
|
||
|
|
const parser = new JsonFrameParser();
|
||
|
|
const startedAt = Date.now();
|
||
|
|
let gotHeaders = false;
|
||
|
|
let docs = 0;
|
||
|
|
const req = http.request({ host, port, path: pathname, method: 'GET', headers: { 'Accept': 'application/json' } }, (res) => {
|
||
|
|
gotHeaders = true;
|
||
|
|
if (gen !== this._gen) { try { req.destroy(); } catch (_) {} return; }
|
||
|
|
if (res.statusCode !== 200) {
|
||
|
|
// 404 = this build of Pro7 lacks the endpoint; not fatal — the other streams carry on.
|
||
|
|
this.warn(`${pathname} -> HTTP ${res.statusCode}; disabling that feed for this connection`);
|
||
|
|
res.resume();
|
||
|
|
this._streams.delete(key);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
res.setEncoding('utf8');
|
||
|
|
let ended = false;
|
||
|
|
// A response still open 1.5s after its headers is a real stream (a one-shot answer has
|
||
|
|
// ended by then). That is what earns the "pro7-stream" badge; anything else is polling.
|
||
|
|
this._timer(() => {
|
||
|
|
if (!ended && gen === this._gen && this.transport !== 'pro7-stream') {
|
||
|
|
this._streamOpenedAt = this._streamOpenedAt || Date.now();
|
||
|
|
this._setStatus('live', { transport: 'pro7-stream', error: null });
|
||
|
|
}
|
||
|
|
}, 1500);
|
||
|
|
res.on('data', (chunk) => {
|
||
|
|
if (gen !== this._gen) return;
|
||
|
|
const list = parser.push(chunk);
|
||
|
|
for (const doc of list) {
|
||
|
|
docs++;
|
||
|
|
this.lastEventAt = Date.now();
|
||
|
|
try { onDoc(doc); } catch (e) { this.warn('feed handler error:', e && e.message || e); }
|
||
|
|
}
|
||
|
|
});
|
||
|
|
res.on('end', () => {
|
||
|
|
ended = true;
|
||
|
|
if (gen !== this._gen) return;
|
||
|
|
this._streams.delete(key);
|
||
|
|
// Answered and closed: poll it.
|
||
|
|
this._timer(() => this._openStream(key, pathname, onDoc, gen), this.cfg.pollMs);
|
||
|
|
});
|
||
|
|
res.on('error', (e) => { if (gen !== this._gen) return; this._streams.delete(key); this._scheduleReconnect(describeError(e)); });
|
||
|
|
});
|
||
|
|
req.setTimeout(STREAM_OPEN_MS, () => { if (!gotHeaders) { try { req.destroy(new Error('timeout')); } catch (_) {} } });
|
||
|
|
req.on('error', (e) => {
|
||
|
|
if (gen !== this._gen) return;
|
||
|
|
this._streams.delete(key);
|
||
|
|
this._scheduleReconnect(describeError(e));
|
||
|
|
});
|
||
|
|
try { req.on('socket', (s) => { try { s.setKeepAlive(true, 10000); s.setNoDelay(true); } catch (_) {} }); } catch (_) {}
|
||
|
|
req.end();
|
||
|
|
this._streams.set(key, { req });
|
||
|
|
}
|
||
|
|
|
||
|
|
_healthLoop(gen) {
|
||
|
|
this._timer(async () => {
|
||
|
|
if (gen !== this._gen) return;
|
||
|
|
try {
|
||
|
|
const r = await httpGet(this.cfg.host, this.cfg.port, '/version', VERSION_PROBE_MS);
|
||
|
|
if (gen !== this._gen) return;
|
||
|
|
if (r.status !== 200) throw new Error('HTTP ' + r.status);
|
||
|
|
this._healthLoop(gen);
|
||
|
|
} catch (e) {
|
||
|
|
if (gen !== this._gen) return;
|
||
|
|
this._scheduleReconnect('lost contact: ' + describeError(e));
|
||
|
|
}
|
||
|
|
}, HEALTH_EVERY_MS);
|
||
|
|
}
|
||
|
|
|
||
|
|
_onStatusSlide(doc) {
|
||
|
|
// { current: { text, notes, ... }, next: {...} }
|
||
|
|
const cur = doc && typeof doc === 'object' ? (doc.current || null) : null;
|
||
|
|
if (!cur || typeof cur !== 'object') return;
|
||
|
|
this._statusSlide = {
|
||
|
|
text: typeof cur.text === 'string' ? cur.text : '',
|
||
|
|
notes: typeof cur.notes === 'string' ? cur.notes : '',
|
||
|
|
uuid: cur.uuid && typeof cur.uuid === 'object' ? (cur.uuid.uuid || '') : (typeof cur.uuid === 'string' ? cur.uuid : ''),
|
||
|
|
at: Date.now()
|
||
|
|
};
|
||
|
|
this._scheduleCompose();
|
||
|
|
}
|
||
|
|
|
||
|
|
_onSlideIndex(layer, doc) {
|
||
|
|
// { presentation_index: { index, presentation_id: { uuid, name, index } } } (or announcement_index)
|
||
|
|
let idx = null;
|
||
|
|
if (doc && typeof doc === 'object') {
|
||
|
|
const key = Object.keys(doc).find((k) => /index$/i.test(k) && doc[k] && typeof doc[k] === 'object');
|
||
|
|
idx = key ? doc[key] : (('index' in doc) ? doc : null);
|
||
|
|
}
|
||
|
|
const L = this._layers[layer] || (this._layers[layer] = { uuid: '', name: '', index: -1, slides: null, at: 0 });
|
||
|
|
if (!idx) {
|
||
|
|
// Layer cleared (no active presentation on it).
|
||
|
|
L.index = -1; L.uuid = ''; L.name = ''; L.slides = null; L.at = Date.now();
|
||
|
|
this._scheduleCompose();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const pid = idx.presentation_id && typeof idx.presentation_id === 'object' ? idx.presentation_id : {};
|
||
|
|
const uuid = typeof pid.uuid === 'string' ? pid.uuid : '';
|
||
|
|
const name = typeof pid.name === 'string' ? pid.name : '';
|
||
|
|
L.index = Number.isFinite(Number(idx.index)) ? Number(idx.index) : -1;
|
||
|
|
L.name = name;
|
||
|
|
L.at = Date.now();
|
||
|
|
if (uuid !== L.uuid || !L.slides) {
|
||
|
|
L.uuid = uuid;
|
||
|
|
L.slides = this._presCache.get(layer + ':' + uuid) || null;
|
||
|
|
if (!L.slides) this._fetchPresentation(layer, uuid);
|
||
|
|
}
|
||
|
|
this._scheduleCompose();
|
||
|
|
}
|
||
|
|
|
||
|
|
async _fetchPresentation(layer, uuid) {
|
||
|
|
const gen = this._gen;
|
||
|
|
const pathname = layer === 'announcement' ? '/v1/announcement/active' : ('/v1/presentation/' + encodeURIComponent(uuid));
|
||
|
|
try {
|
||
|
|
const r = await httpGet(this.cfg.host, this.cfg.port, pathname, 4000);
|
||
|
|
if (gen !== this._gen) return;
|
||
|
|
if (r.status !== 200 || !r.json) return;
|
||
|
|
const pres = r.json.presentation || r.json.announcement || r.json;
|
||
|
|
const slides = flattenSlides(pres);
|
||
|
|
this._presCache.set(layer + ':' + uuid, slides);
|
||
|
|
if (this._presCache.size > 24) { const first = this._presCache.keys().next().value; this._presCache.delete(first); }
|
||
|
|
const L = this._layers[layer];
|
||
|
|
if (L && L.uuid === uuid) { L.slides = slides; this._scheduleCompose(); }
|
||
|
|
} catch (e) {
|
||
|
|
if (gen !== this._gen) return;
|
||
|
|
this.warn('could not fetch ' + layer + ' slides for labels:', describeError(e));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
* The two Pro7 feeds fire within milliseconds of each other on a slide change; compose once they
|
||
|
|
* have both landed. Picks the layer that changed most recently (an announcement-layer scripture
|
||
|
|
* sits on top of the presentation in ProPresenter, so it is the one on screen).
|
||
|
|
*/
|
||
|
|
_scheduleCompose() {
|
||
|
|
if (this._composeTimer) return;
|
||
|
|
this._composeTimer = setTimeout(() => { this._composeTimer = null; this._compose(); }, 60);
|
||
|
|
}
|
||
|
|
|
||
|
|
_compose() {
|
||
|
|
const layers = Object.keys(this._layers).filter((k) => this._layers[k] && this._layers[k].index >= 0);
|
||
|
|
let layer = null;
|
||
|
|
layers.forEach((k) => { if (!layer || this._layers[k].at > this._layers[layer].at) layer = k; });
|
||
|
|
const ss = this._statusSlide;
|
||
|
|
let slide = null;
|
||
|
|
if (layer) {
|
||
|
|
const L = this._layers[layer];
|
||
|
|
const s = (L.slides && L.slides[L.index]) || null;
|
||
|
|
slide = {
|
||
|
|
layer,
|
||
|
|
presentation: L.name || '',
|
||
|
|
index: L.index,
|
||
|
|
uuid: L.uuid || '',
|
||
|
|
label: s ? s.label : '',
|
||
|
|
notes: s ? s.notes : '',
|
||
|
|
text: s ? s.text : ''
|
||
|
|
};
|
||
|
|
// Live text/notes from the status feed beat the cached copy for the presentation layer.
|
||
|
|
if (layer === 'presentation' && ss) {
|
||
|
|
if (ss.text) slide.text = ss.text;
|
||
|
|
if (ss.notes) slide.notes = ss.notes;
|
||
|
|
}
|
||
|
|
} else if (ss && (ss.text || ss.notes)) {
|
||
|
|
slide = { layer: 'presentation', presentation: '', index: -1, uuid: ss.uuid || '', label: '', notes: ss.notes, text: ss.text };
|
||
|
|
}
|
||
|
|
this._publishSlide(slide);
|
||
|
|
}
|
||
|
|
|
||
|
|
_publishSlide(slide) {
|
||
|
|
if (!slide) return; // never clear the last slide — the tab holds its verse
|
||
|
|
const key = JSON.stringify([slide.layer, slide.uuid, slide.index, slide.label, slide.notes, slide.text]);
|
||
|
|
if (this._lastKey === key) return;
|
||
|
|
this._lastKey = key;
|
||
|
|
this.seq++;
|
||
|
|
this.slide = { ...slide, seq: this.seq, ts: Date.now() };
|
||
|
|
this.lastEventAt = this.slide.ts;
|
||
|
|
this._emit();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------- Stage display (Pro 6 / fallback)
|
||
|
|
_startStageDisplay(gen) {
|
||
|
|
if (typeof WebSocket !== 'function') { this._scheduleReconnect('this Node build has no WebSocket client'); return; }
|
||
|
|
const { host, port } = this.cfg;
|
||
|
|
const url = `ws://${host}:${port}/stagedisplay`;
|
||
|
|
let ws;
|
||
|
|
try { ws = new WebSocket(url); } catch (e) { this._scheduleReconnect(describeError(e)); return; }
|
||
|
|
this._ws = ws;
|
||
|
|
let authed = false;
|
||
|
|
const openTimer = this._timer(() => { if (!authed) { try { ws.close(); } catch (_) {} this._scheduleReconnect('stage display did not answer (Pro 6/7 stage display not enabled, or wrong port)'); } }, 6000);
|
||
|
|
ws.addEventListener('open', () => {
|
||
|
|
if (gen !== this._gen) return;
|
||
|
|
try { ws.send(JSON.stringify({ pwd: this._password(), ptl: STAGE_PROTOCOL, acn: 'ath' })); } catch (e) { this._scheduleReconnect(describeError(e)); }
|
||
|
|
});
|
||
|
|
ws.addEventListener('message', (ev) => {
|
||
|
|
if (gen !== this._gen) return;
|
||
|
|
let msg = null;
|
||
|
|
try { msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data)); } catch (_) { return; }
|
||
|
|
if (!msg || typeof msg !== 'object') return;
|
||
|
|
if (msg.acn === 'ath') {
|
||
|
|
if (msg.ath === true || msg.ath === 1) {
|
||
|
|
authed = true;
|
||
|
|
clearTimeout(openTimer);
|
||
|
|
this.info = { name: 'ProPresenter (stage display protocol)', api_version: 'stage-' + STAGE_PROTOCOL };
|
||
|
|
this._setStatus('live', { transport: 'stage-ws', error: null });
|
||
|
|
} else {
|
||
|
|
try { ws.close(); } catch (_) {}
|
||
|
|
this._scheduleReconnect('stage display refused the password' + (msg.err ? ' (' + msg.err + ')' : ''));
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (msg.acn === 'fv' && Array.isArray(msg.ary)) {
|
||
|
|
let text = null, notes = null;
|
||
|
|
msg.ary.forEach((it) => {
|
||
|
|
if (!it || typeof it !== 'object') return;
|
||
|
|
if (it.acn === 'cs') text = typeof it.txt === 'string' ? it.txt : '';
|
||
|
|
else if (it.acn === 'csn') notes = typeof it.txt === 'string' ? it.txt : '';
|
||
|
|
});
|
||
|
|
if (text === null && notes === null) return;
|
||
|
|
const prev = this._statusSlide || { text: '', notes: '' };
|
||
|
|
this._statusSlide = { text: text === null ? prev.text : text, notes: notes === null ? prev.notes : notes, uuid: '', at: Date.now() };
|
||
|
|
this.lastEventAt = Date.now();
|
||
|
|
this._publishSlide({ layer: 'stage', presentation: '', index: -1, uuid: '', label: '', notes: this._statusSlide.notes, text: this._statusSlide.text });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
ws.addEventListener('close', () => { if (gen !== this._gen) return; this._ws = null; this._scheduleReconnect(authed ? 'stage display connection closed' : (this.error || 'stage display connection closed')); });
|
||
|
|
ws.addEventListener('error', () => { if (gen !== this._gen) return; /* close follows */ if (!authed) this.error = 'could not reach the stage display socket'; });
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------- Test Connection (form values, not saved config)
|
||
|
|
static async test(rawCfg, passwordOverride, secrets, currentLink) {
|
||
|
|
const cfg = sanitizeConfig(rawCfg || {});
|
||
|
|
if (!cfg.host) return { ok: false, error: 'Enter the ProPresenter machine\'s IP address or host name.' };
|
||
|
|
const out = { ok: false, host: cfg.host, port: cfg.port, tried: [] };
|
||
|
|
if (cfg.version !== 'pro6') {
|
||
|
|
try {
|
||
|
|
const r = await httpGet(cfg.host, cfg.port, '/version', VERSION_PROBE_MS);
|
||
|
|
if (r.status === 200 && r.json && typeof r.json === 'object') {
|
||
|
|
let statusSlide = null;
|
||
|
|
try { const s = await httpGet(cfg.host, cfg.port, '/v1/status/slide', 2500); statusSlide = s.status; } catch (_) {}
|
||
|
|
return { ...out, ok: true, kind: 'pro7', info: r.json, statusSlideEndpoint: statusSlide,
|
||
|
|
message: `ProPresenter${r.json.name && r.json.name !== 'ProPresenter' ? ' (' + r.json.name + ')' : ''} found${r.json.host_description ? ' on ' + r.json.host_description : ''}${r.json.api_version ? ' (API ' + r.json.api_version + ')' : ''}.` };
|
||
|
|
}
|
||
|
|
out.tried.push(`REST /version answered HTTP ${r.status}`);
|
||
|
|
} catch (e) {
|
||
|
|
out.tried.push('REST: ' + describeError(e));
|
||
|
|
}
|
||
|
|
if (cfg.version === 'pro7') return { ...out, error: out.tried.join('; ') };
|
||
|
|
}
|
||
|
|
// Stage display protocol probe
|
||
|
|
let password = typeof passwordOverride === 'string' ? passwordOverride : null;
|
||
|
|
if (password === null && currentLink) password = currentLink._password();
|
||
|
|
const stage = await new Promise((resolve) => {
|
||
|
|
if (typeof WebSocket !== 'function') return resolve({ ok: false, error: 'no WebSocket client available' });
|
||
|
|
let settled = false;
|
||
|
|
const done = (v) => { if (settled) return; settled = true; try { ws.close(); } catch (_) {} resolve(v); };
|
||
|
|
let ws;
|
||
|
|
try { ws = new WebSocket(`ws://${cfg.host}:${cfg.port}/stagedisplay`); } catch (e) { return resolve({ ok: false, error: describeError(e) }); }
|
||
|
|
const t = setTimeout(() => done({ ok: false, error: 'stage display did not answer within 5s' }), 5000);
|
||
|
|
ws.addEventListener('open', () => { try { ws.send(JSON.stringify({ pwd: password || '', ptl: STAGE_PROTOCOL, acn: 'ath' })); } catch (e) { clearTimeout(t); done({ ok: false, error: describeError(e) }); } });
|
||
|
|
ws.addEventListener('message', (ev) => {
|
||
|
|
let m = null; try { m = JSON.parse(String(ev.data)); } catch (_) {}
|
||
|
|
if (m && m.acn === 'ath') { clearTimeout(t); done(m.ath === true || m.ath === 1 ? { ok: true } : { ok: false, error: 'stage display refused the password' + (m.err ? ' (' + m.err + ')' : ''), auth: true }); }
|
||
|
|
});
|
||
|
|
ws.addEventListener('error', () => { clearTimeout(t); done({ ok: false, error: 'could not open the stage display socket (refused or wrong port)' }); });
|
||
|
|
ws.addEventListener('close', () => { clearTimeout(t); done({ ok: false, error: 'stage display socket closed before answering' }); });
|
||
|
|
});
|
||
|
|
if (stage.ok) return { ...out, ok: true, kind: 'stage', message: 'ProPresenter stage display protocol answered (Pro 6, or Pro 7 without its REST API). Password accepted.' };
|
||
|
|
out.tried.push('Stage display: ' + stage.error);
|
||
|
|
return { ...out, error: out.tried.join('; ') };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function require_dirname(p) {
|
||
|
|
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
||
|
|
return i > 0 ? p.slice(0, i) : '.';
|
||
|
|
}
|
||
|
|
|
||
|
|
export function createProPresenterLink(opts) { return new ProPresenterLink(opts); }
|
||
|
|
export { JsonFrameParser, sanitizeConfig, flattenSlides };
|