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",
|
"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);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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", ()) {
|
output.push_str(&line);
|
||||||
log::warn!("Failed to emit unpack-logs-updated event: {err}");
|
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]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user