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