diff --git a/Makefile b/Makefile index b150fd8..e036190 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,16 @@ test-fsm: test-video-removal: @node test/video-track-removal.test.js +# Mesh state-sync invariant tests — pins the contract fox stated as +# "whatever one device shares all should see, and when unshared none +# should see." Runs the shipped handleRemoteSfuTrack + renderVideoTile + +# removeVideoTile + watchVideoTrackForRemoval against multi-peer +# scenarios with synthetic ontrack/mute/unmute/ended events. Catches +# regressions where one peer's publish/unpublish leaves another peer +# out of sync. +test-mesh: + @node test/multi-peer-mesh.test.js + # zebra-spaces JS↔Go protocol parity + vault + ed25519 + (optionally) a live # server flow. The live-server tier auto-runs when proxy.unturf.com sits # alongside this checkout AND has a Go toolchain — we build the relay binary @@ -51,7 +61,7 @@ test-zebra-spaces: ZEBRA_SPACES_BINARY=$$bin node test/zebra-spaces.test.js; \ rc=$$?; rm -f /tmp/zspc-signal-test; exit $$rc -test-all: test/unit test/integration test/functional test-web test-fsm test-video-removal test-zebra-spaces +test-all: test/unit test/integration test/functional test-web test-fsm test-video-removal test-mesh test-zebra-spaces @echo "--- unit ---" @./test/unit @echo "--- integration ---" @@ -64,6 +74,8 @@ test-all: test/unit test/integration test/functional test-web test-fsm test-vide @node test/zebra-fsm.test.js @echo "--- video-track-removal ---" @node test/video-track-removal.test.js + @echo "--- multi-peer-mesh ---" + @node test/multi-peer-mesh.test.js @echo "--- zebra-spaces ---" @$(MAKE) -s test-zebra-spaces diff --git a/test/multi-peer-mesh.test.js b/test/multi-peer-mesh.test.js new file mode 100644 index 0000000..a86feb9 --- /dev/null +++ b/test/multi-peer-mesh.test.js @@ -0,0 +1,477 @@ +#!/usr/bin/env node +/* multi-peer mesh state-sync tests. + * + * node test/multi-peer-mesh.test.js + * + * Pins the invariant fox stated bluntly: + * "whatever one device shares all should see, and when unshared none + * should see." + * + * Each test instantiates 2-3 fake "browser" sandboxes containing the + * actual shipped receive-side state machine from web/zebra-spaces.html + * (handleRemoteSfuTrack + renderVideoTile + removeVideoTile + + * watchVideoTrackForRemoval + the streams/maps they own). We then + * synthesize ontrack/mute/unmute/ended events that mirror what the + * SFU would push to each subscriber's PC, and assert on the resulting + * state of every browser's cameraStreams / screenStreams / gameStreams + * maps + tile DOM. + * + * No browser, no real WebRTC stack, no proxy server — pure Node. The + * page is the source of truth: extract the functions from HTML and + * sandbox them so the tests can never drift from the shipped code. */ + +const fs = require('fs'); +const path = require('path'); +const src = fs.readFileSync(path.join(__dirname, '..', 'web', 'zebra-spaces.html'), 'utf8'); + +/* lift a top-level function / const literal out of zebra-spaces.html by + * locating its head, then brace-matching. */ +function extractFn(re){ + const m = src.match(re); + if (!m) throw new Error('could not find ' + re); + let i = src.indexOf('{', m.index + m[0].length), depth = 0, j = i; + for (; j < src.length; j++){ + if (src[j] === '{') depth++; + else if (src[j] === '}') { if (--depth === 0) { j++; break; } } + } + return src.slice(m.index, j); +} +function extractConst(name){ + const re = new RegExp('const\\s+' + name + '\\s*=\\s*\\{'); + const m = src.match(re); + if (!m) throw new Error('could not find const ' + name); + let i = src.indexOf('{', m.index), depth = 0, j = i; + for (; j < src.length; j++){ + if (src[j] === '{') depth++; + else if (src[j] === '}') { if (--depth === 0) { j++; break; } } + } + if (src[j] === ';') j++; + return src.slice(m.index, j + 1); +} + +const watchSrc = extractFn(/function watchVideoTrackForRemoval\(/); +const renderTileSrc = extractFn(/function renderVideoTile\(/); +const removeTileSrc = extractFn(/function removeVideoTile\(/); +const renderScreenShim = extractFn(/function renderScreenTile\(/); +const removeScreenShim = extractFn(/function removeScreenTile\(/); +const renderCameraShim = extractFn(/function renderCameraTile\(/); +const removeCameraShim = extractFn(/function removeCameraTile\(/); +const handleTrackSrc = extractFn(/function handleRemoteSfuTrack\(/); +const tileKindsSrc = extractConst('TILE_KINDS'); + +/* shipped constants */ +const camWinMS = parseInt(src.match(/VIDEO_REMOVE_MUTE_WINDOW_MS\s*=\s*(\d+)/)[1], 10); +const screenWinMS = parseInt(src.match(/VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS\s*=\s*(\d+)/)[1], 10); + +/* ============================ fake DOM ============================ */ +function makeDom(){ + let nextId = 0; + function mkEl(tag){ + const el = { + tagName: tag.toUpperCase(), + id: '', + className: '', + _classes: new Set(), + children: [], + parent: null, + _listeners: {}, + addEventListener(ev, fn){ (this._listeners[ev] = this._listeners[ev] || []).push(fn); }, + appendChild(c){ c.parent = this; this.children.push(c); return c; }, + remove(){ + if (!this.parent) return; + const i = this.parent.children.indexOf(this); + if (i >= 0) this.parent.children.splice(i, 1); + this.parent = null; + }, + classList: null, + _attrs: {}, + setAttribute(k, v){ this._attrs[k] = v; }, + _gen: nextId++, + srcObject: null, + play(){ return Promise.resolve(); }, + }; + el.classList = { + add(c){ el._classes.add(c); }, + remove(c){ el._classes.delete(c); }, + contains(c){ return el._classes.has(c); }, + toggle(c, on){ + if (on === undefined) on = !el._classes.has(c); + if (on) el._classes.add(c); else el._classes.delete(c); + }, + }; + Object.defineProperty(el, 'innerHTML', { get(){ return ''; }, set(){} }); + Object.defineProperty(el, 'textContent', { get(){ return ''; }, set(){} }); + return el; + } + const containers = {}; + function ensureContainer(id){ + if (!containers[id]) containers[id] = mkEl('div'); + return containers[id]; + } + return { + $(id){ return ensureContainer(id); }, + createElement(tag){ return mkEl(tag); }, + containers, + }; +} + +/* ============================ fake MediaStream / MediaStreamTrack ============================ */ +let nextStreamId = 0; +function makeTrack(){ + const listeners = {}; + let muted = true; + let readyState = 'live'; + return { + kind: 'video', + muted, + readyState, + get muted(){ return muted; }, + set muted(v){ muted = v; }, + setMuted(v){ muted = v; }, + setReadyState(s){ readyState = s; }, + contentHint: '', + addEventListener(ev, fn){ (listeners[ev] = listeners[ev] || []).push(fn); }, + fire(ev){ for (const fn of (listeners[ev] || [])) fn(); }, + stop(){ + readyState = 'ended'; + muted = true; + // 'ended' fires when the track stops + for (const fn of (listeners.ended || [])) fn(); + }, + }; +} +class FakeMediaStream { + constructor(tracks){ + this.id = 's' + (nextStreamId++); + this.tracks = tracks ? [...tracks] : []; + } + getTracks(){ return this.tracks.slice(); } + getVideoTracks(){ return this.tracks.filter(t => t.kind === 'video'); } + addTrack(t){ this.tracks.push(t); } + removeTrack(t){ + const i = this.tracks.indexOf(t); + if (i >= 0) this.tracks.splice(i, 1); + } +} + +/* ============================ helpers (mirror the page) ============================ */ +function unb64(s){ return Buffer.from(s, 'base64').toString('binary'); } +function hexFromStr(s){ + let h = ''; + for (let i = 0; i < s.length; i++) h += s.charCodeAt(i).toString(16).padStart(2, '0'); + return h; +} +function shortHexFn(h){ return h.slice(0, 4) + '…' + h.slice(-4); } + +/* ============================ fake clock ============================ */ +function makeClock(){ + let now = 0; + let nextId = 1; + let pending = new Map(); + return { + setTimeout(fn, ms){ + const id = nextId++; + pending.set(id, { fireAt: now + ms, fn }); + return id; + }, + clearTimeout(id){ pending.delete(id); }, + advance(ms){ + now += ms; + const ready = [...pending.entries()] + .filter(([, t]) => t.fireAt <= now) + .sort((a, b) => a[1].fireAt - b[1].fireAt); + for (const [id, t] of ready){ + pending.delete(id); + t.fn(); + } + }, + pending(){ return pending.size; }, + }; +} + +/* ============================ Browser sandbox ============================ */ +function makeBrowser(name, pubKeyHex){ + const dom = makeDom(); + const clock = makeClock(); + /* Build the function scope. Inject: + * - shipped constants + * - storage maps the receive-side touches + * - DOM helpers ($) + * - the MediaStream + hex/unb64 helpers + * - logLine stub + * - spotlight + setSpotlight stubs (we don't assert on them here) */ + const fnSrc = + 'const cameraVideos = new Map();\n' + + 'const cameraStreams = new Map();\n' + + 'const screenVideos = new Map();\n' + + 'const screenStreams = new Map();\n' + + 'const gameVideos = new Map();\n' + + 'const gameStreams = new Map();\n' + + 'const sfuStreamsByPubHex = new Map();\n' + + 'const remoteAudio = new Map();\n' + + 'const peers = new Map();\n' + + 'let spotlight = null;\n' + + 'function setSpotlight(){}\n' + + 'function clearSpotlightDOM(){}\n' + + 'function pickNextSpotlight(){}\n' + + 'function updateContainerVisibility(){}\n' + + 'function buildTile(kind, pubHex, label, opts){\n' + + ' const tile = document.createElement("div");\n' + + ' const video = document.createElement("video");\n' + + ' tile.appendChild(video);\n' + + ' return { tile, video };\n' + + '}\n' + + 'function attachSfuTrack(){}\n' + + 'function canSpeak(role){ return role === "host" || role === "cohost" || role === "speaker"; }\n' + + 'function shortHex(h){ return shortHexFn(h); }\n' + + 'const myKeys = { pubHex: "' + pubKeyHex + '" };\n' + + 'let myRole = "speaker";\n' + + 'const members = new Map();\n' + + tileKindsSrc + '\n' + + watchSrc + '\n' + + renderTileSrc + '\n' + + removeTileSrc + '\n' + + renderScreenShim + '\n' + + removeScreenShim + '\n' + + renderCameraShim + '\n' + + removeCameraShim + '\n' + + handleTrackSrc + '\n' + + 'return {\n' + + ' handleRemoteSfuTrack,\n' + + ' members,\n' + + ' cameraStreams, screenStreams, gameStreams,\n' + + ' cameraVideos, screenVideos, gameVideos,\n' + + ' myKeys,\n' + + '};'; + + const factory = new Function( + '$', 'document', 'logLine', 'unb64', 'hex', 'shortHexFn', + 'MediaStream', 'setTimeout', 'clearTimeout', + 'VIDEO_REMOVE_MUTE_WINDOW_MS', 'VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS', + fnSrc, + ); + const ctx = factory( + dom.$.bind(dom), // $ — returns the container by id + { createElement: tag => dom.createElement(tag) }, // document.createElement + () => {}, // logLine stub (silent for tests) + unb64, hexFromStr, shortHexFn, + FakeMediaStream, + clock.setTimeout.bind(clock), clock.clearTimeout.bind(clock), + camWinMS, screenWinMS, + ); + return { name, pubHex: pubKeyHex, ctx, clock, dom }; +} + +/* short prefix of pubHex for streamID building */ +function shortPub(h){ return h.slice(0, 16); } + +/* register a member roster in browser b so handleRemoteSfuTrack can + * resolve a 16-char prefix back to a full pubHex. */ +function addMember(b, uuid, pubHex){ + /* members map uses pubkey in base64 form (the wire format). Encode + * our hex back to base64 for the resolver to find. */ + const pubB64 = Buffer.from(pubHex, 'hex').toString('base64'); + b.ctx.members.set(uuid, { uuid, pubkey: pubB64, handle: uuid, role: 'speaker' }); +} + +/* simulate an ontrack delivery from the SFU to one subscriber browser. */ +function deliverTrack(toBrowser, fromPubHex, kind, track){ + const sid = (kind === 'mic') + ? shortPub(fromPubHex) + : shortPub(fromPubHex) + '-' + kind; + const stream = new FakeMediaStream([track]); + stream.id = sid; + toBrowser.ctx.handleRemoteSfuTrack({ + streams: [stream], + track, + receiver: null, + }); +} + +/* ============================ test harness ============================ */ +let pass = 0, fail = 0; +function test(name, fn){ + try { fn(); console.log(' ✓ ' + name); pass++; } + catch (e){ console.log(' ✗ ' + name + ' — ' + (e.message || e)); fail++; } +} +function eq(a, b, msg){ + if (a !== b) throw new Error((msg || 'expected') + ' — got ' + JSON.stringify(a) + ' want ' + JSON.stringify(b)); +} +function truthy(v, msg){ if (!v) throw new Error(msg || 'expected truthy'); } +function falsy(v, msg){ if (v) throw new Error(msg || 'expected falsy'); } + +/* fixed pubkeys for the three peers */ +const PUB_A = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const PUB_B = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const PUB_C = 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'; + +/* ============================ tests ============================ */ + +console.log('multi-peer mesh:'); + +test('invariant: when one peer shares camera, every other peer ends up with their pubHex in cameraStreams', () => { + const A = makeBrowser('A', PUB_A); + const B = makeBrowser('B', PUB_B); + const C = makeBrowser('C', PUB_C); + for (const [self, peer1, peer2] of [[A, B, C], [B, A, C], [C, A, B]]){ + addMember(self, 'uuid-' + peer1.name, peer1.pubHex); + addMember(self, 'uuid-' + peer2.name, peer2.pubHex); + } + /* A publishes camera. Simulate: SFU delivers ontrack to B and C. */ + const trackToB = makeTrack(); trackToB.setMuted(false); + const trackToC = makeTrack(); trackToC.setMuted(false); + deliverTrack(B, PUB_A, 'camera', trackToB); + deliverTrack(C, PUB_A, 'camera', trackToC); + /* Every OTHER peer has A's pubHex in cameraStreams. A doesn't (own publish). */ + truthy(B.ctx.cameraStreams.has(PUB_A), "B should have A's camera"); + truthy(C.ctx.cameraStreams.has(PUB_A), "C should have A's camera"); + falsy(A.ctx.cameraStreams.has(PUB_A), "A should NOT have its own camera through SFU sub"); + /* Tile rendered on B and C */ + truthy(B.ctx.cameraVideos.has(PUB_A), "B should have a camera tile entry for A"); + truthy(C.ctx.cameraVideos.has(PUB_A), "C should have a camera tile entry for A"); +}); + +test('invariant: when A unshares (track ends), every other peer drops A from cameraStreams', () => { + const A = makeBrowser('A', PUB_A); + const B = makeBrowser('B', PUB_B); + const C = makeBrowser('C', PUB_C); + for (const [self, peer1, peer2] of [[A, B, C], [B, A, C], [C, A, B]]){ + addMember(self, 'uuid-' + peer1.name, peer1.pubHex); + addMember(self, 'uuid-' + peer2.name, peer2.pubHex); + } + const trackToB = makeTrack(); trackToB.setMuted(false); + const trackToC = makeTrack(); trackToC.setMuted(false); + deliverTrack(B, PUB_A, 'camera', trackToB); + deliverTrack(C, PUB_A, 'camera', trackToC); + /* A unpublishes — SFU stops the transceiver, browsers see 'ended' */ + trackToB.fire('ended'); + trackToC.fire('ended'); + falsy(B.ctx.cameraStreams.has(PUB_A), "B should NOT have A's camera after unshare"); + falsy(C.ctx.cameraStreams.has(PUB_A), "C should NOT have A's camera after unshare"); + falsy(B.ctx.cameraVideos.has(PUB_A), "B should have no camera tile entry for A"); + falsy(C.ctx.cameraVideos.has(PUB_A), "C should have no camera tile entry for A"); +}); + +test('invariant: A unshares mid-flow (mute timeout) — every other peer drops A from cameraStreams', () => { + const A = makeBrowser('A', PUB_A); + const B = makeBrowser('B', PUB_B); + for (const [self, peer1] of [[A, B], [B, A]]){ + addMember(self, 'uuid-' + peer1.name, peer1.pubHex); + } + const track = makeTrack(); + track.setMuted(false); /* flowing */ + deliverTrack(B, PUB_A, 'camera', track); + track.fire('unmute'); + truthy(B.ctx.cameraStreams.has(PUB_A)); + /* RTP stops (publisher killed, ICE failure on SFU side, etc.) */ + track.setMuted(true); track.fire('mute'); + B.clock.advance(camWinMS + 1000); + falsy(B.ctx.cameraStreams.has(PUB_A), "B should drop A's camera after mute timeout"); +}); + +test('hiccup supplant: when A republishes (same pubkey, new stream), B keeps the camera tile alive and points it at the new stream', () => { + const A = makeBrowser('A', PUB_A); + const B = makeBrowser('B', PUB_B); + for (const [self, peer1] of [[A, B], [B, A]]){ + addMember(self, 'uuid-' + peer1.name, peer1.pubHex); + } + /* first publish */ + const t1 = makeTrack(); t1.setMuted(false); + deliverTrack(B, PUB_A, 'camera', t1); + const stream1 = B.ctx.cameraStreams.get(PUB_A); + truthy(stream1, "B got A's first camera"); + /* second publish (hiccup rejoin) — same pubkey, new track. Per MSID- + * supplant safety the page builds a fresh MediaStream rather than + * trusting ev.streams[0]. */ + const t2 = makeTrack(); t2.setMuted(false); + deliverTrack(B, PUB_A, 'camera', t2); + const stream2 = B.ctx.cameraStreams.get(PUB_A); + truthy(stream2, "B should still have A's camera entry"); + truthy(stream1 !== stream2, "stream object should have been REPLACED with the new track's stream"); + /* now the OLD track fires ended (SFU stopped the old transceiver + * during the supplant). The stream-identity guard on the OLD + * watcher must NOT reap the tile that the NEW track installed. */ + t1.fire('ended'); + truthy(B.ctx.cameraStreams.has(PUB_A), "old-track 'ended' must NOT reap the new tile"); + truthy(B.ctx.cameraStreams.get(PUB_A) === stream2, "stream is still the new one after old ended"); +}); + +test('hiccup supplant followed by new track ALSO muting past window removes correctly', () => { + const A = makeBrowser('A', PUB_A); + const B = makeBrowser('B', PUB_B); + for (const [self, peer1] of [[A, B], [B, A]]){ + addMember(self, 'uuid-' + peer1.name, peer1.pubHex); + } + const t1 = makeTrack(); t1.setMuted(false); + deliverTrack(B, PUB_A, 'camera', t1); + const t2 = makeTrack(); t2.setMuted(false); + deliverTrack(B, PUB_A, 'camera', t2); + /* old goes ended — guard saves the new tile */ + t1.fire('ended'); + truthy(B.ctx.cameraStreams.has(PUB_A)); + /* new track flowed (unmute already implicit). Then RTP stops for + * real — should reap at window. */ + t2.fire('unmute'); + t2.setMuted(true); t2.fire('mute'); + B.clock.advance(camWinMS + 1000); + falsy(B.ctx.cameraStreams.has(PUB_A), "new tile reaped after sustained mute past window"); +}); + +test('echo guard: A does NOT add their OWN ontrack to cameraStreams', () => { + const A = makeBrowser('A', PUB_A); + const B = makeBrowser('B', PUB_B); + addMember(A, 'uuid-B', PUB_B); + const t = makeTrack(); t.setMuted(false); + /* SFU shouldn't deliver A's own publish to A, but defend regardless */ + deliverTrack(A, PUB_A, 'camera', t); + falsy(A.ctx.cameraStreams.has(PUB_A), "self-publish must be ignored by ontrack"); +}); + +test('screen and camera are independent: A publishes BOTH, B sees both, A unshares ONE, the other stays', () => { + const A = makeBrowser('A', PUB_A); + const B = makeBrowser('B', PUB_B); + addMember(A, 'uuid-B', PUB_B); + addMember(B, 'uuid-A', PUB_A); + const cam = makeTrack(); cam.setMuted(false); + const scr = makeTrack(); scr.setMuted(false); + deliverTrack(B, PUB_A, 'camera', cam); + deliverTrack(B, PUB_A, 'screen', scr); + truthy(B.ctx.cameraStreams.has(PUB_A)); + truthy(B.ctx.screenStreams.has(PUB_A)); + cam.fire('ended'); + falsy(B.ctx.cameraStreams.has(PUB_A), "camera should be gone"); + truthy(B.ctx.screenStreams.has(PUB_A), "screen should still be there"); + scr.fire('ended'); + falsy(B.ctx.screenStreams.has(PUB_A)); +}); + +test('three publishers fan-out: A B C all publish camera, every peer ends with exactly the other two', () => { + const A = makeBrowser('A', PUB_A); + const B = makeBrowser('B', PUB_B); + const C = makeBrowser('C', PUB_C); + for (const [self, peer1, peer2] of [[A, B, C], [B, A, C], [C, A, B]]){ + addMember(self, 'uuid-' + peer1.name, peer1.pubHex); + addMember(self, 'uuid-' + peer2.name, peer2.pubHex); + } + /* every peer publishes; every other peer receives */ + for (const [pub, others] of [[A, [B, C]], [B, [A, C]], [C, [A, B]]]){ + for (const o of others){ + const t = makeTrack(); t.setMuted(false); + deliverTrack(o, pub.pubHex, 'camera', t); + } + } + /* invariants */ + eq(A.ctx.cameraStreams.size, 2, "A should see exactly 2 cameras"); + truthy(A.ctx.cameraStreams.has(PUB_B)); + truthy(A.ctx.cameraStreams.has(PUB_C)); + eq(B.ctx.cameraStreams.size, 2); + truthy(B.ctx.cameraStreams.has(PUB_A)); + truthy(B.ctx.cameraStreams.has(PUB_C)); + eq(C.ctx.cameraStreams.size, 2); + truthy(C.ctx.cameraStreams.has(PUB_A)); + truthy(C.ctx.cameraStreams.has(PUB_B)); +}); + +/* ============================== summary ============================== */ +console.log('\n' + pass + ' passed, ' + fail + ' failed'); +process.exit(fail === 0 ? 0 : 1); diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index a3feb84..baa49b4 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -2415,103 +2415,112 @@ async function sfuUnpublish(){ } } +/* handleRemoteSfuTrack — receive-side mesh state machine. Called once + * per ontrack on the SFU sub PC. Extracted into a named function so + * test/multi-peer-mesh.test.js can drive it directly with synthetic + * events and verify the mesh invariants without needing a real + * RTCPeerConnection or SFU. The signature mirrors a real RTCTrackEvent: + * .streams[0] (MediaStream), .track (MediaStreamTrack), .receiver + * (RTCRtpReceiver — optional, only used for playoutDelayHint). */ +function handleRemoteSfuTrack(ev){ + const sid = ev.streams[0] ? ev.streams[0].id : ''; + if (!sid) return; + /* streamID format (RFC 7941 compliant — Firefox enforces 1*64 token- + * chars and rejects ':'): SHORT16HEX (mic) | SHORT16HEX-screen | + * SHORT16HEX-camera. Resolve the 16-char prefix back to a member's + * full pubkey via lookup so the rest of the code keeps using full + * pubhex as identity. */ + const dash = sid.indexOf('-'); + let pubHex16, kind; + if (dash > 0){ + pubHex16 = sid.slice(0, dash); + kind = sid.slice(dash + 1); + } else { + pubHex16 = sid; + kind = 'mic'; + } + /* skip echo of our own publish — match by prefix */ + if (myKeys && myKeys.pubHex.startsWith(pubHex16)) return; + /* resolve short prefix → full pubhex via member roster */ + let pubHex = pubHex16; + for (const [, mm] of members){ + try { + if (mm.pubkey){ + const fh = hex(unb64(mm.pubkey)); + if (fh.startsWith(pubHex16)){ pubHex = fh; break; } + } + } catch(_){} + } + if (kind === 'screen' || kind === 'camera' || kind === 'game'){ + logLine('', 'sfu ontrack: kind=' + kind + ' pub=' + pubHex + + ' track=' + ev.track.kind + ' mute=' + ev.track.muted + ' state=' + ev.track.readyState); + } + /* MSID-supplant safety: the SFU re-uses the same streamID + * (`shortPub-kind`) when a publisher supplants themselves. WebRTC + * merges the new track into the EXISTING MediaStream — ev.streams[0] + * is literally the same instance as before, containing both the + * dead old track AND the new live one. Setting srcObject to that + * stream doesn't switch the playing track; the video element keeps + * showing the (now-ended) old track's last frame and reports muted. + * Construct a fresh MediaStream containing only the new track so + * the video element binds to the new RTP flow cleanly. + * + * Stream-identity guard on removeFn: when the OLD track's mute → + * ended → removeFn would tear down the tile that the NEW track + * just installed. Only remove if the stream we registered against + * is still the one in the store for this pub. */ + if (kind === 'screen'){ + const s = new MediaStream([ev.track]); + screenStreams.set(pubHex, s); + renderScreenTile(pubHex, s); + watchVideoTrackForRemoval(ev.track, () => { if (screenStreams.get(pubHex) === s) removeScreenTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS); + return; + } + if (kind === 'camera'){ + const s = new MediaStream([ev.track]); + cameraStreams.set(pubHex, s); + renderCameraTile(pubHex, s); + watchVideoTrackForRemoval(ev.track, () => { if (cameraStreams.get(pubHex) === s) removeCameraTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_MS); + return; + } + if (kind === 'game'){ + /* a publisher is sharing their gameplay (Region-Capture cropped + * iframe). Route to its own TILE_KIND so it coexists with a + * normal screen-share from the same person. */ + const s = new MediaStream([ev.track]); + gameStreams.set(pubHex, s); + renderVideoTile('gameshare', pubHex, s); + watchVideoTrackForRemoval(ev.track, () => { if (gameStreams.get(pubHex) === s) removeVideoTile('gameshare', pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS); + return; + } + if (kind !== 'mic'){ + logLine('', 'sfu: unknown kind '+kind+' from '+pubHex); + return; + } + /* mic audio — 400ms jitter-buffer target absorbs Wi-Fi peak jitter */ + try { if (ev.receiver) ev.receiver.playoutDelayHint = 0.4; } catch(_){} + /* cache by full pubkey (already resolved above) so it survives the + * member's session uuid changing across leave/rejoin */ + sfuStreamsByPubHex.set(pubHex, ev.streams[0]); + for (const [uuid, mm] of members){ + try { + if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ + /* speakers get their peers' audio via mesh (lower latency) — + * skip the duplicate SFU mic. screens + cameras still came + * through above. */ + if (canSpeak(myRole) && peers.has(uuid)) return; + attachSfuTrack(uuid, ev.streams[0]); + return; + } + } catch(_){} + } + /* no matching member yet — flushSfuStreams will attach on peer-joined */ +} + async function sfuSubscribe(){ if (sfuSubPC || !roomID) return; const pc = new RTCPeerConnection(rtcConfig); - pc.ontrack = (ev) => { - const sid = ev.streams[0] ? ev.streams[0].id : ''; - if (!sid) return; - /* streamID format (RFC 7941 compliant — Firefox enforces 1*64 token- - * chars and rejects ':'): SHORT16HEX (mic) | SHORT16HEX-screen | - * SHORT16HEX-camera. Resolve the 16-char prefix back to a member's - * full pubkey via lookup so the rest of the code keeps using full - * pubhex as identity. */ - const dash = sid.indexOf('-'); - let pubHex16, kind; - if (dash > 0){ - pubHex16 = sid.slice(0, dash); - kind = sid.slice(dash + 1); - } else { - pubHex16 = sid; - kind = 'mic'; - } - /* skip echo of our own publish — match by prefix */ - if (myKeys && myKeys.pubHex.startsWith(pubHex16)) return; - /* resolve short prefix → full pubhex via member roster */ - let pubHex = pubHex16; - for (const [, mm] of members){ - try { - if (mm.pubkey){ - const fh = hex(unb64(mm.pubkey)); - if (fh.startsWith(pubHex16)){ pubHex = fh; break; } - } - } catch(_){} - } - if (kind === 'screen' || kind === 'camera' || kind === 'game'){ - logLine('', 'sfu ontrack: kind=' + kind + ' pub=' + pubHex + - ' track=' + ev.track.kind + ' mute=' + ev.track.muted + ' state=' + ev.track.readyState); - } - /* MSID-supplant safety: the SFU re-uses the same streamID - * (`shortPub-kind`) when a publisher supplants themselves. WebRTC - * merges the new track into the EXISTING MediaStream — ev.streams[0] - * is literally the same instance as before, containing both the - * dead old track AND the new live one. Setting srcObject to that - * stream doesn't switch the playing track; the video element keeps - * showing the (now-ended) old track's last frame and reports muted. - * Construct a fresh MediaStream containing only the new track so - * the video element binds to the new RTP flow cleanly. - * - * Stream-identity guard on removeFn: when the OLD track's mute → - * ended → removeFn would tear down the tile that the NEW track - * just installed. Only remove if the stream we registered against - * is still the one in the store for this pub. */ - if (kind === 'screen'){ - const s = new MediaStream([ev.track]); - screenStreams.set(pubHex, s); - renderScreenTile(pubHex, s); - watchVideoTrackForRemoval(ev.track, () => { if (screenStreams.get(pubHex) === s) removeScreenTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS); - return; - } - if (kind === 'camera'){ - const s = new MediaStream([ev.track]); - cameraStreams.set(pubHex, s); - renderCameraTile(pubHex, s); - watchVideoTrackForRemoval(ev.track, () => { if (cameraStreams.get(pubHex) === s) removeCameraTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_MS); - return; - } - if (kind === 'game'){ - /* a publisher is sharing their gameplay (Region-Capture cropped - * iframe). Route to its own TILE_KIND so it coexists with a - * normal screen-share from the same person. */ - const s = new MediaStream([ev.track]); - gameStreams.set(pubHex, s); - renderVideoTile('gameshare', pubHex, s); - watchVideoTrackForRemoval(ev.track, () => { if (gameStreams.get(pubHex) === s) removeVideoTile('gameshare', pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS); - return; - } - if (kind !== 'mic'){ - logLine('', 'sfu: unknown kind '+kind+' from '+pubHex); - return; - } - /* mic audio — 400ms jitter-buffer target absorbs Wi-Fi peak jitter */ - try { ev.receiver.playoutDelayHint = 0.4; } catch(_){} - /* cache by full pubkey (already resolved above) so it survives the - * member's session uuid changing across leave/rejoin */ - sfuStreamsByPubHex.set(pubHex, ev.streams[0]); - for (const [uuid, mm] of members){ - try { - if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ - /* speakers get their peers' audio via mesh (lower latency) — - * skip the duplicate SFU mic. screens + cameras still came - * through above. */ - if (canSpeak(myRole) && peers.has(uuid)) return; - attachSfuTrack(uuid, ev.streams[0]); - return; - } - } catch(_){} - } - /* no matching member yet — flushSfuStreams will attach on peer-joined */ - }; + pc.ontrack = handleRemoteSfuTrack; /* server-initiated offer: POST /subscribe (empty body) — SFU answers with * an SDP offer containing one m-line per current publisher. We answer it * and POST the answer back, which completes the initial handshake. */ @@ -3889,8 +3898,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');