/* * 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(`Mock ProPresenter

Mock ProPresenter port ${PORT}

`); 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}")`));