Files
wz-phone/desktop/src/main.ts
Siavash Sameni 921856eba9 feat(reflect): QUIC-native NAT reflection ("STUN for QUIC") — Phase 1
Lets a client ask its registered relay "what IP:port do you see for
me?" over the existing TLS-authenticated signal channel, returning
the client's server-reflexive address as a SocketAddr. Replaces the
need for a classic STUN deployment and becomes the bootstrap step
for future P2P hole-punching: once both peers know their own reflex
addrs, they can advertise them in DirectCallOffer and attempt a
direct QUIC handshake to each other.

Wire protocol (wzp-proto):
- SignalMessage::Reflect — unit variant, client -> relay
- SignalMessage::ReflectResponse { observed_addr: String } — relay -> client
- JSON-serde, appended at end of enum: zero ordinal concerns,
  backward compat with pre-Phase-1 relays by construction (older
  relays log "unexpected message" and drop; newer clients time out
  cleanly within 1s).

Relay handler (wzp-relay/src/main.rs, signal loop):
- New match arm next to Ping reuses the already-bound `addr` from
  connection.remote_address() and replies with observed_addr as a
  string. debug!-level log on success, warn!-level on send failure.

Client side (desktop/src-tauri/src/lib.rs):
- SignalState gains pending_reflect: Option<oneshot::Sender<SocketAddr>>.
- get_reflected_address Tauri command installs the oneshot before
  sending Reflect and awaits it with a 1s timeout; cleans up on
  every exit path (send failure, timeout, parse error).
- recv loop's new ReflectResponse arm fires the pending sender or
  emits a debug log for unsolicited responses — never crashes the
  loop on malformed input.
- Integrated into invoke_handler! alongside the other signal
  commands.

UI (desktop/index.html + src/main.ts):
- New "Network" section in settings panel with a "Detect" button
  that displays the reflected address or a categorized warning
  ("register first" / "relay does not support reflection" / error).

Tests (crates/wzp-relay/tests/reflect.rs — 3 new, all passing):
- reflect_happy_path: client on loopback gets back 127.0.0.1:<its own port>
- reflect_two_clients_distinct_ports: two concurrent clients see
  their own distinct ports, proving per-connection remote_address
- reflect_old_relay_times_out: mock relay that ignores Reflect —
  client times out between 1000-1200ms and does not hang

Also pre-existing test bit-rot unrelated to this PR — fixed so the
full workspace `cargo test` goes green:
- handshake_integration tests in wzp-client, wzp-relay and
  featherchat_compat in wzp-crypto all missed the `alias` field
  addition to CallOffer and the 3-arg form of perform_handshake
  plus 4-tuple return of accept_handshake. Updated to the current
  API surface.

Results:
  cargo test --workspace --exclude wzp-android: 386 passed
  cargo check --workspace: clean
  cargo clippy: no new warnings in touched files

Verification excludes wzp-android because it's dead code on this
branch (Tauri mobile uses wzp-native instead) and can't link -llog
on macOS host — unchanged status quo.

PRD: .taskmaster/docs/prd_reflect_over_quic.txt
Tasks: 39-46 all completed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:29:07 +04:00

