feat: fingerprint at startup, relay+room pairs, auto-reconnect, cleanup
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m34s

#7 Fingerprint shown before connecting — new get_identity command reads
   ~/.wzp/identity at startup (generates if missing). Click to copy.

#8 Recent rooms store (relay, room) pairs — clicking a chip fills both
   fields. Settings panel shows relay alongside room name. Migrates
   old string[] format automatically.

#9 Auto-reconnect on unexpected disconnect — exponential backoff
   (1s, 2s, 4s... max 10s), up to 5 attempts. Yellow blinking dot
   shows reconnecting state. Stops if user clicks hangup.

#10 Audio handle cleanup — CPAL handles stored in SyncWrapper (no more
    mem::forget), dropped properly on CallEngine::stop().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-06 12:15:05 +04:00
parent 21f5b24cbf
commit ed272d29f8
3 changed files with 133 additions and 75 deletions

View File

@@ -37,6 +37,30 @@ struct AppState {
engine: Mutex<Option<CallEngine>>, engine: Mutex<Option<CallEngine>>,
} }
/// Read fingerprint from ~/.wzp/identity without connecting.
#[tauri::command]
fn get_identity() -> Result<String, String> {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
let path = std::path::PathBuf::from(home).join(".wzp").join("identity");
if path.exists() {
if let Ok(hex) = std::fs::read_to_string(&path) {
if let Ok(seed) = wzp_crypto::Seed::from_hex(hex.trim()) {
let fp = seed.derive_identity().public_identity().fingerprint;
return Ok(fp.to_string());
}
}
}
// No identity yet — generate one so we can show the fingerprint
let seed = wzp_crypto::Seed::generate();
let fp = seed.derive_identity().public_identity().fingerprint;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
let hex: String = seed.0.iter().map(|b| format!("{b:02x}")).collect();
std::fs::write(&path, hex).ok();
Ok(fp.to_string())
}
#[tauri::command] #[tauri::command]
async fn connect( async fn connect(
state: tauri::State<'_, Arc<AppState>>, state: tauri::State<'_, Arc<AppState>>,
@@ -86,8 +110,7 @@ async fn disconnect(state: tauri::State<'_, Arc<AppState>>) -> Result<String, St
async fn toggle_mic(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, String> { async fn toggle_mic(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, String> {
let engine_lock = state.engine.lock().await; let engine_lock = state.engine.lock().await;
if let Some(ref engine) = *engine_lock { if let Some(ref engine) = *engine_lock {
let muted = engine.toggle_mic(); Ok(engine.toggle_mic())
Ok(muted)
} else { } else {
Err("not connected".into()) Err("not connected".into())
} }
@@ -97,8 +120,7 @@ async fn toggle_mic(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, Stri
async fn toggle_speaker(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, String> { async fn toggle_speaker(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, String> {
let engine_lock = state.engine.lock().await; let engine_lock = state.engine.lock().await;
if let Some(ref engine) = *engine_lock { if let Some(ref engine) = *engine_lock {
let muted = engine.toggle_speaker(); Ok(engine.toggle_speaker())
Ok(muted)
} else { } else {
Err("not connected".into()) Err("not connected".into())
} }
@@ -153,6 +175,7 @@ fn main() {
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.manage(state) .manage(state)
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_identity,
connect, connect,
disconnect, disconnect,
toggle_mic, toggle_mic,

View File

@@ -12,6 +12,7 @@ const connectBtn = document.getElementById("connect-btn") as HTMLButtonElement;
const connectError = document.getElementById("connect-error")!; const connectError = document.getElementById("connect-error")!;
const roomName = document.getElementById("room-name")!; const roomName = document.getElementById("room-name")!;
const callTimer = document.getElementById("call-timer")!; const callTimer = document.getElementById("call-timer")!;
const callStatus = document.getElementById("call-status")!;
const levelBar = document.getElementById("level-bar")!; const levelBar = document.getElementById("level-bar")!;
const participantsDiv = document.getElementById("participants")!; const participantsDiv = document.getElementById("participants")!;
const micBtn = document.getElementById("mic-btn")!; const micBtn = document.getElementById("mic-btn")!;
@@ -39,15 +40,21 @@ const sClearRecent = document.getElementById("s-clear-recent")!;
let statusInterval: number | null = null; let statusInterval: number | null = null;
let myFingerprint = ""; let myFingerprint = "";
let userDisconnected = false; // true when user clicks hangup (no auto-reconnect)
// ── Settings persistence ── // ── Settings persistence ──
interface RecentRoom {
relay: string;
room: string;
}
interface Settings { interface Settings {
relay: string; relay: string;
room: string; room: string;
alias: string; alias: string;
osAec: boolean; osAec: boolean;
agc: boolean; agc: boolean;
recentRooms: string[]; recentRooms: RecentRoom[];
} }
function loadSettings(): Settings { function loadSettings(): Settings {
@@ -61,7 +68,14 @@ function loadSettings(): Settings {
}; };
try { try {
const raw = localStorage.getItem("wzp-settings"); const raw = localStorage.getItem("wzp-settings");
if (raw) return { ...defaults, ...JSON.parse(raw) }; if (raw) {
const parsed = JSON.parse(raw);
// Migrate old string[] recentRooms to RecentRoom[]
if (parsed.recentRooms && parsed.recentRooms.length > 0 && typeof parsed.recentRooms[0] === "string") {
parsed.recentRooms = parsed.recentRooms.map((r: string) => ({ relay: parsed.relay || defaults.relay, room: r }));
}
return { ...defaults, ...parsed };
}
} catch {} } catch {}
return defaults; return defaults;
} }
@@ -72,13 +86,15 @@ function saveSettings() {
s.room = roomInput.value; s.room = roomInput.value;
s.alias = aliasInput.value; s.alias = aliasInput.value;
s.osAec = osAecCheckbox.checked; s.osAec = osAecCheckbox.checked;
// Add room to recent list (dedup, max 5) // Add (relay, room) pair to recent list (dedup, max 5)
const relay = relayInput.value.trim();
const room = roomInput.value.trim(); const room = roomInput.value.trim();
if (room) { if (room) {
s.recentRooms = [room, ...s.recentRooms.filter((r) => r !== room)].slice( const entry: RecentRoom = { relay, room };
0, s.recentRooms = [
5 entry,
); ...s.recentRooms.filter((r) => !(r.relay === relay && r.room === room)),
].slice(0, 5);
} }
localStorage.setItem("wzp-settings", JSON.stringify(s)); localStorage.setItem("wzp-settings", JSON.stringify(s));
} }
@@ -92,52 +108,54 @@ function applySettings() {
renderRecentRooms(s.recentRooms); renderRecentRooms(s.recentRooms);
} }
function renderRecentRooms(rooms: string[]) { function renderRecentRooms(rooms: RecentRoom[]) {
recentRoomsDiv.innerHTML = rooms recentRoomsDiv.innerHTML = rooms
.map( .map(
(r) => (r) =>
`<span class="recent-room" data-room="${escapeHtml(r)}">${escapeHtml(r)}</span>` `<span class="recent-room" data-relay="${escapeHtml(r.relay)}" data-room="${escapeHtml(r.room)}">${escapeHtml(r.room)}</span>`
) )
.join(""); .join("");
recentRoomsDiv.querySelectorAll(".recent-room").forEach((el) => { recentRoomsDiv.querySelectorAll(".recent-room").forEach((el) => {
el.addEventListener("click", () => { el.addEventListener("click", () => {
roomInput.value = (el as HTMLElement).dataset.room || ""; const ds = (el as HTMLElement).dataset;
roomInput.value = ds.room || "";
relayInput.value = ds.relay || relayInput.value;
}); });
}); });
} }
applySettings(); applySettings();
// Click fingerprint to copy // ── Load fingerprint at startup (no connection needed) ──
myFingerprintEl.addEventListener("click", () => { (async () => {
if (myFingerprint) { try {
navigator.clipboard.writeText(myFingerprint).then(() => { const fp: string = await invoke("get_identity");
const orig = myFingerprintEl.textContent; myFingerprint = fp;
myFingerprintEl.textContent = "Copied!"; myFingerprintEl.textContent = `ID: ${fp}`;
setTimeout(() => { myFingerprintEl.textContent = orig; }, 1000); } catch {}
}); })();
}
});
myFingerprintEl.style.cursor = "pointer";
sFingerprint.addEventListener("click", () => { // Click fingerprint to copy
myFingerprintEl.addEventListener("click", copyFingerprint);
myFingerprintEl.style.cursor = "pointer";
sFingerprint.addEventListener("click", copyFingerprint);
sFingerprint.style.cursor = "pointer";
function copyFingerprint() {
if (myFingerprint) { if (myFingerprint) {
navigator.clipboard.writeText(myFingerprint).then(() => { navigator.clipboard.writeText(myFingerprint).then(() => {
const orig = sFingerprint.textContent; const el = document.activeElement === sFingerprint ? sFingerprint : myFingerprintEl;
sFingerprint.textContent = "Copied!"; const orig = el.textContent;
setTimeout(() => { sFingerprint.textContent = orig; }, 1000); el.textContent = "Copied!";
setTimeout(() => { el.textContent = orig; }, 1000);
}); });
} }
}); }
sFingerprint.style.cursor = "pointer";
// ── Connect ── // ── Connect ──
connectBtn.addEventListener("click", doConnect); connectBtn.addEventListener("click", doConnect);
// Enter key to connect
[relayInput, roomInput, aliasInput].forEach((el) => [relayInput, roomInput, aliasInput].forEach((el) =>
el.addEventListener("keydown", (e) => { el.addEventListener("keydown", (e) => { if (e.key === "Enter") doConnect(); })
if (e.key === "Enter") doConnect();
})
); );
async function doConnect() { async function doConnect() {
@@ -145,6 +163,7 @@ async function doConnect() {
connectBtn.disabled = true; connectBtn.disabled = true;
connectBtn.textContent = "Connecting..."; connectBtn.textContent = "Connecting...";
saveSettings(); saveSettings();
userDisconnected = false;
try { try {
await invoke("connect", { await invoke("connect", {
@@ -165,6 +184,7 @@ function showCallScreen() {
connectScreen.classList.add("hidden"); connectScreen.classList.add("hidden");
callScreen.classList.remove("hidden"); callScreen.classList.remove("hidden");
roomName.textContent = roomInput.value; roomName.textContent = roomInput.value;
callStatus.className = "status-dot";
statusInterval = window.setInterval(pollStatus, 250); statusInterval = window.setInterval(pollStatus, 250);
} }
@@ -198,13 +218,12 @@ spkBtn.addEventListener("click", async () => {
}); });
hangupBtn.addEventListener("click", async () => { hangupBtn.addEventListener("click", async () => {
try { userDisconnected = true;
await invoke("disconnect"); try { await invoke("disconnect"); } catch {}
} catch {}
showConnectScreen(); showConnectScreen();
}); });
// Keyboard shortcuts (only when in call, and not typing in an input) // Keyboard shortcuts (only in call, not in inputs)
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (callScreen.classList.contains("hidden")) return; if (callScreen.classList.contains("hidden")) return;
if ((e.target as HTMLElement).tagName === "INPUT") return; if ((e.target as HTMLElement).tagName === "INPUT") return;
@@ -214,7 +233,7 @@ document.addEventListener("keydown", (e) => {
}); });
// ── Status polling ── // ── Status polling ──
interface CallStatus { interface CallStatusI {
active: boolean; active: boolean;
mic_muted: boolean; mic_muted: boolean;
spk_muted: boolean; spk_muted: boolean;
@@ -232,18 +251,43 @@ function formatDuration(secs: number): string {
return `${m}:${s.toString().padStart(2, "0")}`; return `${m}:${s.toString().padStart(2, "0")}`;
} }
let reconnectAttempts = 0;
const MAX_RECONNECT = 5;
async function pollStatus() { async function pollStatus() {
try { try {
const st: CallStatus = await invoke("get_status"); const st: CallStatusI = await invoke("get_status");
if (!st.active) { if (!st.active) {
// Connection dropped — try auto-reconnect unless user hung up
if (!userDisconnected && reconnectAttempts < MAX_RECONNECT) {
reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 10000);
callStatus.className = "status-dot reconnecting";
statsDiv.textContent = `Reconnecting (${reconnectAttempts}/${MAX_RECONNECT})...`;
setTimeout(async () => {
try {
await invoke("connect", {
relay: relayInput.value,
room: roomInput.value,
alias: aliasInput.value,
osAec: osAecCheckbox.checked,
});
reconnectAttempts = 0;
callStatus.className = "status-dot";
} catch {
// Will retry on next poll
}
}, delay);
return;
}
reconnectAttempts = 0;
showConnectScreen(); showConnectScreen();
return; return;
} }
myFingerprint = st.fingerprint; reconnectAttempts = 0;
myFingerprintEl.textContent = st.fingerprint
? `ID: ${st.fingerprint}` if (st.fingerprint) myFingerprint = st.fingerprint;
: "";
// Mute state // Mute state
micBtn.classList.toggle("muted", st.mic_muted); micBtn.classList.toggle("muted", st.mic_muted);
@@ -254,15 +298,14 @@ async function pollStatus() {
// Timer // Timer
callTimer.textContent = formatDuration(st.call_duration_secs); callTimer.textContent = formatDuration(st.call_duration_secs);
// Audio level (RMS 032767 → percentage, log scale) // Audio level
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 // Participants
if (st.participants.length === 0) { if (st.participants.length === 0) {
participantsDiv.innerHTML = participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>';
'<div class="participants-empty">Waiting for participants...</div>';
} else { } else {
participantsDiv.innerHTML = st.participants participantsDiv.innerHTML = st.participants
.map((p) => { .map((p) => {
@@ -282,7 +325,6 @@ async function pollStatus() {
.join(""); .join("");
} }
// Stats
statsDiv.textContent = `TX: ${st.encode_fps} | RX: ${st.recv_fps}`; statsDiv.textContent = `TX: ${st.encode_fps} | RX: ${st.recv_fps}`;
} catch {} } catch {}
} }
@@ -297,27 +339,19 @@ function escapeHtml(s: string): string {
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") {
if (!userDisconnected) pollStatus(); // triggers reconnect
}
}); });
// ── Settings panel ── // ── Settings panel ──
// Load fingerprint into settings when status is available
async function refreshFingerprint() {
try {
const st: CallStatus = await invoke("get_status");
if (st.fingerprint) {
myFingerprint = st.fingerprint;
myFingerprintEl.textContent = `ID: ${st.fingerprint}`;
}
} catch {}
}
function openSettings() { function openSettings() {
const s = loadSettings(); const s = loadSettings();
sRelay.value = s.relay; sRelay.value = s.relay;
sRoom.value = s.room; sRoom.value = s.room;
sAlias.value = s.alias; sAlias.value = s.alias;
sOsAec.checked = s.osAec; sOsAec.checked = s.osAec;
sFingerprint.textContent = myFingerprint || "(connect to see)"; sFingerprint.textContent = myFingerprint || "(loading...)";
renderSettingsRecentRooms(s.recentRooms); renderSettingsRecentRooms(s.recentRooms);
settingsPanel.classList.remove("hidden"); settingsPanel.classList.remove("hidden");
} }
@@ -326,7 +360,7 @@ function closeSettings() {
settingsPanel.classList.add("hidden"); settingsPanel.classList.add("hidden");
} }
function renderSettingsRecentRooms(rooms: string[]) { function renderSettingsRecentRooms(rooms: RecentRoom[]) {
if (rooms.length === 0) { if (rooms.length === 0) {
sRecentRooms.innerHTML = '<span style="color:var(--text-dim);font-size:12px">No recent rooms</span>'; sRecentRooms.innerHTML = '<span style="color:var(--text-dim);font-size:12px">No recent rooms</span>';
return; return;
@@ -335,7 +369,7 @@ function renderSettingsRecentRooms(rooms: string[]) {
.map( .map(
(r, i) => ` (r, i) => `
<div class="recent-room-item"> <div class="recent-room-item">
<span>${escapeHtml(r)}</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}">&times;</button>
</div>` </div>`
) )
@@ -354,10 +388,7 @@ function renderSettingsRecentRooms(rooms: string[]) {
settingsBtnHome.addEventListener("click", openSettings); settingsBtnHome.addEventListener("click", openSettings);
settingsBtnCall.addEventListener("click", openSettings); settingsBtnCall.addEventListener("click", openSettings);
settingsClose.addEventListener("click", closeSettings); settingsClose.addEventListener("click", closeSettings);
settingsPanel.addEventListener("click", (e) => { if (e.target === settingsPanel) closeSettings(); });
settingsPanel.addEventListener("click", (e) => {
if (e.target === settingsPanel) closeSettings();
});
settingsSave.addEventListener("click", () => { settingsSave.addEventListener("click", () => {
const s = loadSettings(); const s = loadSettings();
@@ -366,7 +397,6 @@ settingsSave.addEventListener("click", () => {
s.alias = sAlias.value; s.alias = sAlias.value;
s.osAec = sOsAec.checked; s.osAec = sOsAec.checked;
localStorage.setItem("wzp-settings", JSON.stringify(s)); localStorage.setItem("wzp-settings", JSON.stringify(s));
// Sync back to main form
relayInput.value = s.relay; relayInput.value = s.relay;
roomInput.value = s.room; roomInput.value = s.room;
aliasInput.value = s.alias; aliasInput.value = s.alias;
@@ -383,17 +413,12 @@ sClearRecent.addEventListener("click", () => {
renderRecentRooms([]); renderRecentRooms([]);
}); });
// Cmd+, (macOS) or Ctrl+, (Windows/Linux) opens settings // Cmd+, / Ctrl+, opens settings, Escape closes
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === ",") { if ((e.metaKey || e.ctrlKey) && e.key === ",") {
e.preventDefault(); e.preventDefault();
if (settingsPanel.classList.contains("hidden")) { settingsPanel.classList.contains("hidden") ? openSettings() : closeSettings();
openSettings();
} else {
closeSettings();
} }
}
// Escape closes settings
if (e.key === "Escape" && !settingsPanel.classList.contains("hidden")) { if (e.key === "Escape" && !settingsPanel.classList.contains("hidden")) {
closeSettings(); closeSettings();
} }

View File

@@ -201,6 +201,16 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
50% { opacity: 0.4; } 50% { opacity: 0.4; }
} }
.status-dot.reconnecting {
background: var(--yellow);
animation: blink 0.5s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.1; }
}
.call-timer { .call-timer {
font-size: 14px; font-size: 14px;
color: var(--text-dim); color: var(--text-dim);