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,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
|
||||||
.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?;
|
|
||||||
|
|
||||||
if !out.status.success() {
|
struct UnrarPaths {
|
||||||
log::error!("unrar stderr: {}", String::from_utf8_lossy(&out.stderr));
|
archive: PathBuf,
|
||||||
bail!(
|
destination: PathBuf,
|
||||||
"unrar failed with status {:?}: {}",
|
destination_arg: String,
|
||||||
out.status.code(),
|
}
|
||||||
String::from_utf8_lossy(&out.stderr)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(());
|
async fn prepare_unrar_paths(
|
||||||
}
|
app_handle: &AppHandle,
|
||||||
log::error!("dest_dir canonicalize failed: {}", dest_dir.display());
|
rar_file: &Path,
|
||||||
} else {
|
dest_dir: &Path,
|
||||||
log::error!("rar_file canonicalize failed: {}", rar_file.display());
|
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.
|
/// 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -781,10 +882,11 @@ const App = () => {
|
|||||||
className="search-input"
|
className="search-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-container">
|
<div className="settings-container">
|
||||||
<button onClick={dialogGameDir} className="settings-button">Set Game Directory</button>
|
<button onClick={() => void openUnpackLogsWindow()} className="settings-button">Unpack Logs</button>
|
||||||
<span className="settings-text">{gameDir}</span>
|
<button onClick={dialogGameDir} className="settings-button">Set Game Directory</button>
|
||||||
</div>
|
<span className="settings-text">{gameDir}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -878,4 +980,8 @@ const App = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
return isUnpackLogsView() ? <UnpackLogsWindow /> : <MainWindow />;
|
||||||
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
Reference in New Issue
Block a user