zebra-report: publish chat UX with signaling-relay auto-connect
This commit is contained in:
parent
9303d5e97f
commit
bc8191622c
1 changed files with 181 additions and 8 deletions
|
|
@ -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 *
|
||||
* ============================================================== */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue