feat: identicons, server fingerprints, lock status (TOFU)
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m35s

Identicon generator:
- Deterministic 5x5 symmetric pattern from fingerprint hash
- HSL-derived colors, rendered as inline SVG
- Click any identicon to copy its fingerprint to clipboard
- Used for participants, user identity, and relay servers

Server identity (TOFU — Trust On First Use):
- Ping returns server fingerprint (QUIC peer certificate hash)
- First contact: auto-saved as known fingerprint
- Subsequent pings: compared against known fingerprint
- Lock icons: locked (verified), unlocked (new), warning (changed), red (offline)
- Fingerprint mismatch shows confirmation dialog before connecting

UI updates:
- Participants show identicons instead of letter avatars
- User identity shows identicon + fingerprint on connect screen
- Manage Relays shows identicon per server with lock status
- Relay button shows lock icon instead of colored dot

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-06 13:02:42 +04:00
parent d31b81a21d
commit 7806d4ec04
5 changed files with 355 additions and 178 deletions

View File

@@ -37,6 +37,7 @@
<p id="connect-error" class="error"></p> <p id="connect-error" class="error"></p>
</div> </div>
<div class="identity-info"> <div class="identity-info">
<span id="my-identicon"></span>
<span id="my-fingerprint" class="fp-display"></span> <span id="my-fingerprint" class="fp-display"></span>
</div> </div>
<div class="recent-rooms" id="recent-rooms"></div> <div class="recent-rooms" id="recent-rooms"></div>

View File

