Add application log viewer
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<u8>) -> 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<Mutex<()>>,
|
||||
}
|
||||
|
||||
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<S> Layer<S> 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<String>,
|
||||
log_target: Option<String>,
|
||||
fields: Vec<String>,
|
||||
}
|
||||
|
||||
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<dyn std::error::Error>> {
|
||||
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<UnpackLogEntry> {
|
||||
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::<PeerEvent>();
|
||||
|
||||
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::<LanSpreadState>();
|
||||
let unpack_logs = load_unpack_logs(&state_dir);
|
||||
tauri::async_runtime::block_on(async {
|
||||
|
||||
@@ -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() ? <UnpackLogsWindow /> : <MainWindow />);
|
||||
const App = () => {
|
||||
if (isMainLogsView()) return <MainLogsWindow />;
|
||||
if (isUnpackLogsView()) return <UnpackLogsWindow />;
|
||||
return <MainWindow />;
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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<LogLevel, number> = {
|
||||
TRACE: 0,
|
||||
DEBUG: 1,
|
||||
INFO: 2,
|
||||
WARN: 3,
|
||||
ERROR: 4,
|
||||
};
|
||||
|
||||
const LEVEL_FILTER_MIN: Record<LevelFilter, number> = {
|
||||
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<string, number>();
|
||||
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<MainLogRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [regexInput, setRegexInput] = useState('');
|
||||
const [levelFilter, setLevelFilter] = useState<LevelFilter>('all');
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [pausedBufferCount, setPausedBufferCount] = useState(0);
|
||||
const [copyStatus, setCopyStatus] = useState<string | null>(null);
|
||||
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const historyLoadedRef = useRef(false);
|
||||
const initialBufferRef = useRef<MainLogRow[]>([]);
|
||||
const pausedBufferRef = useRef<MainLogRow[]>([]);
|
||||
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<MainLogLinePayload>('main-log-line', event => {
|
||||
handleIncomingRow(rowFromPayload(event.payload));
|
||||
});
|
||||
|
||||
const history = await invoke<string>('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 (
|
||||
<main className="main-log-window">
|
||||
<div className="main-log-header">
|
||||
<h1>Application Logs</h1>
|
||||
<div className="main-log-controls">
|
||||
<label className="main-log-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoScroll}
|
||||
onChange={(e) => setAutoScroll(e.target.checked)}
|
||||
/>
|
||||
Auto-scroll
|
||||
</label>
|
||||
<button className="settings-button" onClick={togglePaused}>
|
||||
{paused ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
<button className="settings-button" onClick={clearLogs} disabled={logs.length === 0}>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
className="settings-button"
|
||||
onClick={() => void copyFilteredLogs()}
|
||||
disabled={filteredRows.length === 0}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
{copyStatus && <span className="main-log-copy-status">{copyStatus}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="main-log-filter-row">
|
||||
<div className="main-log-level-filter">
|
||||
<SegmentedRadio
|
||||
value={levelFilter}
|
||||
options={LEVEL_FILTER_OPTIONS}
|
||||
onChange={setLevelFilter}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className={`unpack-log-regex main-log-regex ${regexError ? 'invalid' : ''}`}
|
||||
type="text"
|
||||
placeholder="Filter lines by regex (case-insensitive)..."
|
||||
value={regexInput}
|
||||
onChange={(e) => setRegexInput(e.target.value)}
|
||||
title={regexError ?? ''}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{regexError && (
|
||||
<div className="unpack-log-regex-error">regex error: {regexError}</div>
|
||||
)}
|
||||
{loadError && (
|
||||
<div className="main-log-load-error">load error: {loadError}</div>
|
||||
)}
|
||||
|
||||
<div className="main-log-stats">
|
||||
{loading ? 'loading' : `showing ${formatCount(filteredRows.length, 'line')} of ${formatCount(logs.length, 'line')}`}
|
||||
{paused && pausedBufferCount > 0 && ` - ${formatCount(pausedBufferCount, 'paused line')}`}
|
||||
</div>
|
||||
|
||||
<section ref={viewportRef} className="main-log-viewport" aria-live={paused ? 'off' : 'polite'}>
|
||||
{filteredRows.length === 0 ? (
|
||||
<div className="main-log-empty">
|
||||
{logs.length === 0 ? 'No application logs recorded yet.' : 'No log lines match the current filters.'}
|
||||
</div>
|
||||
) : filteredRows.map((row, index) => (
|
||||
<div
|
||||
key={`${index}-${row.line}`}
|
||||
className={`main-log-line level-${row.level.toLowerCase()}`}
|
||||
>
|
||||
{row.line}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -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<unknown>('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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user