The single shared mesh is replaced by per-session rooms. Visit / and the server mints a random 8-hex-char id, redirects to /r/<id>. That URL IS the session — share the link (or scan the QR code now shown on the page) on another device to join the same room. Bus is now sharded per room. Rooms are created implicitly on first subscribe and GC'd 5 minutes after the last subscriber leaves. No accounts, no persistence, no server-side state beyond the in-memory bus map. Server: - New endpoints: /, /r/<id>, /api/send?room=, /api/stream?room= - Room manager with lazy creation + idle GC - Metrics now labelled by room - New gauge tether_active_rooms Client (Go): - -room flag (accepts bare id OR full /r/<id> URL — paste-friendly) - All API calls now scope to the room - The always-on ct210-rtc-peer systemd unit is disabled — sessions are user-initiated; the user runs tether-client with -room when they want their laptop in a particular session Browser (HTML): - Reads room from /r/<id> path - Shows QR code + URL + "copy link" button at top - "+ new session" link in header to start a fresh room Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
299 lines
13 KiB
HTML
299 lines
13 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
|
<meta name="theme-color" content="#0a0a0a" />
|
|
<title>tether</title>
|
|
<style>
|
|
:root { color-scheme: dark; }
|
|
* { box-sizing: border-box; }
|
|
html, body { margin: 0; height: 100%; }
|
|
body {
|
|
font: 15px -apple-system, "SF Pro Text", system-ui, sans-serif;
|
|
background: #0a0a0a; color: #e5e5e5;
|
|
display: flex; flex-direction: column;
|
|
padding: 18px 16px env(safe-area-inset-bottom);
|
|
gap: 14px;
|
|
}
|
|
header { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
|
h1 { margin: 0; font-size: 22px; font-weight: 600; letter-spacing: -0.5px; }
|
|
.tag { font-size: 11px; color: #888; letter-spacing: 0.5px; text-transform: uppercase; }
|
|
.pill { font-size: 10px; padding: 2px 8px; border-radius: 999px; background: #1f1f1f; color: #888; letter-spacing: 0.4px; }
|
|
.pill.live { background: #052e16; color: #4ade80; }
|
|
.pill.connecting { background: #1f2937; color: #fbbf24; }
|
|
.room {
|
|
font: 12px ui-monospace, "SF Mono", monospace;
|
|
padding: 2px 8px; border-radius: 6px;
|
|
background: #1a1a1a; color: #a3a3a3;
|
|
cursor: pointer; user-select: all;
|
|
}
|
|
.room:hover { color: #f5f5f5; }
|
|
.row { display: flex; flex-direction: column; gap: 6px; }
|
|
label { font-size: 11px; color: #888; letter-spacing: 0.4px; text-transform: uppercase; }
|
|
|
|
.share {
|
|
display: flex; gap: 12px; align-items: center;
|
|
background: #131313; border: 1px solid #1f1f1f; border-radius: 10px;
|
|
padding: 12px;
|
|
}
|
|
.share .qr {
|
|
width: 110px; height: 110px;
|
|
flex-shrink: 0;
|
|
background: #fff; padding: 8px; border-radius: 6px;
|
|
}
|
|
.share .qr img { width: 100%; height: 100%; }
|
|
.share .info { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
|
.share .info small { color: #888; font-size: 11px; letter-spacing: 0.4px; text-transform: uppercase; }
|
|
.share .info .url {
|
|
font: 13px ui-monospace, "SF Mono", monospace; color: #d4d4d4;
|
|
word-break: break-all; user-select: all;
|
|
}
|
|
.share .info button {
|
|
align-self: flex-start; font-size: 12px; padding: 6px 12px;
|
|
background: #1a1a1a; color: #d4d4d4;
|
|
border: 1px solid #2a2a2a; border-radius: 6px; cursor: pointer;
|
|
}
|
|
.share .info button:hover { background: #222; }
|
|
|
|
textarea {
|
|
width: 100%; min-height: 130px; resize: vertical;
|
|
font: 15px -apple-system, ui-monospace, "SF Mono", monospace;
|
|
background: #161616; color: #f5f5f5;
|
|
border: 1px solid #232323; border-radius: 10px;
|
|
padding: 12px; outline: none;
|
|
}
|
|
textarea:focus { border-color: #3a3a3a; }
|
|
.actions { display: flex; gap: 8px; }
|
|
button {
|
|
flex: 1; padding: 12px 16px; font: 600 14px -apple-system, system-ui, sans-serif;
|
|
background: #1a1a1a; color: #f5f5f5;
|
|
border: 1px solid #2a2a2a; border-radius: 10px;
|
|
cursor: pointer; transition: background 0.15s;
|
|
}
|
|
button.primary { background: #4f46e5; border-color: #6366f1; }
|
|
button.primary:active { background: #4338ca; }
|
|
button:active { background: #232323; }
|
|
.status {
|
|
font-size: 13px; color: #888;
|
|
padding: 8px 12px; background: #131313;
|
|
border: 1px solid #1f1f1f; border-radius: 8px;
|
|
min-height: 38px; display: flex; align-items: center;
|
|
}
|
|
.status.ok { color: #4ade80; }
|
|
.status.err { color: #f87171; }
|
|
.feed { display: flex; flex-direction: column; gap: 6px; }
|
|
.feed h2 { font-size: 11px; color: #888; letter-spacing: 0.5px; text-transform: uppercase; margin: 0; }
|
|
.msg {
|
|
background: #131313; border: 1px solid #1f1f1f; border-radius: 8px;
|
|
padding: 10px 12px; font: 13px ui-monospace, "SF Mono", monospace;
|
|
color: #d4d4d4; white-space: pre-wrap; word-break: break-word;
|
|
max-height: 200px; overflow: auto;
|
|
}
|
|
.meta { font-size: 11px; color: #666; margin-top: 4px; }
|
|
.meta .rtc-badge { color: #4ade80; }
|
|
.peers { font-size: 11px; color: #666; 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;
|
|
}
|
|
footer a { color: #888; text-decoration: none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>tether</h1>
|
|
<span class="tag">mesh clipboard</span>
|
|
<span class="pill" id="rtcPill">sse</span>
|
|
<span class="room" id="roomTag" title="room id">…</span>
|
|
<a href="/" style="margin-left:auto; font-size:11px; color:#888;">+ new session</a>
|
|
</header>
|
|
|
|
<div class="share">
|
|
<div class="qr"><img id="qrImg" alt="QR" /></div>
|
|
<div class="info">
|
|
<small>scan to join this session</small>
|
|
<div class="url" id="sessionUrl">…</div>
|
|
<button id="copyBtn">copy link</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="peers" id="peerList"></div>
|
|
|
|
<div class="row">
|
|
<label for="out">send</label>
|
|
<textarea id="out" placeholder="paste or type something…"></textarea>
|
|
<div class="actions">
|
|
<button id="pasteBtn">paste clipboard</button>
|
|
<button class="primary" id="sendBtn">send →</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="status" id="status">idle</div>
|
|
|
|
<div class="feed">
|
|
<h2>received</h2>
|
|
<div id="incoming"></div>
|
|
</div>
|
|
|
|
<footer>
|
|
<a href="https://gitea.pecord.io/pecord/tether">tether on gitea</a> · v0.5
|
|
</footer>
|
|
|
|
<script>
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
// ── Session room ──────────────────────────────────────────────────────
|
|
// URL is /r/<id>. If somehow we don't have one, redirect home (server will mint).
|
|
const roomMatch = window.location.pathname.match(/^\/r\/([a-zA-Z0-9_-]+)/);
|
|
if (!roomMatch) { window.location.replace("/"); }
|
|
const room = roomMatch ? roomMatch[1] : "";
|
|
const fullUrl = window.location.origin + "/r/" + room;
|
|
const peerID = "browser-" + Array.from(crypto.getRandomValues(new Uint8Array(6))).map(b=>b.toString(16).padStart(2,"0")).join("");
|
|
|
|
$("roomTag").textContent = room;
|
|
$("sessionUrl").textContent = fullUrl;
|
|
$("qrImg").src = "https://api.qrserver.com/v1/create-qr-code/?size=240x240&margin=0&data=" + encodeURIComponent(fullUrl);
|
|
|
|
$("copyBtn").addEventListener("click", async () => {
|
|
try { await navigator.clipboard.writeText(fullUrl); status("link copied ✓", "ok"); }
|
|
catch (e) { status("clipboard denied", "err"); }
|
|
});
|
|
|
|
// ── State ─────────────────────────────────────────────────────────────
|
|
const remotePeers = new Map();
|
|
|
|
const status = (msg, cls) => { const s = $("status"); s.textContent = msg; s.className = "status" + (cls ? " " + cls : ""); };
|
|
const setPill = (mode) => { const p = $("rtcPill"); p.textContent = mode; p.className = "pill " + (mode === "rtc" ? "live" : mode === "negotiating" ? "connecting" : ""); };
|
|
function refreshPill() {
|
|
let anyRTC = false, anyConn = false;
|
|
for (const r of remotePeers.values()) {
|
|
if (r.dc && r.dc.readyState === "open") anyRTC = true;
|
|
else if (r.status === "connecting" || r.status === "new") anyConn = true;
|
|
}
|
|
setPill(anyRTC ? "rtc" : (anyConn ? "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");
|
|
el.className = "msg"; el.textContent = text;
|
|
const meta = document.createElement("div");
|
|
meta.className = "meta";
|
|
const badge = viaRTC ? '<span class="rtc-badge">via rtc</span> · ' : '';
|
|
meta.innerHTML = badge + (source || "client") + " @ " + new Date(ts || Date.now()).toLocaleTimeString();
|
|
el.appendChild(meta);
|
|
$("incoming").insertBefore(el, $("incoming").firstChild);
|
|
}
|
|
|
|
// ── Send ──────────────────────────────────────────────────────────────
|
|
$("pasteBtn").addEventListener("click", async () => {
|
|
try { $("out").value = await navigator.clipboard.readText(); status("pasted from clipboard", "ok"); }
|
|
catch (e) { status("clipboard read denied — paste manually", "err"); }
|
|
});
|
|
|
|
$("sendBtn").addEventListener("click", async () => {
|
|
const text = $("out").value;
|
|
if (!text) { status("empty", "err"); return; }
|
|
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; }
|
|
status("sending via http…");
|
|
try {
|
|
const r = await fetch("/api/send?room=" + room, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ type: "clipboard", text, source: "web", from: peerID, room }),
|
|
});
|
|
status(r.ok ? "delivered ✓" : "server returned " + r.status, r.ok ? "ok" : "err");
|
|
} catch (e) { status("network error", "err"); }
|
|
});
|
|
|
|
// ── Signaling ─────────────────────────────────────────────────────────
|
|
async function postMessage(m) {
|
|
m.from = m.from || peerID;
|
|
m.source = m.source || "web";
|
|
m.room = room;
|
|
await fetch("/api/send?room=" + room, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(m),
|
|
});
|
|
}
|
|
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 (_) {} }
|
|
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();
|
|
|
|
pc.onicecandidate = (ev) => { if (ev.candidate) postSignal(fromID, { kind: "ice", candidate: ev.candidate.toJSON() }); };
|
|
pc.onconnectionstatechange = () => {
|
|
r.status = pc.connectionState; refreshPill(); renderPeers();
|
|
if (pc.connectionState === "failed" || pc.connectionState === "closed") remotePeers.delete(fromID);
|
|
};
|
|
pc.ondatachannel = (ev) => {
|
|
const ch = ev.channel; 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(fromID, { kind: "answer", sdp: answer });
|
|
}
|
|
|
|
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);
|
|
postMessage({ type: "presence", role: "browser" }).catch(() => {});
|
|
|
|
// ── SSE main feed ──────────────────────────────────────────────────────
|
|
function connectFeed() {
|
|
const es = new EventSource("/api/stream?room=" + room);
|
|
es.addEventListener("clipboard", (ev) => {
|
|
try { const m = JSON.parse(ev.data); 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;
|
|
if (m.to && m.to !== peerID) return;
|
|
const p = m.signal;
|
|
if (!p) return;
|
|
if (p.kind === "offer" && p.sdp) handleOffer(m.from, p.sdp.sdp);
|
|
else if (p.kind === "ice" && p.candidate) handleIce(m.from, p.candidate);
|
|
} catch (e) {}
|
|
});
|
|
es.onerror = () => setTimeout(connectFeed, 2000);
|
|
}
|
|
connectFeed();
|
|
</script>
|
|
</body>
|
|
</html>
|