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:
2026-05-19 17:27:59 +02:00
parent a8edcd7450
commit b35755f4e6
5 changed files with 433 additions and 43 deletions
@@ -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))