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:
Generated
+1
@@ -2209,6 +2209,7 @@ dependencies = [
|
||||
"log",
|
||||
"mimalloc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,17 +1179,117 @@ async fn record_unpack_failure(
|
||||
|
||||
async fn record_unpack_log(app_handle: &AppHandle, entry: UnpackLogEntry) {
|
||||
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;
|
||||
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 {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = app_handle.emit("unpack-logs-updated", ()) {
|
||||
log::warn!("Failed to emit unpack-logs-updated event: {err}");
|
||||
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 {
|
||||
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::<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]
|
||||
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::<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() {
|
||||
log::warn!("app state directory was already initialized");
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user