diff --git a/web/chat.html b/web/chat.html index fc4a821..bf75e0a 100644 --- a/web/chat.html +++ b/web/chat.html @@ -130,6 +130,27 @@ .rtc-role { margin-top: 0.6rem; padding: 0.5rem; border: 1px dashed #999; } .rtc-role h3 { font-size: 0.85rem; margin-bottom: 0.4rem; font-weight: normal; font-family: monospace; } .step { font-size: 0.7rem; color: #777; margin: 0.5rem 0 0.2rem; } + .share-box { + margin: 0.4rem 0; + display: grid; + grid-template-columns: 1fr auto; + gap: 0.5rem; + align-items: start; + } + .share-box .url-side { min-width: 0; } + .share-box .qr-canvas { + width: 180px; height: 180px; + border: 1px solid #ccc; background: #fff; + display: flex; align-items: center; justify-content: center; + font-size: 0.65rem; color: #999; + } + .share-box input { width: 100%; font-size: 0.7rem; } + .share-box .copy-row { display: flex; gap: 0.4rem; margin-top: 0.3rem; } + .share-box .copy-row button { font-size: 0.75rem; padding: 0.25rem 0.7rem; } + @media (max-width: 600px) { + .share-box { grid-template-columns: 1fr; } + .share-box .qr-canvas { justify-self: center; } + } @@ -244,14 +265,24 @@

role A · start a new connection

-

step 1: click, then copy the offer to your peer (any out-of-band channel):

+

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

- -

step 4: paste your peer's answer here:

- + + +

step 4: paste the answer link (or raw SDP) your peer sends back:

+
@@ -260,14 +291,24 @@

role B · join an existing connection

-

step 2: paste your peer's offer here:

- +

step 2: paste the offer link (or raw SDP) you received:

+
-

step 3: copy your answer to peer:

- +

step 3: share this answer link back to your peer.

+ +

@@ -356,6 +397,30 @@ + + + + + @@ -434,6 +499,63 @@ function escapeHtml(s) { ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch])); } +/* ============================================================== * + * share-link helpers: deflate-raw + base64-url-safe * + * ============================================================== */ +function b64UrlSafe(u8) { + return b64(u8).replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,''); +} +function unb64UrlSafe(s) { + s = s.replace(/-/g,'+').replace(/_/g,'/'); + while (s.length % 4) s += '='; + return unb64(s); +} +async function compressForUrl(text) { + const bytes = new TextEncoder().encode(text); + const cs = new CompressionStream('deflate-raw'); + const w = cs.writable.getWriter(); + w.write(bytes); w.close(); + const out = new Uint8Array(await new Response(cs.readable).arrayBuffer()); + return b64UrlSafe(out); +} +async function decompressFromUrl(b64s) { + const bytes = unb64UrlSafe(b64s); + const ds = new DecompressionStream('deflate-raw'); + const w = ds.writable.getWriter(); + w.write(bytes); w.close(); + const out = await new Response(ds.readable).arrayBuffer(); + return new TextDecoder().decode(out); +} +async function makeShareUrl(sdpDesc, kind /* 'o' | 'a' */) { + const c = await compressForUrl(JSON.stringify(sdpDesc)); + return `${location.origin}${location.pathname}#${kind}=${c}`; +} +/* Accepts a full URL with #o=/#a= hash, raw SDP JSON, or already-extracted base64. + * Returns { kind, sdpJSON } or throws. */ +async function parseShareInput(text) { + const t = (text || '').trim(); + if (!t) throw new Error('empty'); + const m = t.match(/[#?&]([oa])=([A-Za-z0-9_-]+)/); + if (m) { + const sdpJSON = await decompressFromUrl(m[2]); + return { kind: m[1], sdpJSON }; + } + const obj = JSON.parse(t); + if (obj.type === 'offer') return { kind: 'o', sdpJSON: t }; + if (obj.type === 'answer') return { kind: 'a', sdpJSON: t }; + throw new Error('not an offer or answer'); +} +function renderQR(containerId, text) { + const el = $(containerId); + if (!el) return; + el.innerHTML = ''; + try { + new QRCode(el, { text, width: 180, height: 180, correctLevel: QRCode.CorrectLevel.L }); + } catch (e) { + el.textContent = 'QR too long (' + text.length + ' chars). use the copy link button.'; + } +} + /* ============================================================== * * logging * * ============================================================== */ @@ -1075,8 +1197,13 @@ $('btn-create-offer').addEventListener('click', async () => { const off = await pc.createOffer(); await pc.setLocalDescription(off); await waitForIceGathering(pc); - $('local-offer').value = JSON.stringify(pc.localDescription); - $('offer-status').textContent = 'offer ready — copy & send to peer'; + const sdpJSON = JSON.stringify(pc.localDescription); + $('local-offer').value = sdpJSON; + const url = await makeShareUrl(pc.localDescription, 'o'); + $('share-offer-url').value = url; + $('share-offer-box').style.display = ''; + renderQR('qr-offer', url); + $('offer-status').textContent = 'offer ready — share the link or QR with your peer'; $('offer-status').className = 'status-line ok'; } catch (e) { $('offer-status').textContent = 'failed: ' + e.message; @@ -1087,14 +1214,21 @@ $('btn-create-offer').addEventListener('click', async () => { $('btn-create-answer').addEventListener('click', async () => { $('ans-status').textContent = 'parsing offer…'; try { - const off = JSON.parse($('remote-offer').value); + const parsed = await parseShareInput($('remote-offer').value); + if (parsed.kind !== 'o') throw new Error('expected an offer, got an answer'); + const off = JSON.parse(parsed.sdpJSON); ensurePC(); await pc.setRemoteDescription(off); const ans = await pc.createAnswer(); await pc.setLocalDescription(ans); await waitForIceGathering(pc); - $('local-answer').value = JSON.stringify(pc.localDescription); - $('ans-status').textContent = 'answer ready — copy & send to peer'; + const sdpJSON = JSON.stringify(pc.localDescription); + $('local-answer').value = sdpJSON; + const url = await makeShareUrl(pc.localDescription, 'a'); + $('share-answer-url').value = url; + $('share-answer-box').style.display = ''; + renderQR('qr-answer', url); + $('ans-status').textContent = 'answer ready — share the link or QR back to your peer'; $('ans-status').className = 'status-line ok'; } catch (e) { $('ans-status').textContent = 'failed: ' + e.message; @@ -1105,7 +1239,9 @@ $('btn-create-answer').addEventListener('click', async () => { $('btn-accept-answer').addEventListener('click', async () => { $('answer-status').textContent = 'parsing answer…'; try { - const ans = JSON.parse($('remote-answer').value); + const parsed = await parseShareInput($('remote-answer').value); + if (parsed.kind !== 'a') throw new Error('expected an answer, got an offer'); + const ans = JSON.parse(parsed.sdpJSON); if (!pc) throw new Error('no pending offer'); await pc.setRemoteDescription(ans); $('answer-status').textContent = 'answer accepted — waiting for ICE/connection'; @@ -1116,6 +1252,33 @@ $('btn-accept-answer').addEventListener('click', async () => { } }); +/* copy-link buttons */ +function attachCopy(btnId, inputId) { + $(btnId).addEventListener('click', () => { + const v = $(inputId).value; + if (!v) return; + navigator.clipboard.writeText(v).then(() => { + const orig = $(btnId).textContent; + $(btnId).textContent = 'copied'; + setTimeout(() => { $(btnId).textContent = orig; }, 1500); + }); + }); +} +attachCopy('btn-copy-offer-url', 'share-offer-url'); +attachCopy('btn-copy-answer-url', 'share-answer-url'); + +/* show/hide raw SDP toggle */ +function attachToggleRaw(btnId, taId) { + $(btnId).addEventListener('click', () => { + const ta = $(taId); + const show = ta.style.display === 'none'; + ta.style.display = show ? '' : 'none'; + $(btnId).textContent = show ? 'hide raw SDP' : 'show raw SDP'; + }); +} +attachToggleRaw('btn-toggle-offer-raw', 'local-offer'); +attachToggleRaw('btn-toggle-answer-raw', 'local-answer'); + /* ============================================================== * * mode toggle * * ============================================================== */ @@ -1313,6 +1476,33 @@ if (LOOPBACK) { logLine('sys', 'ready — start carrier, set up a WebRTC peer connection, then chat.'); logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry plaintext.'); +/* ============================================================== * + * page-load hash auto-handle: pre-fill offer when joining via link* + * ============================================================== */ +async function handleInboundLink() { + const m = location.hash.match(/^#([oa])=([A-Za-z0-9_-]+)$/); + if (!m) return; + try { + const sdpJSON = await decompressFromUrl(m[2]); + if (m[1] === 'o') { + $('remote-offer').value = sdpJSON; + logLine('sys', 'offer link detected — autofilled. enter passphrase + start audio, then click "create answer".'); + $('ans-status').textContent = 'offer pre-filled — complete identity + carrier, then create answer'; + $('remote-offer').scrollIntoView({ behavior: 'smooth', block: 'center' }); + } else { + $('remote-answer').value = sdpJSON; + logLine('sys', 'answer link detected — autofilled. click "accept answer" once your offer is still pending.'); + $('answer-status').textContent = 'answer pre-filled — click "accept answer"'; + } + /* clean hash so a reload doesn't re-trigger */ + history.replaceState(null, '', location.pathname); + } catch (e) { + logLine('err', 'inbound link parse failed: ' + e.message); + } +} +handleInboundLink(); +window.addEventListener('hashchange', handleInboundLink); + })();