Mirror zebra-spaces fix + refreshed page-integrity stamps from git.unturf.com/engineering/unturf/zebra-report d9852e6.
1662 lines
66 KiB
HTML
1662 lines
66 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: none; margin: 0;
|
|
}
|
|
/* two-panel layout: setup | chat, 50:50 on desktop, stacked on phone */
|
|
.app {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 1.75rem;
|
|
align-items: start;
|
|
}
|
|
.panel { min-width: 0; }
|
|
@media (max-width: 760px) {
|
|
.app { grid-template-columns: 1fr; }
|
|
}
|
|
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: 1.25rem; }
|
|
.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; }
|
|
.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: 320px; height: 320px;
|
|
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; }
|
|
/* cap the QR canvas so it never pushes the page wider than the
|
|
* viewport on narrow phones (320px and up) */
|
|
.share-box .qr-canvas { justify-self: center; max-width: 100%; }
|
|
h1 { font-size: 2.2rem; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h1>zebra report</h1>
|
|
<p class="sub">volume modem chatroom · e2e encrypted · webrtc carrier ·
|
|
<a href="how-it-works.html" style="color:#555">how it works</a> ·
|
|
<a href="host-your-own.html" style="color:#555">host your own</a> ·
|
|
<a href="/" style="color:#555">unturf</a></p>
|
|
|
|
<div class="app">
|
|
|
|
<div class="panel">
|
|
|
|
<section>
|
|
<h2>identity</h2>
|
|
<div class="row">
|
|
<label for="handle-in">handle:</label>
|
|
<input type="text" id="handle-in" style="flex:1; min-width:0" placeholder="your handle">
|
|
<span class="status-line" id="id-status"></span>
|
|
</div>
|
|
<div class="row" style="margin-top:0.5rem">
|
|
<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">group · or 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>
|
|
|
|
<!-- carrier -->
|
|
<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" class="note">offline</label>
|
|
<span class="note" style="margin-left:auto"><label><input type="checkbox" id="monitor-toggle"> monitor</label></span>
|
|
</div>
|
|
<div class="row" style="margin-top:0.4rem">
|
|
<span class="note" style="min-width:3rem">tx</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:3rem">rx</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>
|
|
</section>
|
|
|
|
<!-- connect (signaling relay) -->
|
|
<section>
|
|
<h2>connect</h2>
|
|
<div class="field-row">
|
|
<input type="text" id="rdv-code" placeholder="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</span>
|
|
<button id="btn-relay-disconnect" style="margin-left:auto" disabled>disconnect</button>
|
|
</div>
|
|
<div class="row" style="margin-top:0.3rem">
|
|
<div class="dot warn" id="dot-rtc"></div>
|
|
<span id="rtc-status" class="note">peer link: idle</span>
|
|
<button id="btn-reset-rtc" style="margin-left:auto">reset</button>
|
|
</div>
|
|
<div class="row" style="margin-top:0.3rem">
|
|
<div class="dot" id="dot-path"></div>
|
|
<span id="path-status" class="status-line">path: not connected</span>
|
|
</div>
|
|
<p class="note" style="margin-top:0.5rem">
|
|
both peers type the same code; SDP is exchanged automatically via the relay
|
|
(encrypted with the code). chat content rides the audio carrier.
|
|
</p>
|
|
</section>
|
|
|
|
</div><!-- /panel (setup) -->
|
|
|
|
<div class="panel">
|
|
|
|
<section style="margin-bottom:0">
|
|
<h2>chat</h2>
|
|
<p class="note" style="margin-bottom:0.3rem">peers in room:</p>
|
|
<div class="peer-list" id="peer-list" style="margin-bottom:0.6rem">
|
|
<div class="empty">none yet</div>
|
|
</div>
|
|
<div class="log" id="log" style="height:48vh"></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>
|
|
<div id="outbox-wrap" style="display:none; margin-top:0.4rem">
|
|
<p class="note" style="margin-bottom:0.2rem">outbox — awaiting delivery:</p>
|
|
<div class="peer-list" id="outbox"></div>
|
|
</div>
|
|
</section>
|
|
|
|
</div><!-- /panel (chat) -->
|
|
|
|
</div><!-- /app -->
|
|
|
|
<!-- ============================================================ -->
|
|
<!-- JS -->
|
|
<!-- ============================================================ -->
|
|
<script>
|
|
(async () => {
|
|
|
|
/* ============================================================== *
|
|
* constants (mirror include/zebra.h) *
|
|
* ============================================================== */
|
|
const ZEBRA_VOL_MARK = 0.95; /* near full-scale (no clip) for max margin */
|
|
const ZEBRA_VOL_SPACE = 0.10; /* low but non-zero so the tone stays present */
|
|
|
|
/* Multi-level encoding: each baud tick holds the volume at one of N levels, so
|
|
* a symbol carries log2(N) bits (N=2 -> 1 bit, the old binary scheme; N=16 -> 4
|
|
* bits). Tunable via ?levels=N. N must be a power of 2 that divides 8 cleanly. */
|
|
const _LEVELS_PARAM = parseInt(new URLSearchParams(location.search).get('levels'), 10);
|
|
const ZEBRA_LEVELS = [2, 4, 16].includes(_LEVELS_PARAM) ? _LEVELS_PARAM : 4;
|
|
const ZEBRA_BITS_PER_SYM = Math.log2(ZEBRA_LEVELS); /* 1 | 2 | 4 */
|
|
/* map a level index 0..N-1 onto the usable amplitude band [SPACE, MARK] */
|
|
function ampForLevel(L) {
|
|
return ZEBRA_VOL_SPACE + (L / (ZEBRA_LEVELS - 1)) * (ZEBRA_VOL_MARK - ZEBRA_VOL_SPACE);
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* forward error correction — Hamming(12,8) single-bit correct *
|
|
* *
|
|
* Each data byte becomes a 12-bit codeword (8 data + 4 parity) *
|
|
* carried inside one modem byte-frame, so any single flipped bit *
|
|
* self-corrects on the far side instead of failing the frame CRC. *
|
|
* Toggle with ?fec=0 (both peers must match). Combined with Gray- *
|
|
* coded levels below, an off-by-one quantization is a single-bit *
|
|
* flip — exactly what Hamming repairs. *
|
|
* ============================================================== */
|
|
const ZEBRA_FEC = new URLSearchParams(location.search).get('fec') !== '0';
|
|
const ZEBRA_CW_BITS = ZEBRA_FEC ? 12 : 8; /* bits carried per byte-frame */
|
|
const ZEBRA_SYMS_PER_CW = ZEBRA_CW_BITS / ZEBRA_BITS_PER_SYM;
|
|
const _HAM_DATA_POS = [3, 5, 6, 7, 9, 10, 11, 12]; /* 1-indexed data positions */
|
|
function hammingEncode(byte) {
|
|
const bit = new Array(13).fill(0);
|
|
for (let i = 0; i < 8; i++) bit[_HAM_DATA_POS[i]] = (byte >> i) & 1;
|
|
for (const p of [1, 2, 4, 8]) {
|
|
let par = 0;
|
|
for (let pos = 1; pos <= 12; pos++) if (pos !== p && (pos & p)) par ^= bit[pos];
|
|
bit[p] = par;
|
|
}
|
|
let code = 0;
|
|
for (let pos = 1; pos <= 12; pos++) code |= bit[pos] << (pos - 1);
|
|
return code;
|
|
}
|
|
function hammingDecode(code) {
|
|
const bit = new Array(13).fill(0);
|
|
for (let pos = 1; pos <= 12; pos++) bit[pos] = (code >> (pos - 1)) & 1;
|
|
let syndrome = 0;
|
|
for (const p of [1, 2, 4, 8]) {
|
|
let par = 0;
|
|
for (let pos = 1; pos <= 12; pos++) if (pos & p) par ^= bit[pos];
|
|
if (par) syndrome |= p;
|
|
}
|
|
if (syndrome >= 1 && syndrome <= 12) bit[syndrome] ^= 1; /* correct single-bit error */
|
|
let byte = 0;
|
|
for (let i = 0; i < 8; i++) byte |= bit[_HAM_DATA_POS[i]] << i;
|
|
return byte;
|
|
}
|
|
|
|
/* Gray map: physical level <-> symbol value, so an off-by-one level error is a
|
|
* single-bit value error (which Hamming then corrects). */
|
|
const grayVal2Phys = new Array(ZEBRA_LEVELS);
|
|
const grayPhys2Val = new Array(ZEBRA_LEVELS);
|
|
for (let L = 0; L < ZEBRA_LEVELS; L++) {
|
|
const v = L ^ (L >> 1); /* value carried by physical level L */
|
|
grayPhys2Val[L] = v;
|
|
grayVal2Phys[v] = L;
|
|
}
|
|
/* Link baud for the whole audio modem (handshake + data). Tunable via ?baud=N
|
|
* because the WebRTC Opus path encodes in 20ms frames and smears bit edges:
|
|
* lower baud = more audio quanta per symbol = survives the smear, but slower.
|
|
* Both peers must use the same value (same default, or same ?baud=). */
|
|
const _BAUD_PARAM = parseInt(new URLSearchParams(location.search).get('baud'), 10);
|
|
const ZEBRA_BAUD_HANDSHAKE = (_BAUD_PARAM >= 1 && _BAUD_PARAM <= 1000) ? _BAUD_PARAM : 20;
|
|
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 T_ACK = 0x05;
|
|
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');
|
|
|
|
/* STUN is free + needs no auth; TURN credentials are fetched fresh per
|
|
* connection from /turn-cred (time-limited HMAC, so each client gets its own
|
|
* coturn quota instead of sharing one static login). rtcConfig starts STUN-only
|
|
* and is upgraded with TURN once the creds arrive — if the fetch fails we still
|
|
* connect directly / via STUN, just without the relay fallback. */
|
|
const TURN_CRED_URL = new URLSearchParams(location.search).get('turncred')
|
|
|| 'https://cors-proxy.uncloseai.com/turn-cred';
|
|
let rtcConfig = {
|
|
iceServers: [{ urls: ['stun:proxy.uncloseai.com:3478', 'stun:stun.l.google.com:19302'] }]
|
|
};
|
|
async function refreshTurnCred() {
|
|
try {
|
|
const c = await (await fetch(TURN_CRED_URL, { cache: 'no-store' })).json();
|
|
if (c && c.credential && c.uris) {
|
|
rtcConfig = {
|
|
iceServers: [
|
|
{ urls: c.stun || ['stun:proxy.uncloseai.com:3478'] },
|
|
{ urls: c.uris, username: c.username, credential: c.credential }
|
|
]
|
|
};
|
|
logLine('sys', 'TURN credentials fetched (relay fallback available)');
|
|
}
|
|
} catch (e) {
|
|
logLine('sys', 'no TURN credentials — direct/STUN only (' + e.message + ')');
|
|
}
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* 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 =>
|
|
({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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;
|
|
}
|
|
/* ACK frame: magic(2) type(1) sid(4) ackCrc(4) crc(4) = 15 bytes.
|
|
* ackCrc is the CRC32 of the DATA frame being acknowledged (its message id). */
|
|
async function buildAckFrame(ackCrc) {
|
|
const sid = await senderIdBytes();
|
|
const body = new Uint8Array(11);
|
|
body[0] = HS_MAGIC[0]; body[1] = HS_MAGIC[1]; body[2] = T_ACK;
|
|
body.set(sid, 3);
|
|
body[7] = ackCrc & 0xFF; body[8] = (ackCrc >> 8) & 0xFF;
|
|
body[9] = (ackCrc >> 16) & 0xFF; body[10] = (ackCrc >>> 24) & 0xFF;
|
|
const crc = crc32(body);
|
|
const out = new Uint8Array(15);
|
|
out.set(body);
|
|
out[11] = crc & 0xFF; out[12] = (crc >> 8) & 0xFF;
|
|
out[13] = (crc >> 16) & 0xFF; out[14] = (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, crc: (got >>> 0) };
|
|
}
|
|
if (type === T_ACK) {
|
|
if (bytes.length < 15) return null;
|
|
const sid = bytes.slice(3, 7);
|
|
const ackCrc = (bytes[7] | (bytes[8] << 8) | (bytes[9] << 16) | (bytes[10] << 24)) >>> 0;
|
|
const got = (bytes[11] | (bytes[12] << 8) | (bytes[13] << 16) | (bytes[14] << 24)) >>> 0;
|
|
if (got !== crc32(bytes.slice(0, 11))) return { type, error: 'crc' };
|
|
return { type, sid, ackCrc };
|
|
}
|
|
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;
|
|
/* same freq on both channels: the codec is mono (G.711), so a 440/441 split
|
|
* would downmix into a 1 Hz beat that swamps the amplitude levels. */
|
|
oscR.type = 'sine'; oscR.frequency.value = 440;
|
|
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 } */
|
|
|
|
/* Multi-level demodulator. Each byte arrives framed as:
|
|
* START(level 0 = MIN) | symsPerByte data symbols | STOP(level N-1 = MAX)
|
|
* with the carrier idling at MAX between bytes, so every byte begins with a
|
|
* high->low edge we can lock onto. After collecting a full byte window we read
|
|
* the amplitude at each symbol centre, take START as the low reference and STOP
|
|
* as the high reference, and quantise the data symbols against that span — so
|
|
* the level mapping self-calibrates every byte and tolerates channel gain
|
|
* drift. N=2 collapses to the original binary UART. */
|
|
class MultiLevelDecoder {
|
|
constructor(baud, sampleRate, levels) {
|
|
this.sampleRate = sampleRate;
|
|
this.levels = levels;
|
|
this.bps = Math.log2(levels);
|
|
this.cwSyms = ZEBRA_CW_BITS / this.bps; /* symbols per codeword (8 or 12 bits) */
|
|
this.frameSyms = this.cwSyms + 2; /* + START + STOP */
|
|
this.setBaud(baud);
|
|
this.state = 'hunt';
|
|
this.prevAbove = true;
|
|
this.peak = 0;
|
|
this.peakDecay = Math.pow(0.5, 1.0 / (2.0 * sampleRate)); /* 2s half-life */
|
|
this.amps = [];
|
|
this.q = 0;
|
|
}
|
|
setBaud(b) {
|
|
this.baud = b;
|
|
this.sps = this.sampleRate / b;
|
|
this.windowQ = Math.ceil(this.frameSyms * this.sps);
|
|
}
|
|
/* average the middle ~40% of symbol i's window so we never average across a
|
|
* symbol boundary (which corrupts the level at small samples-per-symbol) */
|
|
_symAmp(i) {
|
|
const lo = Math.max(0, Math.floor((i + 0.3) * this.sps));
|
|
const hi = Math.min(this.amps.length - 1, Math.ceil((i + 0.7) * this.sps));
|
|
let sum = 0, n = 0;
|
|
for (let q = lo; q <= hi; q++) { sum += this.amps[q]; n++; }
|
|
return n ? sum / n : this.amps[this.amps.length - 1];
|
|
}
|
|
_decodeByte() {
|
|
const N1 = this.levels - 1;
|
|
const lowRef = this._symAmp(0); /* START */
|
|
const highRef = this._symAmp(this.frameSyms - 1); /* STOP */
|
|
const span = highRef - lowRef;
|
|
if (span < 0.03) return null; /* not a real START..STOP frame */
|
|
let cw = 0;
|
|
for (let j = 0; j < this.cwSyms; j++) {
|
|
const amp = this._symAmp(1 + j);
|
|
let phys = Math.round((amp - lowRef) / span * N1);
|
|
if (phys < 0) phys = 0; else if (phys > N1) phys = N1;
|
|
cw |= (grayPhys2Val[phys] << (j * this.bps)); /* un-Gray: level -> value */
|
|
}
|
|
return ZEBRA_FEC ? hammingDecode(cw) : cw; /* correct single-bit error */
|
|
}
|
|
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 above = e >= this.peak * 0.50;
|
|
if (this.state === 'hunt') {
|
|
if (this.prevAbove && !above) { /* high->low edge = START of a byte */
|
|
this.state = 'collect';
|
|
this.amps = [e];
|
|
this.q = 1;
|
|
}
|
|
} else {
|
|
this.amps.push(e);
|
|
this.q++;
|
|
if (this.q >= this.windowQ) {
|
|
const out = this._decodeByte();
|
|
this.state = 'hunt';
|
|
this.prevAbove = true; /* STOP was high; next START edge will fire */
|
|
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_ACK) {
|
|
if (this.buf.length < 15) return null;
|
|
const frame = new Uint8Array(this.buf.slice(0, 15));
|
|
this.buf.splice(0, 15);
|
|
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 MultiLevelDecoder(ZEBRA_BAUD_HANDSHAKE, effRate, ZEBRA_LEVELS);
|
|
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 });
|
|
const frameSyms = ZEBRA_SYMS_PER_CW + 2;
|
|
const dataBps = Math.round(ZEBRA_BAUD_HANDSHAKE * 8 / frameSyms);
|
|
logLine('sys', `inbound track attached — decoder live (${ZEBRA_LEVELS} levels, `
|
|
+ `${ZEBRA_BAUD_HANDSHAKE} baud, FEC ${ZEBRA_FEC ? 'on' : 'off'} ≈ ${dataBps} data bit/s)`);
|
|
}
|
|
|
|
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;
|
|
const N1 = ZEBRA_LEVELS - 1;
|
|
let t = audioCtx.currentTime + 0.02;
|
|
/* preamble: idle high (MAX) so the first START is a clean high->low edge */
|
|
for (let i = 0; i < 10; i++) { _scheduleGain(ZEBRA_VOL_MARK, t); t += period; }
|
|
for (const byte of bytes) {
|
|
const cw = ZEBRA_FEC ? hammingEncode(byte) : byte; /* 12 or 8 bits */
|
|
_scheduleGain(ampForLevel(0), t); t += period; /* START = MIN */
|
|
for (let j = 0; j < ZEBRA_SYMS_PER_CW; j++) { /* codeword, LSB-first */
|
|
const v = (cw >> (j * ZEBRA_BITS_PER_SYM)) & N1;
|
|
_scheduleGain(ampForLevel(grayVal2Phys[v]), t); t += period; /* Gray: value -> level */
|
|
}
|
|
_scheduleGain(ampForLevel(N1), t); t += period; /* STOP = MAX */
|
|
}
|
|
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);
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* handshake state *
|
|
* ============================================================== */
|
|
let myMaxBaud = null;
|
|
let negotiatedBaud = null;
|
|
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;
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* 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);
|
|
};
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* reliable delivery: outbox (pending until ACK) + retransmit *
|
|
* ============================================================== */
|
|
const msgPending = new Map(); /* frameCrc -> { handle, text, frame, tries, failed, timer } */
|
|
const rxSeenCrc = new Set(); /* recently received DATA crcs, for de-dup */
|
|
const MAX_TX_TRIES = 5;
|
|
|
|
function frameCrcOf(f) {
|
|
const n = f.length;
|
|
return (f[n-4] | (f[n-3] << 8) | (f[n-2] << 16) | (f[n-1] << 24)) >>> 0;
|
|
}
|
|
/* rough on-air time for a frame of byteLen bytes at the current link settings */
|
|
function estimateTxMs(byteLen) {
|
|
const ticks = 10 + byteLen * (2 + ZEBRA_SYMS_PER_CW) + 5; /* preamble + frame + trailer */
|
|
return ticks / ZEBRA_BAUD_HANDSHAKE * 1000 + 80;
|
|
}
|
|
function renderOutbox() {
|
|
const wrap = $('outbox-wrap'), el = $('outbox');
|
|
if (!wrap || !el) return;
|
|
if (msgPending.size === 0) { wrap.style.display = 'none'; el.innerHTML = ''; return; }
|
|
wrap.style.display = '';
|
|
el.innerHTML = '';
|
|
for (const [, m] of msgPending) {
|
|
const row = document.createElement('div');
|
|
row.className = 'peer';
|
|
const status = m.failed ? '✗ no ack (giving up)'
|
|
: (m.tries > 1 ? `sending… (try ${m.tries})` : 'sending…');
|
|
row.innerHTML = `<span style="color:${m.failed ? '#b00' : '#888'}">`
|
|
+ `⧗ ${escapeHtml(m.text)} — ${status}</span>`;
|
|
el.appendChild(row);
|
|
}
|
|
}
|
|
function scheduleRetransmit(crc) {
|
|
const entry = msgPending.get(crc);
|
|
if (!entry) return;
|
|
const base = estimateTxMs(entry.frame.length) + estimateTxMs(15) + 4000; /* data + ack + margin */
|
|
const wait = base * Math.pow(1.8, entry.tries - 1); /* exponential backoff — don't hammer */
|
|
entry.timer = setTimeout(async () => {
|
|
const e = msgPending.get(crc);
|
|
if (!e) return; /* already ACKed */
|
|
if (e.tries >= MAX_TX_TRIES) { e.failed = true; renderOutbox(); return; }
|
|
e.tries++; renderOutbox();
|
|
try { await txFrame(e.frame, ZEBRA_BAUD_HANDSHAKE); } catch (_) {}
|
|
scheduleRetransmit(crc);
|
|
}, wait);
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* 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_ACK) {
|
|
const entry = msgPending.get(f.ackCrc);
|
|
if (entry) {
|
|
if (entry.timer) clearTimeout(entry.timer);
|
|
msgPending.delete(f.ackCrc);
|
|
logLine('me', `<span class="from">${escapeHtml(entry.handle)}:</span> `
|
|
+ `${escapeHtml(entry.text)} <span style="color:#060">✓</span>`);
|
|
renderOutbox();
|
|
}
|
|
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; }
|
|
}
|
|
/* received + decrypted OK → ACK it (even duplicates, so a retransmit stops
|
|
* when an earlier ACK was lost) */
|
|
try { await txFrame(await buildAckFrame(f.crc), ZEBRA_BAUD_HANDSHAKE); } catch (_) {}
|
|
if (rxSeenCrc.has(f.crc)) return; /* duplicate: re-ACKed, don't re-display */
|
|
rxSeenCrc.add(f.crc);
|
|
if (rxSeenCrc.size > 300) rxSeenCrc.clear();
|
|
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)} at ${ZEBRA_BAUD_HANDSHAKE} baud (link)`);
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* 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(rtcConfig);
|
|
for (const tr of outboundStream.getTracks()) pc.addTrack(tr, outboundStream);
|
|
/* Force a memoryless codec (G.711 PCMU/PCMA, else G.722) ahead of Opus.
|
|
* Opus is a perceptual codec that re-quantizes in 20ms frames and smears the
|
|
* MARK/SPACE bit-edges this modem rides on; G.711 companding is per-sample,
|
|
* preserving the carrier envelope like the PulseAudio path. */
|
|
try {
|
|
const caps = (RTCRtpSender.getCapabilities && RTCRtpSender.getCapabilities('audio')) || null;
|
|
if (caps && caps.codecs) {
|
|
const rank = (mime) => {
|
|
const m = (mime || '').toLowerCase();
|
|
if (m.endsWith('/pcmu')) return 0;
|
|
if (m.endsWith('/pcma')) return 1;
|
|
if (m.endsWith('/g722')) return 2;
|
|
if (m.includes('opus')) return 99;
|
|
return 50;
|
|
};
|
|
const ordered = caps.codecs.slice().sort((a, b) => rank(a.mimeType) - rank(b.mimeType));
|
|
for (const t of pc.getTransceivers()) {
|
|
if (t.setCodecPreferences) { try { t.setCodecPreferences(ordered); } catch (_) {} }
|
|
}
|
|
logLine('sys', 'codec preference: G.711/G.722 ahead of Opus (clean amplitude)');
|
|
}
|
|
} catch (_) {}
|
|
const selfPc = pc; /* snapshot so a superseded pc's late events are ignored */
|
|
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 = () => {
|
|
if (selfPc !== pc) return; /* event from a superseded pc — ignore */
|
|
const s = pc.connectionState;
|
|
rtcStatus.textContent = 'connection: ' + s;
|
|
if (s === 'connected') {
|
|
dotRtc.className = 'dot ok';
|
|
clearRtcRecover(); rtcReconnectTries = 0;
|
|
logLine('sys', 'webrtc connected');
|
|
announceHello().catch(() => {});
|
|
setTimeout(reportTransport, 1200);
|
|
} else if (s === 'disconnected') {
|
|
dotRtc.className = 'dot warn';
|
|
setPath('path: reconnecting…', null);
|
|
logLine('sys', 'webrtc disconnected — waiting for ICE to recover');
|
|
clearRtcRecover();
|
|
rtcRecoverTimer = setTimeout(attemptRtcRestart, 5000); /* often self-heals */
|
|
} else if (s === 'failed') {
|
|
dotRtc.className = 'dot warn';
|
|
setPath('path: reconnecting…', null);
|
|
logLine('sys', 'webrtc failed — restarting ICE');
|
|
attemptRtcRestart();
|
|
} else if (s === 'closed') {
|
|
dotRtc.className = 'dot';
|
|
clearRtcRecover();
|
|
setPath('path: not connected', null);
|
|
}
|
|
};
|
|
pc.oniceconnectionstatechange = () => {
|
|
if (selfPc !== pc) return;
|
|
rtcStatus.textContent = 'ice: ' + pc.iceConnectionState;
|
|
};
|
|
return pc;
|
|
}
|
|
|
|
function setPath(msg, kind) {
|
|
const el = $('path-status'); if (el) { el.textContent = msg; el.className = 'status-line' + (kind ? ' ' + kind : ''); }
|
|
const dot = $('dot-path'); if (dot) dot.className = 'dot' + (kind === 'ok' ? ' ok' : kind === 'err' ? ' warn' : '');
|
|
}
|
|
/* Read the live ICE candidate pair so users can see whether anything sits
|
|
* between them: a direct host/srflx pair = peer-to-peer, nobody in the audio
|
|
* path; a 'relay' candidate = the TURN server is forwarding their (encrypted)
|
|
* audio. */
|
|
async function reportTransport() {
|
|
if (!pc) return;
|
|
try {
|
|
const stats = await pc.getStats();
|
|
let pair = null;
|
|
stats.forEach(r => { if (r.type === 'candidate-pair' && r.nominated && r.state === 'succeeded') pair = r; });
|
|
if (!pair) stats.forEach(r => { if (!pair && r.type === 'candidate-pair' && r.state === 'succeeded') pair = r; });
|
|
if (!pair) { setPath('path: connecting…', null); return; }
|
|
const loc = stats.get(pair.localCandidateId) || {}, rem = stats.get(pair.remoteCandidateId) || {};
|
|
const relayed = loc.candidateType === 'relay' || rem.candidateType === 'relay';
|
|
if (relayed) {
|
|
setPath('path: RELAYED through TURN — a server forwards your (encrypted) audio', 'err');
|
|
logLine('sys', 'transport: relayed via TURN (proxy.uncloseai.com is in the path; audio stays encrypted)');
|
|
logLine('sys', 'to go DIRECT (nobody between): disable mDNS host masking, then reset + reconnect — '
|
|
+ 'Firefox about:config media.peerconnection.ice.obfuscate_host_addresses=false; '
|
|
+ 'Chromium chrome://flags #enable-webrtc-hide-local-ips-with-mdns = Disabled');
|
|
} else {
|
|
setPath(`path: DIRECT peer-to-peer — nobody between you (${loc.candidateType || '?'}/${rem.candidateType || '?'})`, 'ok');
|
|
logLine('sys', `transport: direct peer-to-peer (${loc.candidateType || '?'}/${rem.candidateType || '?'})`);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
function resetConnection() {
|
|
if (typeof disconnectRelay === 'function') disconnectRelay();
|
|
clearRtcRecover(); rtcReconnectTries = 0;
|
|
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();
|
|
rtcStatus.textContent = 'peer link: idle';
|
|
dotRtc.className = 'dot warn';
|
|
setPath('path: not connected', null);
|
|
logLine('sys', 'connection reset — ready to retry');
|
|
}
|
|
|
|
$('btn-reset-rtc').addEventListener('click', resetConnection);
|
|
|
|
/* 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();
|
|
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);
|
|
});
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* 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;
|
|
let relayWantOpen = false; /* true from connect until the user disconnects */
|
|
let relayReconnectTimer = null; /* auto-rejoin the signaling socket if it drops */
|
|
let rtcRecoverTimer = null; /* grace timer before forcing an ICE restart */
|
|
let rtcReconnectTries = 0;
|
|
|
|
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(iceRestart = false) {
|
|
ensurePC();
|
|
const off = await pc.createOffer(iceRestart ? { iceRestart: true } : undefined);
|
|
await pc.setLocalDescription(off);
|
|
await waitForIceGathering(pc);
|
|
await relaySendSDP('offer', pc.localDescription);
|
|
relaySet(iceRestart ? 'reconnecting — new offer sent' : 'offer sent — waiting for answer', 'ok');
|
|
}
|
|
/* Relay-path ICE recovery. A transient blip drops us to 'disconnected' (usually
|
|
* self-heals) or 'failed' (needs an ICE restart). The offerer owns restarts so
|
|
* the two sides don't both re-offer at once (glare); the answerer waits for the
|
|
* new offer to arrive over the relay and answers it on its existing pc. */
|
|
function clearRtcRecover() { if (rtcRecoverTimer) { clearTimeout(rtcRecoverTimer); rtcRecoverTimer = null; } }
|
|
async function attemptRtcRestart() {
|
|
clearRtcRecover();
|
|
if (!pc || pc.connectionState === 'connected') { rtcReconnectTries = 0; return; }
|
|
if (!relayWS || relayWS.readyState !== WebSocket.OPEN) return; /* WS rejoin re-offers */
|
|
if (relayRole !== 'offerer') { relaySet('reconnecting — waiting for peer'); return; }
|
|
if (rtcReconnectTries >= 6) { relaySet('reconnect failed — click "reset" then retry', 'err'); return; }
|
|
rtcReconnectTries++;
|
|
try {
|
|
await refreshTurnCred();
|
|
if (pc.setConfiguration) { try { pc.setConfiguration(rtcConfig); } catch (_) {} }
|
|
await relayMakeOffer(true);
|
|
logLine('sys', `ice restart attempt ${rtcReconnectTries}`);
|
|
} catch (e) { logLine('err', 'ice restart failed: ' + e.message); }
|
|
if (!pc || pc.connectionState !== 'connected') {
|
|
rtcRecoverTimer = setTimeout(attemptRtcRestart, Math.min(2000 * rtcReconnectTries, 10000));
|
|
}
|
|
}
|
|
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; }
|
|
}
|
|
await refreshTurnCred(); /* fetch fresh ephemeral TURN creds before the PC */
|
|
try {
|
|
relayKey = await deriveSignalKey(code);
|
|
const room = await deriveSignalRoom(code);
|
|
relayWantOpen = true;
|
|
relaySet('connecting to relay…');
|
|
openRelaySocket(room);
|
|
} catch (e) {
|
|
relaySet('connect failed: ' + e.message, 'err');
|
|
if (relayWS) { try { relayWS.close(); } catch (_) {} relayWS = null; }
|
|
}
|
|
}
|
|
function openRelaySocket(room) {
|
|
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';
|
|
relayWS = null; relayRole = null;
|
|
if (relayWantOpen) {
|
|
$('btn-relay-disconnect').disabled = false;
|
|
relaySet('relay dropped — reconnecting…');
|
|
if (relayReconnectTimer) clearTimeout(relayReconnectTimer);
|
|
relayReconnectTimer = setTimeout(() => { if (relayWantOpen) openRelaySocket(room); }, 1500);
|
|
} else {
|
|
$('btn-relay-connect').disabled = false;
|
|
$('btn-relay-disconnect').disabled = true;
|
|
relaySet('relay disconnected');
|
|
}
|
|
};
|
|
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)`);
|
|
/* pc already exists ⇒ this is a re-join after a drop: ICE-restart offer */
|
|
if (relayRole === 'offerer' && m.peers >= 2) await relayMakeOffer(!!pc);
|
|
} else if (m.type === 'peer-joined') {
|
|
logLine('sys', 'peer joined the room');
|
|
if (relayRole === 'offerer') await relayMakeOffer(!!pc);
|
|
} 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);
|
|
}
|
|
};
|
|
}
|
|
function disconnectRelay() {
|
|
relayWantOpen = false;
|
|
if (relayReconnectTimer) { clearTimeout(relayReconnectTimer); relayReconnectTimer = null; }
|
|
clearRtcRecover();
|
|
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 *
|
|
* ============================================================== */
|
|
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 440 Hz');
|
|
} catch (e) {
|
|
logLine('err', 'carrier 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();
|
|
if (mode === 'passphrase' && !groupKey) { logLine('err', 'no room key'); return; }
|
|
if (mode === 'pubkey' && !Array.from(peers.values()).some(p => p.sharedKey)) {
|
|
logLine('err', 'no pubkey peers ready'); return;
|
|
}
|
|
/* data travels at ZEBRA_BAUD_HANDSHAKE to match the inbound decoder */
|
|
const baud = ZEBRA_BAUD_HANDSHAKE;
|
|
const handle = handleIn.value.trim() || 'me';
|
|
$('msg-in').value = '';
|
|
/* don't echo to the log yet — hold in the outbox until the far side ACKs,
|
|
* retransmitting until then. the message moves into the log on its ACK. */
|
|
try {
|
|
const frames = [];
|
|
if (mode === 'passphrase') {
|
|
frames.push(await buildDataFrame(await aesEncrypt(groupKey, msg)));
|
|
} else {
|
|
for (const p of peers.values()) {
|
|
if (p.sharedKey) frames.push(await buildDataFrame(await aesEncrypt(p.sharedKey, msg)));
|
|
}
|
|
}
|
|
for (const frame of frames) {
|
|
const crc = frameCrcOf(frame);
|
|
msgPending.set(crc, { handle, text: msg, frame, tries: 1, failed: false, timer: null });
|
|
renderOutbox();
|
|
await txFrame(frame, baud);
|
|
scheduleRetransmit(crc);
|
|
}
|
|
} 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 — set a room passphrase, type a rendezvous code, connect, then chat.');
|
|
logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry plaintext.');
|
|
|
|
})();
|
|
</script>
|
|
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
|
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-06-01</span><br>
|
|
md5 <span class="stamp-md5">f1d9169e022a422b9b2f1fa166b1da3d</span><br>
|
|
sha256 <span class="stamp-sha">e8af1dceb904c649a681670056c1c664c352f0e0102ffdf202d007dbb50e0182</span><br>
|
|
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
|
|
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify it against these hashes; point it at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
|
|
</footer>
|
|
</body>
|
|
</html>
|