diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/default.json b/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/default.json index dbd54dc..a661a17 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/default.json +++ b/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/default.json @@ -7,8 +7,10 @@ ], "permissions": [ "core:default", + "core:window:allow-set-focus", + "core:webview:allow-create-webview-window", "shell:allow-open", "dialog:default", "store:default" ] -} \ No newline at end of file +} diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/unpack-logs.json b/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/unpack-logs.json new file mode 100644 index 0000000..474d22e --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/unpack-logs.json @@ -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" + ] +} diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs index f4264b1..389fa23 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -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>, peer_game_db: Arc>, catalog: Arc>>, + unpack_logs: Arc>>, } struct PeerEventTx(UnboundedSender); @@ -64,19 +66,41 @@ struct GamesListPayload { active_operations: Vec, } +#[derive(Clone, Debug, serde::Serialize)] +struct UnpackLogEntry { + archive: String, + destination: String, + status_code: Option, + 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> { + 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 { + 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::(); + { + 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)) diff --git a/crates/lanspread-tauri-deno-ts/src/App.css b/crates/lanspread-tauri-deno-ts/src/App.css index 99646b5..3efe4a4 100644 --- a/crates/lanspread-tauri-deno-ts/src/App.css +++ b/crates/lanspread-tauri-deno-ts/src/App.css @@ -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; +} diff --git a/crates/lanspread-tauri-deno-ts/src/App.tsx b/crates/lanspread-tauri-deno-ts/src/App.tsx index 81f0730..36bb692 100644 --- a/crates/lanspread-tauri-deno-ts/src/App.tsx +++ b/crates/lanspread-tauri-deno-ts/src/App.tsx @@ -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([]); + + const refreshLogs = useCallback(async () => { + const unpackLogs = await invoke('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 ( +
+
+

Unpack Logs

+ +
+
+ {logs.length === 0 ? ( +
No unpack logs recorded yet.
+ ) : logs.map((entry, index) => ( +
+
+ [{formatLogTime(entry.finished_at_ms)}] {entry.success ? 'OK' : 'FAILED'} + {' '}status={entry.status_code ?? 'none'} +
+
archive: {entry.archive}
+
dest: {entry.destination}
+
+                            {entry.stdout.trim() ? entry.stdout : '(stdout empty)'}
+                        
+ {entry.stderr.trim() && ( +
{entry.stderr}
+ )} +
+ ))} +
+
+ ); +}; + const mergeGameUpdate = ( game: Game, previous?: Game, @@ -187,7 +265,7 @@ const mergeGameUpdate = ( }; }; -const App = () => { +const MainWindow = () => { const [gameItems, setGameItems] = useState([]); 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('tauri://error', (event) => { + console.error('Error opening unpack logs window:', event.payload); + }); + } catch (error) { + console.error('Error opening unpack logs window:', error); + } + }; + return (
@@ -781,10 +882,11 @@ const App = () => { className="search-input" />
-
- - {gameDir} -
+
+ + + {gameDir} +
) : ( @@ -878,4 +980,8 @@ const App = () => { ); }; +const App = () => { + return isUnpackLogsView() ? : ; +}; + export default App;