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();