Merge pull request #83 from J3vb/feature/observability-debugging

feat: observability, debugging, and diagnostics
This commit is contained in:
J3vb
2026-03-28 21:06:11 +01:00
committed by GitHub
20 changed files with 900 additions and 29 deletions
+138
View File
@@ -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"
+2
View File
@@ -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"] }
@@ -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/**"
}
]
}
]
}
+8
View File
@@ -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(())
})
@@ -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<R: Runtime>(
) -> Result<u16, String> {
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<R: Runtime>(
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(())
}
+35 -5
View File
@@ -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<R: Runtime>(
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<R: Runtime>(
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<R: Runtime>(
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<R: Runtime>(
);
}
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<R: Runtime>(
}
// ── 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<R: Runtime>(
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<R: Runtime>(
// 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");
});
@@ -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<void> {
try {
// Show native save dialog with suggested filename
@@ -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<void> {
// Clear IndexedDB
await new Promise<void>((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<void> {
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.
}
}
@@ -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<string, unknown> | null {
if (this.room === null) return null;
try {
const engine = (this.room as any).engine;
if (!engine) return null;
const result: Record<string, unknown> = {};
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 ---
@@ -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<typeof setTimeout> | 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<void> {
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<void> {
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<void> {
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<string> {
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 "";
}
}
+29 -4
View File
@@ -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();
+6
View File
@@ -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");
+89
View File
@@ -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
}
+10
View File
@@ -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":
+15 -4
View File
@@ -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
}
}
+6
View File
@@ -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)
+28 -1
View File
@@ -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()
+4 -4
View File
@@ -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)
}
+14 -1
View File
@@ -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
+10 -1
View File
@@ -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",