Add application log viewer

This commit is contained in:
2026-06-07 15:09:59 +02:00
parent d63d4b9c2f
commit 20404d0145
10 changed files with 1090 additions and 328 deletions
@@ -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 {
+7 -2
View File
@@ -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]);