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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user