Files
bible-app/tools/mock-propresenter.cjs
T
christianandClaude Opus 5 0db5e9b07d Bible App 1.4.34 — full source
First commit on the local Gitea server, made after moving the tree off the
Windows machine. Build outputs, node_modules and the AI models stay out
(see .gitignore); everything the installer build needs is tracked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:05:20 -04:00

169 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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}")`));