feat(tauri): persist unpack logs and clean sidecar output

Unpack logs lived only in memory, so closing the app dropped history.
Unrar progress also flooded stdout with carriage-return redraws, which
made the log viewer noisy and hard to search.

Persist the last twenty entries to unpack-logs.json under the app data
directory, load them on startup, and rewrite stdout/stderr through a
small terminal-sequence cleaner (CR/LF, backspace, control chars) before
storage and display. Sort the unpack-logs window newest-first by finish
or start time.

Test plan:
- cargo test -p lanspread-tauri-deno-ts -- terminal_log unpack_log
- Run an unpack, restart the app, open unpack logs: prior entries remain
- Confirm progress lines collapse to final text instead of spam

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-21 20:27:47 +02:00
parent 19ae1938f6
commit eedfc0105d
4 changed files with 204 additions and 10 deletions
Generated
+1
View File
@@ -2209,6 +2209,7 @@ dependencies = [
"log", "log",
"mimalloc", "mimalloc",
"serde", "serde",
"serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
@@ -37,6 +37,7 @@ eyre = { workspace = true }
log = { workspace = true } log = { workspace = true }
mimalloc = { workspace = true } mimalloc = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true }
tauri = { workspace = true } tauri = { workspace = true }
tauri-plugin-log = { workspace = true } tauri-plugin-log = { workspace = true }
tauri-plugin-shell = { workspace = true } tauri-plugin-shell = { workspace = true }
@@ -75,7 +75,7 @@ struct LauncherGame {
can_host_server: bool, can_host_server: bool,
} }
#[derive(Clone, Debug, serde::Serialize)] #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
struct UnpackLogEntry { struct UnpackLogEntry {
archive: String, archive: String,
destination: String, destination: String,
@@ -91,7 +91,8 @@ struct SidecarUnpacker {
app_handle: AppHandle, 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 { impl Unpacker for SidecarUnpacker {
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> { 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 stdout = clean_terminal_log(&String::from_utf8_lossy(&out.stdout));
let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); let stderr = clean_terminal_log(&String::from_utf8_lossy(&out.stderr));
let status_code = out.status.code(); let status_code = out.status.code();
let success = out.status.success(); let success = out.status.success();
@@ -1178,17 +1179,117 @@ async fn record_unpack_failure(
async fn record_unpack_log(app_handle: &AppHandle, entry: UnpackLogEntry) { async fn record_unpack_log(app_handle: &AppHandle, entry: UnpackLogEntry) {
let state = app_handle.state::<LanSpreadState>(); let state = app_handle.state::<LanSpreadState>();
{ let mut entry = entry;
clean_unpack_log_entry(&mut entry);
let logs = {
let mut logs = state.inner().unpack_logs.write().await; let mut logs = state.inner().unpack_logs.write().await;
logs.push(entry); logs.push(entry);
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<UnpackLogEntry>) {
if logs.len() > MAX_UNPACK_LOGS { if logs.len() > MAX_UNPACK_LOGS {
let overflow = logs.len() - MAX_UNPACK_LOGS; let overflow = logs.len() - MAX_UNPACK_LOGS;
logs.drain(..overflow); logs.drain(..overflow);
} }
} }
if let Err(err) = app_handle.emit("unpack-logs-updated", ()) { fn clean_unpack_log_entry(entry: &mut UnpackLogEntry) {
log::warn!("Failed to emit unpack-logs-updated event: {err}"); 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<UnpackLogEntry> {
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::<Vec<UnpackLogEntry>>(&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::<LanSpreadState>();
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());
} }
} }
@@ -1545,6 +1646,20 @@ fn handle_download_finished(app_handle: &AppHandle, id: String) {
mod tests { mod tests {
use super::*; 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 { fn game_fixture(id: &str, name: &str) -> Game {
Game { Game {
id: id.to_string(), 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::<Vec<_>>();
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::<Vec<_>>();
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] #[test]
fn active_operation_reconciliation_replaces_stale_ui_history() { fn active_operation_reconciliation_replaces_stale_ui_history() {
let mut active_operations = HashMap::from([ let mut active_operations = HashMap::from([
@@ -1825,6 +2006,10 @@ pub fn run() {
let state_dir = app.path().app_data_dir()?; let state_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&state_dir)?; std::fs::create_dir_all(&state_dir)?;
let state = app.state::<LanSpreadState>(); let state = app.state::<LanSpreadState>();
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() { if state.state_dir.set(state_dir).is_err() {
log::warn!("app state directory was already initialized"); log::warn!("app state directory was already initialized");
} }
@@ -33,6 +33,9 @@ const formatLogTime = (timestampMs: number): string => {
return new Date(timestampMs).toLocaleString(); 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 basename = (path: string): string => {
const segments = path.split(/[\\/]/); const segments = path.split(/[\\/]/);
return segments[segments.length - 1] || path; return segments[segments.length - 1] || path;
@@ -97,6 +100,10 @@ export const UnpackLogsWindow = () => {
out.push({ entry, originalIndex, stdoutLines, stderrLines, matchCount }); 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; return out;
}, [logs, errorsOnly, regex]); }, [logs, errorsOnly, regex]);