diff --git a/server/web/index.html b/server/web/index.html index b7b5410..d29846d 100644 --- a/server/web/index.html +++ b/server/web/index.html @@ -262,32 +262,59 @@ } async function postSignal(toID, payload) { await postMessage({ type: "signal", to: toID, signal: payload }); } - async function handleOffer(fromID, sdp) { - let r = remotePeers.get(fromID); - if (r && r.pc) { try { r.pc.close(); } catch (_) {} } + function newPeer(remoteID, asOfferer) { const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }); - r = { pc, dc: null, lastSeen: Date.now(), status: "new" }; - remotePeers.set(fromID, r); - refreshPill(); renderPeers(); + const r = { pc, dc: null, lastSeen: Date.now(), status: "new", role: asOfferer ? "offerer" : "answerer" }; + remotePeers.set(remoteID, r); - pc.onicecandidate = (ev) => { if (ev.candidate) postSignal(fromID, { kind: "ice", candidate: ev.candidate.toJSON() }); }; + pc.onicecandidate = (ev) => { if (ev.candidate) postSignal(remoteID, { kind: "ice", candidate: ev.candidate.toJSON() }); }; pc.onconnectionstatechange = () => { r.status = pc.connectionState; refreshPill(); renderPeers(); - if (pc.connectionState === "failed" || pc.connectionState === "closed") remotePeers.delete(fromID); + if (pc.connectionState === "failed" || pc.connectionState === "closed") remotePeers.delete(remoteID); }; - pc.ondatachannel = (ev) => { - const ch = ev.channel; r.dc = ch; + const wireChannel = (ch) => { + r.dc = ch; ch.onopen = () => { refreshPill(); renderPeers(); }; ch.onclose = () => { refreshPill(); renderPeers(); }; - ch.onmessage = (m) => addIncoming(m.data, fromID.slice(0, 12), Date.now(), true); + ch.onmessage = (m) => addIncoming(m.data, remoteID.slice(0, 12), Date.now(), true); }; + if (asOfferer) { + // We create the channel; peer will receive via ondatachannel + wireChannel(pc.createDataChannel("tether")); + } else { + pc.ondatachannel = (ev) => wireChannel(ev.channel); + } + refreshPill(); renderPeers(); + return r; + } - await pc.setRemoteDescription({ type: "offer", sdp }); - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); + async function initiateOffer(toID) { + if (remotePeers.has(toID)) return; + const r = newPeer(toID, true); + const offer = await r.pc.createOffer(); + await r.pc.setLocalDescription(offer); + await postSignal(toID, { kind: "offer", sdp: offer }); + } + + async function handleOffer(fromID, sdp) { + let r = remotePeers.get(fromID); + // Tie-break: if we're already the offerer to this peer and our id sorts + // lower than theirs, ignore their offer — they should be answering ours. + if (r && r.role === "offerer" && peerID < fromID) return; + if (r && r.pc) { try { r.pc.close(); } catch (_) {} remotePeers.delete(fromID); } + r = newPeer(fromID, false); + await r.pc.setRemoteDescription({ type: "offer", sdp }); + const answer = await r.pc.createAnswer(); + await r.pc.setLocalDescription(answer); await postSignal(fromID, { kind: "answer", sdp: answer }); } + async function handleAnswer(fromID, sdp) { + const r = remotePeers.get(fromID); + if (!r || !r.pc || r.role !== "offerer") return; + try { await r.pc.setRemoteDescription({ type: "answer", sdp }); } catch (e) {} + } + async function handleIce(fromID, candidate) { const r = remotePeers.get(fromID); if (r && r.pc) { try { await r.pc.addIceCandidate(candidate); } catch (e) {} } @@ -312,9 +339,21 @@ const p = m.signal; if (!p) return; if (p.kind === "offer" && p.sdp) handleOffer(m.from, p.sdp.sdp); + else if (p.kind === "answer" && p.sdp) handleAnswer(m.from, p.sdp.sdp); else if (p.kind === "ice" && p.candidate) handleIce(m.from, p.candidate); } catch (e) {} }); + es.addEventListener("presence", (ev) => { + try { + const m = JSON.parse(ev.data); + if (!m.from || m.from === peerID) return; + // Deterministic initiator: the smaller-id peer creates the offer. + // If we already have a PC for them, do nothing. + if (peerID < m.from && !remotePeers.has(m.from)) { + initiateOffer(m.from); + } + } catch (e) {} + }); es.onerror = () => setTimeout(connectFeed, 2000); } connectFeed();