v0.4: symmetric presence chirps + per-peer mesh
Both Go peer and browser now broadcast {type:"presence", from, role}
every 10s on the bus. When either side sees a presence from someone
they don't yet have a RTCPeerConnection to, they initiate a new one
targeted at that specific peerID via the new "to" field on signal
messages. Each side keeps a map<peerID, RTCPeerConnection> instead of
the v0.3 single-connection model.
This means:
- N browsers can pair with M peers (true mesh)
- New tabs auto-discover existing peers via their next 10s chirp
- Restarts and network blips recover within 10s instead of needing
a manual browser refresh
- 45s lastSeen timeout sweeps disconnected peers and tears down their
PeerConnection
The browser UI now shows a row of peer chips that flip green when their
DataChannel opens. The pill shows "rtc" if *any* peer is open, else
"negotiating" if any are in progress, else "sse".
Go side regenerates a random peerID per process start (was static).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -63,6 +63,9 @@
|
||||
}
|
||||
.meta { font-size: 11px; color: #666; margin-top: 4px; }
|
||||
.meta .rtc-badge { color: #4ade80; }
|
||||
.peers { font-size: 11px; color: #666; margin-top: 4px; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.peers .peer { background: #131313; padding: 2px 8px; border-radius: 999px; border: 1px solid #1f1f1f; }
|
||||
.peers .peer.rtc { color: #4ade80; border-color: #052e16; }
|
||||
footer {
|
||||
margin-top: auto; padding-top: 8px;
|
||||
font-size: 11px; color: #555; text-align: center;
|
||||
@@ -73,12 +76,14 @@
|
||||
<body>
|
||||
<header>
|
||||
<h1>tether</h1>
|
||||
<span class="tag">phone ↔ laptop</span>
|
||||
<span class="tag">mesh clipboard</span>
|
||||
<span class="pill" id="rtcPill">sse</span>
|
||||
</header>
|
||||
|
||||
<div class="peers" id="peerList"></div>
|
||||
|
||||
<div class="row">
|
||||
<label for="out">send to laptop</label>
|
||||
<label for="out">send</label>
|
||||
<textarea id="out" placeholder="paste or type something…"></textarea>
|
||||
<div class="actions">
|
||||
<button id="pasteBtn">paste clipboard</button>
|
||||
@@ -89,18 +94,18 @@
|
||||
<div class="status" id="status">idle</div>
|
||||
|
||||
<div class="feed">
|
||||
<h2>received from laptop</h2>
|
||||
<h2>received</h2>
|
||||
<div id="incoming"></div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<a href="https://gitea.pecord.io/pecord/tether">tether on gitea</a> · v0.3
|
||||
<a href="https://gitea.pecord.io/pecord/tether">tether on gitea</a> · v0.4
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const peerID = "tether-browser-" + Math.random().toString(36).slice(2, 8);
|
||||
let rtcChannel = null; // open RTCDataChannel, when we have one
|
||||
const peerID = "browser-" + Array.from(crypto.getRandomValues(new Uint8Array(6))).map(b=>b.toString(16).padStart(2,"0")).join("");
|
||||
const remotePeers = new Map(); // remoteID -> {pc, dc, lastSeen, status}
|
||||
|
||||
const status = (msg, cls) => {
|
||||
const s = $("status");
|
||||
@@ -112,6 +117,25 @@
|
||||
p.textContent = mode;
|
||||
p.className = "pill " + (mode === "rtc" ? "live" : mode === "negotiating" ? "connecting" : "");
|
||||
};
|
||||
function refreshPill() {
|
||||
let anyRTC = false;
|
||||
let anyConnecting = false;
|
||||
for (const r of remotePeers.values()) {
|
||||
if (r.dc && r.dc.readyState === "open") anyRTC = true;
|
||||
else if (r.status === "connecting" || r.status === "new") anyConnecting = true;
|
||||
}
|
||||
setPill(anyRTC ? "rtc" : (anyConnecting ? "negotiating" : "sse"));
|
||||
}
|
||||
function renderPeers() {
|
||||
const el = $("peerList");
|
||||
el.innerHTML = "";
|
||||
for (const [id, r] of remotePeers.entries()) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "peer" + ((r.dc && r.dc.readyState === "open") ? " rtc" : "");
|
||||
span.textContent = id.slice(0, 12) + (r.dc?.readyState === "open" ? " 🟢" : " ⋯");
|
||||
el.appendChild(span);
|
||||
}
|
||||
}
|
||||
|
||||
function addIncoming(text, source, ts, viaRTC) {
|
||||
const el = document.createElement("div");
|
||||
@@ -138,93 +162,122 @@
|
||||
$("sendBtn").addEventListener("click", async () => {
|
||||
const text = $("out").value;
|
||||
if (!text) { status("empty", "err"); return; }
|
||||
if (rtcChannel && rtcChannel.readyState === "open") {
|
||||
rtcChannel.send(text);
|
||||
status("sent via rtc ✓", "ok");
|
||||
} else {
|
||||
status("sending via http…");
|
||||
try {
|
||||
const r = await fetch("/api/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "clipboard", text, source: "web", from: peerID }),
|
||||
});
|
||||
status(r.ok ? "delivered ✓" : "server returned " + r.status, r.ok ? "ok" : "err");
|
||||
} catch (e) { status("network error", "err"); }
|
||||
|
||||
// Fan out to all open DataChannels first
|
||||
let rtcSent = 0;
|
||||
for (const r of remotePeers.values()) {
|
||||
if (r.dc && r.dc.readyState === "open") {
|
||||
try { r.dc.send(text); rtcSent++; } catch (e) {}
|
||||
}
|
||||
}
|
||||
if (rtcSent > 0) {
|
||||
status(`sent via rtc to ${rtcSent} peer(s) ✓`, "ok");
|
||||
return;
|
||||
}
|
||||
// Fallback to HTTP for anyone not RTC-paired
|
||||
status("sending via http…");
|
||||
try {
|
||||
const r = await fetch("/api/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "clipboard", text, source: "web", from: peerID }),
|
||||
});
|
||||
status(r.ok ? "delivered ✓" : "server returned " + r.status, r.ok ? "ok" : "err");
|
||||
} catch (e) { status("network error", "err"); }
|
||||
});
|
||||
|
||||
// ── WebRTC peer ────────────────────────────────────────────────────────
|
||||
// Browser acts as ANSWERER. Listens for offers via SSE, replies via /api/send.
|
||||
async function postSignal(payload) {
|
||||
// ── Signaling ─────────────────────────────────────────────────────────
|
||||
async function postMessage(m) {
|
||||
m.from = m.from || peerID;
|
||||
m.source = m.source || "web";
|
||||
await fetch("/api/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "signal",
|
||||
from: peerID,
|
||||
source: "web",
|
||||
signal: payload,
|
||||
}),
|
||||
body: JSON.stringify(m),
|
||||
});
|
||||
}
|
||||
async function postSignal(toID, payload) {
|
||||
await postMessage({ type: "signal", to: toID, signal: payload });
|
||||
}
|
||||
|
||||
let pc = null;
|
||||
async function handleOffer(sdp) {
|
||||
if (pc) try { pc.close(); } catch (_) {}
|
||||
pc = new RTCPeerConnection({
|
||||
// We accept incoming offers (we're the answerer). Create a fresh PC per
|
||||
// remote peer.
|
||||
async function handleOffer(fromID, sdp) {
|
||||
// If we already have a pc for this peer, close it (re-negotiate).
|
||||
let r = remotePeers.get(fromID);
|
||||
if (r && r.pc) { try { r.pc.close(); } catch (_) {} }
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||
});
|
||||
setPill("negotiating");
|
||||
r = { pc, dc: null, lastSeen: Date.now(), status: "new" };
|
||||
remotePeers.set(fromID, r);
|
||||
refreshPill();
|
||||
renderPeers();
|
||||
|
||||
pc.onicecandidate = (ev) => {
|
||||
if (ev.candidate) postSignal({ kind: "ice", candidate: ev.candidate.toJSON() });
|
||||
if (ev.candidate) postSignal(fromID, { kind: "ice", candidate: ev.candidate.toJSON() });
|
||||
};
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (pc.connectionState === "connected") setPill("rtc");
|
||||
else if (pc.connectionState === "failed" || pc.connectionState === "disconnected") setPill("sse");
|
||||
r.status = pc.connectionState;
|
||||
refreshPill();
|
||||
renderPeers();
|
||||
if (pc.connectionState === "failed" || pc.connectionState === "closed") {
|
||||
remotePeers.delete(fromID);
|
||||
}
|
||||
};
|
||||
pc.ondatachannel = (ev) => {
|
||||
const ch = ev.channel;
|
||||
ch.onopen = () => { rtcChannel = ch; setPill("rtc"); };
|
||||
ch.onclose = () => { rtcChannel = null; setPill("sse"); };
|
||||
ch.onmessage = (m) => addIncoming(m.data, "laptop", Date.now(), true);
|
||||
r.dc = ch;
|
||||
ch.onopen = () => { refreshPill(); renderPeers(); };
|
||||
ch.onclose = () => { refreshPill(); renderPeers(); };
|
||||
ch.onmessage = (m) => addIncoming(m.data, fromID.slice(0, 12), Date.now(), true);
|
||||
};
|
||||
|
||||
await pc.setRemoteDescription({ type: "offer", sdp });
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
await postSignal({ kind: "answer", sdp: answer });
|
||||
await postSignal(fromID, { kind: "answer", sdp: answer });
|
||||
}
|
||||
|
||||
async function handleIceCandidate(candidate) {
|
||||
if (pc) {
|
||||
try { await pc.addIceCandidate(candidate); } catch (e) {}
|
||||
async function handleIce(fromID, candidate) {
|
||||
const r = remotePeers.get(fromID);
|
||||
if (r && r.pc) {
|
||||
try { await r.pc.addIceCandidate(candidate); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Presence chirp ────────────────────────────────────────────────────
|
||||
setInterval(() => {
|
||||
postMessage({ type: "presence", role: "browser" }).catch(() => {});
|
||||
}, 10000);
|
||||
// also do one immediately on load
|
||||
postMessage({ type: "presence", role: "browser" }).catch(() => {});
|
||||
|
||||
// ── SSE main feed ──────────────────────────────────────────────────────
|
||||
function connectFeed() {
|
||||
const es = new EventSource("/api/stream");
|
||||
es.addEventListener("clipboard", (ev) => {
|
||||
try {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.from !== peerID) { // hide our own sends, show everyone else
|
||||
addIncoming(m.text, m.source, m.ts, false);
|
||||
}
|
||||
if (m.from !== peerID) addIncoming(m.text, m.source, m.ts, false);
|
||||
} catch (e) {}
|
||||
});
|
||||
es.addEventListener("signal", (ev) => {
|
||||
try {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.from === peerID) return; // ignore our own
|
||||
if (m.from === peerID) return;
|
||||
if (m.to && m.to !== peerID) return; // not for us
|
||||
const payload = m.signal;
|
||||
if (!payload) return;
|
||||
if (payload.kind === "offer" && payload.sdp) {
|
||||
handleOffer(payload.sdp.sdp);
|
||||
} else if (payload.kind === "ice" && payload.candidate) {
|
||||
handleIceCandidate(payload.candidate);
|
||||
}
|
||||
if (payload.kind === "offer" && payload.sdp) handleOffer(m.from, payload.sdp.sdp);
|
||||
else if (payload.kind === "ice" && payload.candidate) handleIce(m.from, payload.candidate);
|
||||
} catch (e) {}
|
||||
});
|
||||
es.addEventListener("presence", (ev) => {
|
||||
// Browsers just note presence; peers (Go side) act on it.
|
||||
// We could render a "peer online" indicator here later.
|
||||
});
|
||||
es.onerror = () => setTimeout(connectFeed, 2000);
|
||||
}
|
||||
connectFeed();
|
||||
|
||||
Reference in New Issue
Block a user