Files

671 lines
34 KiB
JavaScript
Raw Permalink Normal View History

2026-09-24 13:05:20 -04:00
/*
* 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 };