diff --git a/Client/tauri-client/src-tauri/Cargo.lock b/Client/tauri-client/src-tauri/Cargo.lock index 04f5bdf9..de660561 100644 --- a/Client/tauri-client/src-tauri/Cargo.lock +++ b/Client/tauri-client/src-tauri/Cargo.lock @@ -41,6 +41,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -471,6 +521,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -980,6 +1036,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1977,6 +2056,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -2006,6 +2091,30 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "jni" version = "0.21.1" @@ -2544,6 +2653,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "open" version = "5.3.3" @@ -2596,7 +2711,9 @@ dependencies = [ name = "owncord-client" version = "1.3.0" dependencies = [ + "env_logger", "futures-util", + "log", "ring", "rustls", "serde", @@ -2947,6 +3064,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -5233,6 +5365,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.22.0" diff --git a/Client/tauri-client/src-tauri/Cargo.toml b/Client/tauri-client/src-tauri/Cargo.toml index d325773a..01485a63 100644 --- a/Client/tauri-client/src-tauri/Cargo.toml +++ b/Client/tauri-client/src-tauri/Cargo.toml @@ -35,6 +35,8 @@ tokio = { version = "1", features = ["sync", "net", "io-util", "rt", "macros"] } tokio-rustls = { version = "0.26", default-features = false } rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } ring = "0.17" +log = "0.4" +env_logger = "0.11" [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = ["Win32_Security_Credentials", "Win32_Foundation", "Win32_UI_Input_KeyboardAndMouse"] } diff --git a/Client/tauri-client/src-tauri/capabilities/default.json b/Client/tauri-client/src-tauri/capabilities/default.json index e1ce373c..d0dbc171 100644 --- a/Client/tauri-client/src-tauri/capabilities/default.json +++ b/Client/tauri-client/src-tauri/capabilities/default.json @@ -75,6 +75,54 @@ "path": "**" } ] + }, + { + "identifier": "fs:allow-write-text-file", + "allow": [ + { + "path": "$APPLOG/**" + } + ] + }, + { + "identifier": "fs:allow-mkdir", + "allow": [ + { + "path": "$APPLOG/**" + } + ] + }, + { + "identifier": "fs:allow-exists", + "allow": [ + { + "path": "$APPLOG/**" + } + ] + }, + { + "identifier": "fs:allow-read-dir", + "allow": [ + { + "path": "$APPLOG/**" + } + ] + }, + { + "identifier": "fs:allow-remove", + "allow": [ + { + "path": "$APPLOG/**" + } + ] + }, + { + "identifier": "fs:allow-read-text-file", + "allow": [ + { + "path": "$APPLOG/**" + } + ] } ] } diff --git a/Client/tauri-client/src-tauri/src/lib.rs b/Client/tauri-client/src-tauri/src/lib.rs index 108f18f2..be4e43d4 100644 --- a/Client/tauri-client/src-tauri/src/lib.rs +++ b/Client/tauri-client/src-tauri/src/lib.rs @@ -45,6 +45,14 @@ pub fn run() { commands::open_devtools, ]) .setup(|app| { + // Initialize Rust logging (controlled by RUST_LOG env var, defaults to info). + // try_init avoids panic if another logger (e.g. a Tauri plugin) registered first. + let _ = env_logger::Builder::from_env( + env_logger::Env::default().default_filter_or("info"), + ) + .format_timestamp_millis() + .try_init(); + tray::create_tray(app.handle())?; Ok(()) }) diff --git a/Client/tauri-client/src-tauri/src/livekit_proxy.rs b/Client/tauri-client/src-tauri/src/livekit_proxy.rs index ac55f812..70de49db 100644 --- a/Client/tauri-client/src-tauri/src/livekit_proxy.rs +++ b/Client/tauri-client/src-tauri/src/livekit_proxy.rs @@ -25,6 +25,7 @@ // port in JS becomes stale until the next voice join resets it. // - The accept loop exits after 5 consecutive errors to prevent CPU spin. +use log::{debug, error, info, warn}; use ring::digest::{digest, SHA256}; use std::net::IpAddr; use std::sync::Arc; @@ -190,12 +191,16 @@ pub async fn start_livekit_proxy( ) -> Result { let mut inner = state.inner.lock().await; + info!("[livekit_proxy] start requested for {}", remote_host); + // Reuse existing proxy for same host. if let Some(port) = inner.port { if inner.remote_host == remote_host { + debug!("[livekit_proxy] reusing existing proxy on port {} for {}", port, remote_host); return Ok(port); } // Different host — tear down old proxy. + info!("[livekit_proxy] stopping old proxy for {} (switching to {})", inner.remote_host, remote_host); if let Some(tx) = inner.shutdown_tx.take() { let _ = tx.send(()); } @@ -226,6 +231,8 @@ pub async fn start_livekit_proxy( let host = remote_host.clone(); tokio::spawn(run_proxy_loop(listener, host, fingerprint, shutdown_rx)); + info!("[livekit_proxy] proxy started on 127.0.0.1:{} → {}", port, remote_host); + inner.port = Some(port); inner.remote_host = remote_host; inner.shutdown_tx = Some(shutdown_tx); @@ -266,24 +273,25 @@ async fn run_proxy_loop( tokio::select! { result = listener.accept() => { match result { - Ok((stream, _)) => { + Ok((stream, addr)) => { consecutive_errors = 0; let host = remote_host.clone(); let fp = pinned_fingerprint.clone(); + debug!("[livekit_proxy] accepted connection from {}", addr); tokio::spawn(async move { if let Err(e) = handle_connection(stream, &host, &fp).await { - eprintln!("[livekit_proxy] connection error: {e}"); + warn!("[livekit_proxy] connection to {} failed: {}", host, e); } }); } Err(e) => { consecutive_errors += 1; - eprintln!( - "[livekit_proxy] accept error ({}/{}): {e}", - consecutive_errors, MAX_CONSECUTIVE_ACCEPT_ERRORS + error!( + "[livekit_proxy] accept error ({}/{}): {}", + consecutive_errors, MAX_CONSECUTIVE_ACCEPT_ERRORS, e ); if consecutive_errors >= MAX_CONSECUTIVE_ACCEPT_ERRORS { - eprintln!( + error!( "[livekit_proxy] {} consecutive accept errors, stopping proxy loop", MAX_CONSECUTIVE_ACCEPT_ERRORS ); @@ -370,12 +378,23 @@ async fn handle_connection( .map_err(|e| format!("invalid server name '{hostname}': {e}"))? }; + debug!("[livekit_proxy] connecting TCP to {}", remote_host); let tcp = TcpStream::connect(remote_host).await?; + debug!("[livekit_proxy] starting TLS handshake with {}", remote_host); let mut tls = connector.connect(server_name, tcp).await?; + debug!("[livekit_proxy] TLS handshake complete, forwarding traffic"); // ── 4. Forward request + bidirectional copy ────────────────────────── tls.write_all(modified.as_bytes()).await?; - let _ = io::copy_bidirectional(&mut local, &mut tls).await; + let result = io::copy_bidirectional(&mut local, &mut tls).await; + match result { + Ok((to_remote, from_remote)) => { + debug!("[livekit_proxy] connection closed: {}B sent, {}B received", to_remote, from_remote); + } + Err(e) => { + debug!("[livekit_proxy] bidirectional copy ended: {}", e); + } + } Ok(()) } diff --git a/Client/tauri-client/src-tauri/src/ws_proxy.rs b/Client/tauri-client/src-tauri/src/ws_proxy.rs index f3145ede..bffab3ec 100644 --- a/Client/tauri-client/src-tauri/src/ws_proxy.rs +++ b/Client/tauri-client/src-tauri/src/ws_proxy.rs @@ -7,6 +7,7 @@ // - If the fingerprint changes, the connection is rejected (potential MitM). use futures_util::{SinkExt, StreamExt}; +use log::{debug, error, info, warn}; use ring::digest::{digest, SHA256}; use serde_json::Value; use std::sync::Arc; @@ -189,14 +190,20 @@ pub async fn ws_connect( state: tauri::State<'_, WsState>, url: String, ) -> Result<(), String> { + info!("[ws_proxy] connecting to {}", url); + // Drop any existing connection { let mut tx_lock = state.tx.lock().await; + if tx_lock.is_some() { + debug!("[ws_proxy] dropping existing connection"); + } *tx_lock = None; } // Only allow secure WebSocket connections if !url.starts_with("wss://") { + warn!("[ws_proxy] rejected non-wss URL: {}", url); return Err("Only wss:// connections are permitted".into()); } @@ -222,8 +229,16 @@ pub async fn ws_connect( let (ws_stream, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_future) .await - .map_err(|_| format!("ws connect timed out after {}s", CONNECT_TIMEOUT.as_secs()))? - .map_err(|e| format!("ws connect failed: {e}"))?; + .map_err(|_| { + error!("[ws_proxy] connect timed out after {}s to {}", CONNECT_TIMEOUT.as_secs(), url); + format!("ws connect timed out after {}s", CONNECT_TIMEOUT.as_secs()) + })? + .map_err(|e| { + error!("[ws_proxy] connect failed to {}: {}", url, e); + format!("ws connect failed: {e}") + })?; + + debug!("[ws_proxy] WebSocket handshake complete"); // ── TOFU check ─────────────────────────────────────────────────────── let host = extract_host(&url); @@ -239,6 +254,7 @@ pub async fn ws_connect( match tofu_check(&app, &host, &fingerprint) { Ok(status) => { + info!("[ws_proxy] TOFU check passed for {}: {}", host, status); let _ = app.emit( "cert-tofu", serde_json::json!({ @@ -249,6 +265,8 @@ pub async fn ws_connect( ); } Err(mismatch_msg) => { + warn!("[ws_proxy] TOFU check FAILED for {} — certificate fingerprint mismatch", host); + debug!("[ws_proxy] TOFU detail: {}", mismatch_msg); let _ = app.emit( "cert-tofu", serde_json::json!({ @@ -264,6 +282,7 @@ pub async fn ws_connect( } // ── End TOFU check ─────────────────────────────────────────────────── + info!("[ws_proxy] connected to {}", host); let _ = app.emit("ws-state", "open"); let (mut sink, mut stream) = ws_stream.split(); @@ -285,8 +304,12 @@ pub async fn ws_connect( Ok(Message::Text(text)) => { let _ = app_read.emit("ws-message", text.to_string()); } - Ok(Message::Close(_)) => break, + Ok(Message::Close(frame)) => { + debug!("[ws_proxy] server sent Close frame: {:?}", frame); + break; + } Err(e) => { + warn!("[ws_proxy] read error: {}", e); let _ = app_read.emit("ws-error", format!("{e}")); break; } @@ -307,9 +330,16 @@ pub async fn ws_connect( // When either task ends, abort sibling and emit closed tokio::spawn(async move { tokio::select! { - _ = &mut read_task => { write_task.abort(); } - _ = &mut write_task => { read_task.abort(); } + _ = &mut read_task => { + debug!("[ws_proxy] read task ended, aborting write task"); + write_task.abort(); + } + _ = &mut write_task => { + debug!("[ws_proxy] write task ended, aborting read task"); + read_task.abort(); + } } + info!("[ws_proxy] connection closed"); let _ = app_state.emit("ws-state", "closed"); }); diff --git a/Client/tauri-client/src/components/message-list/attachments.ts b/Client/tauri-client/src/components/message-list/attachments.ts index 42463260..e4ece2b1 100644 --- a/Client/tauri-client/src/components/message-list/attachments.ts +++ b/Client/tauri-client/src/components/message-list/attachments.ts @@ -290,7 +290,10 @@ export function renderAttachment(att: Attachment): HTMLDivElement { return wrap; } -/** Download a file via Tauri HTTP plugin and save to disk with native dialog. */ +/** Download a file via Tauri HTTP plugin and save to disk with native dialog. + * NOTE: This requires fs:allow-write-file with path "**" in capabilities because + * the user chooses the save location via the native OS dialog — the destination is + * not under our control. The dialog itself is the security boundary. */ async function downloadFile(url: string, filename: string): Promise { try { // Show native save dialog with suggested filename diff --git a/Client/tauri-client/src/components/settings/AdvancedTab.ts b/Client/tauri-client/src/components/settings/AdvancedTab.ts index a3176425..c6932ff4 100644 --- a/Client/tauri-client/src/components/settings/AdvancedTab.ts +++ b/Client/tauri-client/src/components/settings/AdvancedTab.ts @@ -1,11 +1,17 @@ /** - * Advanced settings tab — developer mode, hardware acceleration, and debug tools. + * Advanced settings tab — developer mode, hardware acceleration, debug tools, + * and cache management. */ import { createElement, appendChildren } from "@lib/dom"; import { invoke } from "@tauri-apps/api/core"; +import { appLogDir, join } from "@tauri-apps/api/path"; +import { readDir, remove } from "@tauri-apps/plugin-fs"; +import { createLogger } from "@lib/logger"; import { loadPref, savePref, createToggle } from "./helpers"; +const log = createLogger("AdvancedTab"); + export function buildAdvancedTab(signal: AbortSignal): HTMLDivElement { const section = createElement("div", { class: "settings-pane active" }); @@ -70,5 +76,133 @@ export function buildAdvancedTab(signal: AbortSignal): HTMLDivElement { appendChildren(devtoolsRow, devtoolsInfo, devtoolsBtn); section.appendChild(devtoolsRow); + // ---- Storage & Cache section ------------------------------------------------ + + const cacheSep = createElement("div", { class: "settings-separator" }); + section.appendChild(cacheSep); + + const cacheTitle = createElement("div", { class: "settings-section-title" }, "Storage & Cache"); + section.appendChild(cacheTitle); + + // Clear Image Cache + section.appendChild(buildCacheRow( + "Clear Image Cache", + "Remove cached images and link previews. They will be re-downloaded as needed.", + "Clear", + signal, + async (btn) => { + btn.textContent = "Clearing..."; + btn.setAttribute("disabled", ""); + try { + await clearImageCache(); + btn.textContent = "Cleared!"; + setTimeout(() => { btn.textContent = "Clear"; btn.removeAttribute("disabled"); }, 2000); + } catch (err) { + log.error("Failed to clear image cache", err); + btn.textContent = "Failed"; + setTimeout(() => { btn.textContent = "Clear"; btn.removeAttribute("disabled"); }, 2000); + } + }, + )); + + // Clear Log Files + section.appendChild(buildCacheRow( + "Clear Log Files", + "Remove persisted client log files from disk.", + "Clear", + signal, + async (btn) => { + btn.textContent = "Clearing..."; + btn.setAttribute("disabled", ""); + try { + await clearLogFiles(); + btn.textContent = "Cleared!"; + setTimeout(() => { btn.textContent = "Clear"; btn.removeAttribute("disabled"); }, 2000); + } catch (err) { + log.error("Failed to clear log files", err); + btn.textContent = "Failed"; + setTimeout(() => { btn.textContent = "Clear"; btn.removeAttribute("disabled"); }, 2000); + } + }, + )); + + // Clear All Cache (nuclear option) + section.appendChild(buildCacheRow( + "Clear All Cache & Restart", + "Remove all cached data (images, logs, WebView storage) and restart the app. " + + "Server profiles and credentials are preserved.", + "Clear & Restart", + signal, + async (btn) => { + if (!confirm("This will clear all cached data and restart the app. Continue?")) return; + btn.textContent = "Clearing..."; + btn.setAttribute("disabled", ""); + try { + await clearImageCache(); + await clearLogFiles(); + localStorage.clear(); + sessionStorage.clear(); + log.info("All cache cleared, restarting app"); + const { relaunch } = await import("@tauri-apps/plugin-process"); + await relaunch(); + } catch (err) { + log.error("Failed to clear all cache", err); + btn.textContent = "Failed"; + setTimeout(() => { btn.textContent = "Clear & Restart"; btn.removeAttribute("disabled"); }, 2000); + } + }, + )); + return section; } + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function buildCacheRow( + label: string, + desc: string, + btnText: string, + signal: AbortSignal, + onClick: (btn: HTMLButtonElement) => void, +): HTMLDivElement { + const row = createElement("div", { class: "setting-row" }); + const info = createElement("div", {}); + const labelEl = createElement("div", { class: "setting-label" }, label); + const descEl = createElement("div", { class: "setting-desc" }, desc); + appendChildren(info, labelEl, descEl); + + const btn = createElement("button", { class: "ac-btn" }, btnText) as HTMLButtonElement; + btn.addEventListener("click", () => { onClick(btn); }, { signal }); + + appendChildren(row, info, btn); + return row; +} + +/** Delete the IndexedDB image cache database. */ +async function clearImageCache(): Promise { + // Clear IndexedDB + await new Promise((resolve, reject) => { + const req = indexedDB.deleteDatabase("owncord-image-cache"); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + req.onblocked = () => resolve(); // proceed even if blocked + }); +} + +/** Delete all JSONL log files from the app log directory. */ +async function clearLogFiles(): Promise { + try { + const baseDir = await appLogDir(); + const logDir = await join(baseDir, "client-logs"); + const entries = await readDir(logDir); + for (const entry of entries) { + if (entry.name?.endsWith(".jsonl") && !entry.isDirectory) { + await remove(`${logDir}/${entry.name}`); + } + } + } catch { + // Log dir may not exist yet — that's fine. + } +} diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index 6a5cc32f..cb078c21 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -209,6 +209,26 @@ export class LiveKitSession { newRoom.on(RoomEvent.ActiveSpeakersChanged, this.handleActiveSpeakersChanged); newRoom.on(RoomEvent.AudioPlaybackStatusChanged, this.handleAudioPlaybackChanged); newRoom.on(RoomEvent.LocalTrackPublished, this.handleLocalTrackPublished); + + // Room lifecycle event logging for diagnostics + newRoom.on(RoomEvent.Reconnecting, () => { + log.warn("LiveKit room reconnecting"); + }); + newRoom.on(RoomEvent.Reconnected, () => { + log.info("LiveKit room reconnected"); + }); + newRoom.on(RoomEvent.SignalReconnecting, () => { + log.debug("LiveKit signal reconnecting"); + }); + newRoom.on(RoomEvent.MediaDevicesError, (error: Error) => { + log.error("LiveKit media device error", { error: error.message }); + }); + newRoom.on(RoomEvent.ConnectionQualityChanged, (quality, participant) => { + if (participant.isLocal) { + log.debug("Local connection quality changed", { quality }); + } + }); + return newRoom; } @@ -423,6 +443,7 @@ export class LiveKitSession { const resolvedUrl = await this.resolveLiveKitUrl(url, directUrl); await this.room.connect(resolvedUrl, token); log.info("Auto-reconnect succeeded", { attempt, channelId, url: resolvedUrl }); + this.logIceConnectionInfo(); this.room.startAudio().catch((err) => log.debug("Failed to start audio after reconnect", err)); await this.restoreLocalVoiceState("reconnect"); this.setupAudioPipeline(); @@ -695,6 +716,7 @@ export class LiveKitSession { // If the room was discarded (stale join superseded by pending), skip setup. if (this.room !== null) { log.info("Connected to LiveKit room", { channelId, url: resolvedUrl }); + this.logIceConnectionInfo(); this.currentChannelId = channelId; this.latestToken = token; this.lastUrl = url; @@ -1355,8 +1377,91 @@ export class LiveKitSession { currentInputGain: this.currentInputGain, localParticipant: this.room.localParticipant.identity, localTracks, remoteParticipants, + iceConnectionState: this.getIceConnectionState(), }; } + + /** Log ICE connection details for debugging cross-network voice issues. */ + private logIceConnectionInfo(): void { + if (this.room === null) return; + // Access the underlying RTCPeerConnection via LiveKit's engine. + // LiveKit exposes the PeerConnection via room.engine.subscriber/publisher. + try { + const engine = (this.room as any).engine; + if (!engine) return; + + const pcs: Array<{ label: string; pc: RTCPeerConnection }> = []; + if (engine.subscriber?.pc) pcs.push({ label: "subscriber", pc: engine.subscriber.pc }); + if (engine.publisher?.pc) pcs.push({ label: "publisher", pc: engine.publisher.pc }); + + for (const { label, pc } of pcs) { + log.info(`ICE ${label} connection state`, { + iceConnectionState: pc.iceConnectionState, + iceGatheringState: pc.iceGatheringState, + connectionState: pc.connectionState, + signalingState: pc.signalingState, + }); + + // Log selected candidate pair + pc.getStats().then((stats) => { + stats.forEach((report) => { + if (report.type === "candidate-pair" && report.state === "succeeded") { + const localId = report.localCandidateId; + const remoteId = report.remoteCandidateId; + let localType = "unknown"; + let remoteType = "unknown"; + let localProtocol = "unknown"; + + stats.forEach((s) => { + if (s.id === localId && s.type === "local-candidate") { + localType = s.candidateType ?? "unknown"; + localProtocol = s.protocol ?? "unknown"; + } + if (s.id === remoteId && s.type === "remote-candidate") { + remoteType = s.candidateType ?? "unknown"; + } + }); + + log.info(`ICE ${label} selected candidate pair`, { + localType, + remoteType, + localProtocol, + }); + } + }); + }).catch((err) => { + log.debug("Failed to get ICE stats", { error: String(err) }); + }); + } + } catch (err) { + log.debug("Failed to access ICE connection info", { error: String(err) }); + } + } + + /** Get ICE connection state summary for debug panel. */ + private getIceConnectionState(): Record | null { + if (this.room === null) return null; + try { + const engine = (this.room as any).engine; + if (!engine) return null; + const result: Record = {}; + if (engine.subscriber?.pc) { + result.subscriber = { + iceConnectionState: engine.subscriber.pc.iceConnectionState, + connectionState: engine.subscriber.pc.connectionState, + }; + } + if (engine.publisher?.pc) { + result.publisher = { + iceConnectionState: engine.publisher.pc.iceConnectionState, + connectionState: engine.publisher.pc.connectionState, + }; + } + return result; + } catch { + return null; + } + } } // --- Singleton instance + re-exported bound methods --- diff --git a/Client/tauri-client/src/lib/logPersistence.ts b/Client/tauri-client/src/lib/logPersistence.ts new file mode 100644 index 00000000..d484ac04 --- /dev/null +++ b/Client/tauri-client/src/lib/logPersistence.ts @@ -0,0 +1,188 @@ +// Log persistence — writes client logs to rotating JSONL files on disk. +// +// Uses Tauri's FS plugin to write to the app log directory. +// Files: {appLogDir}/client-logs/YYYY-MM-DD.jsonl +// Rotation: keeps the most recent MAX_LOG_FILES days of logs. + +import { appLogDir, join } from "@tauri-apps/api/path"; +import { + mkdir, + writeTextFile, + readDir, + remove, + exists, + readTextFile, +} from "@tauri-apps/plugin-fs"; +import { type LogEntry, addLogListener, createLogger } from "./logger"; + +const log = createLogger("logPersistence"); +const MAX_LOG_FILES = 5; +const LOG_SUBDIR = "client-logs"; + +let logDir: string | null = null; +let currentDate: string | null = null; +let buffer: string[] = []; +let flushTimer: ReturnType | null = null; +let initialized = false; + +/** Get today's date as YYYY-MM-DD. */ +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +/** Resolve the full path for a given date's log file. */ +function logFilePath(dir: string, date: string): string { + return `${dir}/${date}.jsonl`; +} + +/** Flush buffered log lines to disk. */ +async function flushBuffer(): Promise { + if (buffer.length === 0 || !logDir) return; + + const date = today(); + if (date !== currentDate) { + currentDate = date; + await rotateOldFiles(); + } + + const lines = buffer.join("\n") + "\n"; + buffer = []; + + try { + const filePath = logFilePath(logDir, currentDate!); + await writeTextFile(filePath, lines, { append: true }); + } catch (err) { + // Log persistence failure shouldn't crash the app. + log.error("flush failed", err); + } +} + +/** Schedule a flush after a short debounce. */ +function scheduleFlush(): void { + if (flushTimer !== null) return; + flushTimer = setTimeout(() => { + flushTimer = null; + flushBuffer(); + }, 2000); +} + +/** Remove log files older than MAX_LOG_FILES days. */ +async function rotateOldFiles(): Promise { + if (!logDir) return; + try { + const entries = await readDir(logDir); + const jsonlFiles = entries + .filter( + (e) => + e.name?.endsWith(".jsonl") && !e.isDirectory, + ) + .map((e) => e.name!) + .sort(); + + if (jsonlFiles.length > MAX_LOG_FILES) { + const toRemove = jsonlFiles.slice( + 0, + jsonlFiles.length - MAX_LOG_FILES, + ); + for (const file of toRemove) { + await remove(`${logDir}/${file}`); + } + } + } catch (err) { + log.warn("rotation failed", err); + } +} + +/** Handle a log entry by serializing it and buffering for disk write. */ +function onLogEntry(entry: LogEntry): void { + if (!initialized) return; + buffer.push(JSON.stringify(entry)); + scheduleFlush(); +} + +/** + * Initialize log persistence. Call once at app startup. + * Sets up a listener on the logger that writes entries to disk. + * Returns a cleanup function to remove the listener. + */ +export async function initLogPersistence(): Promise<() => void> { + if (initialized) return () => {}; + + try { + const baseDir = await appLogDir(); + logDir = await join(baseDir, LOG_SUBDIR); + + const dirExists = await exists(logDir); + if (!dirExists) { + await mkdir(logDir, { recursive: true }); + } + + currentDate = today(); + initialized = true; + + const removeListener = addLogListener(onLogEntry); + + return () => { + removeListener(); // stop receiving new entries first + initialized = false; + if (flushTimer !== null) { + clearTimeout(flushTimer); + flushTimer = null; + } + // Final flush — best-effort, fire-and-forget + flushBuffer(); + }; + } catch (err) { + log.error("init failed", err); + return () => {}; + } +} + +/** + * Force an immediate flush of any buffered log entries. + * Best-effort — may not complete if called during window teardown + * since Tauri IPC is async and the WebView may be destroyed first. + */ +export async function flushLogs(): Promise { + if (flushTimer !== null) { + clearTimeout(flushTimer); + flushTimer = null; + } + await flushBuffer(); +} + +/** + * Get the log directory path (for use in debug bundle export). + * Returns null if persistence hasn't been initialized. + */ +export function getLogDir(): string | null { + return logDir; +} + +/** + * Read all persisted log files and return their combined content. + * Intended for on-demand export only (reads all files into memory). + */ +export async function readAllPersistedLogs(): Promise { + if (!logDir) return ""; + try { + const entries = await readDir(logDir); + const jsonlFiles = entries + .filter( + (e) => + e.name?.endsWith(".jsonl") && !e.isDirectory, + ) + .map((e) => e.name!) + .sort(); + + const parts: string[] = []; + for (const file of jsonlFiles) { + const content = await readTextFile(`${logDir}/${file}`); + parts.push(content); + } + return parts.join(""); + } catch (err) { + log.warn("readAllPersistedLogs failed", err); + return ""; + } +} diff --git a/Client/tauri-client/src/lib/ws.ts b/Client/tauri-client/src/lib/ws.ts index baca1add..de31d9dc 100644 --- a/Client/tauri-client/src/lib/ws.ts +++ b/Client/tauri-client/src/lib/ws.ts @@ -133,7 +133,12 @@ export function createWsClient() { function scheduleReconnect(): void { if (intentionalClose || certMismatchBlock || !config) return; const delay = getReconnectDelay(); - log.info(`Reconnecting in ${delay}ms (attempt ${reconnectAttempt + 1})`); + log.info("WebSocket reconnecting", { + delayMs: delay, + attempt: reconnectAttempt + 1, + host: config?.host ?? "unknown", + lastSeq, + }); setState("reconnecting"); reconnectTimer = setTimeout(() => { reconnectAttempt++; @@ -194,6 +199,13 @@ export function createWsClient() { // auth_ok — mark as connected if (msg.type === "auth_ok") { + if (reconnectAttempt > 0) { + log.info("WebSocket reconnected successfully", { + afterAttempts: reconnectAttempt, + host: config?.host ?? "unknown", + lastSeq, + }); + } setState("connected"); reconnectAttempt = 0; startHeartbeat(); @@ -236,12 +248,20 @@ export function createWsClient() { if (rustState === "open") { proxyOpen = true; - log.info("WebSocket open, sending auth"); + log.info("WebSocket open, sending auth", { + host: config?.host ?? "unknown", + isReconnect: reconnectAttempt > 0, + lastSeq, + }); setState("authenticating"); send({ type: "auth", payload: { token: config!.token, last_seq: lastSeq } }); } else if (rustState === "closed") { proxyOpen = false; - log.info("WebSocket closed (proxy)"); + log.info("WebSocket closed", { + host: config?.host ?? "unknown", + intentional: intentionalClose, + certBlocked: certMismatchBlock, + }); stopHeartbeat(); if (!intentionalClose) { scheduleReconnect(); @@ -314,7 +334,11 @@ export function createWsClient() { } const wsUrl = `wss://${cfg.host}/api/v1/ws`; - log.info("Connecting to", { url: wsUrl }); + log.info("WebSocket connecting", { + url: wsUrl, + isReconnect: reconnectAttempt > 0, + attempt: reconnectAttempt, + }); // Set up event listeners before connecting cleanupEventListeners(); @@ -364,6 +388,7 @@ export function createWsClient() { function disconnect(): void { intentionalClose = true; + log.info("WebSocket disconnecting (intentional)", { host: config?.host ?? "unknown" }); certMismatchBlock = false; cancelReconnect(); stopHeartbeat(); diff --git a/Client/tauri-client/src/main.ts b/Client/tauri-client/src/main.ts index fd27aa9c..e439b328 100644 --- a/Client/tauri-client/src/main.ts +++ b/Client/tauri-client/src/main.ts @@ -22,6 +22,7 @@ import { initPtt } from "@lib/ptt"; import { createConnectedOverlay } from "@components/ConnectedOverlay"; import type { ConnectedOverlayControl } from "@components/ConnectedOverlay"; import { createLogger } from "@lib/logger"; +import { initLogPersistence, flushLogs } from "@lib/logPersistence"; import { saveCredential, loadCredential, deleteCredential } from "@lib/credentials"; import { initWindowState } from "@lib/window-state"; import { createCertMismatchModal } from "@components/CertMismatchModal"; @@ -438,6 +439,8 @@ window.addEventListener("beforeunload", () => { voiceSessionLeave(false); // false: we send voice_leave below ws.send({ type: "voice_leave", payload: {} }); } + // Flush any buffered log entries to disk before the window closes. + void flushLogs(); }); // Initial render @@ -446,4 +449,7 @@ renderPage(router.getCurrentPage()); // Initialize window state persistence (fire-and-forget) void initWindowState(); +// Initialize log persistence to disk (fire-and-forget) +void initLogPersistence(); + log.info("OwnCord client initialized"); diff --git a/Server/api/diagnostics_handler.go b/Server/api/diagnostics_handler.go new file mode 100644 index 00000000..fb75b6cb --- /dev/null +++ b/Server/api/diagnostics_handler.go @@ -0,0 +1,89 @@ +package api + +import ( + "net/http" + "runtime" + "time" + + "github.com/owncord/server/config" + "github.com/owncord/server/ws" +) + +// diagnosticsResponse is returned by GET /api/v1/diagnostics/connectivity. +type diagnosticsResponse struct { + Server serverDiag `json:"server"` + Voice voiceDiag `json:"voice"` + Client clientDiag `json:"client"` +} + +type serverDiag struct { + Version string `json:"version"` + Uptime int64 `json:"uptime_s"` + GoVersion string `json:"go_version"` + OnlineUsers int `json:"online_users"` +} + +type voiceDiag struct { + Enabled bool `json:"enabled"` + LiveKitURL string `json:"livekit_url,omitempty"` + LiveKitHealth bool `json:"livekit_health"` + NodeIP string `json:"node_ip,omitempty"` + ProxyPath string `json:"proxy_path"` +} + +type clientDiag struct { + RemoteAddr string `json:"remote_addr"` + IsPrivateNet bool `json:"is_private_network"` +} + +func handleDiagnosticsConnectivity( + cfg *config.Config, + ver string, + hub *ws.Hub, +) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + clientAddr := clientIP(r) + + lkHealthy := false + if ok, _ := hub.LiveKitHealthCheck(); ok { + lkHealthy = true + } + + resp := diagnosticsResponse{ + Server: serverDiag{ + Version: ver, + Uptime: int64(time.Since(serverStartTime).Seconds()), + GoVersion: runtime.Version(), + OnlineUsers: hub.ClientCount(), + }, + Voice: voiceDiag{ + Enabled: cfg.Voice.LiveKitURL != "", + LiveKitURL: cfg.Voice.LiveKitURL, + LiveKitHealth: lkHealthy, + NodeIP: cfg.Voice.NodeIP, + ProxyPath: "/livekit", + }, + Client: clientDiag{ + RemoteAddr: clientAddr, + IsPrivateNet: isPrivateIP(clientAddr), + }, + } + + writeJSON(w, http.StatusOK, resp) + } +} + +// isPrivateIP checks if an IP string is in a private/reserved range. +func isPrivateIP(ip string) bool { + for _, prefix := range []string{ + "10.", "172.16.", "172.17.", "172.18.", "172.19.", + "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", + "172.25.", "172.26.", "172.27.", "172.28.", "172.29.", + "172.30.", "172.31.", "192.168.", "127.", "::1", "fc", "fd", + } { + if len(ip) >= len(prefix) && ip[:len(prefix)] == prefix { + return true + } + } + return false +} diff --git a/Server/api/router.go b/Server/api/router.go index bf9ac155..15447ab9 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -146,6 +146,10 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri // hub can send real-time dm_channel_close events to WebSocket clients. MountDMRoutes(r, database, hub) + // Connectivity diagnostics — any authenticated user can check. + r.With(AuthMiddleware(database)).Get("/api/v1/diagnostics/connectivity", + handleDiagnosticsConnectivity(cfg, ver, hub)) + go hub.Run() r.Get("/api/v1/ws", ws.ServeWS(hub, database, cfg.Server.AllowedOrigins)) @@ -261,11 +265,17 @@ func requestLogger(next http.Handler) http.Handler { // Health checks at Debug level; errors at Warn; everything else at Info. path := r.URL.Path + reqID := middleware.GetReqID(r.Context()) attrs := []any{ "method", r.Method, "path", path, "status", status, "duration_ms", elapsed.Milliseconds(), + "bytes", ww.BytesWritten(), + "client_ip", clientIP(r), + } + if reqID != "" { + attrs = append(attrs, "req_id", reqID) } switch { case path == "/health" || path == "/api/v1/health": diff --git a/Server/ws/client.go b/Server/ws/client.go index 1f706553..75b01a35 100644 --- a/Server/ws/client.go +++ b/Server/ws/client.go @@ -25,12 +25,17 @@ type Client struct { voiceChID int64 // voice channel the user is in (0 = not in voice); guarded by voiceMu roleName string // cached role name for chat_message broadcasts tokenHash string // SHA-256 hex of the session token; used for periodic revalidation + connectedAt time.Time // when the WS connection was established + remoteAddr string // client IP:port from the HTTP upgrade request msgCount int // count of messages processed; resets after session check + msgsReceived int64 // total messages received over the lifetime of this connection + msgsSent int64 // total messages sent over the lifetime of this connection + msgsDropped int64 // messages dropped due to full send buffer invalidCount int // consecutive invalid messages; reset on valid parse lastActivity time.Time // last message received from this client; guarded by mu sendClosed bool // true after the send channel has been closed send chan []byte - mu sync.Mutex // guards sendClosed, msgCount, channelID, lastActivity + mu sync.Mutex // guards sendClosed, msgCount, channelID, lastActivity, msgsReceived, msgsSent, msgsDropped voiceMu sync.Mutex // guards voiceChID } @@ -43,13 +48,15 @@ type wsConn interface { // newClient creates a real client wrapping a WebSocket connection (set by serve.go). func newClient(hub *Hub, conn wsConn, user *db.User, tokenHash string) *Client { + now := time.Now() return &Client{ hub: hub, conn: conn, userID: user.ID, user: user, tokenHash: tokenHash, - lastActivity: time.Now(), + connectedAt: now, + lastActivity: now, send: make(chan []byte, sendBufSize), } } @@ -112,10 +119,11 @@ func NewTestClientWithTokenHash(hub *Hub, user *db.User, tokenHash string, chann } } -// touch updates the last activity timestamp to now. +// touch updates the last activity timestamp and increments the received counter. func (c *Client) touch() { c.mu.Lock() c.lastActivity = time.Now() + c.msgsReceived++ c.mu.Unlock() } @@ -166,8 +174,9 @@ func (c *Client) sendMsg(msg []byte) { } select { case c.send <- msg: + c.msgsSent++ default: - // Buffer full — drop rather than block the hub. + c.msgsDropped++ } } @@ -181,8 +190,10 @@ func (c *Client) trySendMsg(msg []byte) bool { } select { case c.send <- msg: + c.msgsSent++ return true default: + c.msgsDropped++ return false } } diff --git a/Server/ws/livekit_webhook.go b/Server/ws/livekit_webhook.go index 4d8d8ebd..099b5a8c 100644 --- a/Server/ws/livekit_webhook.go +++ b/Server/ws/livekit_webhook.go @@ -70,6 +70,12 @@ func (h *Hub) NewLiveKitWebhookHandler(apiKey, apiSecret string) http.HandlerFun return } + slog.Info("livekit webhook received", + "event", event.Event, + "room", event.GetRoom().GetName(), + "participant", event.GetParticipant().GetIdentity(), + ) + switch event.Event { case "participant_joined": h.handleWebhookParticipantJoined(&event) diff --git a/Server/ws/serve.go b/Server/ws/serve.go index fe95ffeb..6e3b16ef 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -53,6 +53,7 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun } c := newClient(hub, conn, user, tokenHash) + c.remoteAddr = r.RemoteAddr hub.Register(c) // Look up role name for protocol-compliant payloads and cache on client. @@ -155,11 +156,36 @@ func writePump(ctx context.Context, conn *websocket.Conn, c *Client) { // readPump reads from the WebSocket and dispatches messages. Blocks until disconnect. func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) { + var lastReadErr error defer func() { + voiceChID := c.getVoiceChID() // capture BEFORE handleVoiceLeave clears it hub.Unregister(c) hub.handleVoiceLeave(c) if c.user != nil { - slog.Info("websocket disconnected", "username", c.user.Username, "user_id", c.userID) + c.mu.Lock() + received := c.msgsReceived + sent := c.msgsSent + dropped := c.msgsDropped + c.mu.Unlock() + duration := time.Since(c.connectedAt) + + attrs := []any{ + "username", c.user.Username, + "user_id", c.userID, + "remote", c.remoteAddr, + "duration_s", int64(duration.Seconds()), + "msgs_received", received, + "msgs_sent", sent, + "msgs_dropped", dropped, + } + if voiceChID > 0 { + attrs = append(attrs, "voice_channel_id", voiceChID) + } + if lastReadErr != nil { + attrs = append(attrs, "last_error", lastReadErr.Error()) + } + slog.Info("websocket disconnected", attrs...) + _ = hub.db.UpdateUserStatus(c.userID, "offline") hub.BroadcastToAll(buildPresenceMsg(c.userID, "offline")) } @@ -168,6 +194,7 @@ func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) { for { _, msg, err := conn.Read(ctx) if err != nil { + lastReadErr = err return } c.touch() diff --git a/Server/ws/voice_controls.go b/Server/ws/voice_controls.go index 8336f8fa..3fc5d217 100644 --- a/Server/ws/voice_controls.go +++ b/Server/ws/voice_controls.go @@ -37,7 +37,7 @@ func (h *Hub) handleVoiceMute(c *Client, payload json.RawMessage) { c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update mute state")) return } - slog.Debug("voice mute changed", "user_id", c.userID, "muted", p.Muted) + slog.Debug("voice mute changed", "user_id", c.userID, "muted", p.Muted, "channel_id", c.getVoiceChID()) h.broadcastVoiceStateUpdate(c) } @@ -71,7 +71,7 @@ func (h *Hub) handleVoiceDeafen(c *Client, payload json.RawMessage) { c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update deafen state")) return } - slog.Debug("voice deafen changed", "user_id", c.userID, "deafened", p.Deafened) + slog.Debug("voice deafen changed", "user_id", c.userID, "deafened", p.Deafened, "channel_id", c.getVoiceChID()) h.broadcastVoiceStateUpdate(c) } @@ -131,7 +131,7 @@ func (h *Hub) handleVoiceCamera(c *Client, payload json.RawMessage) { c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update camera state")) return } - slog.Debug("voice camera changed", "user_id", c.userID, "enabled", p.Enabled) + slog.Debug("voice camera changed", "user_id", c.userID, "enabled", p.Enabled, "channel_id", voiceChID) h.broadcastVoiceStateUpdate(c) } @@ -172,7 +172,7 @@ func (h *Hub) handleVoiceScreenshare(c *Client, payload json.RawMessage) { c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update screenshare state")) return } - slog.Debug("voice screenshare changed", "user_id", c.userID, "enabled", p.Enabled) + slog.Debug("voice screenshare changed", "user_id", c.userID, "enabled", p.Enabled, "channel_id", voiceChID) h.broadcastVoiceStateUpdate(c) } diff --git a/Server/ws/voice_join.go b/Server/ws/voice_join.go index b4519e8f..4f763e5a 100644 --- a/Server/ws/voice_join.go +++ b/Server/ws/voice_join.go @@ -165,7 +165,20 @@ func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) { bitrate := qualityBitrate(quality) c.sendMsg(buildVoiceConfig(channelID, quality, bitrate, maxUsers)) - slog.Info("voice join", "user_id", c.userID, "channel_id", channelID) + lkURL := "" + if h.livekit != nil { + lkURL = h.livekit.URL() + } + slog.Info("voice join", + "user_id", c.userID, + "username", c.user.Username, + "channel_id", channelID, + "remote", c.remoteAddr, + "livekit_url", lkURL, + "quality", quality, + "channel_users", len(existing), + "channel_max", maxUsers, + ) } // handleVoiceTokenRefresh generates a fresh LiveKit token for a client diff --git a/Server/ws/voice_leave.go b/Server/ws/voice_leave.go index 40bd06a3..8e9c6e74 100644 --- a/Server/ws/voice_leave.go +++ b/Server/ws/voice_leave.go @@ -13,7 +13,16 @@ func (h *Hub) handleVoiceLeave(c *Client) { return } - slog.Info("voice leave", "user_id", c.userID, "channel_id", oldChID) + username := "" + if c.user != nil { + username = c.user.Username + } + slog.Info("voice leave", + "user_id", c.userID, + "username", username, + "channel_id", oldChID, + "remote", c.remoteAddr, + ) if leaveErr := h.db.LeaveVoiceChannel(c.userID); leaveErr != nil { slog.Error("ws handleVoiceLeave LeaveVoiceChannel — ghost session may remain in DB",