zebra-report/web/chat.html
Russell Ballestrini 0ff2c788d6 chat.html: add proxy.uncloseai.com TURN to iceServers
with coturn live on proxy.uncloseai.com (proxy.unturf.com:22ccf93),
ICE now offers a relay candidate path on every connection. peers
behind symmetric NAT or strict firewalls fall back to TURN instead
of failing the connection.

static creds zebra:7a4a2b1c8d9e6f5a — public by design (a static
page can't hide them); rate-limited by coturn quotas, not secrecy.
google stun stays as fallback if our stun is unreachable.
2026-05-27 17:10:29 -04:00

1319 lines
49 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>zebra report — chat</title>
<style>
@font-face {
font-family: 'chunkfiveregular';
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; }
body {
font-family: monospace; background: #fff; color: #000;
padding: 2rem; max-width: 820px; margin: 0 auto;
}
h1 {
font-family: 'chunkfiveregular', serif;
font-size: 3rem; font-weight: normal;
letter-spacing: 0.02em; line-height: 1;
margin-bottom: 0.2rem;
}
.sub {
font-size: 0.75rem; color: #555; margin-bottom: 2.5rem;
letter-spacing: 0.05em; text-transform: uppercase;
}
section { margin-bottom: 2rem; }
h2 {
font-family: 'chunkfiveregular', serif;
font-size: 1.1rem; font-weight: normal;
border-bottom: 1px solid #000;
padding-bottom: 0.25rem; margin-bottom: 0.8rem;
}
button {
background: #fff; color: #000; border: 1px solid #000;
padding: 0.4rem 1.2rem; font-family: monospace; font-size: 0.85rem;
cursor: pointer;
}
button:hover:not(:disabled) { background: #f0f0f0; }
button:disabled { opacity: 0.3; cursor: default; }
button.invert { background: #000; color: #fff; }
button.invert:hover:not(:disabled) { background: #333; }
.row { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; flex-wrap: wrap; }
.dot {
width: 9px; height: 9px; border-radius: 50%;
border: 1px solid #000; background: #fff;
flex-shrink: 0; transition: background 0.15s;
}
.dot.on { background: #000; }
.dot.warn { background: #888; }
.dot.ok { background: #060; border-color: #060; }
label, .note { font-size: 0.75rem; color: #555; }
.note { margin-top: 0.4rem; line-height: 1.5; }
input[type=text], input[type=password], textarea, select {
font-family: monospace; font-size: 0.85rem;
border: 1px solid #000; padding: 0.4rem;
background: #fff; color: #000;
}
input[type=text], input[type=password], select { width: 100%; }
textarea { width: 100%; resize: vertical; min-height: 4.5rem; }
.field-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; }
.field-row > input, .field-row > textarea { flex: 1; }
.key-display {
border: 1px solid #ccc; padding: 0.5rem;
font-family: monospace; font-size: 0.7rem;
word-break: break-all; cursor: pointer;
user-select: all; background: #f8f8f8;
line-height: 1.4;
}
.key-display:hover { background: #efefef; }
.status-line { font-size: 0.7rem; color: #555; }
.status-line.ok { color: #060; }
.status-line.err { color: #b00; }
.meter {
height: 12px; border: 1px solid #000; background: #fff;
position: relative; overflow: hidden;
}
.meter-fill {
height: 100%; background: #000; width: 0%;
transition: width 0.05s linear;
}
.log {
border: 1px solid #000; height: 280px; overflow-y: auto;
padding: 0.5rem; font-size: 0.78rem; line-height: 1.5;
background: #fafafa;
}
.log-line { margin-bottom: 0.25rem; word-wrap: break-word; }
.log-line .ts { color: #888; }
.log-line .from { font-weight: bold; }
.log-line.sys { color: #555; font-style: italic; }
.log-line.me { color: #000; }
.log-line.peer { color: #050; }
.log-line.err { color: #b00; }
.peer-list {
font-size: 0.75rem; color: #444;
border: 1px solid #ccc; padding: 0.5rem;
background: #f8f8f8;
}
.peer-list .peer { margin-bottom: 0.15rem; }
.peer-list .peer .h { font-weight: bold; }
.peer-list .empty { color: #999; font-style: italic; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 600px) {
body { padding: 1rem; }
.grid-2 { grid-template-columns: 1fr; }
}
code {
background: #f0f0f0; padding: 0 0.2rem;
font-size: 0.78rem; border: 1px solid #ddd;
}
details > summary {
cursor: pointer; font-size: 0.8rem;
padding: 0.3rem 0; user-select: none;
}
details[open] > summary { margin-bottom: 0.5rem; }
.mode-toggle {
display: inline-flex; border: 1px solid #000;
font-size: 0.78rem; user-select: none;
}
.mode-toggle > label {
padding: 0.35rem 0.8rem; cursor: pointer;
color: #000;
}
.mode-toggle > input { display: none; }
.mode-toggle > input:checked + label { background: #000; color: #fff; }
.mode-panel { display: none; }
.mode-panel.active { display: block; }
.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; }
</style>
</head>
<body>
<h1>zebra report</h1>
<p class="sub">volume modem chatroom &nbsp;·&nbsp; e2e encrypted &nbsp;·&nbsp; webrtc carrier &nbsp;·&nbsp;
<a href="/" style="color:#555">unturf</a></p>
<!-- ============================================================ -->
<section>
<h2>identity</h2>
<div class="row">
<label for="handle-in">handle:</label>
<input type="text" id="handle-in" style="max-width:14rem" placeholder="your handle">
<span class="status-line" id="id-status"></span>
</div>
<div class="row" style="margin-top:0.4rem">
<span class="mode-toggle">
<input type="radio" name="mode" value="passphrase" id="m-pp" checked>
<label for="m-pp">passphrase</label>
<input type="radio" name="mode" value="pubkey" id="m-pk">
<label for="m-pk">pubkey</label>
</span>
<span class="note">passphrase mode = group; pubkey mode = 1-to-1 ECDH.</span>
</div>
</section>
<!-- ============================================================ -->
<section id="sec-passphrase" class="mode-panel active">
<h2>room (passphrase mode)</h2>
<div class="row">
<input type="password" id="passphrase-in" placeholder="room passphrase — everyone in the room types the same one"
style="flex:1; min-width:0">
</div>
<div class="row">
<button id="btn-pp-join" class="invert">enter room</button>
<span class="status-line" id="pp-status">no room key</span>
</div>
<p class="note">
PBKDF2-SHA256 (600k iter) of passphrase + literal salt "zebra-report-v1"
→ 256-bit AES-GCM group key.
</p>
</section>
<!-- ============================================================ -->
<section id="sec-pubkey" class="mode-panel">
<h2>room (pubkey mode)</h2>
<p class="note" style="margin-bottom:0.5rem">your public key (click to copy):</p>
<div class="key-display" id="pub-display">generating…</div>
<div class="row" style="margin-top:0.5rem">
<button id="btn-regen">regenerate</button>
<span class="status-line" id="key-status"></span>
</div>
<div class="field-row" style="margin-top:0.5rem">
<input type="text" id="peer-key-in" placeholder="paste a peer's public key…">
<button id="btn-pk-add">add peer</button>
</div>
</section>
<!-- ============================================================ -->
<section>
<h2>carrier</h2>
<div class="row">
<div class="dot" id="dot-audio"></div>
<button id="btn-audio">start audio</button>
<label id="audio-status">offline</label>
<span class="note" style="margin-left:auto">
<label><input type="checkbox" id="monitor-toggle"> hear local carrier</label>
</span>
</div>
<div class="row" style="margin-top:0.4rem">
<span class="note" style="min-width:6rem">tx gain:</span>
<div class="meter" style="flex:1"><div class="meter-fill" id="meter-tx"></div></div>
<span class="note" id="gain-label" style="min-width:3rem; text-align:right">0%</span>
</div>
<div class="row">
<span class="note" style="min-width:6rem">rx energy:</span>
<div class="meter" style="flex:1"><div class="meter-fill" id="meter-rx"></div></div>
<span class="note" id="rx-label" style="min-width:3rem; text-align:right"></span>
</div>
<p class="note">
a 440/441&nbsp;Hz stereo carrier is generated locally. each frame swings the
output gain MARK (0.80) ↔ SPACE (0.20) per bit. the modulated audio is the
outbound WebRTC track for every connected peer. mic stays off.
</p>
</section>
<!-- ============================================================ -->
<section>
<h2>peer connection (WebRTC)</h2>
<div class="row">
<div class="dot warn" id="dot-rtc"></div>
<span id="rtc-status" class="note">disconnected — start audio first, then pick a role below</span>
<button id="btn-reset-rtc" style="margin-left:auto">reset connection</button>
</div>
<details>
<summary>stuck on the same LAN? host candidates as <code>.local</code>?</summary>
<p class="note">
modern browsers anonymize host IPs as <code>.local</code> mDNS names for
privacy. if a router blocks multicast (common on guest Wi-Fi, sometimes
consumer defaults), peers cannot resolve each other's <code>.local</code>
addresses & host candidates fail. workaround: turn off the obfuscation.
</p>
<p class="note" style="margin-top:0.3rem">
Firefox: <code>about:config</code> → set
<code>media.peerconnection.ice.obfuscate_host_addresses</code> to <code>false</code>.<br>
Chromium: <code>chrome://flags/#enable-webrtc-hide-local-ips-with-mdns</code>
→ set to Disabled.<br>
reload the page after toggling, then retry.
</p>
</details>
<div class="rtc-role">
<h3>role A · start a new connection</h3>
<p class="step">step 1: click, then copy the offer to your peer (any out-of-band channel):</p>
<div class="row">
<button id="btn-create-offer">create offer</button>
<span class="status-line" id="offer-status"></span>
</div>
<textarea id="local-offer" readonly placeholder="(offer SDP appears here once gathered)"></textarea>
<p class="step">step 4: paste your peer's answer here:</p>
<textarea id="remote-answer" placeholder="(paste peer's answer SDP)"></textarea>
<div class="row" style="margin-top:0.4rem">
<button id="btn-accept-answer">accept answer</button>
<span class="status-line" id="answer-status"></span>
</div>
</div>
<div class="rtc-role" style="margin-top:0.6rem">
<h3>role B · join an existing connection</h3>
<p class="step">step 2: paste your peer's offer here:</p>
<textarea id="remote-offer" placeholder="(paste peer's offer SDP)"></textarea>
<div class="row" style="margin-top:0.4rem">
<button id="btn-create-answer">create answer</button>
<span class="status-line" id="ans-status"></span>
</div>
<p class="step">step 3: copy your answer to peer:</p>
<textarea id="local-answer" readonly placeholder="(your answer SDP appears here once gathered)"></textarea>
</div>
<p class="note">
each browser modulates its own outbound audio track (no mic). incoming peer
audio is decoded in-page via <code>AudioWorklet</code>, no native daemon
required. Wireshark sees only encrypted SRTP &mdash; chat content lives in
audio amplitude transitions inside that stream.
</p>
</section>
<!-- ============================================================ -->
<section>
<h2>handshake</h2>
<div class="row">
<button id="btn-bench">benchmark self</button>
<span class="status-line" id="bench-status">not yet measured</span>
</div>
<div class="row">
<button id="btn-offer" disabled>send OFFER frame</button>
<span class="status-line" id="hs-status">idle</span>
</div>
<div class="row">
<span class="note" style="min-width:8rem">negotiated baud:</span>
<span id="baud-label" style="font-weight:bold"></span>
</div>
<p class="note">
OFFER and READY frames travel at fixed <code>ZEBRA_BAUD_HANDSHAKE = 50</code>.
each peer measures its own scheduling jitter, broadcasts a max baud, room
settles on <code>min(all)</code>.
</p>
</section>
<!-- ============================================================ -->
<section>
<h2>chat</h2>
<div class="grid-2">
<div>
<p class="note" style="margin-bottom:0.3rem">peers in room:</p>
<div class="peer-list" id="peer-list">
<div class="empty">none yet</div>
</div>
</div>
<div>
<p class="note" style="margin-bottom:0.3rem">log:</p>
<div class="log" id="log"></div>
</div>
</div>
<div class="field-row" style="margin-top:0.6rem">
<input type="text" id="msg-in" placeholder="type a message and press enter">
<button id="btn-send" disabled>send</button>
</div>
</section>
<!-- ============================================================ -->
<section>
<h2>about</h2>
<p class="note">
The protocol's data path is audio amplitude inside an encrypted WebRTC
SRTP stream. The signaling channel (SDP / ICE) is exchanged once at
connection setup &mdash; it contains no chat content. Every subsequent
bit travels as a MARK/SPACE swing in the carrier audio.
</p>
<details style="margin-top:0.5rem">
<summary>threat model · what this protects against · what it doesn't</summary>
<p class="note" style="margin-top:0.4rem">
<strong>Protects against:</strong> a network-level observer (Wireshark,
IDS, DPI) reading chat content. The bits are inside the SRTP audio
payload, which is end-to-end encrypted by WebRTC, then additionally
encrypted at the app layer (AES-GCM via passphrase or ECDH).
</p>
<p class="note" style="margin-top:0.4rem">
<strong>Does not protect against:</strong>
(1) other same-UID processes that can poll PulseAudio or record the
monitor source on either peer's machine &mdash; <em>that</em> is the
attack surface this whole project documents;
(2) an endpoint compromise of either peer's machine;
(3) traffic-analysis correlation against the encrypted SRTP flow.
</p>
<p class="note" style="margin-top:0.4rem">
<strong>Mitigation for defenders:</strong> require a capability for
same-UID PulseAudio reads. The current Linux audio IPC trust model
grants every same-UID process unrestricted access &mdash; the defect
<a href="https://foxhop.net/linux-audio-ipc-attack-surface.html"
style="color:#555">whitepaper</a> documents this.
</p>
</details>
</section>
<!-- ============================================================ -->
<!-- JS -->
<!-- ============================================================ -->
<script>
(async () => {
/* ============================================================== *
* constants (mirror include/zebra.h) *
* ============================================================== */
const ZEBRA_VOL_MARK = 0.80;
const ZEBRA_VOL_SPACE = 0.20;
const ZEBRA_BAUD_HANDSHAKE = 50;
const ZEBRA_BAUD_MIN = 1;
const ZEBRA_BAUD_MAX = 100000;
const ZEBRA_BAUD_DEFAULT = 10;
const HS_MAGIC = [0x5A, 0x42];
const T_OFFER = 0x01;
const T_READY = 0x02;
const T_DATA = 0x03;
const T_HELLO = 0x04;
const HS_FRAME_LEN = 6;
const PBKDF2_SALT = new TextEncoder().encode('zebra-report-v1');
const PBKDF2_ITER = 600000;
const LOOPBACK = new URLSearchParams(location.search).has('loopback');
const RTC_CONFIG = {
iceServers: [
/* our STUN/TURN on proxy.uncloseai.com — coturn deployed via
* git.unturf.com/engineering/unturf/proxy.unturf.com.
* credentials are public by design (baked into a static page);
* rate-limited via coturn quotas, not via secrecy. */
{ urls: ['stun:proxy.uncloseai.com:3478', 'stun:stun.l.google.com:19302'] },
{
urls: 'turn:proxy.uncloseai.com:3478',
username: 'zebra',
credential: '7a4a2b1c8d9e6f5a'
}
]
};
/* ============================================================== *
* tiny utils *
* ============================================================== */
const $ = (id) => document.getElementById(id);
function b64(buf) {
const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
let s = ''; for (const b of u8) s += String.fromCharCode(b);
return btoa(s);
}
function unb64(s) { return Uint8Array.from(atob(s), c => c.charCodeAt(0)); }
function hex(buf) {
const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
return Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join('');
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
const CRC32_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[i] = c >>> 0;
}
return t;
})();
function crc32(bytes) {
let c = 0xFFFFFFFF;
for (let i = 0; i < bytes.length; i++) c = CRC32_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, ch =>
({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[ch]));
}
/* ============================================================== *
* logging *
* ============================================================== */
const logEl = $('log');
function logLine(kind, html) {
const div = document.createElement('div');
div.className = 'log-line ' + (kind || 'sys');
const ts = new Date().toLocaleTimeString();
div.innerHTML = `<span class="ts">${ts}</span> ${html}`;
logEl.appendChild(div);
logEl.scrollTop = logEl.scrollHeight;
}
/* ============================================================== *
* crypto *
* ============================================================== */
let groupKey = null; /* passphrase mode */
let myPriv = null; /* pubkey mode */
let myPubB64 = null; /* pubkey mode */
let peers = new Map(); /* sid(hex) → { handle, sharedKey, maxBaud } */
async function deriveGroupKey(phrase) {
const baseKey = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(phrase), 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: PBKDF2_SALT, iterations: PBKDF2_ITER, hash: 'SHA-256' },
baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
}
const LS_PRIV = 'zebra_chat_privkey';
const LS_PUB = 'zebra_chat_pubkey';
const LS_HANDLE = 'zebra_chat_handle';
async function genKeypair() {
const kp = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits']);
const privJwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
const pubRaw = await crypto.subtle.exportKey('raw', kp.publicKey);
localStorage.setItem(LS_PRIV, JSON.stringify(privJwk));
localStorage.setItem(LS_PUB, b64(pubRaw));
return { privateKey: kp.privateKey, pubB64: b64(pubRaw) };
}
async function loadOrGenKeypair() {
const p = localStorage.getItem(LS_PRIV), pub = localStorage.getItem(LS_PUB);
if (p && pub) {
try {
const privateKey = await crypto.subtle.importKey(
'jwk', JSON.parse(p), { name: 'ECDH', namedCurve: 'P-256' },
true, ['deriveKey', 'deriveBits']);
return { privateKey, pubB64: pub };
} catch (_) {}
}
return genKeypair();
}
async function deriveShared(myPrivKey, peerPubB64) {
const peerPub = await crypto.subtle.importKey(
'raw', unb64(peerPubB64), { name: 'ECDH', namedCurve: 'P-256' }, false, []);
return crypto.subtle.deriveKey(
{ name: 'ECDH', public: peerPub }, myPrivKey,
{ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
}
async function aesEncrypt(key, str) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, key, new TextEncoder().encode(str)));
const out = new Uint8Array(12 + ct.length);
out.set(iv); out.set(ct, 12);
return out;
}
async function aesDecrypt(key, bytes) {
const iv = bytes.slice(0, 12), ct = bytes.slice(12);
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
return new TextDecoder().decode(pt);
}
/* ============================================================== *
* sender id *
* ============================================================== */
async function senderIdBytes() {
if (currentMode() === 'pubkey' && myPubB64) {
const h = await crypto.subtle.digest('SHA-256', unb64(myPubB64));
return new Uint8Array(h).slice(0, 4);
}
if (!senderIdBytes._cached) {
senderIdBytes._cached = crypto.getRandomValues(new Uint8Array(4));
}
return senderIdBytes._cached;
}
/* ============================================================== *
* frame codec *
* ============================================================== */
function xorChecksum(bytes) {
let x = 0; for (const b of bytes) x ^= b; return x & 0xFF;
}
function buildHandshakeFrame(type, baudVal) {
const b = new Uint8Array(HS_FRAME_LEN);
b[0] = HS_MAGIC[0]; b[1] = HS_MAGIC[1]; b[2] = type;
b[3] = baudVal & 0xFF; b[4] = (baudVal >> 8) & 0xFF;
b[5] = xorChecksum(b.slice(0, 5));
return b;
}
async function buildDataFrame(payloadBytes) {
const sid = await senderIdBytes();
const len = payloadBytes.length;
const head = new Uint8Array(9);
head[0] = HS_MAGIC[0]; head[1] = HS_MAGIC[1]; head[2] = T_DATA;
head.set(sid, 3);
head[7] = len & 0xFF; head[8] = (len >> 8) & 0xFF;
const body = new Uint8Array(head.length + len);
body.set(head); body.set(payloadBytes, head.length);
const crc = crc32(body);
const out = new Uint8Array(body.length + 4);
out.set(body);
out[body.length+0] = crc & 0xFF; out[body.length+1] = (crc >> 8) & 0xFF;
out[body.length+2] = (crc >> 16) & 0xFF; out[body.length+3] = (crc >> 24) & 0xFF;
return out;
}
async function buildHelloFrame(handle, maxBaud) {
const sid = await senderIdBytes();
const hb = new TextEncoder().encode(handle.slice(0, 40));
const body = new Uint8Array(10 + hb.length);
body[0] = HS_MAGIC[0]; body[1] = HS_MAGIC[1]; body[2] = T_HELLO;
body.set(sid, 3);
body[7] = maxBaud & 0xFF; body[8] = (maxBaud >> 8) & 0xFF;
body[9] = hb.length;
body.set(hb, 10);
const crc = crc32(body);
const out = new Uint8Array(body.length + 4);
out.set(body);
out[body.length+0] = crc & 0xFF; out[body.length+1] = (crc >> 8) & 0xFF;
out[body.length+2] = (crc >> 16) & 0xFF; out[body.length+3] = (crc >> 24) & 0xFF;
return out;
}
function parseFrame(bytes) {
if (bytes.length < 3) return null;
if (bytes[0] !== HS_MAGIC[0] || bytes[1] !== HS_MAGIC[1]) return null;
const type = bytes[2];
if (type === T_OFFER || type === T_READY) {
if (bytes.length < HS_FRAME_LEN) return null;
if (xorChecksum(bytes.slice(0, 5)) !== bytes[5]) return { type, error: 'xor' };
const baud = bytes[3] | (bytes[4] << 8);
return { type, baud };
}
if (type === T_DATA) {
if (bytes.length < 13) return null;
const sid = bytes.slice(3, 7);
const len = bytes[7] | (bytes[8] << 8);
if (bytes.length < 9 + len + 4) return null;
const payload = bytes.slice(9, 9 + len);
const frameNoCrc = bytes.slice(0, 9 + len);
const got = bytes[9+len] | (bytes[9+len+1] << 8) | (bytes[9+len+2] << 16) | (bytes[9+len+3] << 24);
if ((got >>> 0) !== crc32(frameNoCrc)) return { type, error: 'crc' };
return { type, sid, payload };
}
if (type === T_HELLO) {
if (bytes.length < 10) return null;
const sid = bytes.slice(3, 7);
const maxBaud = bytes[7] | (bytes[8] << 8);
const hlen = bytes[9];
if (bytes.length < 10 + hlen + 4) return null;
const handle = new TextDecoder().decode(bytes.slice(10, 10 + hlen));
const frameNoCrc = bytes.slice(0, 10 + hlen);
const got = bytes[10+hlen] | (bytes[10+hlen+1] << 8) | (bytes[10+hlen+2] << 16) | (bytes[10+hlen+3] << 24);
if ((got >>> 0) !== crc32(frameNoCrc)) return { type, error: 'crc' };
return { type, sid, maxBaud, handle };
}
return null;
}
/* ============================================================== *
* audio carrier + outbound RTC stream *
* ============================================================== */
let audioCtx = null;
let oscL = null, oscR = null;
let gainL = null, gainR = null;
let merger = null;
let monitorGain = null; /* local audible (off by default) */
let txStreamDest = null; /* outbound media track for RTC */
let outboundStream = null;
let carrierOn = false;
let txBusy = false;
let txQueue = [];
const meterFill = $('meter-tx');
const gainLabel = $('gain-label');
function _meterTick() {
if (!carrierOn) return;
const g = gainL ? gainL.gain.value : 0;
meterFill.style.width = Math.round(g * 100) + '%';
gainLabel.textContent = Math.round(g * 100) + '%';
requestAnimationFrame(_meterTick);
}
function _scheduleGain(v, t) {
gainL.gain.setValueAtTime(v, t);
gainR.gain.setValueAtTime(v, t);
}
async function startCarrier() {
if (carrierOn) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'interactive' });
if (audioCtx.state === 'suspended') await audioCtx.resume();
oscL = audioCtx.createOscillator(); oscR = audioCtx.createOscillator();
oscL.type = 'sine'; oscL.frequency.value = 440;
oscR.type = 'sine'; oscR.frequency.value = 441;
gainL = audioCtx.createGain(); gainR = audioCtx.createGain();
gainL.gain.value = ZEBRA_VOL_MARK;
gainR.gain.value = ZEBRA_VOL_MARK;
merger = audioCtx.createChannelMerger(2);
oscL.connect(gainL); gainL.connect(merger, 0, 0);
oscR.connect(gainR); gainR.connect(merger, 0, 1);
/* outbound: route to RTC media stream destination */
txStreamDest = audioCtx.createMediaStreamDestination();
merger.connect(txStreamDest);
outboundStream = txStreamDest.stream;
/* local monitor (muted by default) */
monitorGain = audioCtx.createGain();
monitorGain.gain.value = 0;
merger.connect(monitorGain);
monitorGain.connect(audioCtx.destination);
oscL.start(); oscR.start();
carrierOn = true;
_meterTick();
await setupWorkletDecoder();
}
$('monitor-toggle').addEventListener('change', (e) => {
if (monitorGain) monitorGain.gain.value = e.target.checked ? 0.04 : 0;
});
/* ============================================================== *
* AudioWorklet: per-quantum peak detector *
* ============================================================== */
const WORKLET_CODE = `
class EnergyDetector extends AudioWorkletProcessor {
process(inputs) {
const input = inputs[0];
if (!input || !input.length || !input[0]) return true;
const ch = input[0];
let peak = 0;
for (let i = 0; i < ch.length; i++) {
const a = Math.abs(ch[i]);
if (a > peak) peak = a;
}
this.port.postMessage(peak);
return true;
}
}
registerProcessor('energy-detector', EnergyDetector);
`;
async function setupWorkletDecoder() {
const url = URL.createObjectURL(new Blob([WORKLET_CODE], { type: 'application/javascript' }));
try {
await audioCtx.audioWorklet.addModule(url);
} catch (e) {
logLine('err', 'audio worklet add failed: ' + e.message);
return;
}
}
/* one decoder per inbound RTC track (peer) */
const rxDecoders = new Map(); /* trackId → { uart, asm, node } */
class UartDecoder {
constructor(baud, sampleRate) {
this.sampleRate = sampleRate;
this.setBaud(baud);
this.state = 'hunt';
this.byte = 0; this.bitIdx = 0; this.curSample = 0;
this.prevAbove = true;
this.peak = 0;
this.peakDecay = Math.pow(0.5, 1.0 / (2.0 * sampleRate)); /* 2s half-life */
}
setBaud(b) { this.baud = b; this.sps = this.sampleRate / b; }
push(e) {
this.peak *= this.peakDecay;
if (e > this.peak) this.peak = e;
if (this.peak < 0.02) { this.state = 'hunt'; this.prevAbove = true; return null; }
const thr = this.peak * 0.50;
const above = e >= thr;
if (this.state === 'hunt') {
if (this.prevAbove && !above) {
this.state = 'sample'; this.curSample = 1; this.byte = 0; this.bitIdx = 0;
}
} else {
this.curSample++;
const center = (1.5 + this.bitIdx) * this.sps;
if (this.curSample >= center) {
const bit = above ? 1 : 0;
this.byte |= (bit << this.bitIdx);
this.bitIdx++;
if (this.bitIdx === 8) {
const out = this.byte;
this.state = 'hunt'; this.prevAbove = true;
return out;
}
}
}
this.prevAbove = above;
return null;
}
}
class FrameAssembler {
constructor() { this.buf = []; }
push(byte) {
this.buf.push(byte);
while (this.buf.length >= 2 && (this.buf[0] !== HS_MAGIC[0] || this.buf[1] !== HS_MAGIC[1])) {
this.buf.shift();
}
if (this.buf.length < 3) return null;
const t = this.buf[2];
if (t === T_OFFER || t === T_READY) {
if (this.buf.length < 6) return null;
const frame = new Uint8Array(this.buf.slice(0, 6));
this.buf.splice(0, 6);
return frame;
}
if (t === T_DATA) {
if (this.buf.length < 9) return null;
const ln = this.buf[7] | (this.buf[8] << 8);
if (ln > 512) { this.buf.splice(0, 2); return null; }
const need = 9 + ln + 4;
if (this.buf.length < need) return null;
const frame = new Uint8Array(this.buf.slice(0, need));
this.buf.splice(0, need);
return frame;
}
if (t === T_HELLO) {
if (this.buf.length < 10) return null;
const hlen = this.buf[9];
if (hlen > 64) { this.buf.splice(0, 2); return null; }
const need = 10 + hlen + 4;
if (this.buf.length < need) return null;
const frame = new Uint8Array(this.buf.slice(0, need));
this.buf.splice(0, need);
return frame;
}
this.buf.splice(0, 2);
return null;
}
}
const rxMeterFill = $('meter-rx');
const rxLabel = $('rx-label');
let lastRxPeak = 0;
function _rxMeterTick() {
rxMeterFill.style.width = Math.round(lastRxPeak * 100) + '%';
rxLabel.textContent = lastRxPeak > 0.001
? Math.round(lastRxPeak * 100) + '%' : '—';
requestAnimationFrame(_rxMeterTick);
}
requestAnimationFrame(_rxMeterTick);
async function attachInboundTrack(stream, trackId) {
if (!audioCtx) return;
/* must attach to a (muted) audio element for the WebRTC stack to deliver */
const a = new Audio();
a.srcObject = stream;
a.muted = true;
a.autoplay = true;
try { await a.play(); } catch (_) {}
const src = audioCtx.createMediaStreamSource(stream);
const node = new AudioWorkletNode(audioCtx, 'energy-detector');
src.connect(node);
/* effective decoder rate: sampleRate / 128 (one peak per audio quantum) */
const effRate = audioCtx.sampleRate / 128;
const uart = new UartDecoder(ZEBRA_BAUD_HANDSHAKE, effRate);
const asm = new FrameAssembler();
node.port.onmessage = (ev) => {
const peak = ev.data;
lastRxPeak = peak;
const byte = uart.push(peak);
if (byte === null) return;
const frame = asm.push(byte);
if (frame) onRxFrame(frame);
};
rxDecoders.set(trackId, { uart, asm, node, audio: a });
logLine('sys', 'inbound track attached — decoder live');
}
function detachInboundTrack(trackId) {
const d = rxDecoders.get(trackId);
if (!d) return;
try { d.node.disconnect(); } catch (_) {}
try { d.audio.pause(); d.audio.srcObject = null; } catch (_) {}
rxDecoders.delete(trackId);
}
/* ============================================================== *
* transmit *
* ============================================================== */
async function txFrame(bytes, baud) {
if (!carrierOn) throw new Error('carrier not started');
txQueue.push({ bytes, baud });
if (txBusy) return;
txBusy = true;
try {
while (txQueue.length) {
const job = txQueue.shift();
await _txOne(job.bytes, job.baud);
if (loopChan) {
try {
const ab = job.bytes.buffer.slice(job.bytes.byteOffset, job.bytes.byteOffset + job.bytes.byteLength);
loopChan.postMessage(ab);
} catch (_) {}
}
}
} finally {
txBusy = false;
}
}
async function _txOne(bytes, baud) {
const period = 1.0 / baud;
let t = audioCtx.currentTime + 0.02;
for (let i = 0; i < 10; i++) { _scheduleGain(ZEBRA_VOL_MARK, t); t += period; }
for (const byte of bytes) {
_scheduleGain(ZEBRA_VOL_SPACE, t); t += period;
for (let bit = 0; bit < 8; bit++) {
_scheduleGain(((byte >> bit) & 1) ? ZEBRA_VOL_MARK : ZEBRA_VOL_SPACE, t);
t += period;
}
_scheduleGain(ZEBRA_VOL_MARK, t); t += period;
}
for (let i = 0; i < 5; i++) { _scheduleGain(ZEBRA_VOL_MARK, t); t += period; }
_scheduleGain(ZEBRA_VOL_MARK, t);
await sleep(Math.max(0, (t - audioCtx.currentTime) * 1000) + 30);
}
async function benchmarkSelf() {
if (!carrierOn) await startCarrier();
const N = 200;
const t0 = performance.now();
let t = audioCtx.currentTime;
for (let i = 0; i < N; i++) { _scheduleGain(i & 1 ? ZEBRA_VOL_MARK : ZEBRA_VOL_SPACE, t); t += 0.0005; }
await sleep(Math.max(0, (t - audioCtx.currentTime) * 1000));
const t1 = performance.now();
_scheduleGain(ZEBRA_VOL_MARK, audioCtx.currentTime + 0.01);
const avgUs = (t1 - t0) * 1000 / N;
const rawBaud = 1e6 / avgUs;
let baud = Math.floor(rawBaud / 2);
baud = Math.max(ZEBRA_BAUD_MIN, Math.min(ZEBRA_BAUD_MAX, baud));
return { baud, avgUs };
}
/* ============================================================== *
* handshake state *
* ============================================================== */
let myMaxBaud = null;
let negotiatedBaud = null;
const baudLabel = $('baud-label');
function recomputeGroupBaud() {
let m = myMaxBaud || ZEBRA_BAUD_DEFAULT;
for (const p of peers.values()) if (p.maxBaud && p.maxBaud < m) m = p.maxBaud;
negotiatedBaud = m;
baudLabel.textContent = m + ' baud';
}
/* ============================================================== *
* loopback (same-browser dev shortcut, ?loopback=1) *
* ============================================================== */
let loopChan = null;
if (LOOPBACK) {
loopChan = new BroadcastChannel('zebra-report-loopback');
loopChan.onmessage = (ev) => {
const buf = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : new Uint8Array(ev.data);
onRxFrame(buf);
};
}
/* ============================================================== *
* frame handler *
* ============================================================== */
async function onRxFrame(bytes) {
const f = parseFrame(bytes);
if (!f) return;
if (f.error) { logLine('err', `rx: malformed (${f.type}, ${f.error})`); return; }
/* drop self-echoes (own sid) */
if (f.sid) {
const mine = await senderIdBytes();
let self = true;
for (let i = 0; i < 4; i++) if (f.sid[i] !== mine[i]) { self = false; break; }
if (self) return;
}
if (f.type === T_OFFER) {
logLine('sys', `rx: OFFER from peer — ${f.baud} baud`);
await txFrame(buildHandshakeFrame(T_READY, myMaxBaud || ZEBRA_BAUD_DEFAULT), ZEBRA_BAUD_HANDSHAKE);
return;
}
if (f.type === T_READY) {
logLine('sys', `rx: READY from peer — ${f.baud} baud`);
return;
}
if (f.type === T_HELLO) {
const sid = hex(f.sid);
const seen = peers.has(sid);
let entry = peers.get(sid) || {};
entry.handle = f.handle; entry.maxBaud = f.maxBaud;
peers.set(sid, entry);
recomputeGroupBaud(); renderPeerList();
logLine('sys', seen
? `peer ${escapeHtml(f.handle)} updated (baud ${f.maxBaud})`
: `peer ${escapeHtml(f.handle)} joined (baud ${f.maxBaud}, sid ${sid.slice(0,8)})`);
if (!seen) await announceHello();
return;
}
if (f.type === T_DATA) {
const sid = hex(f.sid);
let pt = null;
if (currentMode() === 'passphrase') {
if (!groupKey) { logLine('err', 'rx: data but no room key'); return; }
try { pt = await aesDecrypt(groupKey, f.payload); } catch (e) { logLine('err', `rx: decrypt failed (${e.message})`); return; }
} else {
const entry = peers.get(sid);
if (entry && entry.sharedKey) {
try { pt = await aesDecrypt(entry.sharedKey, f.payload); } catch (_) {}
}
if (pt === null) { logLine('err', `rx: data from ${sid.slice(0,8)} (no key)`); return; }
}
const handle = (peers.get(sid) || {}).handle || sid.slice(0,8);
logLine('peer', `<span class="from">${escapeHtml(handle)}:</span> ${escapeHtml(pt)}`);
}
}
/* ============================================================== *
* peer list render *
* ============================================================== */
function renderPeerList() {
const el = $('peer-list');
el.innerHTML = '';
if (peers.size === 0) { el.innerHTML = '<div class="empty">none yet</div>'; return; }
for (const [sid, p] of peers) {
const row = document.createElement('div');
row.className = 'peer';
row.innerHTML = `<span class="h">${escapeHtml(p.handle || '?')}</span>
<span style="color:#888"> · sid ${sid.slice(0,8)} · ${p.maxBaud || '?'} baud</span>`;
el.appendChild(row);
}
}
async function announceHello() {
const handle = $('handle-in').value.trim() || 'anon';
if (!myMaxBaud) myMaxBaud = ZEBRA_BAUD_DEFAULT;
const hello = await buildHelloFrame(handle, myMaxBaud);
await txFrame(hello, ZEBRA_BAUD_HANDSHAKE);
logLine('sys', `tx: HELLO as ${escapeHtml(handle)} (${myMaxBaud} baud)`);
}
/* ============================================================== *
* WebRTC *
* ============================================================== */
let pc = null;
const dotRtc = $('dot-rtc');
const rtcStatus = $('rtc-status');
function ensurePC() {
if (pc) return pc;
if (!carrierOn) throw new Error('start carrier first');
pc = new RTCPeerConnection(RTC_CONFIG);
for (const tr of outboundStream.getTracks()) pc.addTrack(tr, outboundStream);
pc.ontrack = (ev) => {
const stream = ev.streams[0] || new MediaStream([ev.track]);
attachInboundTrack(stream, ev.track.id);
};
pc.onicecandidate = (ev) => {
if (!ev.candidate) return;
const c = ev.candidate.candidate;
const tm = c.match(/typ\s+(\S+)/);
const typ = tm ? tm[1] : '?';
const mdns = c.includes('.local') ? ' (mdns-anonymized)' : '';
logLine('sys', `ice candidate: ${typ}${mdns}`);
};
pc.onconnectionstatechange = () => {
const s = pc.connectionState;
rtcStatus.textContent = 'connection: ' + s;
if (s === 'connected') {
dotRtc.className = 'dot ok';
logLine('sys', 'webrtc connected');
announceHello().catch(() => {});
} else if (s === 'failed' || s === 'closed' || s === 'disconnected') {
dotRtc.className = 'dot';
logLine('err', `webrtc ${s} — click "reset connection" then retry`);
}
};
pc.oniceconnectionstatechange = () => {
rtcStatus.textContent = 'ice: ' + pc.iceConnectionState;
};
return pc;
}
function resetConnection() {
if (pc) {
try { pc.close(); } catch (_) {}
pc = null;
}
for (const [, d] of rxDecoders) {
try { d.node.disconnect(); } catch (_) {}
try { d.audio.pause(); d.audio.srcObject = null; } catch (_) {}
}
rxDecoders.clear();
$('local-offer').value = '';
$('local-answer').value = '';
$('remote-offer').value = '';
$('remote-answer').value = '';
for (const id of ['offer-status','answer-status','ans-status']) {
$(id).textContent = ''; $(id).className = 'status-line';
}
rtcStatus.textContent = 'disconnected — pick a role below';
dotRtc.className = 'dot warn';
logLine('sys', 'connection reset — ready to retry');
}
$('btn-reset-rtc').addEventListener('click', resetConnection);
function waitForIceGathering(p) {
return new Promise(resolve => {
if (p.iceGatheringState === 'complete') return resolve();
const h = () => {
if (p.iceGatheringState === 'complete') {
p.removeEventListener('icegatheringstatechange', h);
resolve();
}
};
p.addEventListener('icegatheringstatechange', h);
});
}
$('btn-create-offer').addEventListener('click', async () => {
$('offer-status').textContent = 'gathering ICE…';
try {
ensurePC();
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';
$('offer-status').className = 'status-line ok';
} catch (e) {
$('offer-status').textContent = 'failed: ' + e.message;
$('offer-status').className = 'status-line err';
}
});
$('btn-create-answer').addEventListener('click', async () => {
$('ans-status').textContent = 'parsing offer…';
try {
const off = JSON.parse($('remote-offer').value);
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';
$('ans-status').className = 'status-line ok';
} catch (e) {
$('ans-status').textContent = 'failed: ' + e.message;
$('ans-status').className = 'status-line err';
}
});
$('btn-accept-answer').addEventListener('click', async () => {
$('answer-status').textContent = 'parsing answer…';
try {
const ans = JSON.parse($('remote-answer').value);
if (!pc) throw new Error('no pending offer');
await pc.setRemoteDescription(ans);
$('answer-status').textContent = 'answer accepted — waiting for ICE/connection';
$('answer-status').className = 'status-line ok';
} catch (e) {
$('answer-status').textContent = 'failed: ' + e.message;
$('answer-status').className = 'status-line err';
}
});
/* ============================================================== *
* mode toggle *
* ============================================================== */
function currentMode() {
const r = document.querySelector('input[name="mode"]:checked');
return r ? r.value : 'passphrase';
}
function showModePanel() {
const m = currentMode();
$('sec-passphrase').classList.toggle('active', m === 'passphrase');
$('sec-pubkey').classList.toggle('active', m === 'pubkey');
}
for (const r of document.querySelectorAll('input[name="mode"]')) {
r.addEventListener('change', () => { showModePanel(); updateSendButton(); });
}
/* ============================================================== *
* identity / passphrase / pubkey UI *
* ============================================================== */
const handleIn = $('handle-in');
handleIn.value = localStorage.getItem(LS_HANDLE) || '';
handleIn.addEventListener('change', () => {
localStorage.setItem(LS_HANDLE, handleIn.value.trim());
});
$('btn-pp-join').addEventListener('click', async () => {
const p = $('passphrase-in').value;
if (!p) { $('pp-status').textContent = 'enter a passphrase first'; return; }
$('pp-status').textContent = 'deriving (~600k iter)…';
try {
groupKey = await deriveGroupKey(p);
$('pp-status').textContent = 'room key ready';
$('pp-status').className = 'status-line ok';
logLine('sys', 'room key derived');
updateSendButton();
if (carrierOn && pc && pc.connectionState === 'connected') await announceHello();
} catch (e) {
$('pp-status').textContent = 'derive failed: ' + e.message;
$('pp-status').className = 'status-line err';
}
});
async function initPubkey() {
const kp = await loadOrGenKeypair();
myPriv = kp.privateKey; myPubB64 = kp.pubB64;
$('pub-display').textContent = myPubB64;
}
initPubkey();
$('pub-display').addEventListener('click', () => {
navigator.clipboard.writeText(myPubB64 || '').then(() => {
$('key-status').textContent = 'copied'; $('key-status').className = 'status-line ok';
setTimeout(() => { $('key-status').textContent = ''; }, 1500);
});
});
$('btn-regen').addEventListener('click', async () => {
if (!confirm('regenerate keys?')) return;
const kp = await genKeypair();
myPriv = kp.privateKey; myPubB64 = kp.pubB64;
$('pub-display').textContent = myPubB64;
for (const p of peers.values()) delete p.sharedKey;
$('key-status').textContent = 'new keys'; $('key-status').className = 'status-line ok';
setTimeout(() => { $('key-status').textContent = ''; }, 2000);
});
$('btn-pk-add').addEventListener('click', async () => {
const peerB64 = $('peer-key-in').value.trim();
if (!peerB64) return;
try {
const sk = await deriveShared(myPriv, peerB64);
const sidBytes = new Uint8Array(await crypto.subtle.digest('SHA-256', unb64(peerB64)));
const sid = hex(sidBytes.slice(0, 4));
const entry = peers.get(sid) || {};
entry.sharedKey = sk;
entry.handle = entry.handle || ('peer-' + sid.slice(0,4));
peers.set(sid, entry);
renderPeerList();
$('peer-key-in').value = '';
logLine('sys', `pubkey peer added: ${sid.slice(0,8)}`);
updateSendButton();
} catch (e) {
logLine('err', 'add peer failed: ' + e.message);
}
});
/* ============================================================== *
* carrier UI *
* ============================================================== */
$('btn-audio').addEventListener('click', async () => {
if (carrierOn) return;
try {
await startCarrier();
$('dot-audio').className = 'dot on';
$('audio-status').textContent = 'online';
$('btn-audio').disabled = true;
logLine('sys', 'carrier started — modulated stereo at 440/441 Hz');
} catch (e) {
logLine('err', 'carrier failed: ' + e.message);
}
});
/* ============================================================== *
* handshake UI *
* ============================================================== */
$('btn-bench').addEventListener('click', async () => {
$('bench-status').textContent = 'measuring…';
try {
if (!carrierOn) await startCarrier();
const r = await benchmarkSelf();
myMaxBaud = r.baud;
$('bench-status').textContent = `${r.baud} baud (avg ${r.avgUs.toFixed(1)} µs/symbol)`;
$('btn-offer').disabled = false;
recomputeGroupBaud();
logLine('sys', `benchmark: ${r.baud} baud max`);
} catch (e) {
$('bench-status').textContent = 'failed: ' + e.message;
}
});
$('btn-offer').addEventListener('click', async () => {
if (!myMaxBaud) return;
$('hs-status').textContent = 'sending OFFER…';
try {
await txFrame(buildHandshakeFrame(T_OFFER, myMaxBaud), ZEBRA_BAUD_HANDSHAKE);
$('hs-status').textContent = 'OFFER sent — waiting for READY';
await announceHello();
} catch (e) {
$('hs-status').textContent = 'tx failed: ' + e.message;
}
});
/* ============================================================== *
* send UI *
* ============================================================== */
function updateSendButton() {
const mode = currentMode();
const haveKey = mode === 'passphrase' ? !!groupKey
: Array.from(peers.values()).some(p => p.sharedKey);
$('btn-send').disabled = !(carrierOn && haveKey);
}
async function sendCurrentMsg() {
const msg = $('msg-in').value;
if (!msg) return;
if (!carrierOn) { logLine('err', 'start carrier first'); return; }
const mode = currentMode();
const baud = negotiatedBaud || myMaxBaud || ZEBRA_BAUD_DEFAULT;
try {
if (mode === 'passphrase') {
if (!groupKey) { logLine('err', 'no room key'); return; }
const ct = await aesEncrypt(groupKey, msg);
await txFrame(await buildDataFrame(ct), baud);
} else {
let sent = 0;
for (const p of peers.values()) {
if (!p.sharedKey) continue;
const ct = await aesEncrypt(p.sharedKey, msg);
await txFrame(await buildDataFrame(ct), baud);
sent++;
}
if (sent === 0) { logLine('err', 'no pubkey peers ready'); return; }
}
const handle = handleIn.value.trim() || 'me';
logLine('me', `<span class="from">${escapeHtml(handle)}:</span> ${escapeHtml(msg)}`);
$('msg-in').value = '';
} catch (e) {
logLine('err', 'send failed: ' + e.message);
}
}
$('btn-send').addEventListener('click', sendCurrentMsg);
$('msg-in').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendCurrentMsg(); }
});
/* ============================================================== *
* boot *
* ============================================================== */
showModePanel();
updateSendButton();
setInterval(updateSendButton, 1000);
if (LOOPBACK) {
const banner = document.createElement('div');
banner.style.cssText =
'background:#fc0;color:#000;padding:0.6rem 1rem;text-align:center;' +
'font-size:0.8rem;font-weight:bold;border:1px solid #000;' +
'margin-bottom:1.2rem;letter-spacing:0.03em';
banner.textContent =
'dev loopback active — frames cross via BroadcastChannel between ' +
'same-browser tabs, NOT via WebRTC. drop ?loopback=1 for real cross-machine path.';
document.body.insertBefore(banner, document.body.firstChild);
logLine('sys', 'loopback mode: frames will echo to other tabs of this same browser.');
}
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.');
})();
</script>
</body>
</html>