169 lines
10 KiB
JavaScript
169 lines
10 KiB
JavaScript
/*
|
||||
|
|
* 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,'<')+'</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}")`));
|