From 20404d0145b0188a3ff2290934f4e8a8a9fe0e0a Mon Sep 17 00:00:00 2001 From: Paul Schulze Date: Sun, 7 Jun 2026 15:09:59 +0200 Subject: [PATCH] Add application log viewer --- Cargo.lock | 374 +++--------------- Cargo.toml | 4 +- THEPLAN.md | 155 ++++++++ .../src-tauri/Cargo.toml | 5 +- .../src-tauri/capabilities/main-logs.json | 11 + .../src-tauri/src/lib.rs | 360 ++++++++++++++++- crates/lanspread-tauri-deno-ts/src/App.tsx | 9 +- .../src/MainLogsWindow.css | 134 +++++++ .../src/MainLogsWindow.tsx | 342 ++++++++++++++++ .../src/windows/MainWindow.tsx | 24 ++ 10 files changed, 1090 insertions(+), 328 deletions(-) create mode 100644 THEPLAN.md create mode 100644 crates/lanspread-tauri-deno-ts/src-tauri/capabilities/main-logs.json create mode 100644 crates/lanspread-tauri-deno-ts/src/MainLogsWindow.css create mode 100644 crates/lanspread-tauri-deno-ts/src/MainLogsWindow.tsx diff --git a/Cargo.lock b/Cargo.lock index 174d370..edf2713 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,17 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -49,23 +38,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[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" @@ -81,12 +53,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - [[package]] name = "atk" version = "0.18.2" @@ -196,18 +162,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "bitvec" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -226,30 +180,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "borsh" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" -dependencies = [ - "borsh-derive", - "bytes", - "cfg_aliases", -] - -[[package]] -name = "borsh-derive" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" -dependencies = [ - "once_cell", - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "brotli" version = "8.0.3" @@ -286,40 +216,6 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "byte-unit" -version = "5.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" -dependencies = [ - "rust_decimal", - "schemars 1.2.1", - "serde", - "utf8-width", -] - -[[package]] -name = "bytecheck" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" -dependencies = [ - "bytecheck_derive", - "ptr_meta", - "simdutf8", -] - -[[package]] -name = "bytecheck_derive" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "bytemuck" version = "1.25.0" @@ -453,12 +349,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chacha20" version = "0.10.0" @@ -949,16 +839,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "env_filter" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" -dependencies = [ - "log", - "regex", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1022,15 +902,6 @@ 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" @@ -1148,12 +1019,6 @@ dependencies = [ "libc", ] -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "futures" version = "0.3.32" @@ -1579,9 +1444,6 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash", -] [[package]] name = "hashbrown" @@ -2233,11 +2095,14 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", - "tauri-plugin-log", "tauri-plugin-shell", "tauri-plugin-store", + "time", "tokio", "tokio-util", + "tracing", + "tracing-log", + "tracing-subscriber", "walkdir", "windows 0.62.2", ] @@ -2368,9 +2233,6 @@ name = "log" version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" -dependencies = [ - "value-bag", -] [[package]] name = "markup5ever" @@ -2528,6 +2390,15 @@ dependencies = [ "bitflags 2.12.1", ] +[[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-conv" version = "0.2.2" @@ -3096,26 +2967,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "ptr_meta" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "quick-xml" version = "0.39.4" @@ -3146,12 +2997,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - [[package]] name = "rand" version = "0.7.3" @@ -3160,22 +3005,11 @@ checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" dependencies = [ "getrandom 0.1.16", "libc", - "rand_chacha 0.2.2", + "rand_chacha", "rand_core 0.5.1", "rand_hc", ] -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.10.1" @@ -3197,16 +3031,6 @@ dependencies = [ "rand_core 0.5.1", ] -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - [[package]] name = "rand_core" version = "0.5.1" @@ -3216,15 +3040,6 @@ dependencies = [ "getrandom 0.1.16", ] -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - [[package]] name = "rand_core" version = "0.10.1" @@ -3315,15 +3130,6 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" -[[package]] -name = "rend" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" -dependencies = [ - "bytecheck", -] - [[package]] name = "reqwest" version = "0.13.4" @@ -3396,52 +3202,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rkyv" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "rust_decimal" -version = "1.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" -dependencies = [ - "arrayvec", - "borsh", - "bytes", - "num-traits", - "rand 0.8.6", - "rkyv", - "serde", - "serde_json", - "wasm-bindgen", -] - [[package]] name = "rustc-hash" version = "2.1.2" @@ -3745,12 +3505,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - [[package]] name = "selectors" version = "0.36.1" @@ -3949,6 +3703,15 @@ dependencies = [ "digest", ] +[[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 = "shared_child" version = "1.1.1" @@ -4003,12 +3766,6 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "siphasher" version = "1.0.3" @@ -4290,7 +4047,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", - "quote", "unicode-ident", ] @@ -4389,12 +4145,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "target-lexicon" version = "0.12.16" @@ -4572,28 +4322,6 @@ dependencies = [ "url", ] -[[package]] -name = "tauri-plugin-log" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" -dependencies = [ - "android_logger", - "byte-unit", - "fern", - "log", - "objc2", - "objc2-foundation", - "serde", - "serde_json", - "serde_repr", - "swift-rs", - "tauri", - "tauri-plugin", - "thiserror 2.0.18", - "time", -] - [[package]] name = "tauri-plugin-shell" version = "2.3.5" @@ -4781,6 +4509,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.47" @@ -5087,6 +4824,32 @@ 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 = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -5231,12 +4994,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "utf8-width" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -5256,10 +5013,10 @@ dependencies = [ ] [[package]] -name = "value-bag" -version = "1.12.0" +name = "valuable" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "vcpkg" @@ -6215,15 +5972,6 @@ dependencies = [ "x11-dl", ] -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "x11" version = "2.21.0" diff --git a/Cargo.toml b/Cargo.toml index 8d5f1f9..f2ab9e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,12 +37,14 @@ sqlx = { strum = { version = "0.28", features = ["derive"] } tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" -tauri-plugin-log = "2" tauri-plugin-shell = "2" tauri-plugin-store = "2" tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7", features = ["codec", "rt"] } tracing = "0.1" +tracing-log = "0.2" +tracing-subscriber = "0.3" +time = { version = "0.3", features = ["local-offset"] } uuid = { version = "1", features = ["v7"] } walkdir = "2" windows = { diff --git a/THEPLAN.md b/THEPLAN.md new file mode 100644 index 0000000..da0fb41 --- /dev/null +++ b/THEPLAN.md @@ -0,0 +1,155 @@ +--- +sessionId: session-260605-123454-ub3w +--- + +# Requirements + +### Overview & Goals +Add a "main log output" viewer so users can inspect the application's runtime logs. Logs must still be written to stdout, must also be captured to a persistent single file in the app data directory, must be bounded to the last ~2 MB, and must be accessible via a dedicated window opened from the main UI. + +### Scope +**In Scope:** +- Capture `log::` and `tracing::` output to a persistent log file in the app data directory. +- Enforce ~2 MB size limit on the stored log (keep the most recent data). +- Expose a Tauri command to retrieve the current log content. +- Add a new companion window (`?view=main-logs`) with a viewer UI modeled on the existing unpack logs feature. +- Support **live tailing** of the logs, displaying new log entries as they are emitted from the backend in real time. +- Support **dynamic log level filtering** in the UI to toggle/filter visible logs by log level (TRACE, DEBUG, INFO, WARN, ERROR). +- Add "Application logs" entry to the main window kebab menu that opens/focuses the logs window. + +**Out of Scope:** +- Configurable log levels or per-module filters in the backend. +- Multi-file rotation or archival of old logs. +- Persisting logs across app reinstalls or explicit export. + +### User Stories +- As a user troubleshooting an issue, I want to open the application logs from the menu so that I can see recent log output without needing external tools. +- As a power user, I want the log file to stay bounded (~2 MB) so that it does not grow unbounded on disk. +- As a developer or user experiencing an application crash, I want the logs to be persistently saved to a file on disk so that I can inspect them externally even if the application UI cannot be opened or crashes. + +### Functional Requirements +- Logger writes to stdout, app-data file target (`lanspread.log`), and a Tauri event stream for real-time UI tailing. +- Log file is kept at ~2 MB; older content is dropped to keep the tail. +- `get_main_logs` command returns up to the last 2 MB of log text. +- Main logs window renders logs in real-time, automatically appending new log lines. +- Auto-scroll to the bottom of the log viewer container when new lines are appended (active by default, can be toggled). +- Pause/Resume control to freeze the log stream so the user can easily scroll, select, and read without being interrupted by new lines. +- Case-insensitive regex filtering of the displayed lines, using the exact same regex input validation, compilation, and error styling (red input border and error label) as the unpack logs window. +- Support **dynamic log level filtering** in the UI via a `SegmentedRadio` filter with options: "All", "Debug+", "Info+", "Warn+", "Error only". +- Support copying all/filtered logs to the clipboard and clearing the active log window's in-memory rows. Clearing the viewer does not delete the persisted log file. +- Window title: "Application Logs"; label: "main-logs". +- Menu item appears in the same kebab menu as "Unpack logs". + +# Technical Design + +### Current Implementation +- Logging setup: `crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs:2223` creates `tauri_plugin_log::Builder` with only `TargetKind::Stdout` + `LevelFilter::Info` (plus mdns off). Plugin registered at 2237. +- State & persistence pattern: `LanSpreadState` holds `unpack_logs: Arc>>`, `state_dir: OnceLock`; `load_unpack_logs` / `persist_unpack_logs` + `trim_unpack_logs` (MAX=20) in the same file; exposed by `get_unpack_logs` (153). +- UI pattern: `src/windows/MainWindow.tsx:23` defines `openLogsWindow` using `WebviewWindow` with label `unpack-logs` and `?view=unpack-logs`; menu item at 110; `src/App.tsx:9` dispatches via `isUnpackLogsView`; full viewer in `src/UnpackLogsWindow.tsx` (filtering, list+detail, regex). +- The repo uses mostly `log::` in the Tauri and peer crates, with some `tracing::` in shared crates. The current Tauri app does not bridge or subscribe to `tracing::` output. + +### Key Decisions +- Store the main log as a single plain-text file at `app.path().app_data_dir()?.join("lanspread.log")`. +- Do not use `TargetKind::LogDir` for this feature. It writes to the OS log directory and uses file replacement rotation, which does not match the requested app-data location or "keep the latest tail" behavior. +- Replace the current `tauri_plugin_log` logger setup with an app-owned logging setup initialized during `.setup` after the app data directory has been resolved. + - Install a `tracing_subscriber` subscriber as the single fan-out path for stdout, persistent file writes, and live UI events. + - Install `tracing_log::LogTracer` so existing `log::` calls are captured by the same subscriber as `tracing::` events. + - If `tauri-plugin-log` is kept registered for future frontend log commands, configure it with `skip_logger()` so it does not compete for the global logger. +- Use one backend formatter for both file and live-event output. Format lines as: + - `[YYYY-MM-DD][HH:MM:SS][target][LEVEL] message` + - This makes historical and live rows comparable and makes client-side level parsing deterministic. +- Implement a shared `MainLogSink` that: + - Appends formatted lines to `lanspread.log`. + - Emits the same formatted line to the frontend via a Tauri event, e.g. `main-log-line`. + - Bounds the file to the latest ~2 MB by rewriting the tail when the file exceeds the configured limit. + - Never logs from inside the logging path; write/emit failures are ignored or recorded without recursive logging. +- Handle the transition from loading history to receiving real-time logs in `MainLogsWindow.tsx` by using a **buffering and deduplication strategy**: + - Register the `main-log-line` listener before requesting history. + - Buffer incoming live rows while the initial `get_main_logs` file history is being requested. + - Once history is loaded, append buffered rows after removing any rows already present in the loaded history by exact line match. + - Transition to direct real-time appending with auto-scrolling to the bottom (if auto-scroll is enabled). +- Implement **dynamic log level filtering client-side** using the existing `SegmentedRadio` component. + - For live-streamed logs, use the structured event payload's level field. + - For historical log files loaded via `get_main_logs`, parse the level from the controlled `[LEVEL]` field, falling back to `Info`. +- Provide UI controls for "Pause / Resume", "Auto-scroll", and "Log Level Filter" to ensure high-quality user experience during busy logging periods. +- Model the new viewer on the unpack-logs style, retaining the identical case-insensitive regex filter ability and regex error reporting UI, but customize it for linear line streaming (sans split-pane list+detail structure, since main logs are a single continuous log flow). + +### Proposed Changes +- **Rust dependencies:** add `tracing-subscriber` and `tracing-log` to the workspace/Tauri crate. +- **Rust (lib.rs):** initialize the custom logging pipeline in `.setup`, add bounded `MainLogSink`, add `get_main_logs`, emit `main-log-line`, and register the command. +- **Tauri capabilities:** add a `main-logs` capability file for the new window label with `core:default`. +- **Frontend (MainLogsWindow.tsx):** implement the scrollable log viewport using `listen('main-log-line')` for live tailing, supporting buffering/deduplication, pause/resume, auto-scroll, regex filtering, copying, and clearing. +- **Frontend (App.tsx & MainWindow.tsx):** dispatch the new companion view and add the kebab menu entry to open the window. +- No changes to peer crate, db, or protocol. + +### File Structure +- Modified: `Cargo.toml`, `Cargo.lock`, `crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml`, `crates/lanspread-tauri-deno-ts/src-tauri/Cargo.lock`, `crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs`, `crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx`, `crates/lanspread-tauri-deno-ts/src/App.tsx` +- Added: `crates/lanspread-tauri-deno-ts/src/MainLogsWindow.tsx` (and `.css` if custom styles needed beyond shared) +- Added: `crates/lanspread-tauri-deno-ts/src-tauri/capabilities/main-logs.json` + +### Risks +- Trimming on every write would be expensive; mitigate by trimming only when the file exceeds the 2 MB limit plus a small slack threshold, then rewriting the latest tail. +- The logging path is synchronous and global; keep it small, lock-protected, and non-recursive. +- Log lines may contain very long messages; UI should handle wrapping/horizontal scrolling with stable dimensions. +- On Windows release builds the console is hidden, so file becomes the only log source — acceptable. +- Installing `LogTracer` plus a `tracing_subscriber` subscriber must happen once. Tests should avoid double-initializing global logging. + +# Delivery Steps + +### Step 1: extend-logger-and-add-get-main-logs-command +Logger writes to stdout, persistent app-data `lanspread.log`, and a live UI event stream. A bounded sink enforces the 2 MB tail. + +- Add workspace/Tauri dependencies for `tracing-subscriber` and `tracing-log`. +- Add constants `MAIN_LOG_FILE_NAME: &str = "lanspread.log"` and `MAX_MAIN_LOG_BYTES: u64 = 2 * 1024 * 1024` near the unpack log consts. +- Implement `main_log_path(state_dir: &Path) -> PathBuf`. +- Implement `trim_main_log_file(path: &Path)` that checks file size and rewrites only the last ~2 MB if exceeded, preserving valid UTF-8 by trimming to a character boundary when needed. +- Implement a `MainLogSink` or equivalent shared target that formats each log record once, appends it to `lanspread.log`, emits `main-log-line`, and trims when the file grows past the limit plus slack. +- Initialize logging during `.setup` after resolving `let state_dir = app.path().app_data_dir()?` and creating the app data directory. + - Install `LogTracer` for `log::` macros. + - Install a `tracing_subscriber` subscriber/layer that writes to stdout and the shared main-log sink. + - Preserve the current global level behavior: `Info` by default and `mdns_sd::service_daemon` off. +- Implement `#[tauri::command] async fn get_main_logs(app_handle: tauri::AppHandle) -> tauri::Result` that resolves `app_handle.path().app_data_dir()`, trims `lanspread.log` if needed, reads the file (or returns empty string), and returns the content. +- Add `get_main_logs` to the `invoke_handler!` macro list. +- Add focused unit tests for tail trimming and level parsing/formatting helpers where practical. + +### Step 2: implement-main-logs-frontend-window-and-dispatch +Implement `MainLogsWindow` with loading + real-time streaming and handle routing. + +- Create `crates/lanspread-tauri-deno-ts/src/MainLogsWindow.tsx`: + - Fetch existing logs via `invoke('get_main_logs')` on load and split into an array of lines. + - Register `listen('main-log-line', ...)` from `@tauri-apps/api/event` before fetching history. + - Buffer incoming logs while loading history, and append/deduplicate them once loaded. + - Store logs as objects containing the log line text and its associated `LogLevel`. + - Extract `LogLevel` for live logs from the event payload. For historical logs, parse `LogLevel` from the controlled `[LEVEL]` field (default to Info). + - Cap in-memory rows/bytes so a long-running logs window does not grow without bound. + - Render the log lines in a scrollable view. + - Implement the identical case-insensitive regex filter ability on lines (compiling regex with error catching, displaying error strings below header, and toggling an invalid input class name). + - Implement dynamic log level filtering in the UI using the shared `SegmentedRadio` component with options: "All", "Debug+", "Info+", "Warn+", "Error only". + - Implement UI controls: "Auto-scroll" checkbox (scrolls viewport to bottom when new logs arrive, active by default), "Pause / Resume" toggle (buffers/pauses incoming stream rendering), "Clear" button, "Copy to clipboard" button. +- Implement `isMainLogsView()` helper that checks the `view=main-logs` query param (export it). +- Update `crates/lanspread-tauri-deno-ts/src/App.tsx` to import `MainLogsWindow, isMainLogsView` and dispatch to it when the query matches (before falling back to MainWindow). +- Create `MainLogsWindow.css` for styling the viewer, search bars, controls, and scrollable log pane. + +### Step 3: add-main-logs-capability +The new companion window can invoke commands and listen for log events. + +- Add `crates/lanspread-tauri-deno-ts/src-tauri/capabilities/main-logs.json`. +- Set `"windows": ["main-logs"]`. +- Grant `"permissions": ["core:default"]`. +- No `log:default` permission is needed because the UI listens to the app-owned `main-log-line` event via Tauri core events. + +### Step 4: add-menu-item-and-window-opener-in-mainwindow +Users can open the main logs viewer from the existing kebab menu. + +- In `crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx`, add an `openMainLogsWindow` async function (copy of `openLogsWindow` but with label `'main-logs'`, title `'Application Logs'`, url `'/?view=main-logs'`). +- Add a new `KebabItem` for `'Application logs'` that calls `openMainLogsWindow`, placed near the existing `'Unpack logs'` entry in the `kebabItems` array. +- Verify that opening the window focuses an existing instance if already open (same pattern as unpack logs). + +### Step 5: verify +Use the repo's just commands. + +- `just fmt` +- `just clippy` +- `just frontend-test` +- `just test` +- Manual smoke test with `just run`: open "Application logs", verify history loads from app-data `lanspread.log`, new backend logs append live, filters work, pause/resume works, copy works, and the file remains bounded near 2 MB. diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml b/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml index 08497d1..0f6da4a 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml +++ b/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml @@ -31,11 +31,14 @@ serde = { workspace = true } serde_json = { workspace = true } tauri = { workspace = true } tauri-plugin-dialog = { workspace = true } -tauri-plugin-log = { workspace = true } tauri-plugin-shell = { workspace = true } tauri-plugin-store = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } +tracing = { workspace = true } +tracing-log = { workspace = true } +tracing-subscriber = { workspace = true } +time = { workspace = true } walkdir = { workspace = true } [build-dependencies] diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/main-logs.json b/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/main-logs.json new file mode 100644 index 0000000..2dc03ee --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/main-logs.json @@ -0,0 +1,11 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "main-logs", + "description": "Capability for the main-logs window", + "windows": [ + "main-logs" + ], + "permissions": [ + "core:default" + ] +} diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs index 4dbec0d..161b9c8 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -1,8 +1,10 @@ use std::{ collections::{HashMap, HashSet}, + fs::{self, OpenOptions}, + io::{Read as _, Seek as _, SeekFrom, Write as _}, net::SocketAddr, path::{Component, Path, PathBuf}, - sync::{Arc, OnceLock}, + sync::{Arc, Mutex, OnceLock}, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -28,6 +30,12 @@ use tokio::sync::{ RwLock, mpsc::{UnboundedReceiver, UnboundedSender}, }; +use tracing::{Event, Level, Metadata, Subscriber, field::Visit}; +use tracing_subscriber::{ + layer::{Context, Layer}, + prelude::*, + registry::LookupSpan, +}; // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ @@ -132,12 +140,21 @@ struct UnpackLogEntry { success: bool, } +#[derive(Clone, Debug, serde::Serialize)] +struct MainLogLinePayload { + line: String, + level: String, +} + struct SidecarUnpacker { app_handle: AppHandle, } const MAX_UNPACK_LOGS: usize = 20; const UNPACK_LOGS_FILE_NAME: &str = "unpack-logs.json"; +const MAIN_LOG_FILE_NAME: &str = "lanspread.log"; +const MAX_MAIN_LOG_BYTES: u64 = 2 * 1024 * 1024; +const MAIN_LOG_TRIM_SLACK_BYTES: u64 = 64 * 1024; impl Unpacker for SidecarUnpacker { fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> { @@ -156,6 +173,20 @@ async fn get_unpack_logs( Ok(state.inner().unpack_logs.read().await.clone()) } +#[tauri::command] +async fn get_main_logs(app_handle: tauri::AppHandle) -> tauri::Result { + let state_dir = app_handle.path().app_data_dir()?; + fs::create_dir_all(&state_dir)?; + let path = main_log_path(&state_dir); + trim_main_log_file(&path)?; + + match fs::read_to_string(&path) { + Ok(contents) => Ok(contents), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(err) => Err(err.into()), + } +} + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] const GAME_SETUP_SCRIPT: &str = "game_setup.cmd"; #[cfg_attr(not(target_os = "windows"), allow(dead_code))] @@ -1349,6 +1380,280 @@ fn unpack_logs_path(state_dir: &Path) -> PathBuf { state_dir.join(UNPACK_LOGS_FILE_NAME) } +fn main_log_path(state_dir: &Path) -> PathBuf { + state_dir.join(MAIN_LOG_FILE_NAME) +} + +fn trim_main_log_file(path: &Path) -> std::io::Result<()> { + trim_main_log_file_to_limit(path, MAX_MAIN_LOG_BYTES) +} + +fn trim_main_log_file_to_limit(path: &Path, max_bytes: u64) -> std::io::Result<()> { + let metadata = match fs::metadata(path) { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err), + }; + + if metadata.len() <= max_bytes { + return Ok(()); + } + + if max_bytes == 0 { + fs::write(path, [])?; + return Ok(()); + } + + let mut file = fs::File::open(path)?; + file.seek(SeekFrom::Start(metadata.len() - max_bytes))?; + + let mut bytes = Vec::with_capacity(usize::try_from(max_bytes).unwrap_or(usize::MAX)); + file.read_to_end(&mut bytes)?; + let tail = valid_utf8_tail(bytes); + fs::write(path, tail.as_bytes()) +} + +fn valid_utf8_tail(bytes: Vec) -> String { + for offset in 0..bytes.len().min(4) { + if let Ok(tail) = std::str::from_utf8(&bytes[offset..]) { + return tail.to_string(); + } + } + + String::from_utf8_lossy(&bytes).into_owned() +} + +#[derive(Clone)] +struct MainLogSink { + app_handle: AppHandle, + path: PathBuf, + file_lock: Arc>, +} + +impl MainLogSink { + fn new(app_handle: AppHandle, path: PathBuf) -> Self { + Self { + app_handle, + path, + file_lock: Arc::new(Mutex::new(())), + } + } + + fn write_line(&self, line: String, level: Level) { + write_main_log_stdout(&line); + self.append_file_line(&line); + + let _ = self.app_handle.emit( + "main-log-line", + MainLogLinePayload { + line, + level: level.as_str().to_string(), + }, + ); + } + + fn append_file_line(&self, line: &str) { + let Ok(_guard) = self.file_lock.lock() else { + return; + }; + + if let Some(parent) = self.path.parent() { + let _ = fs::create_dir_all(parent); + } + + let Ok(mut file) = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + else { + return; + }; + + if writeln!(file, "{line}").is_err() { + return; + } + + let should_trim = file + .metadata() + .is_ok_and(|metadata| { + metadata.len() > MAX_MAIN_LOG_BYTES.saturating_add(MAIN_LOG_TRIM_SLACK_BYTES) + }); + + if should_trim { + let _ = trim_main_log_file(&self.path); + } + } +} + +struct MainLogLayer { + sink: MainLogSink, +} + +impl MainLogLayer { + fn new(sink: MainLogSink) -> Self { + Self { sink } + } +} + +impl Layer for MainLogLayer +where + S: Subscriber + for<'span> LookupSpan<'span>, +{ + fn enabled(&self, metadata: &Metadata<'_>, _ctx: Context<'_, S>) -> bool { + should_capture_main_log_metadata(metadata) + } + + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let metadata = event.metadata(); + if !should_capture_main_log_metadata(metadata) { + return; + } + + let mut visitor = MainLogFieldVisitor::default(); + event.record(&mut visitor); + let target = visitor + .log_target + .clone() + .unwrap_or_else(|| metadata.target().to_string()); + let message = visitor.into_message(); + let (date, time) = current_main_log_timestamp(); + let line = format_main_log_line_parts( + &date, + &time, + &target, + metadata.level().as_str(), + &message, + ); + + self.sink.write_line(line, *metadata.level()); + } +} + +#[derive(Default)] +struct MainLogFieldVisitor { + message: Option, + log_target: Option, + fields: Vec, +} + +impl MainLogFieldVisitor { + fn record_value(&mut self, field_name: &str, value: String) { + match field_name { + "message" => self.message = Some(value), + "log.target" => self.log_target = Some(value), + "log.module_path" | "log.file" | "log.line" => {} + _ => self.fields.push(format!("{field_name}={value}")), + } + } + + fn into_message(self) -> String { + let mut parts = Vec::new(); + if let Some(message) = self.message + && !message.is_empty() + { + parts.push(message); + } + parts.extend(self.fields); + + if parts.is_empty() { + String::from("(no message)") + } else { + normalize_main_log_message(&parts.join(" ")) + } + } +} + +impl Visit for MainLogFieldVisitor { + fn record_bool(&mut self, field: &tracing::field::Field, value: bool) { + self.record_value(field.name(), value.to_string()); + } + + fn record_i64(&mut self, field: &tracing::field::Field, value: i64) { + self.record_value(field.name(), value.to_string()); + } + + fn record_u64(&mut self, field: &tracing::field::Field, value: u64) { + self.record_value(field.name(), value.to_string()); + } + + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + self.record_value(field.name(), value.to_string()); + } + + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + self.record_value(field.name(), format!("{value:?}")); + } +} + +fn should_capture_main_log_metadata(metadata: &Metadata<'_>) -> bool { + if metadata.target().starts_with("mdns_sd::service_daemon") { + return false; + } + + matches!(*metadata.level(), Level::ERROR | Level::WARN | Level::INFO) +} + +fn current_main_log_timestamp() -> (String, String) { + let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc()); + let date = now.date(); + let clock = now.time(); + + ( + format!( + "{:04}-{:02}-{:02}", + date.year(), + u8::from(date.month()), + date.day() + ), + format!( + "{:02}:{:02}:{:02}", + clock.hour(), + clock.minute(), + clock.second() + ), + ) +} + +fn format_main_log_line_parts( + date: &str, + time: &str, + target: &str, + level: &str, + message: &str, +) -> String { + format!( + "[{date}][{time}][{}][{level}] {message}", + normalize_main_log_target(target) + ) +} + +fn normalize_main_log_target(target: &str) -> String { + target.replace(['\r', '\n'], " ") +} + +fn normalize_main_log_message(message: &str) -> String { + message.replace('\r', "\\r").replace('\n', "\\n") +} + +fn write_main_log_stdout(line: &str) { + let stdout = std::io::stdout(); + let mut stdout = stdout.lock(); + let _ = writeln!(stdout, "{line}"); +} + +fn init_main_logging( + app_handle: AppHandle, + path: PathBuf, +) -> Result<(), Box> { + let subscriber = + tracing_subscriber::registry().with(MainLogLayer::new(MainLogSink::new(app_handle, path))); + tracing::subscriber::set_global_default(subscriber)?; + tracing_log::LogTracer::builder() + .with_max_level(log::LevelFilter::Info) + .init()?; + Ok(()) +} + fn load_unpack_logs(state_dir: &Path) -> Vec { let path = unpack_logs_path(state_dir); let contents = match std::fs::read_to_string(&path) { @@ -1918,6 +2223,46 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[test] + fn main_log_line_format_is_stable_and_single_line() { + let line = format_main_log_line_parts( + "2026-06-07", + "12:34:56", + "lanspread\napp", + "WARN", + "first line\nsecond line", + ); + + assert_eq!( + line, + "[2026-06-07][12:34:56][lanspread app][WARN] first line\\nsecond line" + ); + } + + #[test] + fn main_log_trim_keeps_utf8_tail_at_char_boundary() { + let root = std::env::temp_dir().join(format!( + "lanspread-main-log-test-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos() + )); + std::fs::create_dir_all(&root).expect("test state dir should be created"); + let path = main_log_path(&root); + std::fs::write(&path, format!("{}{}", "a".repeat(11), "é".repeat(20))) + .expect("main log should be written"); + + trim_main_log_file_to_limit(&path, 21).expect("main log should trim"); + + let trimmed = std::fs::read_to_string(&path).expect("trimmed log should remain utf-8"); + assert!(trimmed.as_bytes().len() <= 21); + assert!(trimmed.starts_with('é')); + assert!(trimmed.ends_with('é')); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn active_operation_reconciliation_replaces_stale_ui_history() { let mut active_operations = HashMap::from([ @@ -2220,21 +2565,12 @@ mod tests { #[allow(clippy::missing_panics_doc)] #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let tauri_logger_builder = tauri_plugin_log::Builder::new() - .clear_targets() - .target(tauri_plugin_log::Target::new( - tauri_plugin_log::TargetKind::Stdout, - )) - .level(log::LevelFilter::Info) - .level_for("mdns_sd::service_daemon", log::LevelFilter::Off); - // channel to receive events from the peer let (tx_peer_event, rx_peer_event) = tokio::sync::mpsc::unbounded_channel::(); tauri::Builder::default() .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_logger_builder.build()) .plugin(tauri_plugin_shell::init()) .invoke_handler(tauri::generate_handler![ request_games, @@ -2250,13 +2586,15 @@ pub fn run() { open_game_files, get_peer_count, get_game_thumbnail, - get_unpack_logs + get_unpack_logs, + get_main_logs ]) .manage(LanSpreadState::default()) .manage(PeerEventTx(tx_peer_event)) .setup(move |app| { let state_dir = app.path().app_data_dir()?; std::fs::create_dir_all(&state_dir)?; + init_main_logging(app.handle().clone(), main_log_path(&state_dir))?; let state = app.state::(); let unpack_logs = load_unpack_logs(&state_dir); tauri::async_runtime::block_on(async { diff --git a/crates/lanspread-tauri-deno-ts/src/App.tsx b/crates/lanspread-tauri-deno-ts/src/App.tsx index 4871ec5..9b77103 100644 --- a/crates/lanspread-tauri-deno-ts/src/App.tsx +++ b/crates/lanspread-tauri-deno-ts/src/App.tsx @@ -1,11 +1,16 @@ import { MainWindow } from './windows/MainWindow'; +import { MainLogsWindow, isMainLogsView } from './MainLogsWindow'; import { UnpackLogsWindow, isUnpackLogsView } from './UnpackLogsWindow'; /** * Tauri can spawn this bundle in either the main launcher window or the - * unpack-logs companion window. The URL query string disambiguates the two so + * companion log windows. The URL query string disambiguates the views so * a single Vite build serves both. */ -const App = () => (isUnpackLogsView() ? : ); +const App = () => { + if (isMainLogsView()) return ; + if (isUnpackLogsView()) return ; + return ; +}; export default App; diff --git a/crates/lanspread-tauri-deno-ts/src/MainLogsWindow.css b/crates/lanspread-tauri-deno-ts/src/MainLogsWindow.css new file mode 100644 index 0000000..9da2b55 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/MainLogsWindow.css @@ -0,0 +1,134 @@ +.main-log-window { + height: 100vh; + box-sizing: border-box; + padding: 18px; + background: #000313; + color: #D5DBFE; + display: flex; + flex-direction: column; + gap: 12px; +} + +.main-log-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-shrink: 0; +} + +.main-log-header h1 { + margin: 0; + font-size: 22px; +} + +.main-log-controls, +.main-log-filter-row { + display: flex; + align-items: center; + gap: 12px; +} + +.main-log-controls { + flex: 1; + justify-content: flex-end; + min-width: 0; +} + +.main-log-filter-row { + flex-wrap: wrap; + flex-shrink: 0; +} + +.main-log-level-filter { + --accent: #4866b9; +} + +.main-log-toggle { + display: flex; + align-items: center; + gap: 6px; + color: #aeb7df; + font-size: 13px; + cursor: pointer; + user-select: none; + white-space: nowrap; +} + +.main-log-regex { + flex: 1 1 340px; + min-width: 220px; +} + +.main-log-copy-status { + color: #8ee6a6; + font-size: 12px; + min-width: 64px; +} + +.main-log-load-error { + flex-shrink: 0; + color: #ff8a8a; + font-size: 12px; + font-family: Consolas, "Courier New", monospace; +} + +.main-log-stats { + color: #8892b0; + font-size: 12px; + flex-shrink: 0; +} + +.main-log-viewport { + flex: 1; + min-height: 0; + overflow: auto; + padding: 14px; + border: 1px solid #2a3252; + border-radius: 6px; + background: #050813; + font-family: Consolas, "Courier New", monospace; + font-size: 12.5px; + line-height: 1.42; +} + +.main-log-line { + white-space: pre-wrap; + word-break: break-word; + color: #D5DBFE; +} + +.main-log-line.level-trace, +.main-log-line.level-debug { + color: #9aa6c8; +} + +.main-log-line.level-warn { + color: #ffd37a; +} + +.main-log-line.level-error { + color: #ff8a8a; +} + +.main-log-empty { + color: #8892b0; + padding: 8px 4px; +} + +@media (max-width: 720px) { + .main-log-window { + padding: 14px; + } + + .main-log-header { + align-items: flex-start; + flex-direction: column; + } + + .main-log-controls { + justify-content: flex-start; + flex-wrap: wrap; + width: 100%; + } +} diff --git a/crates/lanspread-tauri-deno-ts/src/MainLogsWindow.tsx b/crates/lanspread-tauri-deno-ts/src/MainLogsWindow.tsx new file mode 100644 index 0000000..be217d6 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/MainLogsWindow.tsx @@ -0,0 +1,342 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; + +import { SegmentedRadio } from './components/SegmentedRadio'; + +import './MainLogsWindow.css'; + +export const isMainLogsView = (): boolean => + new URLSearchParams(window.location.search).get('view') === 'main-logs'; + +const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'] as const; +type LogLevel = typeof LOG_LEVELS[number]; +type LevelFilter = 'all' | 'debug' | 'info' | 'warn' | 'error'; + +interface MainLogLinePayload { + line: string; + level: string; +} + +interface MainLogRow { + line: string; + level: LogLevel; +} + +const LEVEL_ORDER: Record = { + TRACE: 0, + DEBUG: 1, + INFO: 2, + WARN: 3, + ERROR: 4, +}; + +const LEVEL_FILTER_MIN: Record = { + all: LEVEL_ORDER.TRACE, + debug: LEVEL_ORDER.DEBUG, + info: LEVEL_ORDER.INFO, + warn: LEVEL_ORDER.WARN, + error: LEVEL_ORDER.ERROR, +}; + +const LEVEL_FILTER_OPTIONS: ReadonlyArray<{ value: LevelFilter; label: string }> = [ + { value: 'all', label: 'All' }, + { value: 'debug', label: 'Debug+' }, + { value: 'info', label: 'Info+' }, + { value: 'warn', label: 'Warn+' }, + { value: 'error', label: 'Error only' }, +]; + +const MAX_IN_MEMORY_LOG_ROWS = 12_000; +const MAX_IN_MEMORY_LOG_CHARS = 2 * 1024 * 1024; + +const isLogLevel = (value: string | undefined): value is LogLevel => + typeof value === 'string' && (LOG_LEVELS as readonly string[]).includes(value); + +const normalizeLogLevel = (value: string | undefined): LogLevel => { + const upper = value?.toUpperCase(); + return isLogLevel(upper) ? upper : 'INFO'; +}; + +const parseLogLevelFromLine = (line: string): LogLevel => { + const match = line.match(/\[(TRACE|DEBUG|INFO|WARN|ERROR)\](?:\s|$)/); + return normalizeLogLevel(match?.[1]); +}; + +const rowFromPayload = (payload: MainLogLinePayload): MainLogRow => ({ + line: payload.line, + level: normalizeLogLevel(payload.level), +}); + +const rowsFromHistory = (text: string): MainLogRow[] => + text + .split(/\r?\n/) + .filter(line => line.length > 0) + .map(line => ({ line, level: parseLogLevelFromLine(line) })); + +const capLogRows = (rows: MainLogRow[]): MainLogRow[] => { + let charCount = 0; + const capped: MainLogRow[] = []; + + for (let index = rows.length - 1; index >= 0; index -= 1) { + const row = rows[index]; + const rowChars = row.line.length + 1; + const wouldExceedRows = capped.length >= MAX_IN_MEMORY_LOG_ROWS; + const wouldExceedChars = charCount + rowChars > MAX_IN_MEMORY_LOG_CHARS; + if (wouldExceedRows || (wouldExceedChars && capped.length > 0)) { + break; + } + + capped.push(row); + charCount += rowChars; + } + + return capped.reverse(); +}; + +const dedupeBufferedRows = (historyRows: MainLogRow[], bufferedRows: MainLogRow[]): MainLogRow[] => { + const lineCounts = new Map(); + historyRows.forEach(row => { + lineCounts.set(row.line, (lineCounts.get(row.line) ?? 0) + 1); + }); + + return bufferedRows.filter(row => { + const count = lineCounts.get(row.line) ?? 0; + if (count <= 0) return true; + lineCounts.set(row.line, count - 1); + return false; + }); +}; + +const formatCount = (count: number, noun: string): string => + `${count.toLocaleString()} ${noun}${count === 1 ? '' : 's'}`; + +export const MainLogsWindow = () => { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [regexInput, setRegexInput] = useState(''); + const [levelFilter, setLevelFilter] = useState('all'); + const [autoScroll, setAutoScroll] = useState(true); + const [paused, setPaused] = useState(false); + const [pausedBufferCount, setPausedBufferCount] = useState(0); + const [copyStatus, setCopyStatus] = useState(null); + + const viewportRef = useRef(null); + const historyLoadedRef = useRef(false); + const initialBufferRef = useRef([]); + const pausedBufferRef = useRef([]); + const pausedRef = useRef(false); + + const appendVisibleRows = useCallback((rows: MainLogRow[]) => { + setLogs(current => capLogRows([...current, ...rows])); + }, []); + + const bufferPausedRows = useCallback((rows: MainLogRow[]) => { + pausedBufferRef.current = capLogRows([...pausedBufferRef.current, ...rows]); + setPausedBufferCount(pausedBufferRef.current.length); + }, []); + + useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | undefined; + + const handleIncomingRow = (row: MainLogRow) => { + if (!historyLoadedRef.current) { + initialBufferRef.current = capLogRows([...initialBufferRef.current, row]); + return; + } + if (pausedRef.current) { + bufferPausedRows([row]); + return; + } + appendVisibleRows([row]); + }; + + const setup = async () => { + try { + unlisten = await listen('main-log-line', event => { + handleIncomingRow(rowFromPayload(event.payload)); + }); + + const history = await invoke('get_main_logs'); + if (cancelled) return; + + const historyRows = rowsFromHistory(history); + const liveRows = dedupeBufferedRows(historyRows, initialBufferRef.current); + initialBufferRef.current = []; + historyLoadedRef.current = true; + + if (pausedRef.current) { + setLogs(capLogRows(historyRows)); + bufferPausedRows(liveRows); + } else { + setLogs(capLogRows([...historyRows, ...liveRows])); + } + setLoadError(null); + } catch (err) { + if (!cancelled) { + historyLoadedRef.current = true; + setLoadError(err instanceof Error ? err.message : String(err)); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void setup(); + + return () => { + cancelled = true; + historyLoadedRef.current = false; + initialBufferRef.current = []; + unlisten?.(); + }; + }, [appendVisibleRows, bufferPausedRows]); + + const { regex, regexError } = useMemo(() => { + if (!regexInput) { + return { regex: null as RegExp | null, regexError: null as string | null }; + } + try { + return { regex: new RegExp(regexInput, 'i'), regexError: null }; + } catch (e) { + return { regex: null, regexError: e instanceof Error ? e.message : String(e) }; + } + }, [regexInput]); + + const filteredRows = useMemo(() => { + const minLevel = LEVEL_FILTER_MIN[levelFilter]; + return logs.filter(row => { + if (LEVEL_ORDER[row.level] < minLevel) return false; + return regex ? regex.test(row.line) : true; + }); + }, [levelFilter, logs, regex]); + + useEffect(() => { + if (!autoScroll) return; + const viewport = viewportRef.current; + if (!viewport) return; + + requestAnimationFrame(() => { + viewport.scrollTop = viewport.scrollHeight; + }); + }, [autoScroll, filteredRows.length]); + + const flushPausedRows = useCallback(() => { + const buffered = pausedBufferRef.current; + if (buffered.length === 0) return; + pausedBufferRef.current = []; + setPausedBufferCount(0); + appendVisibleRows(buffered); + }, [appendVisibleRows]); + + const togglePaused = useCallback(() => { + if (paused) { + pausedRef.current = false; + setPaused(false); + flushPausedRows(); + return; + } + + pausedRef.current = true; + setPaused(true); + }, [flushPausedRows, paused]); + + const clearLogs = useCallback(() => { + setLogs([]); + initialBufferRef.current = []; + pausedBufferRef.current = []; + setPausedBufferCount(0); + }, []); + + const copyFilteredLogs = useCallback(async () => { + try { + await navigator.clipboard.writeText(filteredRows.map(row => row.line).join('\n')); + setCopyStatus('Copied'); + } catch { + setCopyStatus('Copy failed'); + } + window.setTimeout(() => setCopyStatus(null), 1600); + }, [filteredRows]); + + return ( +
+
+

Application Logs

+
+ + + + + {copyStatus && {copyStatus}} +
+
+ +
+
+ +
+ setRegexInput(e.target.value)} + title={regexError ?? ''} + spellCheck={false} + /> +
+ + {regexError && ( +
regex error: {regexError}
+ )} + {loadError && ( +
load error: {loadError}
+ )} + +
+ {loading ? 'loading' : `showing ${formatCount(filteredRows.length, 'line')} of ${formatCount(logs.length, 'line')}`} + {paused && pausedBufferCount > 0 && ` - ${formatCount(pausedBufferCount, 'paused line')}`} +
+ +
+ {filteredRows.length === 0 ? ( +
+ {logs.length === 0 ? 'No application logs recorded yet.' : 'No log lines match the current filters.'} +
+ ) : filteredRows.map((row, index) => ( +
+ {row.line} +
+ ))} +
+
+ ); +}; diff --git a/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx b/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx index b028ff9..47ac404 100644 --- a/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx +++ b/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx @@ -43,6 +43,29 @@ const openLogsWindow = async () => { } }; +const openMainLogsWindow = async () => { + const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow'); + try { + const existing = await WebviewWindow.getByLabel('main-logs'); + if (existing) { + await existing.setFocus(); + return; + } + const win = new WebviewWindow('main-logs', { + url: '/?view=main-logs', + title: 'Application Logs', + width: 980, + height: 720, + resizable: true, + }); + await win.once('tauri://error', (event) => { + console.error('Error opening application logs window:', event.payload); + }); + } catch (err) { + console.error('Error opening application logs window:', err); + } +}; + export const MainWindow = () => { const { settings, set: setSetting } = useSettings(); const { gameDir, hasGameDirectory, setGameDir, rescan } = useGameDirectory(); @@ -107,6 +130,7 @@ export const MainWindow = () => { { kind: 'item', label: 'Settings', onClick: () => setSettingsOpen(true) }, { kind: 'item', label: 'Refresh library', onClick: () => rescan() }, { kind: 'separator' }, + { kind: 'item', label: 'Application logs', onClick: () => void openMainLogsWindow() }, { kind: 'item', label: 'Unpack logs', onClick: () => void openLogsWindow() }, ], [rescan]);