feat(tauri): add unpack logs viewer for unrar attempts
Captures stdout, stderr, exit status and start/finish timestamps for every unrar sidecar invocation and exposes them through a dedicated "Unpack Logs" window. Triggered by the need to debug why a particular game's archive failed to extract -- previously the only artifact of a failed unpack was a log line in the Tauri process stdout, which is awkward to inspect on an end-user machine. Implementation: * `LanSpreadState` gains an in-memory ring buffer (`unpack_logs`) capped at `MAX_UNPACK_LOGS` (100). The previous monolithic `do_unrar` is split into `prepare_unrar_paths` and `run_unrar_sidecar` so every failure path (mkdir failure, canonicalize failure, non-UTF-8 destination, sidecar spawn error, non-zero exit) records an `UnpackLogEntry` before bailing. * A `get_unpack_logs` Tauri command returns the current snapshot; an `unpack-logs-updated` event is emitted after every write so the viewer can refresh without polling. * The React `App` component now routes on `?view=unpack-logs` and renders a dedicated `UnpackLogsWindow`. The main window opens the viewer via `WebviewWindow` with label `unpack-logs`; an existing window is focused instead of being recreated. Capability scoping: the new window is given its own capability file (`capabilities/unpack-logs.json`) granting only `core:default`. The main capability is unchanged in window scope and only gains the two permissions the main window itself needs (`core:window:allow-set-focus` to focus an existing log window, `core:webview:allow-create-webview-window` to spawn it). Splitting the capability keeps the log window from inheriting `shell:allow-open`, `dialog:default` and `store:default`, which it has no reason to use. Known limitations (intentionally out of scope here): * Logs are process-local; they vanish on app restart. Persistence can be added later if it turns out users want to inspect failures across runs. * Entries are presented as a flat chronological list identified by archive path. No per-game grouping or filtering yet -- the archive filename is usually enough to identify the game in practice. * The `unpack-logs-updated` event carries no payload; the viewer re-fetches the full snapshot on every notification. Acceptable given the 100-entry cap, but a payload-bearing event would be cheaper if the cap grows. Test plan: * `just clippy` and `just build` are clean. * Manual: start the GUI, point it at a games directory containing at least one peer-hosted game, trigger an install, then click "Unpack Logs". The window should show one entry per unrar invocation with stdout, stderr, status code and timestamps; stderr/error lines render in the warning color. Triggering further unpacks should update the open window live via the `unpack-logs-updated` event without manual refresh. * Negative path: rename or remove the archive between handshake and extraction to force a canonicalize failure; confirm a failed entry with the corresponding stderr appears in the viewer. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -7,8 +7,10 @@
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-set-focus",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"shell:allow-open",
|
||||
"dialog:default",
|
||||
"store:default"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "unpack-logs",
|
||||
"description": "Capability for the unpack-logs window",
|
||||
"windows": [
|
||||
"unpack-logs"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use std::{
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use eyre::bail;
|
||||
@@ -40,6 +41,7 @@ struct LanSpreadState {
|
||||
games_folder: Arc<RwLock<String>>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
catalog: Arc<RwLock<HashSet<String>>>,
|
||||
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
|
||||
}
|
||||
|
||||
struct PeerEventTx(UnboundedSender<PeerEvent>);
|
||||
@@ -64,19 +66,41 @@ struct GamesListPayload {
|
||||
active_operations: Vec<UiActiveOperation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
struct UnpackLogEntry {
|
||||
archive: String,
|
||||
destination: String,
|
||||
status_code: Option<i32>,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
started_at_ms: u64,
|
||||
finished_at_ms: u64,
|
||||
success: bool,
|
||||
}
|
||||
|
||||
struct SidecarUnpacker {
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
const MAX_UNPACK_LOGS: usize = 100;
|
||||
|
||||
impl Unpacker for SidecarUnpacker {
|
||||
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let sidecar = self.app_handle.shell().sidecar("unrar")?;
|
||||
do_unrar(sidecar, archive, dest).await
|
||||
let app_handle = self.app_handle.clone();
|
||||
let sidecar = app_handle.shell().sidecar("unrar")?;
|
||||
do_unrar(&app_handle, sidecar, archive, dest).await
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_unpack_logs(
|
||||
state: tauri::State<'_, LanSpreadState>,
|
||||
) -> tauri::Result<Vec<UnpackLogEntry>> {
|
||||
Ok(state.inner().unpack_logs.read().await.clone())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const FIRST_START_DONE_FILE: &str = ".softlan_first_start_done";
|
||||
|
||||
@@ -596,47 +620,216 @@ fn add_final_slash(path: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_unrar(sidecar: Command, rar_file: &Path, dest_dir: &Path) -> eyre::Result<()> {
|
||||
if let Ok(()) = std::fs::create_dir_all(dest_dir) {
|
||||
if let Ok(rar_file) = rar_file.canonicalize() {
|
||||
if let Ok(dest_dir) = dest_dir.canonicalize() {
|
||||
let dest_dir = dest_dir
|
||||
.to_str()
|
||||
.ok_or_else(|| eyre::eyre!("failed to get str of dest_dir"))?;
|
||||
async fn do_unrar(
|
||||
app_handle: &AppHandle,
|
||||
sidecar: Command,
|
||||
rar_file: &Path,
|
||||
dest_dir: &Path,
|
||||
) -> eyre::Result<()> {
|
||||
let started_at_ms = now_millis();
|
||||
let paths = prepare_unrar_paths(app_handle, rar_file, dest_dir, started_at_ms).await?;
|
||||
|
||||
log::info!(
|
||||
"unrar game: {} to {}",
|
||||
rar_file.canonicalize()?.display(),
|
||||
dest_dir
|
||||
);
|
||||
log::info!(
|
||||
"unrar game: {} to {}",
|
||||
paths.archive.display(),
|
||||
paths.destination.display()
|
||||
);
|
||||
|
||||
let out = sidecar
|
||||
.arg("x") // extract files
|
||||
.arg(rar_file.canonicalize()?)
|
||||
.arg("-y") // Assume Yes on all queries
|
||||
.arg("-o") // Set overwrite mode
|
||||
.arg(add_final_slash(dest_dir))
|
||||
.output()
|
||||
.await?;
|
||||
run_unrar_sidecar(app_handle, sidecar, &paths, started_at_ms).await
|
||||
}
|
||||
|
||||
if !out.status.success() {
|
||||
log::error!("unrar stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
bail!(
|
||||
"unrar failed with status {:?}: {}",
|
||||
out.status.code(),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
struct UnrarPaths {
|
||||
archive: PathBuf,
|
||||
destination: PathBuf,
|
||||
destination_arg: String,
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
log::error!("dest_dir canonicalize failed: {}", dest_dir.display());
|
||||
} else {
|
||||
log::error!("rar_file canonicalize failed: {}", rar_file.display());
|
||||
async fn prepare_unrar_paths(
|
||||
app_handle: &AppHandle,
|
||||
rar_file: &Path,
|
||||
dest_dir: &Path,
|
||||
started_at_ms: u64,
|
||||
) -> eyre::Result<UnrarPaths> {
|
||||
let original_archive = rar_file.display().to_string();
|
||||
let original_destination = dest_dir.display().to_string();
|
||||
|
||||
if let Err(err) = std::fs::create_dir_all(dest_dir) {
|
||||
let stderr = format!("failed to create directory {}: {err}", dest_dir.display());
|
||||
record_unpack_failure(
|
||||
app_handle,
|
||||
original_archive,
|
||||
original_destination,
|
||||
started_at_ms,
|
||||
stderr.clone(),
|
||||
)
|
||||
.await;
|
||||
bail!("{stderr}");
|
||||
}
|
||||
|
||||
let rar_file = match rar_file.canonicalize() {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
let stderr = format!(
|
||||
"rar_file canonicalize failed for {}: {err}",
|
||||
rar_file.display()
|
||||
);
|
||||
record_unpack_failure(
|
||||
app_handle,
|
||||
original_archive,
|
||||
original_destination,
|
||||
started_at_ms,
|
||||
stderr.clone(),
|
||||
)
|
||||
.await;
|
||||
bail!("{stderr}");
|
||||
}
|
||||
};
|
||||
let dest_dir = match dest_dir.canonicalize() {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
let stderr = format!(
|
||||
"dest_dir canonicalize failed for {}: {err}",
|
||||
dest_dir.display()
|
||||
);
|
||||
record_unpack_failure(
|
||||
app_handle,
|
||||
rar_file.display().to_string(),
|
||||
original_destination,
|
||||
started_at_ms,
|
||||
stderr.clone(),
|
||||
)
|
||||
.await;
|
||||
bail!("{stderr}");
|
||||
}
|
||||
};
|
||||
let Some(dest_dir_arg) = dest_dir.to_str().map(add_final_slash) else {
|
||||
let stderr = format!("failed to get str of dest_dir {}", dest_dir.display());
|
||||
record_unpack_failure(
|
||||
app_handle,
|
||||
rar_file.display().to_string(),
|
||||
dest_dir.display().to_string(),
|
||||
started_at_ms,
|
||||
stderr.clone(),
|
||||
)
|
||||
.await;
|
||||
bail!("{stderr}");
|
||||
};
|
||||
|
||||
Ok(UnrarPaths {
|
||||
archive: rar_file,
|
||||
destination: dest_dir,
|
||||
destination_arg: dest_dir_arg,
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_unrar_sidecar(
|
||||
app_handle: &AppHandle,
|
||||
sidecar: Command,
|
||||
paths: &UnrarPaths,
|
||||
started_at_ms: u64,
|
||||
) -> eyre::Result<()> {
|
||||
let out = match sidecar
|
||||
.arg("x") // extract files
|
||||
.arg(&paths.archive)
|
||||
.arg("-y") // Assume Yes on all queries
|
||||
.arg("-o") // Set overwrite mode
|
||||
.arg(&paths.destination_arg)
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(out) => out,
|
||||
Err(err) => {
|
||||
let stderr = format!("failed to run unrar sidecar: {err}");
|
||||
record_unpack_failure(
|
||||
app_handle,
|
||||
paths.archive.display().to_string(),
|
||||
paths.destination.display().to_string(),
|
||||
started_at_ms,
|
||||
stderr.clone(),
|
||||
)
|
||||
.await;
|
||||
bail!("{stderr}");
|
||||
}
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||
let status_code = out.status.code();
|
||||
let success = out.status.success();
|
||||
|
||||
record_unpack_log(
|
||||
app_handle,
|
||||
UnpackLogEntry {
|
||||
archive: paths.archive.display().to_string(),
|
||||
destination: paths.destination.display().to_string(),
|
||||
status_code,
|
||||
stdout: stdout.clone(),
|
||||
stderr: stderr.clone(),
|
||||
started_at_ms,
|
||||
finished_at_ms: now_millis(),
|
||||
success,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
if !success {
|
||||
if !stdout.trim().is_empty() {
|
||||
log::error!("unrar stdout: {stdout}");
|
||||
}
|
||||
if !stderr.trim().is_empty() {
|
||||
log::error!("unrar stderr: {stderr}");
|
||||
}
|
||||
bail!("unrar failed with status {status_code:?}: {stderr}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record_unpack_failure(
|
||||
app_handle: &AppHandle,
|
||||
archive: String,
|
||||
destination: String,
|
||||
started_at_ms: u64,
|
||||
stderr: String,
|
||||
) {
|
||||
record_unpack_log(
|
||||
app_handle,
|
||||
UnpackLogEntry {
|
||||
archive,
|
||||
destination,
|
||||
status_code: None,
|
||||
stdout: String::new(),
|
||||
stderr,
|
||||
started_at_ms,
|
||||
finished_at_ms: now_millis(),
|
||||
success: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn record_unpack_log(app_handle: &AppHandle, entry: UnpackLogEntry) {
|
||||
let state = app_handle.state::<LanSpreadState>();
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
bail!("failed to create directory: {dest_dir:?}");
|
||||
if let Err(err) = app_handle.emit("unpack-logs-updated", ()) {
|
||||
log::warn!("Failed to emit unpack-logs-updated event: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn now_millis() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |duration| {
|
||||
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the bundled catalog database packaged with the Tauri application.
|
||||
@@ -1123,7 +1316,8 @@ pub fn run() {
|
||||
update_game,
|
||||
uninstall_game,
|
||||
get_peer_count,
|
||||
get_game_thumbnail
|
||||
get_game_thumbnail,
|
||||
get_unpack_logs
|
||||
])
|
||||
.manage(LanSpreadState::default())
|
||||
.manage(PeerEventTx(tx_peer_event))
|
||||
|
||||
Reference in New Issue
Block a user