From 3a7fd60d06d95d76fe90361387c1e972e9f13568 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 28 May 2026 08:13:39 -0400 Subject: [PATCH] chat.html: auto-connect via signaling relay (no copy-paste handshake) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both peers type the same rendezvous code and connect automatically through proxy.uncloseai.com — no manual offer/answer relay. The code derives an opaque room ID and an AES key that wraps the SDP before it leaves the page, so the relay sees only ciphertext. The existing share-link/QR exchange stays as the offline fallback. Also caps waitForIceGathering with a timeout: some environments never fire the ICE 'complete' transition even after all candidates are gathered, which previously hung offer/answer creation forever (affected the manual path too). --- web/chat.html | 185 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 179 insertions(+), 6 deletions(-) diff --git a/web/chat.html b/web/chat.html index b79721d..972a0ed 100644 --- a/web/chat.html +++ b/web/chat.html @@ -263,6 +263,28 @@

+
+

auto-connect · signaling relay (no copy-paste)

+

+ both peers type the same rendezvous code and click connect. SDP is + exchanged automatically through proxy.uncloseai.com — + 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. +

+
+ + +
+
+
+ not connected to relay + +
+
+ +

— or exchange manually (offline / no relay reachable) —

+

role A · start a new connection

step 1: click, then share the link with your peer (Signal, SMS, anywhere). They open it on their device.

@@ -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://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 * * ============================================================== */