mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Merge pull request #83 from J3vb/feature/observability-debugging
feat: observability, debugging, and diagnostics
This commit is contained in:
Generated
+138
@@ -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"
|
||||
|
||||
@@ -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/**"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user