Merge origin/main (shell 1.13.0): version fix lands as 1.13.1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Mock ProPresenter slide status + mock Overflow Dashboard API, for developing
|
||||
* the overflow-tap module without a live ProPresenter or an Overflow account.
|
||||
*
|
||||
* node tools/overflow-tap-mock.js [--pp=1602] [--overflow=24720] [--step=4000]
|
||||
* [--login=<email>:<password>] [--expire=<seconds>]
|
||||
* [--fail-switch] [--slow] [--no-announcements]
|
||||
*
|
||||
* ProPresenter side (127.0.0.1:1602 by default) — the routes the module uses,
|
||||
* with a service that advances every --step ms:
|
||||
* GET /version reachability probe
|
||||
* GET /v1/status/slide { current: { uuid, text, notes }, next }, cycling
|
||||
* through slides whose notes carry keywords
|
||||
* ("give", "Next steps: connect", "prayer"),
|
||||
* slides with other notes ("forgiveness" —
|
||||
* must NOT fire "give"), a slide whose
|
||||
* keyword is not mapped on the account, and
|
||||
* a stretch with nothing live.
|
||||
* GET /v1/announcement/slide_index the announcement layer: a three-slide
|
||||
* document whose index advances every third
|
||||
* step; its middle slide carries "events".
|
||||
* GET /v1/announcement/active that document, groups → slides → notes.
|
||||
* --no-announcements answers 404 to both,
|
||||
* like an older ProPresenter.
|
||||
*
|
||||
* Overflow side (127.0.0.1:24720 by default; run ProdDash with
|
||||
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720) — the Dashboard API the
|
||||
* module speaks (see modules/overflow-tap/overflow.js):
|
||||
* POST /nonprofit-dashboard/authentication/login/jwt any non-empty email +
|
||||
* password signs in (--login=<email>:<password> demands that pair,
|
||||
* answering 401 otherwise); the token expires after --expire seconds
|
||||
* (3600 by default — set 20 to exercise the sign-in-again path). An
|
||||
* @overflow.co email gets a staff token with no nonprofit of its own.
|
||||
* GET /nonprofit-dashboard/propresenter/nonprofits staff: the nonprofits
|
||||
* POST /nonprofit-dashboard/propresenter/nonprofits/{id}/select
|
||||
* GET /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords four mappings:
|
||||
* give → Give Online (every group), connect → Connect Card (Lobby),
|
||||
* prayer → Prayer Request (every group), events → Events (Lobby, Kids
|
||||
* Wing). "baptism" is deliberately unmapped.
|
||||
* POST /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords?syncNonprofit=true
|
||||
* { keywords } — moves the groups and answers the updated list.
|
||||
* --fail-switch answers 500 instead, to exercise the failure path.
|
||||
* Every signed-in call needs a Bearer token this mock issued and has not
|
||||
* expired, or it answers 401. --slow adds a two-second delay to every Overflow
|
||||
* answer.
|
||||
*
|
||||
* Every incoming request is logged so we can confirm what the module does.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (name, dflt) => {
|
||||
const a = args.find((x) => x.startsWith(`--${name}=`));
|
||||
return a ? a.slice(name.length + 3) : dflt;
|
||||
};
|
||||
const PP_PORT = Number.parseInt(opt('pp', '1602'), 10);
|
||||
const OF_PORT = Number.parseInt(opt('overflow', '24720'), 10);
|
||||
const STEP_MS = Number.parseInt(opt('step', '4000'), 10);
|
||||
const LOGIN = opt('login', '');
|
||||
const EXPIRE_S = Number.parseInt(opt('expire', '3600'), 10);
|
||||
const FAIL_SWITCH = args.includes('--fail-switch');
|
||||
const SLOW = args.includes('--slow');
|
||||
const NO_ANN = args.includes('--no-announcements');
|
||||
|
||||
function sendJson(res, code, body, extra = {}) {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json', ...extra });
|
||||
res.end(body === undefined ? '' : JSON.stringify(body));
|
||||
}
|
||||
|
||||
/* ── ProPresenter ────────────────────────────────────────────────── */
|
||||
|
||||
const SLIDES = [
|
||||
{ uuid: 'S1', text: 'Welcome to Waters Church', notes: '' },
|
||||
{ uuid: 'S2', text: 'Give online', notes: 'give' },
|
||||
{ uuid: 'S3', text: 'Amazing grace, how sweet the sound', notes: 'Chorus x2' },
|
||||
{ uuid: 'S4', text: 'Fill out a Connect Card', notes: 'Next steps: connect' },
|
||||
{ uuid: 'S5', text: 'A sermon on forgiveness', notes: 'forgiveness — do not switch on this' },
|
||||
{ uuid: 'S6', text: 'Pray with us', notes: 'prayer' },
|
||||
{ uuid: 'S7', text: 'Baptism Sunday', notes: 'baptism' },
|
||||
null, // nothing live
|
||||
{ uuid: 'S8', text: 'Give online (again)', notes: 'give' },
|
||||
{ uuid: 'S9', text: 'See you next week', notes: '' },
|
||||
];
|
||||
const ANNOUNCEMENT = {
|
||||
id: { uuid: 'ANN-1', name: 'Announcements', index: 0 },
|
||||
presentation_path: 'Announcements',
|
||||
groups: [
|
||||
{ name: 'Loop', slides: [
|
||||
{ enabled: true, notes: '', text: 'Welcome', label: '' },
|
||||
{ enabled: true, notes: 'events', text: 'Upcoming events', label: '' },
|
||||
{ enabled: true, notes: '', text: 'Thanks for coming', label: '' },
|
||||
] },
|
||||
],
|
||||
};
|
||||
let step = 0;
|
||||
setInterval(() => { step += 1; }, STEP_MS).unref();
|
||||
const slideIdx = () => step % SLIDES.length;
|
||||
const annIdx = () => Math.floor(step / 3) % ANNOUNCEMENT.groups[0].slides.length;
|
||||
|
||||
function slidePayload() {
|
||||
const cur = SLIDES[slideIdx()];
|
||||
const nxt = SLIDES[(slideIdx() + 1) % SLIDES.length];
|
||||
return {
|
||||
current: cur ? { uuid: cur.uuid, text: cur.text, notes: cur.notes } : null,
|
||||
next: nxt ? { uuid: nxt.uuid, text: nxt.text, notes: nxt.notes } : null,
|
||||
};
|
||||
}
|
||||
|
||||
http.createServer((req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const p = url.pathname;
|
||||
console.log(`[pp] ${req.method} ${p}`);
|
||||
if (p === '/version') return sendJson(res, 200, { name: 'Overflow mock', platform: 'mac', host_description: 'ProPresenter 21 (mock)', api_version: 'v1' });
|
||||
if (p === '/v1/status/slide') return sendJson(res, 200, slidePayload());
|
||||
if (p === '/v1/status/layers') return sendJson(res, 200, { slide: Boolean(SLIDES[slideIdx()]), media: false, announcements: !NO_ANN });
|
||||
if (p === '/v1/announcement/slide_index') {
|
||||
if (NO_ANN) return sendJson(res, 404, { error: 'not found' });
|
||||
return sendJson(res, 200, { announcement_index: { index: annIdx(), presentation_id: ANNOUNCEMENT.id } });
|
||||
}
|
||||
if (p === '/v1/announcement/active') {
|
||||
if (NO_ANN) return sendJson(res, 404, { error: 'not found' });
|
||||
return sendJson(res, 200, { announcement: ANNOUNCEMENT });
|
||||
}
|
||||
sendJson(res, 404, { error: 'not found' });
|
||||
}).listen(PP_PORT, '127.0.0.1', () => {
|
||||
console.log(`[pp] mock ProPresenter on http://127.0.0.1:${PP_PORT} (slide every ${STEP_MS} ms${NO_ANN ? ', no announcement layer' : ''})`);
|
||||
});
|
||||
|
||||
/* ── Overflow Dashboard ──────────────────────────────────────────── */
|
||||
|
||||
const NONPROFIT = { id: '6710f34fd5061afeec3eab00', name: 'Waters Church (mock)', slug: 'waters-church' };
|
||||
const DESTINATIONS = {
|
||||
give: { id: '6710f34fd5061afeec3eab01', name: 'Give Online' },
|
||||
connect: { id: '6710f34fd5061afeec3eab02', name: 'Connect Card' },
|
||||
prayer: { id: '6710f34fd5061afeec3eab03', name: 'Prayer Request' },
|
||||
events: { id: '6710f34fd5061afeec3eab04', name: 'Events' },
|
||||
};
|
||||
const GROUPS = [
|
||||
{ id: '6710f34fd5061afeec3eab11', tapGroupName: 'Auditorium', destinationId: DESTINATIONS.events.id },
|
||||
{ id: '6710f34fd5061afeec3eab12', tapGroupName: 'Lobby', destinationId: DESTINATIONS.events.id },
|
||||
{ id: '6710f34fd5061afeec3eab13', tapGroupName: 'Kids Wing', destinationId: DESTINATIONS.connect.id },
|
||||
];
|
||||
const groupRef = (g) => ({ id: g.id, tapGroupName: g.tapGroupName });
|
||||
const KEYWORDS = [
|
||||
{ id: 'kw1', keyword: 'give', destinationId: DESTINATIONS.give.id, destinationName: DESTINATIONS.give.name, tapGroups: GROUPS.map(groupRef), active: true },
|
||||
{ id: 'kw2', keyword: 'connect', destinationId: DESTINATIONS.connect.id, destinationName: DESTINATIONS.connect.name, tapGroups: [groupRef(GROUPS[1])], active: true },
|
||||
{ id: 'kw3', keyword: 'prayer', destinationId: DESTINATIONS.prayer.id, destinationName: DESTINATIONS.prayer.name, tapGroups: GROUPS.map(groupRef), active: true },
|
||||
{ id: 'kw4', keyword: 'events', destinationId: DESTINATIONS.events.id, destinationName: DESTINATIONS.events.name, tapGroups: [groupRef(GROUPS[1]), groupRef(GROUPS[2])], active: true },
|
||||
];
|
||||
const stampedKeywords = () => KEYWORDS.map((k) => ({ ...k, nonprofitId: NONPROFIT.id, updatedAt: new Date().toISOString() }));
|
||||
|
||||
const tokens = new Map(); // token → { email, exp, nonprofitId }
|
||||
|
||||
function issueToken(email) {
|
||||
const staff = email.toLowerCase().endsWith('@overflow.co');
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
id: 'user-1',
|
||||
email,
|
||||
fullName: 'Mock User',
|
||||
role: staff ? 'overflowSupport' : 'orgAdmin',
|
||||
permissions: [{ action: 'manage', subject: 'TAP' }],
|
||||
nonprofits: staff ? [] : [{ id: NONPROFIT.id, name: NONPROFIT.name, slug: NONPROFIT.slug }],
|
||||
iat: now,
|
||||
exp: now + EXPIRE_S,
|
||||
};
|
||||
if (!staff) payload.nonprofitId = NONPROFIT.id;
|
||||
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
||||
const token = `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}.${crypto.randomBytes(16).toString('base64url')}`;
|
||||
tokens.set(token, { email, exp: payload.exp, nonprofitId: staff ? '' : NONPROFIT.id });
|
||||
return token;
|
||||
}
|
||||
|
||||
function bearer(req) {
|
||||
const m = /^Bearer\s+(.+)$/.exec(String(req.headers.authorization || ''));
|
||||
if (!m) return null;
|
||||
const t = tokens.get(m[1]);
|
||||
if (!t) return null;
|
||||
if (Date.now() / 1000 >= t.exp) return null;
|
||||
return t;
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => (body += c));
|
||||
req.on('end', () => {
|
||||
try { resolve(JSON.parse(body || '{}')); } catch { resolve(null); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const p = url.pathname;
|
||||
const answer = (code, body) => {
|
||||
const go = () => sendJson(res, code, body);
|
||||
if (SLOW) setTimeout(go, 2000);
|
||||
else go();
|
||||
};
|
||||
console.log(`[overflow] ${req.method} ${p}${url.search}`);
|
||||
|
||||
if (req.method === 'POST' && p === '/nonprofit-dashboard/authentication/login/jwt') {
|
||||
const body = await readBody(req);
|
||||
const email = String((body && body.email) || '').trim();
|
||||
const password = String((body && body.password) || '');
|
||||
if (!email || !password) return answer(400, { statusCode: 400, message: 'email and password are required' });
|
||||
if (LOGIN && `${email}:${password}` !== LOGIN) return answer(401, { statusCode: 401, message: 'Invalid email or password' });
|
||||
console.log(`[overflow] signed in ${email} (token expires in ${EXPIRE_S} s)`);
|
||||
return answer(200, { token: issueToken(email) });
|
||||
}
|
||||
|
||||
const session = bearer(req);
|
||||
if (!session) return answer(401, { statusCode: 401, message: 'Unauthorized' });
|
||||
|
||||
if (req.method === 'GET' && p === '/nonprofit-dashboard/propresenter/nonprofits') {
|
||||
return answer(200, { data: { nonprofits: [NONPROFIT] } });
|
||||
}
|
||||
const sel = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/select$/.exec(p);
|
||||
if (req.method === 'POST' && sel) {
|
||||
if (sel[1] !== NONPROFIT.id) return answer(404, { statusCode: 404, message: 'Nonprofit not found' });
|
||||
session.nonprofitId = NONPROFIT.id;
|
||||
return answer(204);
|
||||
}
|
||||
const kw = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/keywords$/.exec(p);
|
||||
if (kw) {
|
||||
if (kw[1] !== NONPROFIT.id || session.nonprofitId !== NONPROFIT.id) return answer(403, { statusCode: 403, message: 'Forbidden' });
|
||||
if (req.method === 'GET') return answer(200, stampedKeywords());
|
||||
if (req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const keys = body && Array.isArray(body.keywords) ? body.keywords.map((k) => String(k).toLowerCase().trim()) : [];
|
||||
if (!keys.length) return answer(400, { statusCode: 400, message: 'keywords is required' });
|
||||
if (FAIL_SWITCH) return answer(500, { statusCode: 500, message: 'Internal server error' });
|
||||
for (const k of keys) {
|
||||
const entry = KEYWORDS.find((x) => x.keyword === k);
|
||||
if (!entry) {
|
||||
console.log(`[overflow] "${k}" is not a mapped keyword — ignored`);
|
||||
continue;
|
||||
}
|
||||
for (const g of entry.tapGroups) {
|
||||
const group = GROUPS.find((x) => x.id === g.id);
|
||||
if (group) group.destinationId = entry.destinationId;
|
||||
}
|
||||
console.log(`[overflow] ${entry.tapGroups.map((g) => g.tapGroupName).join(', ')} → ${entry.destinationName} (${k})`);
|
||||
}
|
||||
return answer(200, stampedKeywords());
|
||||
}
|
||||
}
|
||||
answer(404, { statusCode: 404, message: `Cannot ${req.method} ${p}` });
|
||||
}).listen(OF_PORT, '127.0.0.1', () => {
|
||||
console.log(`[overflow] mock Overflow Dashboard API on http://127.0.0.1:${OF_PORT}${FAIL_SWITCH ? ' (every switch answers 500)' : ''}${LOGIN ? ' (one login accepted)' : ''}${EXPIRE_S < 3600 ? ` (tokens expire after ${EXPIRE_S} s)` : ''}`);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* A stand-in for the ProPresenter Mac's network MIDI session, for the Vista
|
||||
* Cues module: an RTP-MIDI (AppleMIDI) session that accepts invitations and
|
||||
* plays a cue sheet's notes to everyone in it, the way ProPresenter's MIDI
|
||||
* actions reach Vista.
|
||||
*
|
||||
* node tools/vista-midi-mock.js 5104 # wait to be invited on UDP 5104/5105
|
||||
* node tools/vista-midi-mock.js 5104 --invite 127.0.0.1:5040
|
||||
* # …or invite ProdDash yourself
|
||||
* --name "Tracks Laptop" session name (default "ProPresenter Mock")
|
||||
* --every 4 seconds between cues (default 4)
|
||||
* --channel 1 MIDI channel (default 1)
|
||||
* --notes 2,23,8,30 play these notes in a loop instead of the built-in sheet's
|
||||
*
|
||||
* Point Vista Cues' "First sender" at 127.0.0.1 port 5104 and paste the
|
||||
* sheet this prints into "Cue lists". Kill it to watch the tile go red;
|
||||
* start it again to watch it come back.
|
||||
*
|
||||
* Written separately from the module's own rtpmidi.js on purpose, so a bug
|
||||
* there is not mirrored here.
|
||||
*/
|
||||
|
||||
const dgram = require('dgram');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const opt = (name, dflt) => {
|
||||
const i = argv.indexOf(`--${name}`);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const PORT = Number(argv.find((a) => /^\d+$/.test(a))) || 5104;
|
||||
const NAME = opt('name', 'ProPresenter Mock');
|
||||
const EVERY = Number(opt('every', 4)) * 1000;
|
||||
const CHANNEL = Math.min(16, Math.max(1, Number(opt('channel', 1)))) - 1;
|
||||
const INVITE = opt('invite', '');
|
||||
|
||||
const SHEET = [
|
||||
'60 = Walk-in',
|
||||
'61 = Worship 1',
|
||||
'62 = Worship 2',
|
||||
'63 = Message',
|
||||
'64 pause Worship 2',
|
||||
'70 release Walk-in',
|
||||
'71 release Worship 1',
|
||||
'127 release all',
|
||||
];
|
||||
const NOTES = opt('notes', '');
|
||||
const SCRIPT = NOTES ? NOTES.split(',').map(Number).filter((n) => n >= 0 && n <= 127) : [60, 61, 70, 62, 64, 64, 71, 90, 63, 127];
|
||||
|
||||
const ssrc = (Math.random() * 0xffffffff) >>> 0;
|
||||
const t0 = process.hrtime.bigint();
|
||||
const now = () => (process.hrtime.bigint() - t0) / 100000n;
|
||||
let seq = Math.floor(Math.random() * 0xffff);
|
||||
|
||||
const peers = new Map(); // ssrc → { address, controlPort, dataPort, token, name, ready }
|
||||
|
||||
function session(cmd, token, name) {
|
||||
const n = name == null ? Buffer.alloc(0) : Buffer.from(`${name}\0`);
|
||||
const b = Buffer.alloc(16 + n.length);
|
||||
b.writeUInt16BE(0xffff, 0);
|
||||
b.write(cmd, 2, 'ascii');
|
||||
b.writeUInt32BE(2, 4);
|
||||
b.writeUInt32BE(token >>> 0, 8);
|
||||
b.writeUInt32BE(ssrc, 12);
|
||||
n.copy(b, 16);
|
||||
return b;
|
||||
}
|
||||
|
||||
function ck(count, ts) {
|
||||
const b = Buffer.alloc(36);
|
||||
b.writeUInt16BE(0xffff, 0);
|
||||
b.write('CK', 2, 'ascii');
|
||||
b.writeUInt32BE(ssrc, 4);
|
||||
b[8] = count;
|
||||
ts.forEach((t, i) => b.writeBigUInt64BE(BigInt.asUintN(64, t), 12 + i * 8));
|
||||
return b;
|
||||
}
|
||||
|
||||
/** One RTP-MIDI packet: note on, then (delta 0, running status) the note off. */
|
||||
function notePacket(note) {
|
||||
const midi = Buffer.from([0x90 | CHANNEL, note, 100, 0x00, note, 0x00]);
|
||||
const b = Buffer.alloc(12 + 1 + midi.length);
|
||||
b[0] = 0x80;
|
||||
b[1] = 0x61;
|
||||
seq = (seq + 1) & 0xffff;
|
||||
b.writeUInt16BE(seq, 2);
|
||||
b.writeUInt32BE(Number(now() & 0xffffffffn), 4);
|
||||
b.writeUInt32BE(ssrc, 8);
|
||||
b[12] = midi.length; // B=0 J=0 Z=0 P=0, LEN
|
||||
midi.copy(b, 13);
|
||||
return b;
|
||||
}
|
||||
|
||||
const control = dgram.createSocket('udp4');
|
||||
const data = dgram.createSocket('udp4');
|
||||
|
||||
function onPacket(sock, which) {
|
||||
return (buf, r) => {
|
||||
if (buf.length < 4 || buf.readUInt16BE(0) !== 0xffff) return;
|
||||
const cmd = buf.toString('ascii', 2, 4);
|
||||
if (cmd === 'IN') {
|
||||
const token = buf.readUInt32BE(8);
|
||||
const them = buf.readUInt32BE(12);
|
||||
const end = buf.indexOf(0, 16);
|
||||
const name = buf.toString('utf8', 16, end < 0 ? buf.length : end);
|
||||
sock.send(session('OK', token, NAME), r.port, r.address);
|
||||
const p = peers.get(them) || { address: r.address, token, name, ready: false };
|
||||
if (which === 'control') p.controlPort = r.port;
|
||||
else { p.dataPort = r.port; p.ready = true; console.log(`[mock] "${name}" joined from ${r.address}`); }
|
||||
peers.set(them, p);
|
||||
} else if (cmd === 'OK') {
|
||||
// Our own invitation (--invite) was accepted.
|
||||
const them = buf.readUInt32BE(12);
|
||||
const end = buf.indexOf(0, 16);
|
||||
const name = buf.toString('utf8', 16, end < 0 ? buf.length : end);
|
||||
if (which === 'control') {
|
||||
pending.stage = 'data';
|
||||
data.send(session('IN', pending.token, NAME), pending.port + 1, pending.host);
|
||||
} else {
|
||||
pending.stage = 'done';
|
||||
peers.set(them, { address: r.address, controlPort: pending.port, dataPort: r.port, token: pending.token, name, ready: true });
|
||||
console.log(`[mock] joined "${name}" at ${r.address}:${pending.port}`);
|
||||
}
|
||||
} else if (cmd === 'BY') {
|
||||
const them = buf.readUInt32BE(12);
|
||||
if (peers.delete(them)) console.log('[mock] a participant left');
|
||||
} else if (cmd === 'CK') {
|
||||
const count = buf[8];
|
||||
const ts = [buf.readBigUInt64BE(12), buf.readBigUInt64BE(20), buf.readBigUInt64BE(28)];
|
||||
if (count === 0) data.send(ck(1, [ts[0], now(), 0n]), r.port, r.address);
|
||||
else if (count === 1) data.send(ck(2, [ts[0], ts[1], now()]), r.port, r.address);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const pending = { stage: '', host: '', port: 0, token: 0 };
|
||||
|
||||
control.on('message', onPacket(control, 'control'));
|
||||
data.on('message', onPacket(data, 'data'));
|
||||
control.bind(PORT, () => data.bind(PORT + 1, () => {
|
||||
console.log(`[mock] session "${NAME}" on UDP ${PORT}/${PORT + 1}, channel ${CHANNEL + 1}`);
|
||||
console.log('[mock] cue sheet for Vista Cues → Cue lists:\n ' + SHEET.join('\n '));
|
||||
if (INVITE) {
|
||||
const [host, port] = INVITE.split(':');
|
||||
Object.assign(pending, { stage: 'control', host, port: Number(port) || 5040, token: (Math.random() * 0xffffffff) >>> 0 });
|
||||
const tryInvite = () => {
|
||||
if (pending.stage === 'done') return;
|
||||
pending.stage = 'control';
|
||||
control.send(session('IN', pending.token, NAME), pending.port, pending.host);
|
||||
setTimeout(tryInvite, 2000);
|
||||
};
|
||||
tryInvite();
|
||||
}
|
||||
}));
|
||||
|
||||
// Clock sync, as the session that started it would do (harmless otherwise).
|
||||
setInterval(() => {
|
||||
for (const p of peers.values()) if (p.ready) data.send(ck(0, [now(), 0n, 0n]), p.dataPort, p.address);
|
||||
}, 10000);
|
||||
|
||||
let step = 0;
|
||||
setInterval(() => {
|
||||
const ready = [...peers.values()].filter((p) => p.ready);
|
||||
if (!ready.length) return;
|
||||
const note = SCRIPT[step % SCRIPT.length];
|
||||
step += 1;
|
||||
const pkt = notePacket(note);
|
||||
for (const p of ready) data.send(pkt, p.dataPort, p.address);
|
||||
console.log(`[mock] note ${note} → ${ready.length} participant${ready.length === 1 ? '' : 's'}`);
|
||||
}, EVERY);
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
for (const p of peers.values()) control.send(session('BY', p.token), p.controlPort, p.address);
|
||||
setTimeout(() => process.exit(0), 100);
|
||||
});
|
||||
Reference in New Issue
Block a user