diff --git a/Client/tauri-client/src-tauri/Cargo.lock b/Client/tauri-client/src-tauri/Cargo.lock index f30bd028..c6882c97 100644 --- a/Client/tauri-client/src-tauri/Cargo.lock +++ b/Client/tauri-client/src-tauri/Cargo.lock @@ -32,6 +32,23 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -1256,27 +1273,14 @@ dependencies = [ [[package]] name = "env_filter" -version = "2.0.0" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" dependencies = [ "log", "regex", ] -[[package]] -name = "env_logger" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1340,6 +1344,15 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + [[package]] name = "field-offset" version = "0.3.6" @@ -2350,30 +2363,6 @@ 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" @@ -2802,6 +2791,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc2" version = "0.6.4" @@ -3013,7 +3011,6 @@ name = "owncord-client" version = "1.1.0-alpha.2" dependencies = [ "device_query", - "env_logger", "futures-util", "keyring", "log", @@ -3029,6 +3026,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-http", + "tauri-plugin-log", "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-process", @@ -3431,15 +3429,6 @@ 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" @@ -5073,6 +5062,27 @@ dependencies = [ "urlpattern", ] +[[package]] +name = "tauri-plugin-log" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c" +dependencies = [ + "android_logger", + "fern", + "log", + "objc2", + "objc2-foundation", + "serde", + "serde_json", + "serde_repr", + "swift-rs", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", +] + [[package]] name = "tauri-plugin-notification" version = "2.3.3" @@ -5442,7 +5452,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", diff --git a/Client/tauri-client/src-tauri/Cargo.toml b/Client/tauri-client/src-tauri/Cargo.toml index 7aae1b0a..7a2640d7 100644 --- a/Client/tauri-client/src-tauri/Cargo.toml +++ b/Client/tauri-client/src-tauri/Cargo.toml @@ -48,7 +48,10 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" webpki-roots = "1" ring = "0.17" log = "0.4" -env_logger = "0.11" +# Writes Rust logs to a rotating file under the OS app-log dir (alongside the +# TS client-logs) so a shipped user can retrieve them — a release build detaches +# the console, so stdout/stderr logging is otherwise unreachable. +tauri-plugin-log = "2" keyring = "3" rfd = { version = "0.16", default-features = false } diff --git a/Client/tauri-client/src-tauri/src/commands.rs b/Client/tauri-client/src-tauri/src/commands.rs index e734a34b..5ae07de2 100644 --- a/Client/tauri-client/src-tauri/src/commands.rs +++ b/Client/tauri-client/src-tauri/src/commands.rs @@ -47,6 +47,13 @@ pub fn get_settings(app: tauri::AppHandle) -> Result { Ok(Value::Object(map)) } +/// Log a command failure Rust-side — so it lands in the retrievable log file — +/// and return the same message unchanged to the JS caller. +fn log_cmd_err(cmd: &str, msg: String) -> String { + log::warn!("{cmd}: {msg}"); + msg +} + #[tauri::command] pub fn save_settings(app: tauri::AppHandle, key: String, value: Value) -> Result<(), String> { if !is_settings_key_allowed(&key) { @@ -55,12 +62,12 @@ pub fn save_settings(app: tauri::AppHandle, key: String, value: Value) -> Result let store = app .store(SETTINGS_STORE) - .map_err(|e| format!("failed to open settings store: {e}"))?; + .map_err(|e| log_cmd_err("save_settings", format!("failed to open settings store: {e}")))?; store.set(&key, value); store .save() - .map_err(|e| format!("failed to persist settings: {e}"))?; + .map_err(|e| log_cmd_err("save_settings", format!("failed to persist settings: {e}")))?; Ok(()) } @@ -102,9 +109,9 @@ pub fn store_cert_fingerprint( } } - let store = app - .store(CERTS_STORE) - .map_err(|e| format!("failed to open certs store: {e}"))?; + let store = app.store(CERTS_STORE).map_err(|e| { + log_cmd_err("store_cert_fingerprint", format!("failed to open certs store: {e}")) + })?; // Capture old value before mutating so we can restore it if save fails. let old_value = store.get(&host); @@ -117,7 +124,10 @@ pub fn store_cert_fingerprint( Some(v) => { store.set(&host, v); } None => { let _ = store.delete(&host); } } - return Err(format!("failed to persist cert fingerprint: {e}")); + return Err(log_cmd_err( + "store_cert_fingerprint", + format!("failed to persist cert fingerprint: {e}"), + )); } Ok(()) } diff --git a/Client/tauri-client/src-tauri/src/http_proxy.rs b/Client/tauri-client/src-tauri/src/http_proxy.rs index e9a0b22d..82d6bce2 100644 --- a/Client/tauri-client/src-tauri/src/http_proxy.rs +++ b/Client/tauri-client/src-tauri/src/http_proxy.rs @@ -107,12 +107,21 @@ pub async fn start_http_proxy( .port(); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - tokio::spawn(run_proxy_loop( + let loop_handle = tokio::spawn(run_proxy_loop( app.clone(), listener, remote_host.clone(), shutdown_rx, )); + // Watch the loop so a panic is logged instead of vanishing silently (which + // would leave JS with a stale cached port and no error). + tokio::spawn(async move { + match loop_handle.await { + Ok(()) => info!("[http_proxy] proxy loop exited"), + Err(e) if e.is_panic() => error!("[http_proxy] proxy loop panicked: {e:?}"), + Err(e) => warn!("[http_proxy] proxy loop join error: {e:?}"), + } + }); info!( "[http_proxy] tunnel started on 127.0.0.1:{} → {}", diff --git a/Client/tauri-client/src-tauri/src/lib.rs b/Client/tauri-client/src-tauri/src/lib.rs index 27c142bf..4b14958d 100644 --- a/Client/tauri-client/src-tauri/src/lib.rs +++ b/Client/tauri-client/src-tauri/src/lib.rs @@ -9,6 +9,26 @@ mod tray; mod update_commands; mod ws_proxy; +/// Map the RUST_LOG env var to a global level filter for the log plugin. +/// ponytail: only the simple global form is honoured ("debug", "info", …); +/// per-module directives like "ws_proxy=debug" fall back to Info. Add a real +/// parser only if per-module control is actually needed. +fn log_level_from_env() -> log::LevelFilter { + match std::env::var("RUST_LOG") + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str() + { + "trace" => log::LevelFilter::Trace, + "debug" => log::LevelFilter::Debug, + "warn" => log::LevelFilter::Warn, + "error" => log::LevelFilter::Error, + "off" => log::LevelFilter::Off, + _ => log::LevelFilter::Info, + } +} + // Only used by the desktop-only single-instance closure below. #[cfg(desktop)] use tauri::Manager; @@ -31,6 +51,24 @@ pub fn run() { })); let builder = builder + // Log plugin registered early so logging is available to everything + // after it. Writes to stdout (dev) and a rotating file in the OS + // app-log dir so a shipped user — whose release build has no console — + // can retrieve logs. + .plugin( + tauri_plugin_log::Builder::new() + .target(tauri_plugin_log::Target::new( + tauri_plugin_log::TargetKind::Stdout, + )) + .target(tauri_plugin_log::Target::new( + tauri_plugin_log::TargetKind::LogDir { + file_name: Some("owncord-client".into()), + }, + )) + .level(log_level_from_env()) + .max_file_size(10_000_000) // 10 MB rolling file (default 40 KB is too small) + .build(), + ) .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_http::init()) @@ -88,14 +126,7 @@ 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(); - + // Rust logging is initialized by tauri_plugin_log (registered above). 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 dde9f4d8..e5f03f3d 100644 --- a/Client/tauri-client/src-tauri/src/livekit_proxy.rs +++ b/Client/tauri-client/src-tauri/src/livekit_proxy.rs @@ -134,7 +134,15 @@ pub async fn start_livekit_proxy( let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); let host = remote_host.clone(); - tokio::spawn(run_proxy_loop(listener, host, fingerprint, shutdown_rx)); + let loop_handle = tokio::spawn(run_proxy_loop(listener, host, fingerprint, shutdown_rx)); + // Watch the loop so a panic is logged instead of vanishing silently. + tokio::spawn(async move { + match loop_handle.await { + Ok(()) => info!("[livekit_proxy] proxy loop exited"), + Err(e) if e.is_panic() => error!("[livekit_proxy] proxy loop panicked: {e:?}"), + Err(e) => warn!("[livekit_proxy] proxy loop join error: {e:?}"), + } + }); info!("[livekit_proxy] proxy started on 127.0.0.1:{} → {}", port, remote_host); diff --git a/Client/tauri-client/src-tauri/src/ws_proxy.rs b/Client/tauri-client/src-tauri/src/ws_proxy.rs index 963e1523..a66bf84e 100644 --- a/Client/tauri-client/src-tauri/src/ws_proxy.rs +++ b/Client/tauri-client/src-tauri/src/ws_proxy.rs @@ -307,13 +307,17 @@ pub fn accept_cert_fingerprint( return Err("fingerprint must be SHA-256 colon-hex format (e.g. aa:bb:cc:...)".into()); } - let store = app - .store(CERTS_STORE) - .map_err(|e| format!("failed to open certs store: {e}"))?; + let store = app.store(CERTS_STORE).map_err(|e| { + log::warn!("[ws_proxy] accept_cert_fingerprint: failed to open certs store: {e}"); + format!("failed to open certs store: {e}") + })?; // Capture old value before mutating so we can restore it if save fails. let old_value = store.get(&host); - store.set(&host, Value::String(fingerprint)); + // A pin that replaces a *different* existing fingerprint is security- + // significant (cert rotation — or a MITM the user just accepted). + let changed = matches!(&old_value, Some(Value::String(s)) if *s != fingerprint); + store.set(&host, Value::String(fingerprint.clone())); if let Err(e) = store.save() { // Restore previous in-memory state: put back old fingerprint if one // existed, or delete if there was none. Without this, the new @@ -323,7 +327,14 @@ pub fn accept_cert_fingerprint( Some(v) => { store.set(&host, v); } None => { let _ = store.delete(&host); } } + log::warn!("[ws_proxy] accept_cert_fingerprint: failed to persist pin for {host}: {e}"); return Err(format!("failed to persist cert fingerprint: {e}")); } + // Fingerprints are public cert hashes — safe to log; this is the TOFU audit trail. + if changed { + log::warn!("[ws_proxy] cert pin CHANGED for {host} -> {fingerprint}"); + } else { + log::info!("[ws_proxy] cert pin accepted for {host} -> {fingerprint}"); + } Ok(()) } diff --git a/Client/tauri-client/src/lib/api.ts b/Client/tauri-client/src/lib/api.ts index 1e74f416..74547982 100644 --- a/Client/tauri-client/src/lib/api.ts +++ b/Client/tauri-client/src/lib/api.ts @@ -129,6 +129,9 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: status: res.status, code: err.error, message: err.message, + // Server echoes its request ID in this header — logging it lets a + // client-side failure be matched to the server's log line for it. + reqId: res.headers.get("x-request-id") ?? undefined, }); throw new ApiClientError(res.status, err.error, err.message); } diff --git a/Client/tauri-client/src/lib/logPersistence.ts b/Client/tauri-client/src/lib/logPersistence.ts index 54508e11..23824c0a 100644 --- a/Client/tauri-client/src/lib/logPersistence.ts +++ b/Client/tauri-client/src/lib/logPersistence.ts @@ -6,7 +6,7 @@ import { appLogDir, join } from "@tauri-apps/api/path"; import { mkdir, writeTextFile, readDir, remove, exists } from "@tauri-apps/plugin-fs"; -import { type LogEntry, addLogListener, createLogger } from "./logger"; +import { type LogEntry, addLogListener, createLogger, getLogBuffer } from "./logger"; const log = createLogger("logPersistence"); const MAX_LOG_FILES = 5; @@ -131,7 +131,18 @@ export async function initLogPersistence(): Promise<() => void> { currentDate = today(); initialized = true; + // Persist entries logged before this listener attached (the bootstrap + // window) so a startup-time problem lands on disk, not just in the + // in-memory ring. Runs synchronously right before addLogListener, so there + // is no gap and no double-capture. + for (const entry of getLogBuffer()) { + buffer.push(JSON.stringify(entry)); + } + const removeListener = addLogListener(onLogEntry); + if (buffer.length > 0) { + scheduleFlush(); + } return () => { removeListener(); // stop receiving new entries first diff --git a/Client/tauri-client/src/lib/ws.ts b/Client/tauri-client/src/lib/ws.ts index 35cd4396..7085b7cb 100644 --- a/Client/tauri-client/src/lib/ws.ts +++ b/Client/tauri-client/src/lib/ws.ts @@ -220,7 +220,9 @@ export function createWsClient() { try { parsed = JSON.parse(raw) as { type?: string; payload?: unknown; id?: string; seq?: number }; } catch { - log.warn("Failed to parse WS message", { data: raw }); + // Log the size only — `raw` is the decrypted frame (chat plaintext, + // usernames) and this line is persisted to the on-disk log. + log.warn("Failed to parse WS message", { bytes: raw.length }); return; } @@ -489,8 +491,11 @@ export function createWsClient() { if (msg.includes("channel full")) { // Outbound channel is saturated — surface the drop to listeners so an // optimistic row fails with retry instead of silently losing the send. + // Log id + size only — a slice of `json` can contain the auth + // envelope's bearer token, and this line is persisted to disk. log.warn("ws_send: outbound channel full, message dropped (backpressure)", { - messagePreview: json.slice(0, 120), + id, + bytes: json.length, }); notifySendFailure(id, "NETWORK"); } else { diff --git a/Client/tauri-client/src/main.ts b/Client/tauri-client/src/main.ts index 72ead6ad..a14d8550 100644 --- a/Client/tauri-client/src/main.ts +++ b/Client/tauri-client/src/main.ts @@ -226,7 +226,10 @@ function runHealthChecks( version: health.version ?? null, onlineUsers: health.online_users ?? null, }); - } catch { + } catch (err) { + // Record why the check failed (TLS/cert-pin/network) — otherwise a + // "can't connect" report has no logged cause to diagnose. + log.warn("health check failed", { host: profile.host, error: String(err) }); connectPage.updateHealthStatus(profile.host, { status: "offline", latencyMs: null, diff --git a/Client/tauri-client/tests/unit/log-persistence.test.ts b/Client/tauri-client/tests/unit/log-persistence.test.ts index db1f752c..9793785d 100644 --- a/Client/tauri-client/tests/unit/log-persistence.test.ts +++ b/Client/tauri-client/tests/unit/log-persistence.test.ts @@ -13,6 +13,7 @@ const { mockRemove, mockReadTextFile, mockAddLogListener, + mockGetLogBuffer, } = vi.hoisted(() => ({ mockAppLogDir: vi.fn().mockResolvedValue("/mock/logs"), mockJoin: vi.fn((...parts: string[]) => parts.join("/")), @@ -23,6 +24,7 @@ const { mockRemove: vi.fn().mockResolvedValue(undefined), mockReadTextFile: vi.fn().mockResolvedValue(""), mockAddLogListener: vi.fn(), + mockGetLogBuffer: vi.fn(() => [] as unknown[]), })); vi.mock("@tauri-apps/api/path", () => ({ @@ -41,6 +43,7 @@ vi.mock("@tauri-apps/plugin-fs", () => ({ vi.mock("@lib/logger", () => ({ addLogListener: mockAddLogListener, + getLogBuffer: mockGetLogBuffer, createLogger: () => ({ debug: vi.fn(), info: vi.fn(), @@ -102,6 +105,7 @@ describe("log persistence", () => { mockRemove.mockReset().mockResolvedValue(undefined); mockReadTextFile.mockReset().mockResolvedValue(""); mockAddLogListener.mockReset(); + mockGetLogBuffer.mockReset().mockReturnValue([]); }); afterEach(() => { @@ -148,6 +152,20 @@ describe("log persistence", () => { expect(typeof mockAddLogListener.mock.calls[0]![0]).toBe("function"); }); + it("persists entries buffered before init (bootstrap drain)", async () => { + mockGetLogBuffer.mockReturnValue([makeEntry({ message: "bootstrap-line" })]); + const { initLogPersistence } = await freshImport(); + captureListener(); + await initLogPersistence(); + + // The drain scheduled a flush; advance past the 2000ms debounce. + await vi.advanceTimersByTimeAsync(2500); + + expect(mockWriteTextFile).toHaveBeenCalled(); + const written = mockWriteTextFile.mock.calls.map((c) => String(c[1])).join(""); + expect(written).toContain("bootstrap-line"); + }); + it("returns a no-op cleanup if already initialized", async () => { const { initLogPersistence } = await freshImport(); captureListener(); diff --git a/Client/tauri-client/tests/unit/ws.test.ts b/Client/tauri-client/tests/unit/ws.test.ts index c3a731f6..4930f594 100644 --- a/Client/tauri-client/tests/unit/ws.test.ts +++ b/Client/tauri-client/tests/unit/ws.test.ts @@ -42,6 +42,7 @@ vi.spyOn(console, "error").mockImplementation(() => {}); // Import after mocks are set up import { createWsClient, toConnectionStatus } from "../../src/lib/ws"; +import { addLogListener, type LogEntry } from "../../src/lib/logger"; /** Simulate Tauri emitting an event to JS */ function emitTauriEvent(event: string, payload: unknown): void { @@ -291,6 +292,23 @@ describe("WebSocket Client (Tauri proxy)", () => { expect(messages).toHaveLength(0); }); + it("does not log raw frame content on parse failure (no plaintext leak)", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const entries: LogEntry[] = []; + const remove = addLogListener((e) => entries.push(e)); + const secret = "SUPER_SECRET_eyJhbGciOiJIUzI1NiJ9"; + emitTauriEvent("ws-message", secret + " not-json{{{"); + remove(); + + // The decrypted frame must never reach the (on-disk-persisted) log... + expect(JSON.stringify(entries)).not.toContain(secret); + // ...but the parse failure is still recorded so it stays debuggable. + expect(entries.some((e) => e.message.includes("Failed to parse WS message"))).toBe(true); + }); + it("disconnect prevents reconnect", async () => { client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10);