zebra-report: publish chat UX with signaling-relay auto-connect

This commit is contained in:
russell@unturf.com 2026-05-28 10:09:02 -04:00
parent 9303d5e97f
commit bc8191622c
No known key found for this signature in database

View file

@ -7,8 +7,8 @@
<style>
@font-face {
font-family: 'chunkfiveregular';
src: url('/css/chunkfive/chunkfive-regular-webfont.woff2') format('woff2'),
url('/css/chunkfive/chunkfive-regular-webfont.woff') format('woff');
src: url('fonts/chunkfive-regular-webfont.woff2') format('woff2'),
url('fonts/chunkfive-regular-webfont.woff') format('woff');
font-weight: normal; font-style: normal;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@ -263,6 +263,28 @@
</p>
</details>
<div class="rtc-role" style="border-style:solid">
<h3>auto-connect · signaling relay (no copy-paste)</h3>
<p class="step">
both peers type the same rendezvous code and click connect. SDP is
exchanged automatically through <code>proxy.uncloseai.com</code>
encrypted with the code first, so the relay only ever sees ciphertext.
this is just the connection bootstrap; pick a room passphrase above for
the chat content itself.
</p>
<div class="field-row">
<input type="text" id="rdv-code" placeholder="shared rendezvous code — both peers type the same thing">
<button id="btn-relay-connect" class="invert">connect</button>
</div>
<div class="row" style="margin-top:0.3rem">
<div class="dot warn" id="dot-relay"></div>
<span class="status-line" id="relay-status">not connected to relay</span>
<button id="btn-relay-disconnect" style="margin-left:auto" disabled>disconnect relay</button>
</div>
</div>
<p class="step" style="margin:0.9rem 0 0.2rem">— or exchange manually (offline / no relay reachable) —</p>
<div class="rtc-role">
<h3>role A · start a new connection</h3>
<p class="step">step 1: click, then share the link with your peer (Signal, SMS, anywhere). They open it on their device.</p>
@ -1154,6 +1176,7 @@ function ensurePC() {
}
function resetConnection() {
if (typeof disconnectRelay === 'function') disconnectRelay();
if (pc) {
try { pc.close(); } catch (_) {}
pc = null;
@ -1177,16 +1200,25 @@ function resetConnection() {
$('btn-reset-rtc').addEventListener('click', resetConnection);
function waitForIceGathering(p) {
/* Resolve when ICE gathering completes, OR after timeoutMs — whichever comes
* first. Some environments (and some TURN configs) never fire the 'complete'
* state transition even after every candidate has been gathered, which would
* otherwise hang offer/answer creation forever. On timeout we proceed with
* whatever candidates are already in the local description. */
function waitForIceGathering(p, timeoutMs = 6000) {
return new Promise(resolve => {
if (p.iceGatheringState === 'complete') return resolve();
const h = () => {
if (p.iceGatheringState === 'complete') {
p.removeEventListener('icegatheringstatechange', h);
resolve();
}
let done = false;
const finish = () => {
if (done) return;
done = true;
p.removeEventListener('icegatheringstatechange', h);
clearTimeout(timer);
resolve();
};
const h = () => { if (p.iceGatheringState === 'complete') finish(); };
p.addEventListener('icegatheringstatechange', h);
const timer = setTimeout(finish, timeoutMs);
});
}
@ -1279,6 +1311,147 @@ function attachToggleRaw(btnId, taId) {
attachToggleRaw('btn-toggle-offer-raw', 'local-offer');
attachToggleRaw('btn-toggle-answer-raw', 'local-answer');
/* ============================================================== *
* signaling relay — auto-connect, no copy-paste *
* *
* both peers type the same rendezvous code. it derives (a) an *
* opaque room id the relay routes on and (b) an AES key that wraps *
* the SDP before it leaves the page. the relay pairs the two peers *
* (first = offerer, second = answerer) and blindly forwards the *
* ciphertext. it never sees plaintext SDP or the code. *
* ============================================================== */
const SIGNAL_URL = new URLSearchParams(location.search).get('signal')
|| 'wss://cors-proxy.uncloseai.com/zebra-signal';
const SIGNAL_SALT = new TextEncoder().encode('zebra-signal-v1');
let relayWS = null;
let relayRole = null;
let relayKey = null;
async function deriveSignalRoom(code) {
const h = await crypto.subtle.digest('SHA-256',
new TextEncoder().encode('zebra-signal-room|' + code));
return hex(h); /* 64 hex chars — matches relay's room id charset */
}
async function deriveSignalKey(code) {
const baseKey = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(code), 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: SIGNAL_SALT, iterations: PBKDF2_ITER, hash: 'SHA-256' },
baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
}
function relaySet(msg, cls) {
const el = $('relay-status');
el.textContent = msg;
el.className = 'status-line' + (cls ? ' ' + cls : '');
}
function relaySend(obj) {
if (relayWS && relayWS.readyState === WebSocket.OPEN) relayWS.send(JSON.stringify(obj));
}
async function relaySendSDP(kind, desc) {
const ct = await aesEncrypt(relayKey, JSON.stringify(desc));
relaySend({ type: 'sdp', kind, data: b64(ct) });
}
async function relayMakeOffer() {
ensurePC();
const off = await pc.createOffer();
await pc.setLocalDescription(off);
await waitForIceGathering(pc);
await relaySendSDP('offer', pc.localDescription);
relaySet('offer sent — waiting for answer', 'ok');
}
async function onRelaySDP(kind, descJSON) {
const desc = JSON.parse(descJSON);
if (kind === 'offer') {
ensurePC();
await pc.setRemoteDescription(desc);
const ans = await pc.createAnswer();
await pc.setLocalDescription(ans);
await waitForIceGathering(pc);
await relaySendSDP('answer', pc.localDescription);
relaySet('answer sent — connecting', 'ok');
} else if (kind === 'answer') {
if (!pc) return;
await pc.setRemoteDescription(desc);
relaySet('answer received — connecting', 'ok');
}
}
async function connectRelay() {
const code = $('rdv-code').value.trim();
if (!code) { relaySet('enter a rendezvous code first', 'err'); return; }
if (relayWS) { relaySet('already connected — disconnect first'); return; }
if (!carrierOn) {
try {
await startCarrier();
$('dot-audio').className = 'dot on';
$('audio-status').textContent = 'online';
$('btn-audio').disabled = true;
logLine('sys', 'carrier auto-started for relay connect');
} catch (e) { relaySet('carrier failed: ' + e.message, 'err'); return; }
}
try {
relayKey = await deriveSignalKey(code);
const room = await deriveSignalRoom(code);
relaySet('connecting to relay…');
relayWS = new WebSocket(SIGNAL_URL + '?room=' + encodeURIComponent(room));
relayWS.onopen = () => {
$('dot-relay').className = 'dot ok';
$('btn-relay-connect').disabled = true;
$('btn-relay-disconnect').disabled = false;
logLine('sys', 'relay connected — waiting for peer');
};
relayWS.onclose = () => {
$('dot-relay').className = 'dot warn';
$('btn-relay-connect').disabled = false;
$('btn-relay-disconnect').disabled = true;
if (relayWS) relaySet('relay disconnected');
relayWS = null; relayRole = null;
};
relayWS.onerror = () => relaySet('relay connection error', 'err');
relayWS.onmessage = async (ev) => {
let m; try { m = JSON.parse(ev.data); } catch (_) { return; }
if (m.type === 'welcome') {
relayRole = m.role;
relaySet(`in room as ${m.role} — ${m.peers} peer(s) present`);
logLine('sys', `relay role: ${m.role} (${m.peers} present)`);
if (relayRole === 'offerer' && m.peers >= 2) await relayMakeOffer();
} else if (m.type === 'peer-joined') {
logLine('sys', 'peer joined the room');
if (relayRole === 'offerer') await relayMakeOffer();
} else if (m.type === 'peer-left') {
logLine('sys', 'peer left the room');
relaySet('peer left — waiting for a peer');
} else if (m.type === 'sdp') {
try {
const pt = await aesDecrypt(relayKey, unb64(m.data));
await onRelaySDP(m.kind, pt);
} catch (e) {
logLine('err', 'relay sdp failed (wrong code?): ' + e.message);
relaySet('sdp exchange failed — codes must match', 'err');
}
} else if (m.type === 'error') {
relaySet('relay: ' + m.message, 'err');
logLine('err', 'relay: ' + m.message);
}
};
} catch (e) {
relaySet('connect failed: ' + e.message, 'err');
if (relayWS) { try { relayWS.close(); } catch (_) {} relayWS = null; }
}
}
function disconnectRelay() {
if (relayWS) { const w = relayWS; relayWS = null; try { w.close(); } catch (_) {} }
$('dot-relay').className = 'dot warn';
$('btn-relay-connect').disabled = false;
$('btn-relay-disconnect').disabled = true;
relaySet('not connected to relay');
}
$('btn-relay-connect').addEventListener('click', connectRelay);
$('btn-relay-disconnect').addEventListener('click', disconnectRelay);
$('rdv-code').addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); connectRelay(); }
});
/* ============================================================== *
* mode toggle *
* ============================================================== */