diff --git a/web/chat.html b/web/chat.html index c1ce370..4693385 100644 --- a/web/chat.html +++ b/web/chat.html @@ -382,6 +382,10 @@ +
@@ -481,6 +485,7 @@ 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'); @@ -729,6 +734,22 @@ async function buildHelloFrame(handle, maxBaud) { 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; @@ -748,7 +769,15 @@ function parseFrame(bytes) { 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 }; + 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; @@ -968,6 +997,12 @@ class FrameAssembler { 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]; @@ -1116,6 +1151,52 @@ if (LOOPBACK) { }; } +/* ============================================================== * + * 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_BYTE) + 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 = `` + + `⧗ ${escapeHtml(m.text)} — ${status}`; + el.appendChild(row); + } +} +function scheduleRetransmit(crc) { + const entry = msgPending.get(crc); + if (!entry) return; + const wait = estimateTxMs(entry.frame.length) + estimateTxMs(15) + 4000; /* data + ack + margin */ + 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 * * ============================================================== */ @@ -1152,6 +1233,17 @@ async function onRxFrame(bytes) { 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', `${escapeHtml(entry.handle)}: ` + + `${escapeHtml(entry.text)} ✓`); + renderOutbox(); + } + return; + } if (f.type === T_DATA) { const sid = hex(f.sid); let pt = null; @@ -1165,6 +1257,12 @@ async function onRxFrame(bytes) { } 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', `${escapeHtml(handle)}: ${escapeHtml(pt)}`); } @@ -1684,25 +1782,28 @@ async function sendCurrentMsg() { if (mode === 'pubkey' && !Array.from(peers.values()).some(p => p.sharedKey)) { logLine('err', 'no pubkey peers ready'); return; } - /* must match the inbound decoder, which stays at ZEBRA_BAUD_HANDSHAKE for the - * life of the link — data sent at any other baud won't decode on the far side. */ + /* data travels at ZEBRA_BAUD_HANDSHAKE to match the inbound decoder */ const baud = ZEBRA_BAUD_HANDSHAKE; const handle = handleIn.value.trim() || 'me'; - /* echo immediately so the sender sees the message without waiting for the - * (slow, multi-second at low baud) transmit to finish */ - logLine('me', `${escapeHtml(handle)}: ${escapeHtml(msg)}`); $('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') { - const ct = await aesEncrypt(groupKey, msg); - await txFrame(await buildDataFrame(ct), baud); + frames.push(await buildDataFrame(await aesEncrypt(groupKey, msg))); } else { for (const p of peers.values()) { - if (!p.sharedKey) continue; - const ct = await aesEncrypt(p.sharedKey, msg); - await txFrame(await buildDataFrame(ct), baud); + 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); }