web/chat.html: add ?loopback=1 dev loopback via BroadcastChannel

without zebrad running, the page is TX-only: tabs see their own
outbound frames but nothing crosses. for UX validation while the
introspector daemon doesn't exist yet, gate a BroadcastChannel
fallback on the ?loopback=1 query param.

behavior:
  * with ?loopback=1: after each txFrame completes its modulation,
    the raw frame bytes are postMessage'd on a same-origin
    BroadcastChannel('zebra-report-loopback'). other tabs of the
    same browser subscribe & feed received bytes into onRxFrame
    exactly as zebrad would. BroadcastChannel suppresses echo to
    sender by spec, so the originating tab does not see itself.
  * without ?loopback=1: behavior unchanged. real PA transport
    stays the production path.

a yellow banner at the top of the page warns when loopback is
active: "frames cross via BroadcastChannel between same-browser
tabs, NOT via PulseAudio". no risk of mistaking dev mode for
production.
This commit is contained in:
Russell Ballestrini 2026-05-27 15:47:00 -04:00
parent d8607cb000
commit 5d76d713a6

View file

@ -334,6 +334,10 @@ const WS_URL = 'ws://127.0.0.1:7777';
const PBKDF2_SALT = new TextEncoder().encode('zebra-report-v1');
const PBKDF2_ITER = 600000;
/* dev loopback: bypass PulseAudio, ride BroadcastChannel between tabs.
* gated on ?loopback=1. real PA transport stays the production path. */
const LOOPBACK = new URLSearchParams(location.search).has('loopback');
/* ============================================================== *
* tiny utils *
* ============================================================== */
@ -630,6 +634,14 @@ async function txFrame(bytes, baud) {
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;
@ -705,10 +717,19 @@ function recomputeGroupBaud() {
}
/* ============================================================== *
* introspector bridge (RX path) *
* introspector bridge (RX path) + dev loopback *
* ============================================================== */
let ws = null;
let wsReconnectTimer = null;
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);
};
}
const dotRx = $('dot-rx');
const rxStatus = $('rx-status');
@ -1053,6 +1074,19 @@ setInterval(updateSendButton, 1000);
/* try to find the introspector; quiet failure */
rxConnect(false);
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 PulseAudio. drop ?loopback=1 from the URL for production.';
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 the carrier, derive a room key, then send.');
logLine('sys', 'no chat content ever enters an IP packet. wireshark blind.');