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 @@
+
+ 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.
+
— or exchange manually (offline / no relay reachable) —
+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 * * ============================================================== */