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
@@ -349,3 +349,80 @@ h1.align-center {
left: 20px;
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;
}
+111 -5
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { open } from '@tauri-apps/plugin-dialog';
import { load } from '@tauri-apps/plugin-store';
@@ -71,6 +72,17 @@ interface GamesListPayload {
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 {
gameId: string;
alt: string;
@@ -151,6 +163,72 @@ const normalizeGamesListPayload = (payload: GamesListPayload | Game[]): GamesLis
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 = (
game: Game,
previous?: Game,
@@ -187,7 +265,7 @@ const mergeGameUpdate = (
};
};
const App = () => {
const MainWindow = () => {
const [gameItems, setGameItems] = useState<Game[]>([]);
const [searchTerm, setSearchTerm] = 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 (
<main className="container">
<div className="fixed-header">
@@ -781,10 +882,11 @@ const App = () => {
className="search-input"
/>
</div>
<div className="settings-container">
<button onClick={dialogGameDir} className="settings-button">Set Game Directory</button>
<span className="settings-text">{gameDir}</span>
</div>
<div className="settings-container">
<button onClick={() => void openUnpackLogsWindow()} className="settings-button">Unpack Logs</button>
<button onClick={dialogGameDir} className="settings-button">Set Game Directory</button>
<span className="settings-text">{gameDir}</span>
</div>
</div>
</div>
) : (
@@ -878,4 +980,8 @@ const App = () => {
);
};
const App = () => {
return isUnpackLogsView() ? <UnpackLogsWindow /> : <MainWindow />;
};
export default App;