Files
bible-app/tools/mock-propresenter.cjs
T

169 lines
10 KiB
JavaScript
Raw Normal View History

2026-09-24 13:05:20 -04:00
/*
* mock-propresenter.cjs — a stand-in ProPresenter for testing the Sync tab without the real thing.
*
* node tools/mock-propresenter.cjs [port=10250]
*
* Speaks enough of both protocols the app uses:
* Pro 7 REST: GET /version, /v1/status/slide[?chunked=true], /v1/presentation/slide_index[?chunked=true],
* /v1/announcement/slide_index[?chunked=true], /v1/presentation/{uuid}, /v1/announcement/active
* Pro 6 style: ws://host:port/stagedisplay (auth {pwd, ptl, acn:'ath'} -> 'fv' frames with cs/csn)
* password: "stage"
* Control page at / (Next / Prev / jump), plus GET /mock/next, /mock/prev, /mock/goto/{n}, /mock/state.
*/
const http = require('http');
const crypto = require('crypto');
const PORT = parseInt(process.argv[2], 10) || 10250;
const STAGE_PASSWORD = 'stage';
const PRESENTATION = {
id: { uuid: 'A1B2C3D4-0000-4000-8000-000000000001', name: 'Sunday — Pastor Notes', index: 0 },
groups: [
{ name: 'Welcome', color: {}, slides: [
{ enabled: true, label: 'Welcome', notes: '', text: 'Welcome to Waters Church', size: {} },
{ enabled: true, label: 'Announcements', notes: 'Coffee after service', text: 'Announcements', size: {} }
] },
{ name: 'Message', color: {}, slides: [
{ enabled: true, label: 'John 3:16', notes: 'John 3:16 (NIV)', text: 'For God so loved the world that he gave his one and only Son, that whoever believes in him shall not perish but have eternal life.', size: {} },
{ enabled: true, label: '', notes: 'Romans 8:28 NIV', text: 'And we know that in all things God works for the good of those who love him, who have been called according to his purpose.', size: {} },
{ enabled: true, label: '', notes: '', text: 'Love is patient, love is kind. It does not envy, it does not boast, it is not proud.\n1 Corinthians 13:4–7', size: {} },
{ enabled: true, label: 'Point 2', notes: 'transition slide, no scripture', text: 'God is faithful', size: {} },
{ enabled: true, label: 'Ps 23', notes: '', text: 'The LORD is my shepherd; I shall not want.', size: {} },
{ enabled: true, label: 'Rev. 21:4', notes: '', text: 'He will wipe every tear from their eyes.', size: {} },
{ enabled: true, label: 'John 4:100', notes: '', text: 'out of range verse — clamp test', size: {} },
{ enabled: true, label: 'Isaiah 53:5', notes: '', text: 'But he was pierced for our transgressions', size: {} }
] },
{ name: 'Closing', color: {}, slides: [
{ enabled: true, label: 'Amazing Grace', notes: '', text: 'Amazing grace, how sweet the sound', size: {} }
] }
]
};
const FLAT = PRESENTATION.groups.flatMap(g => g.slides);
let index = 0;
const streams = new Set(); // { res, kind }
const wsClients = new Set(); // sockets
let eventsSent = 0;
function slideIndexDoc() { return { presentation_index: { index, presentation_id: PRESENTATION.id } }; }
function statusSlideDoc() {
const cur = FLAT[index] || { text: '', notes: '' };
const nxt = FLAT[index + 1] || { text: '', notes: '' };
return { current: { text: cur.text, notes: cur.notes, uuid: PRESENTATION.id.uuid }, next: { text: nxt.text, notes: nxt.notes } };
}
function announcementDoc() { return { announcement_index: null }; }
function pushAll() {
for (const s of streams) {
const doc = s.kind === 'status' ? statusSlideDoc() : s.kind === 'pres' ? slideIndexDoc() : announcementDoc();
try { s.res.write(JSON.stringify(doc) + '\r\n\r\n'); eventsSent++; } catch (_) {}
}
const cur = FLAT[index] || { text: '', notes: '' };
const fv = JSON.stringify({ acn: 'fv', ary: [
{ acn: 'cs', uid: 'slide-' + index, txt: cur.text },
{ acn: 'ns', uid: 'slide-' + (index + 1), txt: (FLAT[index + 1] || {}).text || '' },
{ acn: 'csn', txt: cur.notes },
{ acn: 'nsn', txt: (FLAT[index + 1] || {}).notes || '' }
] });
for (const sock of wsClients) { try { sock.write(wsFrame(fv)); } catch (_) {} }
console.log(`[MOCK] slide ${index + 1}/${FLAT.length}: ${JSON.stringify(cur.label || cur.notes || cur.text.slice(0, 40))}`);
}
function goTo(n) { index = Math.max(0, Math.min(FLAT.length - 1, n)); pushAll(); }
function json(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify(obj)); }
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://localhost');
const p = u.pathname;
const chunked = u.searchParams.get('chunked') === 'true';
if (p === '/version') return json(res, 200, { name: 'ProPresenter', platform: 'win', os_version: '11', host_description: 'MOCK-PC', api_version: 'v1' });
const streamOrOnce = (kind, doc) => {
if (!chunked) return json(res, 200, doc());
res.writeHead(200, { 'Content-Type': 'application/json', 'Transfer-Encoding': 'chunked', 'Cache-Control': 'no-cache' });
res.write(JSON.stringify(doc()) + '\r\n\r\n');
const rec = { res, kind };
streams.add(rec);
req.on('close', () => streams.delete(rec));
};
if (p === '/v1/status/slide') return streamOrOnce('status', statusSlideDoc);
if (p === '/v1/presentation/slide_index') return streamOrOnce('pres', slideIndexDoc);
if (p === '/v1/announcement/slide_index') return streamOrOnce('ann', announcementDoc);
if (p === '/v1/presentation/active' || p === '/v1/presentation/' + PRESENTATION.id.uuid) return json(res, 200, { presentation: PRESENTATION });
if (p === '/v1/announcement/active') return json(res, 200, { announcement: null });
if (p === '/mock/next') { goTo(index + 1); return json(res, 200, { index }); }
if (p === '/mock/prev') { goTo(index - 1); return json(res, 200, { index }); }
if (p.startsWith('/mock/goto/')) { goTo(parseInt(p.split('/').pop(), 10) - 1); return json(res, 200, { index }); }
if (p === '/mock/state') return json(res, 200, { index, total: FLAT.length, streams: streams.size, ws: wsClients.size, eventsSent, slide: FLAT[index] });
if (p === '/') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<!doctype html><title>Mock ProPresenter</title>
<style>body{font:15px system-ui;background:#111;color:#eee;padding:20px}button{font:inherit;padding:8px 14px;margin:4px;border-radius:8px;border:1px solid #555;background:#222;color:#eee;cursor:pointer}button.cur{background:#2a5aa0;border-color:#4a9eff}#st{margin:12px 0;color:#9c9}</style>
<h2>Mock ProPresenter <small>port ${PORT}</small></h2>
<div><button onclick="hit('/mock/prev')">◀ Prev</button><button onclick="hit('/mock/next')">Next ▶</button></div>
<div id="st"></div><div id="list"></div>
<script>
const slides=${JSON.stringify(FLAT.map((s, i) => ({ i, t: s.label || s.notes || s.text.slice(0, 40) })))};
async function hit(u){await fetch(u);refresh();}
async function refresh(){const s=await (await fetch('/mock/state')).json();document.getElementById('st').textContent='slide '+(s.index+1)+'/'+s.total+' · REST streams: '+s.streams+' · stage WS clients: '+s.ws+' · events sent: '+s.eventsSent;
document.getElementById('list').innerHTML=slides.map(x=>'<button class="'+(x.i===s.index?'cur':'')+'" onclick="hit(\\'/mock/goto/'+(x.i+1)+'\\')">'+(x.i+1)+'. '+x.t.replace(/</g,'&lt;')+'</button>').join('');}
refresh();setInterval(refresh,2000);
</script>`);
return;
}
json(res, 404, { error: 'not found', path: p });
});
// ---- minimal RFC6455 server for ws://…/stagedisplay ----
function wsFrame(text) {
const payload = Buffer.from(text, 'utf8');
let header;
if (payload.length < 126) header = Buffer.from([0x81, payload.length]);
else if (payload.length < 65536) { header = Buffer.alloc(4); header[0] = 0x81; header[1] = 126; header.writeUInt16BE(payload.length, 2); }
else { header = Buffer.alloc(10); header[0] = 0x81; header[1] = 127; header.writeBigUInt64BE(BigInt(payload.length), 2); }
return Buffer.concat([header, payload]);
}
function parseFrames(buf, onText) {
let off = 0;
while (buf.length - off >= 2) {
const b0 = buf[off], b1 = buf[off + 1];
const opcode = b0 & 0x0f, masked = !!(b1 & 0x80);
let len = b1 & 0x7f, hdr = 2;
if (len === 126) { if (buf.length - off < 4) break; len = buf.readUInt16BE(off + 2); hdr = 4; }
else if (len === 127) { if (buf.length - off < 10) break; len = Number(buf.readBigUInt64BE(off + 2)); hdr = 10; }
const maskLen = masked ? 4 : 0;
if (buf.length - off < hdr + maskLen + len) break;
let payload = buf.subarray(off + hdr + maskLen, off + hdr + maskLen + len);
if (masked) { const mask = buf.subarray(off + hdr, off + hdr + 4); payload = Buffer.from(payload.map((b, i) => b ^ mask[i % 4])); }
if (opcode === 1) onText(payload.toString('utf8'));
if (opcode === 8) return { close: true, rest: Buffer.alloc(0) };
off += hdr + maskLen + len;
}
return { rest: buf.subarray(off) };
}
server.on('upgrade', (req, socket) => {
const u = new URL(req.url, 'http://localhost');
if (u.pathname !== '/stagedisplay') { socket.destroy(); return; }
const key = req.headers['sec-websocket-key'];
const accept = crypto.createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ' + accept + '\r\n\r\n');
let buf = Buffer.alloc(0);
let authed = false;
socket.on('data', (d) => {
buf = Buffer.concat([buf, d]);
const r = parseFrames(buf, (text) => {
let msg = null; try { msg = JSON.parse(text); } catch (_) {}
if (msg && msg.acn === 'ath') {
const ok = String(msg.pwd || '') === STAGE_PASSWORD;
socket.write(wsFrame(JSON.stringify({ acn: 'ath', ath: ok, err: ok ? '' : 'Incorrect password' })));
console.log('[MOCK] stagedisplay auth', ok ? 'OK' : 'REFUSED', 'ptl', msg.ptl);
if (ok) { authed = true; wsClients.add(socket); setTimeout(pushAll, 50); } else socket.end();
}
});
if (r.close) { socket.end(); return; }
buf = r.rest;
});
const drop = () => { wsClients.delete(socket); };
socket.on('close', drop); socket.on('error', drop); socket.on('end', drop);
});
server.listen(PORT, () => console.log(`[MOCK] ProPresenter mock on http://localhost:${PORT} (stage password "${STAGE_PASSWORD}")`));