diff --git a/Cargo.lock b/Cargo.lock index 43888f3..73f7104 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2209,6 +2209,7 @@ dependencies = [ "log", "mimalloc", "serde", + "serde_json", "tauri", "tauri-build", "tauri-plugin-dialog", diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml b/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml index 551ae01..81efff6 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml +++ b/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml @@ -37,6 +37,7 @@ eyre = { workspace = true } log = { workspace = true } mimalloc = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } tauri = { workspace = true } tauri-plugin-log = { workspace = true } tauri-plugin-shell = { workspace = true } 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 30e8908..30d2e54 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -75,7 +75,7 @@ struct LauncherGame { can_host_server: bool, } -#[derive(Clone, Debug, serde::Serialize)] +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] struct UnpackLogEntry { archive: String, destination: String, @@ -91,7 +91,8 @@ struct SidecarUnpacker { app_handle: AppHandle, } -const MAX_UNPACK_LOGS: usize = 100; +const MAX_UNPACK_LOGS: usize = 20; +const UNPACK_LOGS_FILE_NAME: &str = "unpack-logs.json"; impl Unpacker for SidecarUnpacker { fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> { @@ -1120,8 +1121,8 @@ async fn run_unrar_sidecar( } }; - let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + let stdout = clean_terminal_log(&String::from_utf8_lossy(&out.stdout)); + let stderr = clean_terminal_log(&String::from_utf8_lossy(&out.stderr)); let status_code = out.status.code(); let success = out.status.success(); @@ -1178,20 +1179,120 @@ async fn record_unpack_failure( async fn record_unpack_log(app_handle: &AppHandle, entry: UnpackLogEntry) { let state = app_handle.state::(); - { + let mut entry = entry; + clean_unpack_log_entry(&mut entry); + let logs = { let mut logs = state.inner().unpack_logs.write().await; logs.push(entry); - if logs.len() > MAX_UNPACK_LOGS { - let overflow = logs.len() - MAX_UNPACK_LOGS; - logs.drain(..overflow); - } - } + trim_unpack_logs(&mut logs); + logs.clone() + }; + + persist_unpack_logs(app_handle, &logs).await; if let Err(err) = app_handle.emit("unpack-logs-updated", ()) { log::warn!("Failed to emit unpack-logs-updated event: {err}"); } } +fn trim_unpack_logs(logs: &mut Vec) { + if logs.len() > MAX_UNPACK_LOGS { + let overflow = logs.len() - MAX_UNPACK_LOGS; + logs.drain(..overflow); + } +} + +fn clean_unpack_log_entry(entry: &mut UnpackLogEntry) { + let stdout = clean_terminal_log(&entry.stdout); + let stderr = clean_terminal_log(&entry.stderr); + entry.stdout = stdout; + entry.stderr = stderr; +} + +fn clean_terminal_log(input: &str) -> String { + let mut output = String::new(); + let mut line = String::new(); + let mut chars = input.chars().peekable(); + + while let Some(ch) = chars.next() { + match ch { + '\r' if chars.peek() == Some(&'\n') => { + let _ = chars.next(); + output.push_str(&line); + output.push('\n'); + line.clear(); + } + '\r' => { + line.clear(); + } + '\n' => { + output.push_str(&line); + output.push('\n'); + line.clear(); + } + '\u{8}' => { + let _ = line.pop(); + } + '\t' => line.push(ch), + ch if ch.is_control() => {} + ch => line.push(ch), + } + } + + output.push_str(&line); + output +} + +fn unpack_logs_path(state_dir: &Path) -> PathBuf { + state_dir.join(UNPACK_LOGS_FILE_NAME) +} + +fn load_unpack_logs(state_dir: &Path) -> Vec { + let path = unpack_logs_path(state_dir); + let contents = match std::fs::read_to_string(&path) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(), + Err(err) => { + log::warn!("Failed to read unpack logs from {}: {err}", path.display()); + return Vec::new(); + } + }; + + let mut logs = match serde_json::from_str::>(&contents) { + Ok(logs) => logs, + Err(err) => { + log::warn!("Failed to parse unpack logs from {}: {err}", path.display()); + return Vec::new(); + } + }; + logs.iter_mut().for_each(clean_unpack_log_entry); + trim_unpack_logs(&mut logs); + logs +} + +async fn persist_unpack_logs(app_handle: &AppHandle, logs: &[UnpackLogEntry]) { + let state = app_handle.state::(); + let Some(state_dir) = state.state_dir.get().cloned() else { + log::warn!("Cannot persist unpack logs before app state directory is initialized"); + return; + }; + let path = unpack_logs_path(&state_dir); + let contents = match serde_json::to_vec_pretty(logs) { + Ok(contents) => contents, + Err(err) => { + log::warn!( + "Failed to serialize unpack logs for {}: {err}", + path.display() + ); + return; + } + }; + + if let Err(err) = tokio::fs::write(&path, contents).await { + log::warn!("Failed to persist unpack logs to {}: {err}", path.display()); + } +} + fn now_millis() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -1545,6 +1646,20 @@ fn handle_download_finished(app_handle: &AppHandle, id: String) { mod tests { use super::*; + fn unpack_log_fixture(index: usize) -> UnpackLogEntry { + let timestamp = u64::try_from(index).unwrap_or(u64::MAX); + UnpackLogEntry { + archive: format!("archive-{index}.rar"), + destination: format!("destination-{index}"), + status_code: Some(0), + stdout: format!("stdout {index}"), + stderr: String::new(), + started_at_ms: timestamp, + finished_at_ms: timestamp, + success: true, + } + } + fn game_fixture(id: &str, name: &str) -> Game { Game { id: id.to_string(), @@ -1565,6 +1680,72 @@ mod tests { } } + #[test] + fn terminal_log_cleanup_preserves_crlf_and_collapses_redrawn_lines() { + let input = "Extracting foo 10%\rExtracting foo 80%\rExtracting foo OK\r\nAll done\r\n"; + + assert_eq!(clean_terminal_log(input), "Extracting foo OK\nAll done\n"); + } + + #[test] + fn terminal_log_cleanup_applies_backspaces() { + assert_eq!(clean_terminal_log("abc\u{8}\u{8}de\n"), "ade\n"); + } + + #[test] + fn terminal_log_cleanup_removes_other_controls() { + assert_eq!(clean_terminal_log("a\u{7}b\tc"), "ab\tc"); + } + + #[test] + fn unpack_log_retention_keeps_last_twenty_entries() { + let mut logs = (0..25).map(unpack_log_fixture).collect::>(); + + trim_unpack_logs(&mut logs); + + assert_eq!(logs.len(), MAX_UNPACK_LOGS); + assert_eq!( + logs.first().map(|entry| entry.archive.as_str()), + Some("archive-5.rar") + ); + assert_eq!( + logs.last().map(|entry| entry.archive.as_str()), + Some("archive-24.rar") + ); + } + + #[test] + fn unpack_logs_load_from_app_state_dir_and_apply_retention() { + let root = std::env::temp_dir().join(format!( + "lanspread-unpack-logs-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 logs = (0..25).map(unpack_log_fixture).collect::>(); + std::fs::write( + unpack_logs_path(&root), + serde_json::to_vec(&logs).expect("logs should serialize"), + ) + .expect("logs should be written"); + + let loaded = load_unpack_logs(&root); + + assert_eq!(loaded.len(), MAX_UNPACK_LOGS); + assert_eq!( + loaded.first().map(|entry| entry.archive.as_str()), + Some("archive-5.rar") + ); + assert_eq!( + loaded.last().map(|entry| entry.archive.as_str()), + Some("archive-24.rar") + ); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn active_operation_reconciliation_replaces_stale_ui_history() { let mut active_operations = HashMap::from([ @@ -1825,6 +2006,10 @@ pub fn run() { let state_dir = app.path().app_data_dir()?; std::fs::create_dir_all(&state_dir)?; let state = app.state::(); + let unpack_logs = load_unpack_logs(&state_dir); + tauri::async_runtime::block_on(async { + *state.unpack_logs.write().await = unpack_logs; + }); if state.state_dir.set(state_dir).is_err() { log::warn!("app state directory was already initialized"); } diff --git a/crates/lanspread-tauri-deno-ts/src/UnpackLogsWindow.tsx b/crates/lanspread-tauri-deno-ts/src/UnpackLogsWindow.tsx index 16a4ce5..b18a95d 100644 --- a/crates/lanspread-tauri-deno-ts/src/UnpackLogsWindow.tsx +++ b/crates/lanspread-tauri-deno-ts/src/UnpackLogsWindow.tsx @@ -33,6 +33,9 @@ const formatLogTime = (timestampMs: number): string => { return new Date(timestampMs).toLocaleString(); }; +const logSortTime = (entry: UnpackLogEntry): number => + entry.finished_at_ms > 0 ? entry.finished_at_ms : entry.started_at_ms; + const basename = (path: string): string => { const segments = path.split(/[\\/]/); return segments[segments.length - 1] || path; @@ -97,6 +100,10 @@ export const UnpackLogsWindow = () => { out.push({ entry, originalIndex, stdoutLines, stderrLines, matchCount }); }); + out.sort((a, b) => { + const timestampDelta = logSortTime(b.entry) - logSortTime(a.entry); + return timestampDelta !== 0 ? timestampDelta : b.originalIndex - a.originalIndex; + }); return out; }, [logs, errorsOnly, regex]);