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
@@ -7,6 +7,8 @@
], ],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:allow-set-focus",
"core:webview:allow-create-webview-window",
"shell:allow-open", "shell:allow-open",
"dialog:default", "dialog:default",
"store: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, net::SocketAddr,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc, sync::Arc,
time::{SystemTime, UNIX_EPOCH},
}; };
use eyre::bail; use eyre::bail;
@@ -40,6 +41,7 @@ struct LanSpreadState {
games_folder: Arc<RwLock<String>>, games_folder: Arc<RwLock<String>>,
peer_game_db: Arc<RwLock<PeerGameDB>>, peer_game_db: Arc<RwLock<PeerGameDB>>,
catalog: Arc<RwLock<HashSet<String>>>, catalog: Arc<RwLock<HashSet<String>>>,
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
} }
struct PeerEventTx(UnboundedSender<PeerEvent>); struct PeerEventTx(UnboundedSender<PeerEvent>);
@@ -64,19 +66,41 @@ struct GamesListPayload {
active_operations: Vec<UiActiveOperation>, 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 { struct SidecarUnpacker {
app_handle: AppHandle, app_handle: AppHandle,
} }
const MAX_UNPACK_LOGS: usize = 100;
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> {
Box::pin(async move { Box::pin(async move {
let sidecar = self.app_handle.shell().sidecar("unrar")?; let app_handle = self.app_handle.clone();
do_unrar(sidecar, archive, dest).await 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")] #[cfg(target_os = "windows")]
const FIRST_START_DONE_FILE: &str = ".softlan_first_start_done"; 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<()> { async fn do_unrar(
if let Ok(()) = std::fs::create_dir_all(dest_dir) { app_handle: &AppHandle,
if let Ok(rar_file) = rar_file.canonicalize() { sidecar: Command,
if let Ok(dest_dir) = dest_dir.canonicalize() { rar_file: &Path,
let dest_dir = dest_dir dest_dir: &Path,
.to_str() ) -> eyre::Result<()> {
.ok_or_else(|| eyre::eyre!("failed to get str of dest_dir"))?; let started_at_ms = now_millis();
let paths = prepare_unrar_paths(app_handle, rar_file, dest_dir, started_at_ms).await?;
log::info!( log::info!(
"unrar game: {} to {}", "unrar game: {} to {}",
rar_file.canonicalize()?.display(), paths.archive.display(),
dest_dir paths.destination.display()
); );
let out = sidecar run_unrar_sidecar(app_handle, sidecar, &paths, started_at_ms).await
}
struct UnrarPaths {
archive: PathBuf,
destination: PathBuf,
destination_arg: String,
}
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("x") // extract files
.arg(rar_file.canonicalize()?) .arg(&paths.archive)
.arg("-y") // Assume Yes on all queries .arg("-y") // Assume Yes on all queries
.arg("-o") // Set overwrite mode .arg("-o") // Set overwrite mode
.arg(add_final_slash(dest_dir)) .arg(&paths.destination_arg)
.output() .output()
.await?; .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}");
}
};
if !out.status.success() { let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
log::error!("unrar stderr: {}", String::from_utf8_lossy(&out.stderr)); let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
bail!( let status_code = out.status.code();
"unrar failed with status {:?}: {}", let success = out.status.success();
out.status.code(),
String::from_utf8_lossy(&out.stderr) 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}");
} }
return Ok(()); Ok(())
} }
log::error!("dest_dir canonicalize failed: {}", dest_dir.display());
} else { async fn record_unpack_failure(
log::error!("rar_file canonicalize failed: {}", rar_file.display()); 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. /// Resolve the bundled catalog database packaged with the Tauri application.
@@ -1123,7 +1316,8 @@ pub fn run() {
update_game, update_game,
uninstall_game, uninstall_game,
get_peer_count, get_peer_count,
get_game_thumbnail get_game_thumbnail,
get_unpack_logs
]) ])
.manage(LanSpreadState::default()) .manage(LanSpreadState::default())
.manage(PeerEventTx(tx_peer_event)) .manage(PeerEventTx(tx_peer_event))
@@ -349,3 +349,80 @@ h1.align-center {
left: 20px; left: 20px;
z-index: 1001; z-index: 1001;
} }
.unpack-log-window {
min-height: 100vh;
box-sizing: border-box;
padding: 18px;
background: #000313;
color: #D5DBFE;
}
.unpack-log-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.unpack-log-header h1 {
margin: 0;
font-size: 22px;
}
.unpack-log-textfield {
height: calc(100vh - 84px);
box-sizing: border-box;
overflow: auto;
padding: 14px;
border: 1px solid #444;
border-radius: 6px;
background: #050813;
font-family: Consolas, "Courier New", monospace;
font-size: 13px;
line-height: 1.35;
white-space: pre-wrap;
}
.unpack-log-empty {
color: #8892b0;
}
.unpack-log-entry {
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 1px solid #26304f;
}
.unpack-log-entry:last-child {
margin-bottom: 0;
border-bottom: 0;
}
.unpack-log-meta {
margin-bottom: 6px;
font-weight: bold;
}
.unpack-log-meta.success {
color: #8ee6a6;
}
.unpack-log-meta.error,
.unpack-log-stream.stderr {
color: #ff8a8a;
}
.unpack-log-path {
color: #aeb7df;
}
.unpack-log-stream {
margin: 8px 0 0;
font: inherit;
}
.unpack-log-stream.stdout {
color: #D5DBFE;
}
+107 -1
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import { load } from '@tauri-apps/plugin-store'; import { load } from '@tauri-apps/plugin-store';
@@ -71,6 +72,17 @@ interface GamesListPayload {
active_operations?: ActiveOperation[]; active_operations?: ActiveOperation[];
} }
interface UnpackLogEntry {
archive: string;
destination: string;
status_code: number | null;
stdout: string;
stderr: string;
started_at_ms: number;
finished_at_ms: number;
success: boolean;
}
interface GameThumbnailProps { interface GameThumbnailProps {
gameId: string; gameId: string;
alt: string; alt: string;
@@ -151,6 +163,72 @@ const normalizeGamesListPayload = (payload: GamesListPayload | Game[]): GamesLis
return payload; return payload;
}; };
const isUnpackLogsView = (): boolean => {
return new URLSearchParams(window.location.search).get('view') === 'unpack-logs';
};
const formatLogTime = (timestampMs: number): string => {
if (timestampMs <= 0) {
return 'unknown time';
}
return new Date(timestampMs).toLocaleString();
};
const UnpackLogsWindow = () => {
const [logs, setLogs] = useState<UnpackLogEntry[]>([]);
const refreshLogs = useCallback(async () => {
const unpackLogs = await invoke<UnpackLogEntry[]>('get_unpack_logs');
setLogs(unpackLogs);
}, []);
useEffect(() => {
let unlisten: (() => void) | undefined;
const setup = async () => {
await refreshLogs();
unlisten = await listen('unpack-logs-updated', () => {
void refreshLogs();
});
};
void setup();
return () => {
unlisten?.();
};
}, [refreshLogs]);
return (
<main className="unpack-log-window">
<div className="unpack-log-header">
<h1>Unpack Logs</h1>
<button className="settings-button" onClick={() => void refreshLogs()}>Refresh</button>
</div>
<div className="unpack-log-textfield" role="textbox" aria-readonly="true" tabIndex={0}>
{logs.length === 0 ? (
<div className="unpack-log-empty">No unpack logs recorded yet.</div>
) : logs.map((entry, index) => (
<section className="unpack-log-entry" key={`${entry.finished_at_ms}-${index}`}>
<div className={`unpack-log-meta ${entry.success ? 'success' : 'error'}`}>
[{formatLogTime(entry.finished_at_ms)}] {entry.success ? 'OK' : 'FAILED'}
{' '}status={entry.status_code ?? 'none'}
</div>
<div className="unpack-log-path">archive: {entry.archive}</div>
<div className="unpack-log-path">dest: {entry.destination}</div>
<pre className="unpack-log-stream stdout">
{entry.stdout.trim() ? entry.stdout : '(stdout empty)'}
</pre>
{entry.stderr.trim() && (
<pre className="unpack-log-stream stderr">{entry.stderr}</pre>
)}
</section>
))}
</div>
</main>
);
};
const mergeGameUpdate = ( const mergeGameUpdate = (
game: Game, game: Game,
previous?: Game, previous?: Game,
@@ -187,7 +265,7 @@ const mergeGameUpdate = (
}; };
}; };
const App = () => { const MainWindow = () => {
const [gameItems, setGameItems] = useState<Game[]>([]); const [gameItems, setGameItems] = useState<Game[]>([]);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [gameDir, setGameDir] = useState(''); const [gameDir, setGameDir] = useState('');
@@ -733,6 +811,29 @@ const App = () => {
} }
}; };
const openUnpackLogsWindow = async () => {
try {
const existing = await WebviewWindow.getByLabel('unpack-logs');
if (existing) {
await existing.setFocus();
return;
}
const logWindow = new WebviewWindow('unpack-logs', {
url: '/?view=unpack-logs',
title: 'Unpack Logs',
width: 900,
height: 700,
resizable: true,
});
await logWindow.once<unknown>('tauri://error', (event) => {
console.error('Error opening unpack logs window:', event.payload);
});
} catch (error) {
console.error('Error opening unpack logs window:', error);
}
};
return ( return (
<main className="container"> <main className="container">
<div className="fixed-header"> <div className="fixed-header">
@@ -782,6 +883,7 @@ const App = () => {
/> />
</div> </div>
<div className="settings-container"> <div className="settings-container">
<button onClick={() => void openUnpackLogsWindow()} className="settings-button">Unpack Logs</button>
<button onClick={dialogGameDir} className="settings-button">Set Game Directory</button> <button onClick={dialogGameDir} className="settings-button">Set Game Directory</button>
<span className="settings-text">{gameDir}</span> <span className="settings-text">{gameDir}</span>
</div> </div>
@@ -878,4 +980,8 @@ const App = () => {
); );
}; };
const App = () => {
return isUnpackLogsView() ? <UnpackLogsWindow /> : <MainWindow />;
};
export default App; export default App;