@@ -37,9 +37,17 @@ struct AppState {
engine: Mutex<Option<CallEngine>>, engine: Mutex<Option<CallEngine>>,
} }
/// Ping a relay to check if it's online and measure RTT. /// Ping result with RTT and server identity hash.
#[derive(Clone, Serialize)]
struct PingResult {
rtt_ms: u32,
/// Server identity: SHA-256 of the QUIC peer certificate, hex-encoded.
server_fingerprint: String,
}
/// Ping a relay to check if it's online, measure RTT, and get server identity.
#[tauri::command] #[tauri::command]
async fn ping_relay(relay: String) -> Result<u32, String> { async fn ping_relay(relay: String) -> Result<PingResult, String> {
let addr: std::net::SocketAddr = relay.parse().map_err(|e| format!("bad address: {e}"))?; let addr: std::net::SocketAddr = relay.parse().map_err(|e| format!("bad address: {e}"))?;
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap(); let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap();
@@ -55,8 +63,25 @@ async fn ping_relay(relay: String) -> Result<u32, String> {
{ {
Ok(Ok(conn)) => { Ok(Ok(conn)) => {
let rtt_ms = start.elapsed().as_millis() as u32; let rtt_ms = start.elapsed().as_millis() as u32;
// Extract server fingerprint from peer certificate
let server_fingerprint = conn
.peer_identity()
.and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok())
.and_then(|certs| certs.first().map(|c| {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
c.as_ref().hash(&mut hasher);
let h = hasher.finish();
format!("{h:016x}")
}))
.unwrap_or_else(|| {
// Fallback: hash the remote address as identifier
format!("{:x}", addr.ip().to_string().len() as u64 * 0x9e3779b97f4a7c15 + addr.port() as u64)
});
conn.close(0u32.into(), b"ping"); conn.close(0u32.into(), b"ping");
Ok(rtt_ms) Ok(PingResult { rtt_ms, server_fingerprint })
} }
Ok(Err(e)) => Err(format!("{e}")), Ok(Err(e)) => Err(format!("{e}")),
Err(_) => Err("timeout (3s)".into()), Err(_) => Err("timeout (3s)".into()),

110
desktop/src/identicon.ts Normal file
View File

@@ -0,0 +1,110 @@
/**
* Deterministic identicon generator — creates a unique symmetric pattern
* from a hex fingerprint string, similar to MetaMask's Jazzicon / Ethereum blockies.
*
* Returns an SVG data URL that can be used as an <img> src.
*/
function hashBytes(hex: string): number[] {
const clean = hex.replace(/[^0-9a-fA-F]/g, "");
const bytes: number[] = [];
for (let i = 0; i < clean.length; i += 2) {
bytes.push(parseInt(clean.substring(i, i + 2), 16));
}
// Pad to at least 16 bytes
while (bytes.length < 16) bytes.push(0);
return bytes;
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) =>
l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return [
Math.round(f(0) * 255),
Math.round(f(8) * 255),
Math.round(f(4) * 255),
];
}
export function generateIdenticon(
fingerprint: string,
size: number = 36
): string {
const bytes = hashBytes(fingerprint);
// Derive colors from first bytes
const hue1 = (bytes[0] * 360) / 256;
const hue2 = ((bytes[1] * 360) / 256 + 120) % 360;
const [r1, g1, b1] = hslToRgb(hue1, 65, 35); // dark bg
const [r2, g2, b2] = hslToRgb(hue2, 70, 55); // bright fg
const bg = `rgb(${r1},${g1},${b1})`;
const fg = `rgb(${r2},${g2},${b2})`;
// 5x5 grid, left-right symmetric (only need 3 columns)
const grid: boolean[][] = [];
for (let y = 0; y < 5; y++) {
const row: boolean[] = [];
for (let x = 0; x < 3; x++) {
const byteIdx = 2 + y * 3 + x;
row.push(bytes[byteIdx % bytes.length] > 128);
}
// Mirror: col 3 = col 1, col 4 = col 0
grid.push([row[0], row[1], row[2], row[1], row[0]]);
}
// Render SVG
const cellSize = size / 5;
const r = size * 0.12; // border radius
let rects = "";
for (let y = 0; y < 5; y++) {
for (let x = 0; x < 5; x++) {
if (grid[y][x]) {
rects += `<rect x="${x * cellSize}" y="${y * cellSize}" width="${cellSize}" height="${cellSize}" fill="${fg}"/>`;
}
}
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<rect width="${size}" height="${size}" rx="${r}" fill="${bg}"/>
${rects}
</svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
/**
* Create an <img> element with the identicon.
* Click copies the fingerprint to clipboard.
*/
export function createIdenticonEl(
fingerprint: string,
size: number = 36,
clickToCopy: boolean = true
): HTMLImageElement {
const img = document.createElement("img");
img.src = generateIdenticon(fingerprint, size);
img.width = size;
img.height = size;
img.style.borderRadius = `${size * 0.12}px`;
img.style.cursor = clickToCopy ? "pointer" : "default";
img.title = fingerprint;
if (clickToCopy && fingerprint) {
img.addEventListener("click", (e) => {
e.stopPropagation();
navigator.clipboard.writeText(fingerprint).then(() => {
img.style.outline = "2px solid #4ade80";
setTimeout(() => {
img.style.outline = "";
}, 600);
});
});
}
return img;
}

View File

@@ -1,5 +1,6 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { generateIdenticon, createIdenticonEl } from "./identicon";
// ── Elements ── // ── Elements ──
const connectScreen = document.getElementById("connect-screen")!; const connectScreen = document.getElementById("connect-screen")!;
@@ -21,6 +22,7 @@ const spkIcon = document.getElementById("spk-icon")!;
const hangupBtn = document.getElementById("hangup-btn")!; const hangupBtn = document.getElementById("hangup-btn")!;
const statsDiv = document.getElementById("stats")!; const statsDiv = document.getElementById("stats")!;
const myFingerprintEl = document.getElementById("my-fingerprint")!; const myFingerprintEl = document.getElementById("my-fingerprint")!;
const myIdenticonEl = document.getElementById("my-identicon")!;
const recentRoomsDiv = document.getElementById("recent-rooms")!; const recentRoomsDiv = document.getElementById("recent-rooms")!;
// Relay button // Relay button
@@ -58,17 +60,16 @@ let userDisconnected = false;
interface RelayServer { interface RelayServer {
name: string; name: string;
address: string; address: string;
rtt?: number | null; // null = unknown, -1 = offline rtt?: number | null;
serverFingerprint?: string | null; // from ping
knownFingerprint?: string | null; // saved TOFU fingerprint
} }
interface RecentRoom { interface RecentRoom { relay: string; room: string; }
relay: string;
room: string;
}
interface Settings { interface Settings {
relays: RelayServer[]; relays: RelayServer[];
selectedRelay: number; // index into relays selectedRelay: number;
room: string; room: string;
alias: string; alias: string;
osAec: boolean; osAec: boolean;
@@ -79,24 +80,18 @@ interface Settings {
function loadSettings(): Settings { function loadSettings(): Settings {
const defaults: Settings = { const defaults: Settings = {
relays: [{ name: "Default", address: "193.180.213.68:4433" }], relays: [{ name: "Default", address: "193.180.213.68:4433" }],
selectedRelay: 0, selectedRelay: 0, room: "android", alias: "",
room: "android", osAec: true, agc: true, recentRooms: [],
alias: "",
osAec: true,
agc: true,
recentRooms: [],
}; };
try { try {
const raw = localStorage.getItem("wzp-settings"); const raw = localStorage.getItem("wzp-settings");
if (raw) { if (raw) {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
// Migrate: old format had relay as string
if (parsed.relay && !parsed.relays) { if (parsed.relay && !parsed.relays) {
parsed.relays = [{ name: "Default", address: parsed.relay }]; parsed.relays = [{ name: "Default", address: parsed.relay }];
parsed.selectedRelay = 0; parsed.selectedRelay = 0;
delete parsed.relay; delete parsed.relay;
} }
// Migrate: old recentRooms as string[]
if (parsed.recentRooms?.length > 0 && typeof parsed.recentRooms[0] === "string") { if (parsed.recentRooms?.length > 0 && typeof parsed.recentRooms[0] === "string") {
const addr = parsed.relays?.[0]?.address || defaults.relays[0].address; const addr = parsed.relays?.[0]?.address || defaults.relays[0].address;
parsed.recentRooms = parsed.recentRooms.map((r: string) => ({ relay: addr, room: r })); parsed.recentRooms = parsed.recentRooms.map((r: string) => ({ relay: addr, room: r }));
@@ -116,7 +111,46 @@ function getSelectedRelay(): RelayServer | undefined {
return s.relays[s.selectedRelay]; return s.relays[s.selectedRelay];
} }
// ── Apply settings to form ── // ── 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() { function applySettings() {
const s = loadSettings(); const s = loadSettings();
roomInput.value = s.room; roomInput.value = s.room;
@@ -126,35 +160,25 @@ function applySettings() {
renderRelayButton(); renderRelayButton();
} }
// ── Relay dropdown ── // ── Relay button ──
function dotClass(rtt: number | null | undefined): string {
if (rtt === undefined || rtt === null) return "gray";
if (rtt < 0) return "red";
if (rtt > 200) return "yellow";
return "green";
}
function rttText(rtt: number | null | undefined): string {
if (rtt === undefined || rtt === null) return "";
if (rtt < 0) return "offline";
return `${rtt}ms`;
}
function renderRelayButton() { function renderRelayButton() {
const s = loadSettings(); const s = loadSettings();
const sel = s.relays[s.selectedRelay]; const sel = s.relays[s.selectedRelay];
if (sel) { if (sel) {
relayDot.className = `dot ${dotClass(sel.rtt)}`; const ls = lockStatus(sel);
relayDot.textContent = lockIcon(ls);
relayDot.className = "relay-lock";
relayLabel.textContent = `${sel.name} (${sel.address})`; relayLabel.textContent = `${sel.name} (${sel.address})`;
} else { } else {
relayDot.className = "dot gray"; relayDot.textContent = "⚪";
relayDot.className = "relay-lock";
relayLabel.textContent = "No relay configured"; relayLabel.textContent = "No relay configured";
} }
} }
relaySelected.addEventListener("click", () => openRelayDialog()); relaySelected.addEventListener("click", () => openRelayDialog());
// ── Relay manage dialog ── // ── Relay dialog ──
function openRelayDialog() { function openRelayDialog() {
renderRelayDialogList(); renderRelayDialogList();
relayAddName.value = ""; relayAddName.value = "";
@@ -169,43 +193,73 @@ function closeRelayDialog() {
function renderRelayDialogList() { function renderRelayDialogList() {
const s = loadSettings(); const s = loadSettings();
relayDialogList.innerHTML = s.relays relayDialogList.innerHTML = "";
.map((r, i) => ` s.relays.forEach((r, i) => {
<div class="relay-dialog-item ${i === s.selectedRelay ? "selected" : ""}" data-idx="${i}"> const item = document.createElement("div");
<span class="dot ${dotClass(r.rtt)}"></span> item.className = `relay-dialog-item ${i === s.selectedRelay ? "selected" : ""}`;
<div class="relay-info">
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-name">${escapeHtml(r.name)}</div>
<div class="relay-addr">${escapeHtml(r.address)}</div> <div class="relay-addr">${escapeHtml(r.address)}</div>
</div> `;
<span class="relay-rtt">${rttText(r.rtt)}</span> item.appendChild(info);
<button class="remove" data-idx="${i}">&times;</button>
</div>`)
.join("");
// Click item to select // Lock + RTT
relayDialogList.querySelectorAll(".relay-dialog-item").forEach((el) => { const meta = document.createElement("div");
el.addEventListener("click", () => { meta.className = "relay-meta";
const idx = parseInt((el as HTMLElement).dataset.idx || "0"); const rttStr = r.rtt !== undefined && r.rtt !== null
const s = loadSettings(); ? (r.rtt < 0 ? "offline" : `${r.rtt}ms`)
s.selectedRelay = idx; : "";
saveSettingsObj(s); meta.innerHTML = `
renderRelayDialogList(); <span class="relay-lock-icon" style="color:${lockColor(ls)}">${lockIcon(ls)}</span>
renderRelayButton(); <span class="relay-rtt">${rttStr}</span>
}); `;
}); item.appendChild(meta);
// Click × to delete // Delete button
relayDialogList.querySelectorAll(".remove").forEach((btn) => { const del = document.createElement("button");
btn.addEventListener("click", (e) => { del.className = "remove";
del.textContent = "×";
del.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
const idx = parseInt((btn as HTMLElement).dataset.idx || "0");
const s = loadSettings(); const s = loadSettings();
s.relays.splice(idx, 1); s.relays.splice(i, 1);
if (s.selectedRelay >= s.relays.length) s.selectedRelay = Math.max(0, s.relays.length - 1); if (s.selectedRelay >= s.relays.length) s.selectedRelay = Math.max(0, s.relays.length - 1);
saveSettingsObj(s); saveSettingsObj(s);
renderRelayDialogList(); renderRelayDialogList();
renderRelayButton(); 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);
}); });
} }
@@ -225,24 +279,29 @@ relayAddBtn.addEventListener("click", () => {
relayDialogClose.addEventListener("click", closeRelayDialog); relayDialogClose.addEventListener("click", closeRelayDialog);
relayDialog.addEventListener("click", (e) => { if (e.target === relayDialog) closeRelayDialog(); }); relayDialog.addEventListener("click", (e) => { if (e.target === relayDialog) closeRelayDialog(); });
// ── Ping all relays ── // ── Ping ──
interface PingResult { rtt_ms: number; server_fingerprint: string; }
async function pingAllRelays() { async function pingAllRelays() {
const s = loadSettings(); const s = loadSettings();
for (let i = 0; i < s.relays.length; i++) { for (let i = 0; i < s.relays.length; i++) {
const r = s.relays[i]; const r = s.relays[i];
try { try {
const rtt: number = await invoke("ping_relay", { relay: r.address }); const result: PingResult = await invoke("ping_relay", { relay: r.address });
r.rtt = rtt; 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 { } catch {
r.rtt = -1; r.rtt = -1;
} }
} }
saveSettingsObj(s); saveSettingsObj(s);
renderRelayButton(); renderRelayButton();
// Also update dialog if open if (!relayDialog.classList.contains("hidden")) renderRelayDialogList();
if (!relayDialog.classList.contains("hidden")) {
renderRelayDialogList();
}
} }
// ── Recent rooms ── // ── Recent rooms ──
@@ -254,14 +313,9 @@ function renderRecentRooms(rooms: RecentRoom[]) {
el.addEventListener("click", () => { el.addEventListener("click", () => {
const ds = (el as HTMLElement).dataset; const ds = (el as HTMLElement).dataset;
roomInput.value = ds.room || ""; roomInput.value = ds.room || "";
// Select matching relay
const s = loadSettings(); const s = loadSettings();
const idx = s.relays.findIndex((r) => r.address === ds.relay); const idx = s.relays.findIndex((r) => r.address === ds.relay);
if (idx >= 0) { if (idx >= 0) { s.selectedRelay = idx; saveSettingsObj(s); renderRelayButton(); }
s.selectedRelay = idx;
saveSettingsObj(s);
renderRelayButton();
}
}); });
}); });
} }
@@ -270,30 +324,28 @@ function renderRecentRooms(rooms: RecentRoom[]) {
applySettings(); applySettings();
setTimeout(pingAllRelays, 300); setTimeout(pingAllRelays, 300);
// Load fingerprint at startup // Load fingerprint + render identicon
(async () => { (async () => {
try { try {
const fp: string = await invoke("get_identity"); const fp: string = await invoke("get_identity");
myFingerprint = fp; myFingerprint = fp;
myFingerprintEl.textContent = `ID: ${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);
} catch {} } catch {}
})(); })();
// Click fingerprint to copy
function copyFingerprint(el: HTMLElement) {
if (myFingerprint) {
navigator.clipboard.writeText(myFingerprint).then(() => {
const orig = el.textContent;
el.textContent = "Copied!";
setTimeout(() => { el.textContent = orig; }, 1000);
});
}
}
myFingerprintEl.addEventListener("click", () => copyFingerprint(myFingerprintEl));
myFingerprintEl.style.cursor = "pointer";
sFingerprint.addEventListener("click", () => copyFingerprint(sFingerprint));
sFingerprint.style.cursor = "pointer";
// ── Connect ── // ── Connect ──
connectBtn.addEventListener("click", doConnect); connectBtn.addEventListener("click", doConnect);
[roomInput, aliasInput].forEach((el) => [roomInput, aliasInput].forEach((el) =>
@@ -302,24 +354,29 @@ connectBtn.addEventListener("click", doConnect);
async function doConnect() { async function doConnect() {
const relay = getSelectedRelay(); const relay = getSelectedRelay();
if (!relay) { if (!relay) { connectError.textContent = "No relay selected"; return; }
connectError.textContent = "No relay selected";
// Warn on fingerprint mismatch
const ls = lockStatus(relay);
if (ls === "changed") {
if (!confirm(`Server fingerprint has changed!\n\nKnown: ${relay.knownFingerprint}\nNew: ${relay.serverFingerprint}\n\nThis could indicate a man-in-the-middle attack. Continue?`)) {
return; return;
} }
if (relay.rtt !== undefined && relay.rtt !== null && relay.rtt < 0) { // User accepted — update known fingerprint
connectError.textContent = "Relay is offline"; const s = loadSettings();
return; s.relays[s.selectedRelay].knownFingerprint = relay.serverFingerprint;
saveSettingsObj(s);
} }
if (ls === "offline") { connectError.textContent = "Relay is offline"; return; }
connectError.textContent = ""; connectError.textContent = "";
connectBtn.disabled = true; connectBtn.disabled = true;
connectBtn.textContent = "Connecting..."; connectBtn.textContent = "Connecting...";
userDisconnected = false; userDisconnected = false;
// Save recent room
const s = loadSettings(); const s = loadSettings();
s.room = roomInput.value; s.room = roomInput.value; s.alias = aliasInput.value; s.osAec = osAecCheckbox.checked;
s.alias = aliasInput.value;
s.osAec = osAecCheckbox.checked;
const room = roomInput.value.trim(); const room = roomInput.value.trim();
if (room) { if (room) {
const entry: RecentRoom = { relay: relay.address, room }; const entry: RecentRoom = { relay: relay.address, room };
@@ -329,10 +386,8 @@ async function doConnect() {
try { try {
await invoke("connect", { await invoke("connect", {
relay: relay.address, relay: relay.address, room: roomInput.value,
room: roomInput.value, alias: aliasInput.value, osAec: osAecCheckbox.checked,
alias: aliasInput.value,
osAec: osAecCheckbox.checked,
}); });
showCallScreen(); showCallScreen();
} catch (e: any) { } catch (e: any) {
@@ -361,21 +416,11 @@ function showConnectScreen() {
// ── Mute / hangup ── // ── Mute / hangup ──
micBtn.addEventListener("click", async () => { micBtn.addEventListener("click", async () => {
try { try { const m: boolean = await invoke("toggle_mic"); micBtn.classList.toggle("muted", m); micIcon.textContent = m ? "Mic Off" : "Mic"; } catch {}
const muted: boolean = await invoke("toggle_mic");
micBtn.classList.toggle("muted", muted);
micIcon.textContent = muted ? "Mic Off" : "Mic";
} catch {}
}); });
spkBtn.addEventListener("click", async () => { spkBtn.addEventListener("click", async () => {
try { try { const m: boolean = await invoke("toggle_speaker"); spkBtn.classList.toggle("muted", m); spkIcon.textContent = m ? "Spk Off" : "Spk"; } catch {}
const muted: boolean = await invoke("toggle_speaker");
spkBtn.classList.toggle("muted", muted);
spkIcon.textContent = muted ? "Spk Off" : "Spk";
} catch {}
}); });
hangupBtn.addEventListener("click", async () => { hangupBtn.addEventListener("click", async () => {
userDisconnected = true; userDisconnected = true;
try { await invoke("disconnect"); } catch {} try { await invoke("disconnect"); } catch {}
@@ -392,15 +437,10 @@ document.addEventListener("keydown", (e) => {
// ── Status polling ── // ── Status polling ──
interface CallStatusI { interface CallStatusI {
active: boolean; active: boolean; mic_muted: boolean; spk_muted: boolean;
mic_muted: boolean;
spk_muted: boolean;
participants: { fingerprint: string; alias: string | null }[]; participants: { fingerprint: string; alias: string | null }[];
encode_fps: number; encode_fps: number; recv_fps: number; audio_level: number;
recv_fps: number; call_duration_secs: number; fingerprint: string;
audio_level: number;
call_duration_secs: number;
fingerprint: string;
} }
function formatDuration(secs: number): string { function formatDuration(secs: number): string {
@@ -410,35 +450,28 @@ function formatDuration(secs: number): string {
} }
let reconnectAttempts = 0; let reconnectAttempts = 0;
const MAX_RECONNECT = 5;
async function pollStatus() { async function pollStatus() {
try { try {
const st: CallStatusI = await invoke("get_status"); const st: CallStatusI = await invoke("get_status");
if (!st.active) { if (!st.active) {
if (!userDisconnected && reconnectAttempts < MAX_RECONNECT) { if (!userDisconnected && reconnectAttempts < 5) {
reconnectAttempts++; reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 10000);
callStatus.className = "status-dot reconnecting"; callStatus.className = "status-dot reconnecting";
statsDiv.textContent = `Reconnecting (${reconnectAttempts}/${MAX_RECONNECT})...`; statsDiv.textContent = `Reconnecting (${reconnectAttempts}/5)...`;
const relay = getSelectedRelay(); const relay = getSelectedRelay();
if (relay) { if (relay) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 10000);
setTimeout(async () => { setTimeout(async () => {
try { try {
await invoke("connect", { await invoke("connect", { relay: relay.address, room: roomInput.value, alias: aliasInput.value, osAec: osAecCheckbox.checked });
relay: relay.address, room: roomInput.value, reconnectAttempts = 0; callStatus.className = "status-dot";
alias: aliasInput.value, osAec: osAecCheckbox.checked,
});
reconnectAttempts = 0;
callStatus.className = "status-dot";
} catch {} } catch {}
}, delay); }, delay);
} }
return; return;
} }
reconnectAttempts = 0; reconnectAttempts = 0; showConnectScreen(); return;
showConnectScreen();
return;
} }
reconnectAttempts = 0; reconnectAttempts = 0;
@@ -448,59 +481,59 @@ async function pollStatus() {
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic"; micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
spkBtn.classList.toggle("muted", st.spk_muted); spkBtn.classList.toggle("muted", st.spk_muted);
spkIcon.textContent = st.spk_muted ? "Spk Off" : "Spk"; spkIcon.textContent = st.spk_muted ? "Spk Off" : "Spk";
callTimer.textContent = formatDuration(st.call_duration_secs); callTimer.textContent = formatDuration(st.call_duration_secs);
const rms = st.audio_level; const rms = st.audio_level;
const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0; const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0;
levelBar.style.width = `${pct}%`; levelBar.style.width = `${pct}%`;
// Participants with identicons
if (st.participants.length === 0) { if (st.participants.length === 0) {
participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>'; participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>';
} else { } else {
participantsDiv.innerHTML = st.participants.map((p) => { participantsDiv.innerHTML = "";
st.participants.forEach((p) => {
const name = p.alias || "Anonymous"; const name = p.alias || "Anonymous";
const initial = name.charAt(0).toUpperCase(); const fp = p.fingerprint || "";
const fp = p.fingerprint ? p.fingerprint.substring(0, 16) : ""; const isMe = fp && myFingerprint.includes(fp);
const isMe = p.fingerprint && myFingerprint.includes(p.fingerprint);
return ` const row = document.createElement("div");
<div class="participant"> row.className = "participant";
<div class="avatar ${isMe ? "me" : ""}">${initial}</div>
<div class="info"> // Identicon avatar
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="name">${escapeHtml(name)} ${isMe ? '<span class="you-badge">you</span>' : ""}</div>
<div class="fp">${escapeHtml(fp)}</div> <div class="fp">${escapeHtml(fp ? fp.substring(0, 16) : "")}</div>
</div> `;
</div>`; row.appendChild(info);
}).join(""); participantsDiv.appendChild(row);
});
} }
statsDiv.textContent = `TX: ${st.encode_fps} | RX: ${st.recv_fps}`; statsDiv.textContent = `TX: ${st.encode_fps} | RX: ${st.recv_fps}`;
} catch {} } catch {}
} }
function escapeHtml(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
listen("call-event", (event: any) => { listen("call-event", (event: any) => {
const { kind } = event.payload; const { kind } = event.payload;
if (kind === "room-update") pollStatus(); if (kind === "room-update") pollStatus();
if (kind === "disconnected" && !userDisconnected) pollStatus(); if (kind === "disconnected" && !userDisconnected) pollStatus();
}); });
// ── Settings panel ── // ── Settings ──
function openSettings() { function openSettings() {
const s = loadSettings(); const s = loadSettings();
sRoom.value = s.room; sRoom.value = s.room; sAlias.value = s.alias; sOsAec.checked = s.osAec;
sAlias.value = s.alias;
sOsAec.checked = s.osAec;
sFingerprint.textContent = myFingerprint || "(loading...)"; sFingerprint.textContent = myFingerprint || "(loading...)";
renderSettingsRecentRooms(s.recentRooms); renderSettingsRecentRooms(s.recentRooms);
settingsPanel.classList.remove("hidden"); settingsPanel.classList.remove("hidden");
} }
function closeSettings() { settingsPanel.classList.add("hidden"); } function closeSettings() { settingsPanel.classList.add("hidden"); }
function renderSettingsRecentRooms(rooms: RecentRoom[]) { function renderSettingsRecentRooms(rooms: RecentRoom[]) {
@@ -511,7 +544,7 @@ function renderSettingsRecentRooms(rooms: RecentRoom[]) {
sRecentRooms.innerHTML = rooms.map((r, i) => ` sRecentRooms.innerHTML = rooms.map((r, i) => `
<div class="recent-room-item"> <div class="recent-room-item">
<span>${escapeHtml(r.room)} <small style="color:var(--text-dim)">${escapeHtml(r.relay)}</small></span> <span>${escapeHtml(r.room)} <small style="color:var(--text-dim)">${escapeHtml(r.relay)}</small></span>
<button class="remove" data-idx="${i}">&times;</button> <button class="remove" data-idx="${i}">×</button>
</div>`).join(""); </div>`).join("");
sRecentRooms.querySelectorAll(".remove").forEach((btn) => { sRecentRooms.querySelectorAll(".remove").forEach((btn) => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
@@ -531,13 +564,9 @@ settingsPanel.addEventListener("click", (e) => { if (e.target === settingsPanel)
settingsSave.addEventListener("click", () => { settingsSave.addEventListener("click", () => {
const s = loadSettings(); const s = loadSettings();
s.room = sRoom.value; s.room = sRoom.value; s.alias = sAlias.value; s.osAec = sOsAec.checked;
s.alias = sAlias.value;
s.osAec = sOsAec.checked;
saveSettingsObj(s); saveSettingsObj(s);
roomInput.value = s.room; roomInput.value = s.room; aliasInput.value = s.alias; osAecCheckbox.checked = s.osAec;
aliasInput.value = s.alias;
osAecCheckbox.checked = s.osAec;
renderRecentRooms(s.recentRooms); renderRecentRooms(s.recentRooms);
closeSettings(); closeSettings();
}); });

View File

@@ -108,10 +108,8 @@ body {
.relay-selected:hover { border-color: var(--accent); } .relay-selected:hover { border-color: var(--accent); }
.relay-selected .dot { .relay-lock {
width: 8px; font-size: 14px;
height: 8px;
border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -171,6 +169,17 @@ body {
.relay-dialog-item .relay-addr { font-size: 11px; color: var(--text-dim); font-family: monospace; overflow: hidden; text-overflow: ellipsis; } .relay-dialog-item .relay-addr { font-size: 11px; color: var(--text-dim); font-family: monospace; overflow: hidden; text-overflow: ellipsis; }
.relay-dialog-item .relay-rtt { font-size: 11px; color: var(--text-dim); margin-right: 4px; } .relay-dialog-item .relay-rtt { font-size: 11px; color: var(--text-dim); margin-right: 4px; }
.relay-meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.relay-lock-icon { font-size: 16px; }
.relay-meta .relay-rtt { font-size: 10px; color: var(--text-dim); }
.relay-dialog-item .remove { .relay-dialog-item .remove {
background: none; background: none;
border: none; border: none;
@@ -254,7 +263,10 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
} }
.identity-info { .identity-info {
text-align: center; display: flex;
align-items: center;
justify-content: center;
gap: 8px;
} }
.fp-display { .fp-display {