1086 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { generateIdenticon, createIdenticonEl } from "./identicon";
// ── WebView hardening ──
// Suppress the browser-style right-click context menu on desktop Tauri — it
// exposes Inspect/Reload/Back/Forward entries that don't belong in a native-
// feeling VoIP app. Dev tools remain accessible via the usual keyboard
// shortcuts (F12 / Cmd-Opt-I). On Android there is no right-click so this is
// a no-op there.
document.addEventListener("contextmenu", (e) => e.preventDefault());
// Also suppress browser-level zoom via keyboard (Ctrl/Cmd + / - / 0) so the
// fixed-layout UI can't be accidentally scaled. Pinch-zoom is already handled
// at the viewport meta level in index.html.
document.addEventListener(
"keydown",
(e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === "+" || e.key === "-" || e.key === "=" || e.key === "0")) {
e.preventDefault();
}
},
{ capture: true },
);
// Block gesture-based zoom on browsers that fire these legacy events (mainly
// Safari / WebKit). Chromium sends `wheel` with ctrlKey for trackpad pinch —
// catch that too.
document.addEventListener("gesturestart", (e) => e.preventDefault());
document.addEventListener("gesturechange", (e) => e.preventDefault());
document.addEventListener("gestureend", (e) => e.preventDefault());
document.addEventListener(
"wheel",
(e) => {
if (e.ctrlKey) e.preventDefault();
},
{ passive: false },
);
// ── Elements ──
const connectScreen = document.getElementById("connect-screen")!;
const callScreen = document.getElementById("call-screen")!;
const roomInput = document.getElementById("room") as HTMLInputElement;
const aliasInput = document.getElementById("alias") as HTMLInputElement;
const osAecCheckbox = document.getElementById("os-aec") as HTMLInputElement;
const connectBtn = document.getElementById("connect-btn") as HTMLButtonElement;
const connectError = document.getElementById("connect-error")!;
const roomName = document.getElementById("room-name")!;
const callTimer = document.getElementById("call-timer")!;
const callStatus = document.getElementById("call-status")!;
const levelBar = document.getElementById("level-bar")!;
const participantsDiv = document.getElementById("participants")!;
const micBtn = document.getElementById("mic-btn")!;
const micIcon = document.getElementById("mic-icon")!;
const spkBtn = document.getElementById("spk-btn")!;
const spkIcon = document.getElementById("spk-icon")!;
const hangupBtn = document.getElementById("hangup-btn")!;
const statsDiv = document.getElementById("stats")!;
const myFingerprintEl = document.getElementById("my-fingerprint")!;
const myIdenticonEl = document.getElementById("my-identicon")!;
const recentRoomsDiv = document.getElementById("recent-rooms")!;
// Relay button
const relaySelected = document.getElementById("relay-selected")!;
const relayDot = document.getElementById("relay-dot")!;
const relayLabel = document.getElementById("relay-label")!;
// Relay dialog
const relayDialog = document.getElementById("relay-dialog")!;
const relayDialogClose = document.getElementById("relay-dialog-close")!;
const relayDialogList = document.getElementById("relay-dialog-list")!;
const relayAddName = document.getElementById("relay-add-name") as HTMLInputElement;
const relayAddAddr = document.getElementById("relay-add-addr") as HTMLInputElement;
const relayAddBtn = document.getElementById("relay-add-btn")!;
// Settings
const settingsPanel = document.getElementById("settings-panel")!;
const settingsClose = document.getElementById("settings-close")!;
const settingsSave = document.getElementById("settings-save")!;
const settingsBtnHome = document.getElementById("settings-btn-home")!;
const settingsBtnCall = document.getElementById("settings-btn-call")!;
const sRoom = document.getElementById("s-room") as HTMLInputElement;
const sAlias = document.getElementById("s-alias") as HTMLInputElement;
const sOsAec = document.getElementById("s-os-aec") as HTMLInputElement;
const sDredDebug = document.getElementById("s-dred-debug") as HTMLInputElement;
const sReflectedAddr = document.getElementById("s-reflected-addr") as HTMLSpanElement;
const sReflectBtn = document.getElementById("s-reflect-btn") as HTMLButtonElement;
const sAgc = document.getElementById("s-agc") as HTMLInputElement;
const sQuality = document.getElementById("s-quality") as HTMLInputElement;
const sQualityLabel = document.getElementById("s-quality-label")!;
// Quality slider config — best (left/green) to worst (right/red)
const QUALITY_STEPS = ["studio-64k", "studio-48k", "studio-32k", "auto", "good", "degraded", "codec2-3200", "catastrophic"];
const QUALITY_LABELS = ["Studio 64k", "Studio 48k", "Studio 32k", "Auto", "Opus 24k", "Opus 6k", "Codec2 3.2k", "Codec2 1.2k"];
const QUALITY_COLORS = ["#22c55e", "#4ade80", "#86efac", "#a3e635", "#facc15", "#f59e0b", "#e97320", "#991b1b"];
function qualityToIndex(q: string): number {
const idx = QUALITY_STEPS.indexOf(q);
return idx >= 0 ? idx : 3; // default to "auto" (index 3)
}
function updateQualityUI(index: number) {
sQualityLabel.textContent = QUALITY_LABELS[index];
sQualityLabel.style.color = QUALITY_COLORS[index];
sQuality.style.background = `linear-gradient(90deg, #22c55e 0%, #86efac 25%, #facc15 50%, #e97320 75%, #991b1b 100%)`;
}
sQuality.addEventListener("input", () => {
updateQualityUI(parseInt(sQuality.value));
});
const sFingerprint = document.getElementById("s-fingerprint")!;
const sRecentRooms = document.getElementById("s-recent-rooms")!;
const sClearRecent = document.getElementById("s-clear-recent")!;
// Key warning dialog
const keyWarning = document.getElementById("key-warning")!;
const kwOldFp = document.getElementById("kw-old-fp")!;
const kwNewFp = document.getElementById("kw-new-fp")!;
const kwAccept = document.getElementById("kw-accept")!;
const kwCancel = document.getElementById("kw-cancel")!;
let statusInterval: number | null = null;
let myFingerprint = "";
let userDisconnected = false;
// ── Data types ──
interface RelayServer {
name: string;
address: string;
rtt?: number | null;
serverFingerprint?: string | null; // from ping
knownFingerprint?: string | null; // saved TOFU fingerprint
}
interface RecentRoom { relay: string; room: string; }
interface Settings {
relays: RelayServer[];
selectedRelay: number;
room: string;
alias: string;
osAec: boolean;
agc: boolean;
quality: string;
recentRooms: RecentRoom[];
/// When true, the Rust side emits the chatty per-frame DRED parse +
/// reconstruction + classical-PLC logs and adds DRED counters to the
/// recv heartbeat. Off in normal mode keeps logcat clean.
dredDebugLogs: boolean;
}
function loadSettings(): Settings {
const defaults: Settings = {
relays: [
// Local laptop relay — used during Android rewrite testing so the phone
// and the relay logs are on the same host. Laptop IP on the test LAN.
{ name: "Laptop", address: "172.16.81.125:4433" },
{ name: "Default", address: "193.180.213.68:4433" },
],
selectedRelay: 0, room: "general", alias: "",
osAec: true, agc: true, quality: "auto", recentRooms: [],
dredDebugLogs: false,
};
try {
const raw = localStorage.getItem("wzp-settings");
if (raw) {
const parsed = JSON.parse(raw);
if (parsed.relay && !parsed.relays) {
parsed.relays = [{ name: "Default", address: parsed.relay }];
parsed.selectedRelay = 0;
delete parsed.relay;
}
if (parsed.recentRooms?.length > 0 && typeof parsed.recentRooms[0] === "string") {
const addr = parsed.relays?.[0]?.address || defaults.relays[0].address;
parsed.recentRooms = parsed.recentRooms.map((r: string) => ({ relay: addr, room: r }));
}
// Ensure the Laptop test relay is present as the first entry for
// existing installs — otherwise users with cached settings keep using
// the remote default and we have to manually add it each install.
// Remove this block once the Android rewrite is stable.
const LAPTOP_ADDR = "172.16.81.125:4433";
if (Array.isArray(parsed.relays) && !parsed.relays.some((r: any) => r.address === LAPTOP_ADDR)) {
parsed.relays.unshift({ name: "Laptop", address: LAPTOP_ADDR });
parsed.selectedRelay = 0;
}
return { ...defaults, ...parsed };
}
} catch {}
return defaults;
}
function saveSettingsObj(s: Settings) {
localStorage.setItem("wzp-settings", JSON.stringify(s));
}
function getSelectedRelay(): RelayServer | undefined {
const s = loadSettings();
return s.relays[s.selectedRelay];
}
// ── Helpers ──
function escapeHtml(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
// ── Lock status ──
type LockStatus = "verified" | "new" | "changed" | "offline" | "unknown";
function lockStatus(relay: RelayServer): LockStatus {
if (relay.rtt === undefined || relay.rtt === null) return "unknown";
if (relay.rtt < 0) return "offline";
if (!relay.serverFingerprint) return "new";
if (!relay.knownFingerprint) return "new"; // first time
if (relay.serverFingerprint === relay.knownFingerprint) return "verified";
return "changed";
}
function lockIcon(status: LockStatus): string {
switch (status) {
case "verified": return "🔒";
case "new": return "🔓";
case "changed": return "⚠️";
case "offline": return "🔴";
case "unknown": return "⚪";
}
}
function lockColor(status: LockStatus): string {
switch (status) {
case "verified": return "var(--green)";
case "new": return "var(--yellow)";
case "changed": return "var(--red)";
case "offline": return "var(--red)";
case "unknown": return "var(--text-dim)";
}
}
// ── Apply settings ──
function applySettings() {
const s = loadSettings();
roomInput.value = s.room;
aliasInput.value = s.alias;
osAecCheckbox.checked = s.osAec;
renderRecentRooms(s.recentRooms);
renderRelayButton();
}
// ── Relay button ──
function renderRelayButton() {
const s = loadSettings();
const sel = s.relays[s.selectedRelay];
if (sel) {
const ls = lockStatus(sel);
relayDot.textContent = lockIcon(ls);
relayDot.className = "relay-lock";
relayLabel.textContent = `${sel.name} (${sel.address})`;
} else {
relayDot.textContent = "⚪";
relayDot.className = "relay-lock";
relayLabel.textContent = "No relay configured";
}
}
relaySelected.addEventListener("click", () => openRelayDialog());
// ── Relay dialog ──
function openRelayDialog() {
renderRelayDialogList();
relayAddName.value = "";
relayAddAddr.value = "";
relayDialog.classList.remove("hidden");
}
function closeRelayDialog() {
relayDialog.classList.add("hidden");
renderRelayButton();
}
function renderRelayDialogList() {
const s = loadSettings();
relayDialogList.innerHTML = "";
s.relays.forEach((r, i) => {
const item = document.createElement("div");
item.className = `relay-dialog-item ${i === s.selectedRelay ? "selected" : ""}`;
const ls = lockStatus(r);
const fp = r.serverFingerprint || r.address;
// Identicon
const icon = createIdenticonEl(fp, 32, true);
icon.title = r.serverFingerprint
? `Server: ${r.serverFingerprint}\nClick to copy`
: `No fingerprint yet`;
item.appendChild(icon);
// Info
const info = document.createElement("div");
info.className = "relay-info";
info.innerHTML = `
<div class="relay-name">${escapeHtml(r.name)}</div>
<div class="relay-addr">${escapeHtml(r.address)}</div>
`;
item.appendChild(info);
// Lock + RTT
const meta = document.createElement("div");
meta.className = "relay-meta";
const rttStr = r.rtt !== undefined && r.rtt !== null
? (r.rtt < 0 ? "offline" : `${r.rtt}ms`)
: "";
meta.innerHTML = `
<span class="relay-lock-icon" style="color:${lockColor(ls)}">${lockIcon(ls)}</span>
<span class="relay-rtt">${rttStr}</span>
`;
item.appendChild(meta);
// Delete button
const del = document.createElement("button");
del.className = "remove";
del.textContent = "×";
del.addEventListener("click", (e) => {
e.stopPropagation();
const s = loadSettings();
s.relays.splice(i, 1);
if (s.selectedRelay >= s.relays.length) s.selectedRelay = Math.max(0, s.relays.length - 1);
saveSettingsObj(s);
renderRelayDialogList();
renderRelayButton();
});
item.appendChild(del);
// Click to select
item.addEventListener("click", () => {
const s = loadSettings();
s.selectedRelay = i;
// TOFU: if first time seeing this server, trust its fingerprint
if (r.serverFingerprint && !r.knownFingerprint) {
s.relays[i].knownFingerprint = r.serverFingerprint;
}
saveSettingsObj(s);
renderRelayDialogList();
renderRelayButton();
});
relayDialogList.appendChild(item);
});
}
relayAddBtn.addEventListener("click", () => {
const name = relayAddName.value.trim();
const addr = relayAddAddr.value.trim();
if (!addr) return;
const s = loadSettings();
s.relays.push({ name: name || addr, address: addr });
saveSettingsObj(s);
relayAddName.value = "";
relayAddAddr.value = "";
renderRelayDialogList();
pingAllRelays();
});
relayDialogClose.addEventListener("click", closeRelayDialog);
relayDialog.addEventListener("click", (e) => { if (e.target === relayDialog) closeRelayDialog(); });
// ── Ping ──
interface PingResult { rtt_ms: number; server_fingerprint: string; }
async function pingAllRelays() {
const s = loadSettings();
for (let i = 0; i < s.relays.length; i++) {
const r = s.relays[i];
try {
const result: PingResult = await invoke("ping_relay", { relay: r.address });
r.rtt = result.rtt_ms;
r.serverFingerprint = result.server_fingerprint;
// TOFU: auto-save fingerprint on first contact
if (!r.knownFingerprint) {
r.knownFingerprint = result.server_fingerprint;
}
} catch {
r.rtt = -1;
}
}
saveSettingsObj(s);
renderRelayButton();
if (!relayDialog.classList.contains("hidden")) renderRelayDialogList();
}
// ── Recent rooms ──
function renderRecentRooms(rooms: RecentRoom[]) {
recentRoomsDiv.innerHTML = rooms
.map((r) => `<span class="recent-room" data-relay="${escapeHtml(r.relay)}" data-room="${escapeHtml(r.room)}">${escapeHtml(r.room)}</span>`)
.join("");
recentRoomsDiv.querySelectorAll(".recent-room").forEach((el) => {
el.addEventListener("click", () => {
const ds = (el as HTMLElement).dataset;
roomInput.value = ds.room || "";
const s = loadSettings();
const idx = s.relays.findIndex((r) => r.address === ds.relay);
if (idx >= 0) { s.selectedRelay = idx; saveSettingsObj(s); renderRelayButton(); }
});
});
}
// ── Init ──
applySettings();
setTimeout(pingAllRelays, 300);
// Hydrate the Rust DRED verbose-logs flag from saved settings on boot so
// the choice survives app restarts without needing the user to reopen
// the settings panel.
invoke("set_dred_verbose_logs", { enabled: !!loadSettings().dredDebugLogs }).catch(() => {});
// Load fingerprint + alias + git hash + render identicon
interface AppInfo { git_hash: string; alias: string; fingerprint: string; data_dir: string }
(async () => {
try {
const info: AppInfo = await invoke("get_app_info");
const fp = info.fingerprint;
myFingerprint = fp;
myFingerprintEl.textContent = fp;
myFingerprintEl.style.cursor = "pointer";
myFingerprintEl.addEventListener("click", () => {
navigator.clipboard.writeText(fp).then(() => {
const orig = myFingerprintEl.textContent;
myFingerprintEl.textContent = "Copied!";
setTimeout(() => { myFingerprintEl.textContent = orig; }, 1000);
});
});
// Identicon next to fingerprint
const icon = createIdenticonEl(fp, 28, true);
myIdenticonEl.innerHTML = "";
myIdenticonEl.appendChild(icon);
// Prefill alias if the user hasn't typed one yet
if (!aliasInput.value.trim()) {
aliasInput.value = info.alias;
const s = loadSettings();
s.alias = info.alias;
saveSettingsObj(s);
}
// Stamp the build hash on the home screen so we can prove which build
// is installed (this caused us a lot of grief on the Kotlin app).
let buildEl = document.getElementById("build-hash");
if (!buildEl) {
buildEl = document.createElement("div");
buildEl.id = "build-hash";
buildEl.style.cssText = "font-size:10px;opacity:0.6;text-align:center;margin-top:4px;font-family:monospace";
myFingerprintEl.parentElement?.appendChild(buildEl);
}
buildEl.textContent = `build ${info.git_hash}${info.alias}`;
buildEl.title = info.data_dir;
} catch (e) {
console.error("get_app_info failed", e);
}
})();
// ── Connect ──
connectBtn.addEventListener("click", doConnect);
[roomInput, aliasInput].forEach((el) =>
el.addEventListener("keydown", (e) => { if (e.key === "Enter") doConnect(); })
);
function showKeyWarning(oldFp: string, newFp: string): Promise<boolean> {
return new Promise((resolve) => {
kwOldFp.textContent = oldFp;
kwNewFp.textContent = newFp;
keyWarning.classList.remove("hidden");
const cleanup = () => {
keyWarning.classList.add("hidden");
kwAccept.removeEventListener("click", onAccept);
kwCancel.removeEventListener("click", onCancel);
keyWarning.removeEventListener("click", onBackdrop);
};
const onAccept = () => { cleanup(); resolve(true); };
const onCancel = () => { cleanup(); resolve(false); };
const onBackdrop = (e: Event) => { if (e.target === keyWarning) { cleanup(); resolve(false); } };
kwAccept.addEventListener("click", onAccept);
kwCancel.addEventListener("click", onCancel);
keyWarning.addEventListener("click", onBackdrop);
});
}
async function doConnect() {
const relay = getSelectedRelay();
if (!relay) { connectError.textContent = "No relay selected"; return; }
// Warn on fingerprint mismatch
const ls = lockStatus(relay);
if (ls === "changed") {
const accepted = await showKeyWarning(relay.knownFingerprint || "", relay.serverFingerprint || "");
if (!accepted) return;
// User accepted — update known fingerprint
const s = loadSettings();
s.relays[s.selectedRelay].knownFingerprint = relay.serverFingerprint;
saveSettingsObj(s);
renderRelayButton();
}
// Don't block connect on offline — ping may have failed transiently
connectError.textContent = "";
connectBtn.disabled = true;
connectBtn.textContent = "Connecting...";
userDisconnected = false;
const s = loadSettings();
s.room = roomInput.value; s.alias = aliasInput.value; s.osAec = osAecCheckbox.checked;
const room = roomInput.value.trim();
if (room) {
const entry: RecentRoom = { relay: relay.address, room };
s.recentRooms = [entry, ...s.recentRooms.filter((r) => !(r.relay === relay.address && r.room === room))].slice(0, 5);
}
saveSettingsObj(s);
try {
await invoke("connect", {
relay: relay.address, room: roomInput.value,
alias: aliasInput.value, osAec: osAecCheckbox.checked,
quality: s.quality || "auto",
});
showCallScreen();
} catch (e: any) {
connectError.textContent = String(e);
connectBtn.disabled = false;
connectBtn.textContent = "Connect";
}
}
function showCallScreen() {
connectScreen.classList.add("hidden");
callScreen.classList.remove("hidden");
roomName.textContent = roomInput.value;
callStatus.className = "status-dot";
statusInterval = window.setInterval(pollStatus, 250);
// Sync the Speaker/Earpiece label with the OS state (Android only; on
// desktop the command is a no-op returning false so we land on "Earpiece"
// which is fine because desktop has no routing concept).
invoke<boolean>("is_speakerphone_on")
.then((on) => { speakerphoneOn = !!on; updateSpkLabel(); })
.catch(() => { speakerphoneOn = false; updateSpkLabel(); });
}
function showConnectScreen() {
callScreen.classList.add("hidden");
connectScreen.classList.remove("hidden");
connectBtn.disabled = false;
connectBtn.textContent = "Connect";
levelBar.style.width = "0%";
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
}
// ── Mute / hangup ──
micBtn.addEventListener("click", async () => {
try { const m: boolean = await invoke("toggle_mic"); micBtn.classList.toggle("muted", m); micIcon.textContent = m ? "Mic Off" : "Mic"; } catch {}
});
// Speaker routing (Android) — toggles AudioManager.setSpeakerphoneOn + then
// stops and restarts the Oboe streams so AAudio reconfigures with the new
// routing. The Rust-side Tauri command handles the restart, we just swap
// the button label.
//
// Earpiece is NOT a "muted" state, so DO NOT add the `.muted` CSS class
// (which would tint the button red); that was a bug in 0178cbd that made
// earpiece mode look like playback was off. A separate `.speaker-on` class
// is available for css styling if we want to visually indicate loud mode.
let speakerphoneOn = false;
let speakerphoneBusy = false;
function updateSpkLabel() {
spkBtn.classList.toggle("speaker-on", speakerphoneOn);
spkBtn.classList.remove("muted");
spkIcon.textContent = speakerphoneOn ? "🔊 Speaker" : "🔈 Earpiece";
}
spkBtn.addEventListener("click", async () => {
if (speakerphoneBusy) return; // debounce — the restart takes ~60ms
speakerphoneBusy = true;
const next = !speakerphoneOn;
spkBtn.disabled = true;
try {
await invoke("set_speakerphone", { on: next });
speakerphoneOn = next;
updateSpkLabel();
} catch (e) {
console.error("set_speakerphone failed:", e);
} finally {
spkBtn.disabled = false;
speakerphoneBusy = false;
}
});
hangupBtn.addEventListener("click", async () => {
userDisconnected = true;
try { await invoke("disconnect"); } catch {}
showConnectScreen();
});
document.addEventListener("keydown", (e) => {
if (callScreen.classList.contains("hidden")) return;
if ((e.target as HTMLElement).tagName === "INPUT") return;
if (e.key === "m") micBtn.click();
if (e.key === "s") spkBtn.click();
if (e.key === "q") hangupBtn.click();
});
// ── Status polling ──
interface CallStatusI {
active: boolean; mic_muted: boolean; spk_muted: boolean;
participants: { fingerprint: string; alias: string | null }[];
encode_fps: number; recv_fps: number; audio_level: number;
call_duration_secs: number; fingerprint: string;
}
function formatDuration(secs: number): string {
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
let reconnectAttempts = 0;
async function pollStatus() {
try {
const st: CallStatusI = await invoke("get_status");
if (!st.active) {
if (!userDisconnected && reconnectAttempts < 5) {
reconnectAttempts++;
callStatus.className = "status-dot reconnecting";
statsDiv.textContent = `Reconnecting (${reconnectAttempts}/5)...`;
const relay = getSelectedRelay();
if (relay) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 10000);
setTimeout(async () => {
try {
await invoke("connect", { relay: relay.address, room: roomInput.value, alias: aliasInput.value, osAec: osAecCheckbox.checked });
reconnectAttempts = 0; callStatus.className = "status-dot";
} catch {}
}, delay);
}
return;
}
reconnectAttempts = 0; showConnectScreen(); return;
}
reconnectAttempts = 0;
if (st.fingerprint) myFingerprint = st.fingerprint;
micBtn.classList.toggle("muted", st.mic_muted);
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
// NB: spkBtn label is driven by the Android audio routing state
// (speakerphoneOn / updateSpkLabel), not by the engine's spk_muted.
// Skip that here so pollStatus doesn't clobber the routing UI.
callTimer.textContent = formatDuration(st.call_duration_secs);
const rms = st.audio_level;
const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0;
levelBar.style.width = `${pct}%`;
// Participants grouped by relay
if (st.participants.length === 0) {
participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>';
} else {
participantsDiv.innerHTML = "";
// Group by relay_label (null = this relay)
const groups: Record<string, typeof st.participants> = {};
st.participants.forEach((p: any) => {
const relay = p.relay_label || "This Relay";
if (!groups[relay]) groups[relay] = [];
groups[relay].push(p);
});
Object.entries(groups).forEach(([relay, members]) => {
// Relay header
const header = document.createElement("div");
header.className = "relay-group-header";
const isLocal = relay === "This Relay";
header.innerHTML = `<span class="relay-dot-small ${isLocal ? "green" : "blue"}"></span> ${escapeHtml(relay)}`;
participantsDiv.appendChild(header);
// Participants under this relay
(members as any[]).forEach((p) => {
const name = p.alias || "Anonymous";
const fp = p.fingerprint || "";
const isMe = fp && myFingerprint.includes(fp);
const row = document.createElement("div");
row.className = "participant";
const icon = createIdenticonEl(fp || name, 36, true);
if (isMe) icon.style.outline = "2px solid var(--accent)";
row.appendChild(icon);
const info = document.createElement("div");
info.className = "info";
info.innerHTML = `
<div class="name">${escapeHtml(name)} ${isMe ? '<span class="you-badge">you</span>' : ""}</div>
<div class="fp">${escapeHtml(fp ? fp.substring(0, 16) : "")}</div>
`;
row.appendChild(info);
participantsDiv.appendChild(row);
});
});
}
// Stats line with codec badges
const txBadge = (st as any).tx_codec ? `<span class="codec-badge tx">${escapeHtml((st as any).tx_codec)}</span>` : "";
const rxBadge = (st as any).rx_codec ? `<span class="codec-badge rx">${escapeHtml((st as any).rx_codec)}</span>` : "";
statsDiv.innerHTML = `${txBadge} ${rxBadge} TX: ${st.encode_fps} | RX: ${st.recv_fps}`;
} catch {}
}
listen("call-event", (event: any) => {
const { kind } = event.payload;
if (kind === "room-update") pollStatus();
if (kind === "disconnected" && !userDisconnected) pollStatus();
});
// ── Settings ──
function openSettings() {
const s = loadSettings();
sRoom.value = s.room; sAlias.value = s.alias; sOsAec.checked = s.osAec;
sDredDebug.checked = !!s.dredDebugLogs;
const qi = qualityToIndex(s.quality || "auto");
sQuality.value = String(qi);
updateQualityUI(qi);
sFingerprint.textContent = myFingerprint || "(loading...)";
renderSettingsRecentRooms(s.recentRooms);
settingsPanel.classList.remove("hidden");
}
function closeSettings() { settingsPanel.classList.add("hidden"); }
function renderSettingsRecentRooms(rooms: RecentRoom[]) {
if (rooms.length === 0) {
sRecentRooms.innerHTML = '<span style="color:var(--text-dim);font-size:12px">No recent rooms</span>';
return;
}
sRecentRooms.innerHTML = rooms.map((r, i) => `
<div class="recent-room-item">
<span>${escapeHtml(r.room)} <small style="color:var(--text-dim)">${escapeHtml(r.relay)}</small></span>
<button class="remove" data-idx="${i}">×</button>
</div>`).join("");
sRecentRooms.querySelectorAll(".remove").forEach((btn) => {
btn.addEventListener("click", () => {
const idx = parseInt((btn as HTMLElement).dataset.idx || "0");
const s = loadSettings();
s.recentRooms.splice(idx, 1);
saveSettingsObj(s);
renderSettingsRecentRooms(s.recentRooms);
});
});
}
settingsBtnHome.addEventListener("click", openSettings);
settingsBtnCall.addEventListener("click", openSettings);
// "STUN for QUIC" — ask the registered relay for our own public
// address. Requires register_signal to have been run first
// (otherwise the Rust side returns "not registered"). The button
// shows its working state inline so the user knows it's waiting on
// the relay rather than the network.
sReflectBtn.addEventListener("click", async () => {
sReflectedAddr.textContent = "querying...";
sReflectBtn.disabled = true;
try {
const addr = await invoke<string>("get_reflected_address");
sReflectedAddr.textContent = addr;
sReflectedAddr.style.color = "var(--green)";
} catch (e: any) {
// Two main failure modes surfaced via the error string:
// - "not registered" — user hasn't registered
// against a relay yet
// - "reflect timeout (relay may not support reflection)"
// — old relay, pre-Phase-1
const msg = String(e);
sReflectedAddr.textContent = msg.includes("not registered")
? "⚠ register first"
: msg.includes("timeout")
? "⚠ relay does not support reflection"
: `${msg}`;
sReflectedAddr.style.color = "var(--yellow)";
} finally {
sReflectBtn.disabled = false;
}
});
settingsClose.addEventListener("click", closeSettings);
settingsPanel.addEventListener("click", (e) => { if (e.target === settingsPanel) closeSettings(); });
settingsSave.addEventListener("click", () => {
const s = loadSettings();
s.room = sRoom.value; s.alias = sAlias.value; s.osAec = sOsAec.checked;
s.quality = QUALITY_STEPS[parseInt(sQuality.value)] || "auto";
s.dredDebugLogs = sDredDebug.checked;
saveSettingsObj(s);
// Push the new flag to the Rust side immediately so the next encoded
// frame already honors it without waiting for an app restart.
invoke("set_dred_verbose_logs", { enabled: s.dredDebugLogs }).catch(() => {});
roomInput.value = s.room; aliasInput.value = s.alias; osAecCheckbox.checked = s.osAec;
renderRecentRooms(s.recentRooms);
closeSettings();
});
sClearRecent.addEventListener("click", () => {
const s = loadSettings();
s.recentRooms = [];
saveSettingsObj(s);
renderSettingsRecentRooms([]);
renderRecentRooms([]);
});
document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === ",") {
e.preventDefault();
settingsPanel.classList.contains("hidden") ? openSettings() : closeSettings();
}
if (e.key === "Escape") {
if (!relayDialog.classList.contains("hidden")) closeRelayDialog();
else if (!settingsPanel.classList.contains("hidden")) closeSettings();
}
});
// ── Direct Calling UI ──
const modeRoom = document.getElementById("mode-room")!;
const modeDirect = document.getElementById("mode-direct")!;
const roomModeDiv = document.getElementById("room-mode")!;
const directModeDiv = document.getElementById("direct-mode")!;
const registerBtn = document.getElementById("register-btn") as HTMLButtonElement;
const deregisterBtn = document.getElementById("deregister-btn") as HTMLButtonElement;
const directRegistered = document.getElementById("direct-registered")!;
const incomingCallPanel = document.getElementById("incoming-call-panel")!;
const incomingCaller = document.getElementById("incoming-caller")!;
const acceptCallBtn = document.getElementById("accept-call-btn")!;
const rejectCallBtn = document.getElementById("reject-call-btn")!;
const targetFpInput = document.getElementById("target-fp") as HTMLInputElement;
const callBtn = document.getElementById("call-btn") as HTMLButtonElement;
const callStatusText = document.getElementById("call-status-text")!;
const recentContactsSection = document.getElementById("recent-contacts-section")!;
const recentContactsList = document.getElementById("recent-contacts-list")!;
const callHistorySection = document.getElementById("call-history-section")!;
const callHistoryList = document.getElementById("call-history-list")!;
const clearHistoryBtn = document.getElementById("clear-history-btn") as HTMLButtonElement;
let currentCallMode = "room";
modeRoom.addEventListener("click", () => {
currentCallMode = "room";
modeRoom.classList.add("active");
modeDirect.classList.remove("active");
roomModeDiv.classList.remove("hidden");
directModeDiv.classList.add("hidden");
// Show room/alias inputs
(document.querySelector('label:has(#room)') as HTMLElement)?.classList.remove("hidden");
(document.querySelector('label:has(#alias)') as HTMLElement)?.classList.remove("hidden");
});
modeDirect.addEventListener("click", () => {
currentCallMode = "direct";
modeDirect.classList.add("active");
modeRoom.classList.remove("active");
directModeDiv.classList.remove("hidden");
roomModeDiv.classList.add("hidden");
// Hide room input, keep alias
(document.querySelector('label:has(#room)') as HTMLElement)?.classList.add("hidden");
});
// ── Call history + recent contacts rendering ──
interface CallHistoryEntry {
call_id: string;
peer_fp: string;
peer_alias: string | null;
direction: "placed" | "received" | "missed";
timestamp_unix: number;
}
function fmtTimestamp(unix: number): string {
const d = new Date(unix * 1000);
const now = new Date();
const sameDay =
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
if (sameDay) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
return d.toLocaleDateString([], { month: "short", day: "numeric" }) +
" " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function directionIcon(dir: string): string {
switch (dir) {
case "placed": return "↗";
case "received": return "↙";
case "missed": return "✗";
default: return "•";
}
}
function directionLabel(dir: string): string {
switch (dir) {
case "placed": return "Outgoing";
case "received": return "Incoming";
case "missed": return "Missed";
default: return dir;
}
}
function directionClass(dir: string): string {
return `dir-${dir}`;
}
function callByFingerprint(fp: string) {
targetFpInput.value = fp;
callBtn.click();
}
async function refreshHistory() {
try {
const [history, contacts] = await Promise.all([
invoke<CallHistoryEntry[]>("get_call_history"),
invoke<CallHistoryEntry[]>("get_recent_contacts"),
]);
// Recent contacts (top 6)
if (contacts.length === 0) {
recentContactsSection.classList.add("hidden");
} else {
recentContactsSection.classList.remove("hidden");
recentContactsList.innerHTML = "";
contacts.slice(0, 6).forEach((c) => {
const btn = document.createElement("button");
btn.className = "contact-chip";
const label = c.peer_alias || c.peer_fp.substring(0, 16);
btn.innerHTML = `<span class="contact-dot"></span><span class="contact-label">${label}</span>`;
btn.title = c.peer_fp;
btn.addEventListener("click", () => callByFingerprint(c.peer_fp));
recentContactsList.appendChild(btn);
});
}
// Full history
if (history.length === 0) {
callHistorySection.classList.add("hidden");
} else {
callHistorySection.classList.remove("hidden");
callHistoryList.innerHTML = "";
history.slice(0, 50).forEach((e) => {
const row = document.createElement("div");
row.className = `history-row ${directionClass(e.direction)}`;
const label = e.peer_alias || e.peer_fp.substring(0, 16);
row.innerHTML = `
<span class="history-dir">${directionIcon(e.direction)}</span>
<div class="history-meta">
<span class="history-peer">${label}</span>
<span class="history-time">${directionLabel(e.direction)} · ${fmtTimestamp(e.timestamp_unix)}</span>
</div>
<button class="history-call-btn" title="Call back">Call</button>
`;
row.title = e.peer_fp;
const cb = row.querySelector(".history-call-btn") as HTMLButtonElement;
cb.addEventListener("click", (ev) => {
ev.stopPropagation();
callByFingerprint(e.peer_fp);
});
callHistoryList.appendChild(row);
});
}
} catch (e) {
console.error("refreshHistory failed:", e);
}
}
// Live-refresh whenever the backend logs a new entry
listen("history-changed", () => { refreshHistory(); });
clearHistoryBtn.addEventListener("click", async () => {
if (!confirm("Clear call history?")) return;
try {
await invoke("clear_call_history");
refreshHistory();
} catch (e) { console.error(e); }
});
registerBtn.addEventListener("click", async () => {
const relay = getSelectedRelay();
if (!relay) { connectError.textContent = "No relay selected"; return; }
registerBtn.disabled = true;
registerBtn.textContent = "Registering...";
try {
const fp = await invoke<string>("register_signal", { relay: relay.address });
registerBtn.classList.add("hidden");
directRegistered.classList.remove("hidden");
callStatusText.textContent = `Your fingerprint: ${fp}`;
refreshHistory();
} catch (e: any) {
connectError.textContent = String(e);
registerBtn.disabled = false;
registerBtn.textContent = "Register on Relay";
}
});
deregisterBtn.addEventListener("click", async () => {
try {
await invoke("deregister");
directRegistered.classList.add("hidden");
registerBtn.classList.remove("hidden");
registerBtn.disabled = false;
registerBtn.textContent = "Register on Relay";
callStatusText.textContent = "";
incomingCallPanel.classList.add("hidden");
} catch (e) {
console.error("deregister failed:", e);
}
});
callBtn.addEventListener("click", async () => {
const target = targetFpInput.value.trim();
if (!target) return;
callStatusText.textContent = "Calling...";
try {
await invoke("place_call", { targetFp: target });
} catch (e: any) {
callStatusText.textContent = `Error: ${e}`;
}
});
acceptCallBtn.addEventListener("click", async () => {
const status = await invoke<any>("get_signal_status");
if (status.incoming_call_id) {
await invoke("answer_call", { callId: status.incoming_call_id, mode: 2 });
incomingCallPanel.classList.add("hidden");
}
});
rejectCallBtn.addEventListener("click", async () => {
const status = await invoke<any>("get_signal_status");
if (status.incoming_call_id) {
await invoke("answer_call", { callId: status.incoming_call_id, mode: 0 });
incomingCallPanel.classList.add("hidden");
}
});
// Listen for signal events from Rust backend
listen("signal-event", (event: any) => {
const data = event.payload;
switch (data.type) {
case "ringing":
callStatusText.textContent = "🔔 Ringing...";
break;
case "incoming":
incomingCallPanel.classList.remove("hidden");
incomingCaller.textContent = `From: ${data.caller_alias || data.caller_fp?.substring(0, 16) || "unknown"}`;
break;
case "answered":
callStatusText.textContent = `Call answered (${data.mode})`;
break;
case "setup":
callStatusText.textContent = "Connecting to media...";
// Auto-connect to the call room
(async () => {
try {
await invoke("connect", {
relay: data.relay_addr,
room: data.room,
alias: aliasInput.value,
osAec: osAecCheckbox.checked,
quality: loadSettings().quality || "auto",
});
showCallScreen();
} catch (e: any) {
callStatusText.textContent = `Media connect failed: ${e}`;
}
})();
break;
case "hangup":
callStatusText.textContent = "";
incomingCallPanel.classList.add("hidden");
break;
}
});