From 7b795831774bd3c74e3ae61881d437e1680dbd5f Mon Sep 17 00:00:00 2001 From: blocknodes Date: Wed, 5 Aug 2026 05:05:22 +0000 Subject: [PATCH] feat(diagnostics): in-app log viewer in Wallet settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing was subscribing to the app's `tracing` output, so diagnostics (including the new sync timing logs) went nowhere. Add a global subscriber that formats every event to stdout AND an in-memory ring buffer (logbuf.rs, bounded at 3000 lines, honouring RUST_LOG; default info + our crates at debug). The buffer is process-only and never written to disk — wallet logs can contain addresses/amounts. New commands get_logs / clear_logs expose the buffer. Wallet settings gains a "Diagnostics log" card: live auto-refresh (2s), copy-all, refresh, clear, and a scroll-pinned monospace view — so a user can grab and share logs (e.g. the `pipelined scan: … blocks/s` lines) without a terminal. logbuf unit tests cover ring-buffer capping/order and line splitting. Backend builds, tsc clean, no new clippy warnings. Co-Authored-By: Claude Opus 4.8 --- src-tauri/Cargo.lock | 113 +++++++++++++++++---- src-tauri/Cargo.toml | 2 + src-tauri/src/commands/server.rs | 14 +++ src-tauri/src/lib.rs | 7 ++ src-tauri/src/logbuf.rs | 168 +++++++++++++++++++++++++++++++ src/ipc/commands.ts | 4 + src/screens/Wallet.tsx | 100 +++++++++++++++++- 7 files changed, 387 insertions(+), 21 deletions(-) create mode 100644 src-tauri/src/logbuf.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0ddd137..06d7108 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -60,9 +60,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -1044,9 +1044,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "dbus" @@ -1619,6 +1619,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "tracing-subscriber", "uuid", "zeroize", ] @@ -2261,9 +2262,9 @@ dependencies = [ [[package]] name = "halo2_proofs" -version = "0.3.4" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f63a999d9223fa9d3b9db3031fedc316e6039a0ae0f67394408b16ef670c69f" +checksum = "f5aca1c66059a919227dec97444a11a4350d2f9c820ca48690988f0aa0e81cbf" dependencies = [ "blake2b_simd", "ff", @@ -2716,9 +2717,9 @@ checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is-docker" @@ -2951,9 +2952,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" dependencies = [ "libc", ] @@ -3020,6 +3021,15 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -3229,6 +3239,15 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "549e471b99ccaf2f89101bec68f4d244457d5a95a9c3d0672e9564124397741d" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -3565,9 +3584,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orchard" -version = "0.15.4" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793e2e8c2323f35f082d1b3467ca8f576d646f9c93aef8c5168809d099245af8" +checksum = "a3cb2b35534bba3c63fbf640dc6cd9dfd1ece2fae886cdab4ab1f1e380dd6ca1" dependencies = [ "aes", "bitvec", @@ -3694,9 +3713,9 @@ dependencies = [ [[package]] name = "pczt" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7d4693dcb3d72f30064e0fdab122292d803bd9325a95b25df08679cf895452f" +checksum = "aead0b7ecb4363d8560ac76687c02ef896292fb836c1c68f3a4d99443f32e1a0" dependencies = [ "blake2b_simd", "bls12_381", @@ -4391,9 +4410,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -5075,6 +5094,15 @@ dependencies = [ "digest 0.11.0-pre.9", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shardtree" version = "0.7.1" @@ -5815,11 +5843,20 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" -version = "0.3.54" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "num-conv", @@ -6218,6 +6255,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -6425,6 +6492,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -7614,9 +7687,9 @@ dependencies = [ [[package]] name = "zcash_protocol" -version = "0.10.3" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f074493fff337207e28bcfa5bbdf0e2a125c4203a4bdd07e72067eea81e9e7b" +checksum = "043686451284bcb72e40ffa18e2cdcea3108e8468e3d021062c0b32c4a060c98" dependencies = [ "corez", "document-features", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ec56688..0607dd8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,6 +32,7 @@ tokio-util = { workspace = true } postcard = { workspace = true } dirs = { workspace = true } tracing = { workspace = true } +tracing-subscriber = { workspace = true } [dev-dependencies] tauri = { version = "2", features = ["test"] } @@ -66,5 +67,6 @@ postcard = "1.1" bech32 = "0.11" dirs = "5" tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } sha2 = "0.10" base64 = "0.22" diff --git a/src-tauri/src/commands/server.rs b/src-tauri/src/commands/server.rs index e7fdb33..17a7709 100644 --- a/src-tauri/src/commands/server.rs +++ b/src-tauri/src/commands/server.rs @@ -236,6 +236,20 @@ pub async fn tunnel_status(state: State<'_, AppState>) -> AppResult AppResult> { + Ok(crate::logbuf::global().snapshot()) +} + +/// Discard the buffered log lines. +#[tauri::command] +pub async fn clear_logs() -> AppResult<()> { + crate::logbuf::global().clear(); + Ok(()) +} + #[tauri::command] pub async fn sidecar_status(state: State<'_, AppState>) -> AppResult { sidecar::status(&state).await diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c053c44..f84ddbb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ pub mod commands; pub mod error; +pub mod logbuf; pub mod sidecar; pub mod state; pub mod tunnel; @@ -44,6 +45,10 @@ async fn run_auto_lock_monitor(app: tauri::AppHandle) { } pub fn run() { + // Start capturing `tracing` output (to stdout + the in-app log buffer) before + // anything else runs, so early diagnostics are recorded too. + logbuf::init_logging(); + tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .manage(AppState::new()) @@ -106,6 +111,8 @@ pub fn run() { commands::server::start_tunnel, commands::server::stop_tunnel, commands::server::tunnel_status, + commands::server::get_logs, + commands::server::clear_logs, commands::dkg::start_dkg, commands::dkg::cancel_ceremony, commands::signing::create_signing_session, diff --git a/src-tauri/src/logbuf.rs b/src-tauri/src/logbuf.rs new file mode 100644 index 0000000..d2bddaa --- /dev/null +++ b/src-tauri/src/logbuf.rs @@ -0,0 +1,168 @@ +//! In-app log capture. +//! +//! The app emits diagnostics via `tracing`, but nothing was subscribing to them, +//! so they went nowhere. This installs a global subscriber that formats every +//! event to **stdout** (for terminal runs) and into an in-memory **ring buffer** +//! that the UI can read back — so a user can copy the logs (e.g. sync timing) +//! straight from the app without a terminal. +//! +//! The buffer is bounded ([`MAX_LINES`]) and lives only for the process: it is +//! not persisted to disk (logs can contain addresses/amounts, and a wallet +//! should not silently write those to a log file). Restarting the app clears it. + +use std::collections::VecDeque; +use std::io; +use std::sync::{Arc, Mutex, OnceLock}; + +use tracing_subscriber::fmt::writer::MakeWriter; + +/// Maximum number of log lines retained in memory. Older lines are dropped as +/// new ones arrive, so a long-running session can't grow memory without bound. +const MAX_LINES: usize = 3000; + +/// A bounded, shareable ring buffer of formatted log lines. +#[derive(Clone)] +pub struct LogBuffer { + lines: Arc>>, +} + +impl LogBuffer { + fn new() -> Self { + Self { + lines: Arc::new(Mutex::new(VecDeque::with_capacity(MAX_LINES))), + } + } + + /// A copy of the currently buffered lines, oldest first. + pub fn snapshot(&self) -> Vec { + self.lines + .lock() + .map(|l| l.iter().cloned().collect()) + .unwrap_or_default() + } + + /// Drop all buffered lines. + pub fn clear(&self) { + if let Ok(mut l) = self.lines.lock() { + l.clear(); + } + } + + fn push_line(&self, line: String) { + if let Ok(mut l) = self.lines.lock() { + while l.len() >= MAX_LINES { + l.pop_front(); + } + l.push_back(line); + } + } +} + +/// The process-wide log buffer, shared by the subscriber (writer) and the +/// `get_logs` command (reader). +static LOG_BUFFER: OnceLock = OnceLock::new(); + +/// Access the global log buffer, creating it on first use. +pub fn global() -> &'static LogBuffer { + LOG_BUFFER.get_or_init(LogBuffer::new) +} + +/// A short-lived writer for one `tracing` event. `fmt` formats the whole event +/// (a single line ending in `\n`) into this, then drops it; on drop we split the +/// accumulated bytes into lines and append them to the buffer. +pub struct LineWriter { + buf: LogBuffer, + pending: Vec, +} + +impl io::Write for LineWriter { + fn write(&mut self, data: &[u8]) -> io::Result { + self.pending.extend_from_slice(data); + Ok(data.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl Drop for LineWriter { + fn drop(&mut self) { + if self.pending.is_empty() { + return; + } + let text = String::from_utf8_lossy(&self.pending); + for line in text.split('\n') { + let line = line.trim_end_matches('\r'); + if !line.is_empty() { + self.buf.push_line(line.to_string()); + } + } + } +} + +impl<'a> MakeWriter<'a> for LogBuffer { + type Writer = LineWriter; + fn make_writer(&'a self) -> Self::Writer { + LineWriter { + buf: self.clone(), + pending: Vec::new(), + } + } +} + +/// Install the global `tracing` subscriber. Idempotent: a second call (e.g. in +/// tests) is a no-op rather than a panic. Formats to stdout and the in-app ring +/// buffer, honouring `RUST_LOG` when set; otherwise our crates log at `debug` +/// (so sync timing is captured) and dependencies stay at `info`. +pub fn init_logging() { + use tracing_subscriber::fmt::writer::MakeWriterExt; + use tracing_subscriber::EnvFilter; + + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info,frost_app=debug,frost_app_core=debug")); + + // Tee: the same formatted event goes to stdout and the in-app buffer. + let writer = std::io::stdout.and(global().clone()); + + let _ = tracing_subscriber::fmt() + .with_ansi(false) + .with_target(false) + .with_env_filter(filter) + .with_writer(writer) + .try_init(); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn ring_buffer_caps_and_orders() { + let buf = LogBuffer::new(); + for i in 0..(MAX_LINES + 50) { + buf.push_line(format!("line {i}")); + } + let snap = buf.snapshot(); + assert_eq!(snap.len(), MAX_LINES, "buffer is capped"); + assert_eq!(snap.first().unwrap(), "line 50", "oldest dropped first"); + assert_eq!(snap.last().unwrap(), &format!("line {}", MAX_LINES + 49)); + buf.clear(); + assert!(buf.snapshot().is_empty()); + } + + #[test] + fn line_writer_splits_events_into_lines() { + let buf = LogBuffer::new(); + { + let mut w = buf.make_writer(); + w.write_all(b"first line\n").unwrap(); + } // dropped here -> flushed + { + let mut w = buf.make_writer(); + w.write_all(b"second\nthird\n").unwrap(); + } + assert_eq!(buf.snapshot(), vec!["first line", "second", "third"]); + } +} diff --git a/src/ipc/commands.ts b/src/ipc/commands.ts index 64f46dd..4efc01c 100644 --- a/src/ipc/commands.ts +++ b/src/ipc/commands.ts @@ -349,6 +349,10 @@ export const exportSidecarCert = () => invoke("export_sidecar_cert"); export const startTunnel = () => invoke("start_tunnel"); export const stopTunnel = () => invoke("stop_tunnel"); export const tunnelStatus = () => invoke("tunnel_status"); +/** The in-app application log (oldest line first), captured from tracing since + * app start. In-memory and bounded; cleared on restart. */ +export const getLogs = () => invoke("get_logs"); +export const clearLogs = () => invoke("clear_logs"); // Ceremonies export type Ciphersuite = "ed25519" | "redpallas"; diff --git a/src/screens/Wallet.tsx b/src/screens/Wallet.tsx index 081b8df..b247d9a 100644 --- a/src/screens/Wallet.tsx +++ b/src/screens/Wallet.tsx @@ -1,9 +1,11 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getWalletConfig, lightwalletdInfo, setWalletConfig, + getLogs, + clearLogs, AppError, LightwalletdInfo, } from "../ipc/commands"; @@ -279,6 +281,8 @@ export default function Wallet() { {testErr &&
{testErr}
} + + {showMainnetModal && ( { @@ -293,3 +297,97 @@ export default function Wallet() { ); } + +/** In-app diagnostics log: shows what the app has logged this session (sync + * timing, errors, ceremony steps) so it can be copied and shared without a + * terminal. In-memory only — cleared when the app restarts. */ +function LogsCard() { + const [live, setLive] = useState(true); + const [copied, setCopied] = useState(false); + const preRef = useRef(null); + const atBottomRef = useRef(true); + + const logs = useQuery({ + queryKey: ["app-logs"], + queryFn: getLogs, + refetchInterval: live ? 2000 : false, + }); + const lines = logs.data ?? []; + + const clear = useMutation({ + mutationFn: clearLogs, + onSuccess: () => logs.refetch(), + }); + + // Keep the view pinned to the newest line while live, unless the user has + // scrolled up to read older output. + useEffect(() => { + const el = preRef.current; + if (el && atBottomRef.current) el.scrollTop = el.scrollHeight; + }, [lines.length]); + + const onScroll = () => { + const el = preRef.current; + if (!el) return; + atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24; + }; + + const copyAll = async () => { + await navigator.clipboard.writeText(lines.join("\n")); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( +
+
+

Diagnostics log

+ + {lines.length} line{lines.length === 1 ? "" : "s"} · this session + +
+

+ What the app has logged while running (sync timing, errors, ceremony + steps). Kept in memory only and cleared on restart — copy it here to share + for troubleshooting. +

+ +
+ + + + +
+ +
+        {lines.length ? lines.join("\n") : "No log output yet."}
+      
+
+ ); +}