diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8eff76b..6c18b3b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -180,17 +180,21 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | sudo apt update - # The gstreamer plugin packages provide the elements WebKitGTK needs - # for audio/media playback (appsink, autoaudiosink, vorbis, ...). They - # are copied into the AppImage by `bundleMediaFramework: true`, so the - # packages have to be installed here for the bundler to find them. + # The gstreamer plugin packages provide the media elements WebKitGTK + # looks up at runtime. The AppImage bundler copies every installed + # plugin verbatim (`bundleMediaFramework: true`), so only what is + # listed here ends up inside the AppImage: + # -plugins-base: appsink, ogg/vorbis, opus, isomp4 + # -plugins-good: autoaudiosink, vpx (VP8/VP9), matroska + # -plugins-bad: fakevideosink, WebVTT encoder, H.264 (openh264), AAC (faad) sudo apt install -y \ libwebkit2gtk-4.1-dev \ libappindicator3-dev \ librsvg2-dev \ patchelf \ gstreamer1.0-plugins-base \ - gstreamer1.0-plugins-good + gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad - name: Setup Rust uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c122ab0..ba7b1a9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,3 +56,32 @@ jobs: name: coverage-report path: coverage/ retention-days: 14 + + rust: + name: Rust tests (Linux) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install Linux system dependencies + run: | + sudo apt update + sudo apt install -y \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - name: Run Rust tests + working-directory: src-tauri + run: cargo test --locked diff --git a/CHANGELOG.md b/CHANGELOG.md index 15cc33d..1b9641d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ### Changes +- Fixed playtime briefly landing in the offline file every time the session was refreshed: the tracker now retries with the fresh session immediately, and the session is refreshed earlier so a token never expires in the middle of a tick. +- Fixed playtime stopping to count while a game was running: the tracker now asks for a fresh session as soon as the server rejects an increment, pauses its requests instead of retrying every minute, and credits the minutes it stored offline as soon as the session works again. +- Fixed playtime that was stored offline being stranded until an app restart: offline minutes are now synced in the background and immediately when the connection or the session returns. +- Fixed the time tracker re-scanning every installed game's folder every minute, which could delay ticks on large installations. +- Fixed playtime being lost when a game's launcher briefly restarts the game process: tracking now continues for a short grace period instead of ending the session. - Added a Refresh button to the window title bar that reloads the current page. - Auto-install on Linux now makes extracted shell scripts (`.sh`/`.bash`/`.run`/etc.) executable automatically. - Fixed "Open Folder" doing nothing on Linux: folder and URL opening now use the official `tauri-plugin-opener`, with WSL support preserved. @@ -18,6 +23,7 @@ - Fixed the Wayland startup crash on the Linux AppImage (`Could not create default EGL display: EGL_BAD_PARAMETER`) by preloading the host's `libwayland-client` through a patched AppImage `AppRun` wrapper. The `.deb` build already uses the system library and is unaffected. - Administrators can delete progress entries of other users - Added a native OS taskbar/dock download progress indicator for desktop builds (Windows taskbar, macOS dock, Linux launcher) +- Fixed the Windows taskbar progress bar staying stuck at 100% after a download, extraction or installation had finished. It now disappears as soon as there is nothing left to do, instead of only disappearing when you delete the download. - Added an "Installed" badge to server game cards so already-installed games are easy to spot at a glance. - The download button on a game's page now collapses into a compact icon-only button when the game is already installed. - Downloads interrupted by the app quitting are now automatically resumed on the next launch; downloads you intentionally paused or cancelled stay paused. @@ -55,16 +61,30 @@ - Fixed downloads aborting with a 401 error when you refresh (F5) mid-download: auto-resumed downloads now wait for a valid session token instead of firing before login has finished. - Added a keyboard shortcut (Ctrl/Cmd+Shift+O) to toggle Streamer/OPSEC mode from anywhere in the app. - Cleaned up the Community Page UI - ---- - - Administrators can now view a game's files (their paths, sizes and other metadata) in the game settings and delete individual game files from the server. - Made the game settings and user settings dialogs more responsive and easier to use on tablets and phones, and removed the release date from the game files list. - Deleting a game's last version file now closes the settings, removes the game from the library, and returns you to the library if you were viewing that game. - Settings → About now shows the full system diagnostics (operating system, display and monitors, GPU/WebGL, WebGPU, CPU/memory, platform, language and user agent) that previously only lived in the Developer Tools menu, replacing the shorter system info list there. - Moved the Hardware Acceleration setting from the Developer Tools menu into Settings → Appearance (Linux desktop builds), where it now sits next to Smooth Scrolling. -- Fixed the Linux AppImage failing to play sounds with `GStreamer element appsink not found. Please install it.`: the GStreamer plugins WebKitGTK needs are now bundled into the AppImage (which grows by ~15–35 MB), so no GStreamer packages have to be installed on the system. +- Fixed the Linux AppImage failing to play sound effects and trailers with `GStreamer element appsink not found. Please install it.` (followed by `fakevideosink` and WebVTT encoder errors): the full GStreamer plugin set WebKitGTK needs — base, good and bad, including H.264/AAC decoding, MSE support and the WebVTT encoder — is now bundled into the AppImage, so no GStreamer packages have to be installed on the system. - Fixed the system information under Settings → About not matching the machine it runs on +- [#36](https://github.com/Phalcode/gamevault-frontend/issues/36) Fixed disc images that only contain a UDF filesystem (e.g. UDF-only DVD/Blu-ray ISOs) failing to extract with an error. Disc images are now read by a combined ISO 9660 and UDF reader. + +--- + +as + +- Added a one-time warning on the first launch of an Early Access or unstable build, explaining that these builds are meant for the early access program/developers and testers, that extra setup steps may be needed, and that more issues are to be expected than on a stable release. +- Refreshing the app (F5) or using the window's Refresh button no longer interrupts work in progress: downloads, extractions, installations, umu-launcher setup and app updates keep running in the background, their progress is picked back up instead of starting over, and a refresh can no longer start a second download, extraction, installation or update over the one that is still running. +- Fixed the app freezing while uninstalling a game, while launching a Windows game that still needs umu-launcher set up, and while deleting or scanning large game folders. +- The Early Access/unstable warning is now shown only once per installation instead of again after every app update. +- Linux/umu-launcher defaults (GAMEID, STORE and Proton version) can now be defined once per game as custom metadata on the server: the game settings dialog has matching fields, and newly downloaded games inherit them automatically instead of needing per-client setup. +- Fixed Linux/umu-launcher Wine/Proton prefixes: installing, launching and uninstalling a game now all use the same prefix per game (named like the game's install folder). Previously a game could run in a different prefix than it was installed into, and without a GAMEID every game shared umu's default prefix. Existing prefixes are reused, so nothing has to be moved and no save games are lost. +- Uninstalling a game now offers to also delete its Wine/Proton prefix, showing the folder path and keeping it by default (local saves and game settings can live inside it). Only prefixes GameVault manages are offered. +- The per-game Wine Prefix field now shows the path that is actually used (with a click-to-copy) instead of a path GameVault never used, and the prefix settings explain the real default location. +- Fixed uninstalling a game silently dropping the per-game umu-launcher overrides (GAMEID, STORE, Proton version, Wine prefix) and the "run as administrator" flag from the game's configuration. +- Added launch logs: the complete output of every game start (Proton/Wine/umu output included) is written to rotating log files and shown in a dedicated log window. A new setting ("Always show launch logs") opens that window automatically while a game starts, a failed launch opens it regardless, and earlier logs can be browsed or cleared from the window. Installer runs are logged too. +- The per-game Proton (PROTONPATH) field now offers a dropdown with the Proton builds already installed in `compatibilitytools.d` (Steam and Flatpak Steam), so version names don't have to be typed by hand. ## 17.0.0 diff --git a/README.md b/README.md index c69bc02..dc63bfb 100644 --- a/README.md +++ b/README.md @@ -89,3 +89,5 @@ The choice is persisted in `localStorage` (`gv_update_channel`). Generate the keys once with `cargo tauri signer generate -w ~/.tauri/gamevault-updater.key`. Keep the private key and password safe — losing them breaks updates for existing installs. **Local builds** – a normal `tauri build` produces no updater artifacts. To create them locally, build with the release config and signing env (`pnpm tauri build --config src-tauri/tauri.release.conf.json`). CI uses `src-tauri/tauri.release.generated.json` with the pubkey injected, since the Tauri bundler requires `plugins.updater.pubkey` in the parsed config. + +**Linux AppImage media support** – `bundle.linux.appimage.bundleMediaFramework` is enabled, which makes the bundler copy the GStreamer plugins installed on the _build machine_ into the AppImage (they end up in `usr/lib/gstreamer-1.0`). A local AppImage build therefore needs `gstreamer1.0-plugins-base`, `gstreamer1.0-plugins-good` and `gstreamer1.0-plugins-bad` installed (the same packages CI installs); without them the AppImage ships without audio/video support and WebKitGTK logs `GStreamer element ... not found`. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3aeff3e..d39bdbf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -119,7 +119,7 @@ dependencies = [ "flate2", "futures-util", "getrandom 0.2.17", - "iso9660_simple", + "isomage", "log", "reqwest 0.12.28", "semver", @@ -402,12 +402,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - [[package]] name = "bitflags" version = "1.3.2" @@ -2493,15 +2487,10 @@ dependencies = [ ] [[package]] -name = "iso9660_simple" -version = "0.2.7" +name = "isomage" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24bb0456b675bf7afaa0071c16879703ba487431d68f7f6e3c70540100799e3d" -dependencies = [ - "bitflags 2.13.1", - "ucs2", - "zerocopy", -] +checksum = "78e76f0ebdb7f589dafb6f6deb9dd4c341b108272af52a97185ba80510527fce" [[package]] name = "iterator-sorted" @@ -5876,15 +5865,6 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "ucs2" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79298e11f316400c57ec268f3c2c29ac3c4d4777687955cd3d4f3a35ce7eba" -dependencies = [ - "bit_field", -] - [[package]] name = "uds_windows" version = "1.2.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a157891..872c312 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -48,7 +48,7 @@ flate2 = "1.1.2" bzip2 = "0.5.2" xz2 = "0.1.7" zstd = "0.13.3" -iso9660_simple = "0.2.7" +isomage = "2.1.0" [target.'cfg(windows)'.dependencies] winapi = { version = "0.3", features = ["fileapi", "shellapi", "winnt", "winuser", "synchapi", "handleapi", "winbase", "winerror", "processthreadsapi", "sysinfoapi"] } diff --git a/src-tauri/capabilities/launch-log.json b/src-tauri/capabilities/launch-log.json new file mode 100644 index 0000000..f6dcd7a --- /dev/null +++ b/src-tauri/capabilities/launch-log.json @@ -0,0 +1,13 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "launch-log", + "description": "Permissions for the launch log window", + "windows": ["launch-log"], + "permissions": [ + "core:default", + "core:window:allow-close", + "core:window:allow-minimize", + "core:window:allow-start-dragging", + "core:window:allow-set-focus" + ] +} diff --git a/src-tauri/src/downloads.rs b/src-tauri/src/downloads.rs index b9d86a0..b26d9da 100644 --- a/src-tauri/src/downloads.rs +++ b/src-tauri/src/downloads.rs @@ -5,7 +5,19 @@ use std::fs; use std::path::PathBuf; #[tauri::command] -pub(crate) fn recover_download_cards(selected_root: String) -> Result, String> { +pub(crate) async fn recover_download_cards( + selected_root: String, +) -> Result, String> { + // Scans every version folder under the root to rebuild the download cards, + // which must happen off the UI thread. + tauri::async_runtime::spawn_blocking(move || recover_download_cards_blocking(selected_root)) + .await + .map_err(|error| format!("Recovering downloads failed: {error}"))? +} + +pub(crate) fn recover_download_cards_blocking( + selected_root: String, +) -> Result, String> { let candidate = PathBuf::from(&selected_root).join("GameVault"); let root = if candidate.exists() { candidate @@ -209,9 +221,10 @@ pub(crate) fn recover_download_cards(selected_root: String) -> Result>> = OnceLock::new(); +static DOWNLOAD_TASK_TOKEN: AtomicU64 = AtomicU64::new(1); + +fn active_download_tasks() -> &'static Mutex> { + ACTIVE_DOWNLOAD_TASKS.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Registers a new download task and returns a guard that deregisters it again, +/// on every exit path including panics. +fn register_download_task(game_id: i64) -> ActiveDownloadEntry { + let token = DOWNLOAD_TASK_TOKEN.fetch_add(1, Ordering::Relaxed); + if let Ok(mut tasks) = active_download_tasks().lock() { + tasks.insert(game_id, token); + } + ActiveDownloadEntry { game_id, token } +} + +fn is_download_task_live(game_id: i64) -> bool { + active_download_tasks() + .lock() + .map(|tasks| tasks.contains_key(&game_id)) + .unwrap_or(false) +} + #[tauri::command] pub(crate) fn download_game_version( app: tauri::AppHandle, @@ -311,6 +373,28 @@ pub(crate) fn download_game_version( resume_position: Option, ) -> Result<(), String> { let control_flag = Arc::new(AtomicU8::new(DOWNLOAD_CONTROL_RUNNING)); + + // A download for this game may already be running: the task survives a + // webview reload, and the frontend resumes downloads it finds on disk + // afterwards. Starting a second task would put two writers on the same file, + // so keep the running one and let the UI re-attach to its progress events. + // + // A task that was paused or cancelled is on its way out and does not count: + // retrying right after cancelling must still work. + let previous_is_still_downloading = control_flags() + .lock() + .ok() + .and_then(|flags| flags.get(&game_id).cloned()) + .map(|flag| flag.load(Ordering::Relaxed) == DOWNLOAD_CONTROL_RUNNING) + .unwrap_or(false); + if previous_is_still_downloading && is_download_task_live(game_id) { + return Ok(()); + } + + // Registered before the task starts so a second call in the same tick can + // never slip past the guard above. + let active_entry = register_download_task(game_id); + { let mut guard = control_flags() .lock() @@ -319,6 +403,8 @@ pub(crate) fn download_game_version( } tauri::async_runtime::spawn(async move { + let _active_entry = active_entry; + let client = reqwest::Client::new(); let mut req = client.get(&url).header("Accept", "*/*"); if let Some(auth) = auth_header.as_ref() { diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index 167248d..366445f 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -110,6 +110,19 @@ pub(crate) fn emit_extract_progress( _ => None, }; + // Remember the state in-process as well: the webview that started the + // extraction may be reloaded (F5) while the task keeps running, and it needs + // to be able to re-attach to it afterwards. + crate::state::set_extraction_snapshot(crate::state::ExtractionSnapshot { + game_id, + status: status.to_string(), + processed, + total, + progress, + current_file: current_file.clone(), + error: error.clone(), + }); + let _ = app.emit( "extract-progress", ExtractProgressEvent { @@ -138,6 +151,20 @@ pub(crate) fn emit_install_copy_progress( _ => None, }; + // Also record it in-process: the install copy runs on a detached thread, so + // the UI must be able to re-attach to it after a webview reload (F5). + crate::state::set_installation_snapshot(crate::state::InstallationSnapshot { + game_id, + step: "copy".to_string(), + status: status.to_string(), + processed, + total, + progress, + current_file: current_file.clone(), + exit_code: None, + error: error.clone(), + }); + let _ = app.emit( "install-copy-progress", InstallCopyProgressEvent { @@ -160,6 +187,20 @@ pub(crate) fn emit_installer_status( exit_code: Option, error: Option, ) { + // Mirror the installer state in-process so a reloaded UI can re-attach to a + // running installer (which keeps running as a detached thread). + crate::state::set_installation_snapshot(crate::state::InstallationSnapshot { + game_id, + step: "installer".to_string(), + status: status.to_string(), + processed: 0, + total: None, + progress: None, + current_file: current_file.clone(), + exit_code, + error: error.clone(), + }); + let _ = app.emit( "installer-status", InstallerStatusEvent { @@ -218,6 +259,14 @@ pub(crate) fn emit_umu_status( line: Option, message: Option, ) { + // Keep the phase in-process too: umu setup keeps running when the webview is + // reloaded, and the overlay must be able to re-attach to it. + crate::state::set_umu_snapshot(Some(crate::state::UmuSnapshot { + status: phase.to_string(), + message: message.clone().or_else(|| line.clone()), + game_title: game_title.map(String::from), + })); + let _ = app.emit( "umu-status", UmuStatusEvent { diff --git a/src-tauri/src/extraction.rs b/src-tauri/src/extraction.rs index fcc1b50..f520269 100644 --- a/src-tauri/src/extraction.rs +++ b/src-tauri/src/extraction.rs @@ -249,75 +249,103 @@ fn extract_7z_archive( Ok(()) } -// ── ISO 9660 extraction support ────────────────────────────────────────────── +// ── ISO 9660 / UDF optical image extraction support ────────────────────────── -struct IsoFileDevice { - file: std::fs::File, -} +/// Byte offset of the volume recognition sequence in an optical image +/// (sector 16 of 2048-byte sectors). +const OPTICAL_IMAGE_VRS_OFFSET: u64 = 16 * 2048; -impl iso9660_simple::Read for IsoFileDevice { - fn read(&mut self, position: usize, buffer: &mut [u8]) -> Option<()> { - use std::io::Seek; - if self - .file - .seek(std::io::SeekFrom::Start(position as u64)) - .is_err() - { - return None; - } - self.file.read_exact(buffer).ok() - } -} +/// Volume recognition sequence identifiers of the optical filesystems we can +/// read: ISO 9660 images carry a `CD001` descriptor, UDF images announce +/// themselves with `NSR02`/`NSR03`, and hybrid images contain both. +const OPTICAL_IMAGE_IDENTIFIERS: [&[u8; 5]; 7] = [ + b"CD001", b"BEA01", b"NSR02", b"NSR03", b"TEA01", b"BOOT2", b"CDW02", +]; -fn is_iso_archive(path: &PathBuf) -> bool { +fn is_optical_image(path: &PathBuf) -> bool { use std::io::{Read, Seek}; let mut file = match std::fs::File::open(path) { Ok(f) => f, Err(_) => return false, }; - if file.seek(std::io::SeekFrom::Start(0x8001)).is_err() { + if file + .seek(std::io::SeekFrom::Start(OPTICAL_IMAGE_VRS_OFFSET)) + .is_err() + { return false; } - let mut sig = [0u8; 5]; - if file.read_exact(&mut sig).is_err() { + + // Read the two descriptor sectors (16 and 17) so the `CD001` primary volume + // descriptor of ISO 9660 images and the UDF volume recognition sequence are + // both covered. The `CD001` signature sits one byte into its sector. + let mut buffer = [0u8; 4096]; + if file.read_exact(&mut buffer).is_err() { return false; } - &sig == b"CD001" + + let has_identifier = |offset: usize, identifier: &[u8; 5]| { + buffer[offset..offset + identifier.len()] == *identifier + }; + + OPTICAL_IMAGE_IDENTIFIERS.iter().any(|identifier| { + has_identifier(0, identifier) + || has_identifier(1, identifier) + || has_identifier(2048, identifier) + }) +} + +/// Accepts plain file and directory names taken from an image's directory +/// records. Anything able to escape the destination (separators, `..`, NUL +/// bytes) is rejected so an adversarial image cannot write outside the +/// extraction folder. +fn optical_image_entry_name(name: &str) -> Option<&str> { + if name.is_empty() + || name == "." + || name == ".." + || name.contains('/') + || name.contains('\\') + || name.contains('\0') + { + return None; + } + Some(name) } -fn count_iso_entries(iso: &mut iso9660_simple::ISO9660, lba: usize) -> u64 { +fn count_optical_image_entries(node: &isomage::TreeNode) -> u64 { let mut count = 0u64; - let entries: Vec<_> = iso.read_directory(lba).collect(); - for entry in &entries { - if entry.name == "." || entry.name == ".." { - continue; - } + for child in &node.children { count += 1; - if entry.is_folder() { - count += count_iso_entries(iso, entry.record.lba.get() as usize); + if child.is_directory { + count += count_optical_image_entries(child); } } count } -fn extract_iso_directory( - iso: &mut iso9660_simple::ISO9660, - lba: usize, +fn extract_optical_image_directory( + image: &mut std::fs::File, + node: &isomage::TreeNode, dest: &Path, processed: &mut u64, total: u64, app: &tauri::AppHandle, game_id: i64, ) -> Result<(), (bool, String)> { - let entries: Vec<_> = iso.read_directory(lba).collect(); - for entry in &entries { - if entry.name == "." || entry.name == ".." { - continue; - } + for child in &node.children { + let name = match optical_image_entry_name(&child.name) { + Some(name) => name, + None => { + log::warn!( + "Skipping optical image entry with an unsafe name: {:?}", + child.name + ); + continue; + } + }; - let dest_path = dest.join(&entry.name); + let dest_path = dest.join(name); - if entry.is_folder() { + if child.is_directory { fs::create_dir_all(&dest_path) .map_err(|e| (false, format!("Failed to create directory: {e}")))?; @@ -328,13 +356,13 @@ fn extract_iso_directory( "extracting", *processed, Some(total), - Some(entry.name.clone()), + Some(name.to_string()), None, ); - extract_iso_directory( - iso, - entry.record.lba.get() as usize, + extract_optical_image_directory( + image, + child, &dest_path, processed, total, @@ -347,31 +375,17 @@ fn extract_iso_directory( .map_err(|e| (false, format!("Failed to create parent directory: {e}")))?; } - let file_size = entry.file_size() as usize; let mut output = std::fs::File::create(&dest_path) .map_err(|e| (false, format!("Failed to create output file: {e}")))?; - let mut offset = 0usize; - let mut buffer = vec![0u8; 8192]; - - while offset < file_size { - let remaining = file_size - offset; - let to_read = std::cmp::min(remaining, buffer.len()); - let buf_slice = &mut buffer[..to_read]; - - if iso.read_file(entry, offset, buf_slice).is_none() { - return Err(( - false, - format!("Failed to read ISO file data at offset {}", offset), - )); - } - - output - .write_all(buf_slice) - .map_err(|e| (false, format!("Failed to write output file: {e}")))?; - - offset += to_read; - } + // Streams the file's bytes straight from the image in fixed-size chunks, + // so multi-gigabyte files never have to be held in memory. + isomage::cat_node(image, child, &mut output).map_err(|e| { + ( + false, + format!("Failed to extract {name:?} from the optical image: {e}"), + ) + })?; *processed += 1; emit_extract_progress( @@ -380,7 +394,7 @@ fn extract_iso_directory( "extracting", *processed, Some(total), - Some(entry.name.clone()), + Some(name.to_string()), None, ); } @@ -388,28 +402,33 @@ fn extract_iso_directory( Ok(()) } -fn extract_iso_archive( +fn extract_optical_image( app: &tauri::AppHandle, game_id: i64, archive: &PathBuf, destination: &PathBuf, ) -> Result<(), (bool, String)> { - let file = std::fs::File::open(archive) - .map_err(|e| (false, format!("Failed to open ISO archive: {e}")))?; + let mut image = std::fs::File::open(archive) + .map_err(|e| (false, format!("Failed to open optical image: {e}")))?; - let device = IsoFileDevice { file }; - let mut iso = iso9660_simple::ISO9660::from_device(device) - .ok_or_else(|| (false, "Failed to parse ISO 9660 filesystem".to_string()))?; + // Reads ISO 9660 (including Joliet and Rock Ridge) and falls back to UDF, so + // UDF-only disc images work alongside classic and hybrid ones. + let image_name = archive.to_string_lossy().to_string(); + let root = isomage::detect_and_parse_filesystem(&mut image, &image_name).map_err(|e| { + ( + false, + format!("Failed to read the optical image's filesystem: {e}"), + ) + })?; - let root_lba = iso.root().lba.get() as usize; - let total = count_iso_entries(&mut iso, root_lba); + let total = count_optical_image_entries(&root); emit_extract_progress(app, game_id, "extracting", 0, Some(total), None, None); let mut processed = 0u64; - extract_iso_directory( - &mut iso, - root_lba, + extract_optical_image_directory( + &mut image, + &root, destination, &mut processed, total, @@ -717,8 +736,8 @@ fn run_extraction( }; } - if is_iso_archive(&archive) { - return match extract_iso_archive(&app, game_id, &archive, &destination) { + if is_optical_image(&archive) { + return match extract_optical_image(&app, game_id, &archive, &destination) { Ok(_) => Ok(ExtractArchiveResponse { success: true, needs_password: false, @@ -752,7 +771,7 @@ fn run_extraction( success: false, needs_password: false, message: Some( - "Unsupported archive format for built-in extractor. Supported formats include ZIP, RAR, 7z, ISO, TAR, TAR.GZ/TGZ, TAR.BZ2/TBZ2, TAR.XZ/TXZ, TAR.ZST/TZST, and single-file GZ/BZ2/XZ/ZST." + "Unsupported archive format for built-in extractor. Supported formats include ZIP, RAR, 7z, ISO 9660/UDF disc images, TAR, TAR.GZ/TGZ, TAR.BZ2/TBZ2, TAR.XZ/TXZ, TAR.ZST/TZST, and single-file GZ/BZ2/XZ/ZST." .to_string(), ), }); @@ -784,6 +803,49 @@ fn run_extraction( } } +/// Publishes the final state of an extraction. +/// +/// The invoke response only reaches the webview that started the extraction, +/// which may have been reloaded (F5) in the meantime. Emitting the terminal +/// state as an event (which also records it in the extraction registry) keeps +/// the UI in sync no matter what happened to the webview. +fn finish_extraction( + app: &tauri::AppHandle, + game_id: i64, + outcome: &Result, +) { + match outcome { + Ok(response) if response.success => { + emit_extract_progress(app, game_id, "completed", 1, Some(1), None, None); + } + Ok(response) if response.needs_password => { + emit_extract_progress( + app, + game_id, + "needs-password", + 0, + None, + None, + response.message.clone(), + ); + } + Ok(response) => { + emit_extract_progress( + app, + game_id, + "error", + 0, + None, + None, + response.message.clone(), + ); + } + Err(err) => { + emit_extract_progress(app, game_id, "error", 0, None, None, Some(err.clone())); + } + } +} + #[tauri::command] pub(crate) async fn extract_archive( app: tauri::AppHandle, @@ -795,12 +857,46 @@ pub(crate) async fn extract_archive( let archive = PathBuf::from(archive_path); let destination = PathBuf::from(destination_path); + // Two extractions writing into the same directory would corrupt the result. + // This can happen when the page is reloaded (F5) mid-extraction and the user + // starts another one before the UI re-attached to the running task. + if crate::state::is_extraction_running(game_id) { + return Ok(ExtractArchiveResponse { + success: false, + needs_password: false, + message: Some("An extraction for this game is already running.".to_string()), + }); + } + + // Record the running state before the work starts so a webview reload during + // the extraction can re-attach to it. + emit_extract_progress(&app, game_id, "extracting", 0, None, None, None); + // Extraction is CPU- and I/O-bound blocking work. Offload it to a dedicated // blocking thread so the event loop / main thread stays responsive and the // UI does not freeze while large archives are being extracted. - tauri::async_runtime::spawn_blocking(move || { - run_extraction(app, game_id, archive, destination, password) + let blocking_app = app.clone(); + let joined = tauri::async_runtime::spawn_blocking(move || { + let outcome = run_extraction( + blocking_app.clone(), + game_id, + archive, + destination, + password, + ); + finish_extraction(&blocking_app, game_id, &outcome); + outcome }) - .await - .map_err(|e| format!("Extraction task failed: {e}"))? + .await; + + match joined { + Ok(outcome) => outcome, + Err(err) => { + // The blocking task panicked or was cancelled: make sure neither the + // registry nor the UI keeps showing an extraction that will never finish. + let message = format!("Extraction task failed: {err}"); + emit_extract_progress(&app, game_id, "error", 0, None, None, Some(message.clone())); + Err(message) + } + } } diff --git a/src-tauri/src/fs_commands.rs b/src-tauri/src/fs_commands.rs index 98aae16..09311c9 100644 --- a/src-tauri/src/fs_commands.rs +++ b/src-tauri/src/fs_commands.rs @@ -96,31 +96,42 @@ fn dir_has_leftover_content(path: &std::path::Path) -> bool { } #[tauri::command] -pub(crate) fn fs_has_leftover_content(path: String) -> Result { - let p = std::path::PathBuf::from(&path); - if !p.exists() { - return Ok(false); - } - if p.is_dir() { - Ok(dir_has_leftover_content(&p)) - } else { - Ok(!is_gamevault_metadata_file(&p)) - } +pub(crate) async fn fs_has_leftover_content(path: String) -> Result { + // Walks the folder recursively: keep it off the UI thread. + tauri::async_runtime::spawn_blocking(move || { + let p = std::path::PathBuf::from(&path); + if !p.exists() { + return Ok(false); + } + if p.is_dir() { + Ok(dir_has_leftover_content(&p)) + } else { + Ok(!is_gamevault_metadata_file(&p)) + } + }) + .await + .map_err(|e| format!("fs_has_leftover_content failed: {e}"))? } #[tauri::command] -pub(crate) fn fs_remove(path: String, recursive: bool) -> Result<(), String> { - let p = std::path::Path::new(&path); - if !p.exists() { - return Ok(()); - } - if recursive && p.is_dir() { - std::fs::remove_dir_all(&path).map_err(|e| format!("fs_remove (recursive) failed for '{}': {}", path, e)) - } else if p.is_dir() { - std::fs::remove_dir(&path).map_err(|e| format!("fs_remove (dir) failed for '{}': {}", path, e)) - } else { - std::fs::remove_file(&path).map_err(|e| format!("fs_remove (file) failed for '{}': {}", path, e)) - } +pub(crate) async fn fs_remove(path: String, recursive: bool) -> Result<(), String> { + // Removing a game folder can delete tens of gigabytes, which must not block + // the UI thread. + tauri::async_runtime::spawn_blocking(move || { + let p = std::path::Path::new(&path); + if !p.exists() { + return Ok(()); + } + if recursive && p.is_dir() { + std::fs::remove_dir_all(&path).map_err(|e| format!("fs_remove (recursive) failed for '{}': {}", path, e)) + } else if p.is_dir() { + std::fs::remove_dir(&path).map_err(|e| format!("fs_remove (dir) failed for '{}': {}", path, e)) + } else { + std::fs::remove_file(&path).map_err(|e| format!("fs_remove (file) failed for '{}': {}", path, e)) + } + }) + .await + .map_err(|e| format!("fs_remove failed: {e}"))? } /// Returns true if `path` contains no real content: no files other than diff --git a/src-tauri/src/games.rs b/src-tauri/src/games.rs index 45956b5..a129eca 100644 --- a/src-tauri/src/games.rs +++ b/src-tauri/src/games.rs @@ -6,6 +6,7 @@ use crate::util::{ }; use serde::Serialize; use std::fs; +use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -24,7 +25,18 @@ pub(crate) fn directory_has_entries(path: &Path) -> bool { use std::os::windows::process::CommandExt; #[tauri::command] -pub(crate) fn list_installed_games( +pub(crate) async fn list_installed_games( + selected_root: String, +) -> Result, String> { + // Scanning the library reads every version folder on disk, which can take a + // while and must not block the UI thread. + tauri::async_runtime::spawn_blocking(move || list_installed_games_blocking(selected_root)) + .await + .map_err(|error| format!("Listing installed games failed: {error}"))? +} + +/// Blocking implementation, also used by the time tracker on its own thread. +pub(crate) fn list_installed_games_blocking( selected_root: String, ) -> Result, String> { let candidate = PathBuf::from(&selected_root).join("GameVault"); @@ -172,7 +184,20 @@ pub(crate) struct DiskUsage { } #[tauri::command] -pub(crate) fn get_disk_usage( +pub(crate) async fn get_disk_usage( + selected_root: String, + current_version_dir: Option, +) -> Result { + // Walks every installed version on disk to size it up: blocking work that + // does not belong on the UI thread. + tauri::async_runtime::spawn_blocking(move || { + get_disk_usage_blocking(selected_root, current_version_dir) + }) + .await + .map_err(|error| format!("Disk usage scan failed: {error}"))? +} + +fn get_disk_usage_blocking( selected_root: String, current_version_dir: Option, ) -> Result { @@ -371,20 +396,72 @@ pub(crate) fn open_external_url(url: String) -> Result<(), String> { .map_err(|e| format!("Failed to open URL: {e}")) } +/// Safety limits for the recursive candidate walk: a game folder can contain +/// a huge file tree (or a junction to one), and the walk runs on every tracker +/// tick for every installed game. +const MAX_CANDIDATE_DEPTH: usize = 6; +const MAX_CANDIDATE_ENTRIES: usize = 20_000; + pub(crate) fn collect_launch_candidates( root: &Path, current: &Path, results: &mut Vec, ) -> Result<(), String> { - let entries = - fs::read_dir(current).map_err(|e| format!("Failed to read installation folder: {e}"))?; + let mut visited = 0usize; + collect_launch_candidates_inner(root, current, results, 0, &mut visited) +} + +fn collect_launch_candidates_inner( + root: &Path, + current: &Path, + results: &mut Vec, + depth: usize, + visited: &mut usize, +) -> Result<(), String> { + if depth > MAX_CANDIDATE_DEPTH || *visited >= MAX_CANDIDATE_ENTRIES { + return Ok(()); + } + + // A folder we cannot read (permissions, deleted mid-walk) must not abort + // the whole scan: skip it and keep the other candidates. + let entries = match fs::read_dir(current) { + Ok(entries) => entries, + Err(error) => { + log::warn!( + "Skipping unreadable folder during launch candidate scan: {} ({error})", + current.display() + ); + return Ok(()); + } + }; for entry in entries { - let entry = entry.map_err(|e| format!("Failed to read installation folder entry: {e}"))?; + if *visited >= MAX_CANDIDATE_ENTRIES { + log::warn!( + "Launch candidate scan stopped after {MAX_CANDIDATE_ENTRIES} entries in {}", + root.display() + ); + return Ok(()); + } + + let Ok(entry) = entry else { + continue; + }; + *visited += 1; + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; - if path.is_dir() { - collect_launch_candidates(root, &path, results)?; + // Never follow links/junctions: they can point at a parent folder and + // turn this walk into an endless loop. + if file_type.is_symlink() { + continue; + } + + if file_type.is_dir() { + collect_launch_candidates_inner(root, &path, results, depth + 1, visited)?; continue; } @@ -492,7 +569,19 @@ fn collect_non_executable_scripts( } #[tauri::command] -pub(crate) fn list_launch_executables( +pub(crate) async fn list_launch_executables( + app: tauri::AppHandle, + installation_path: String, +) -> Result { + // Recursive scan of the installation folder: keep it off the UI thread. + tauri::async_runtime::spawn_blocking(move || { + list_launch_executables_blocking(app, installation_path) + }) + .await + .map_err(|error| format!("Listing launch executables failed: {error}"))? +} + +fn list_launch_executables_blocking( app: tauri::AppHandle, installation_path: String, ) -> Result { @@ -557,10 +646,49 @@ pub(crate) fn make_script_executable( } #[tauri::command] +pub(crate) async fn launch_game( + app: tauri::AppHandle, + game_title: String, + game_id: Option, + version_directory: Option, + installation_path: String, + executable_relative_path: String, + launch_parameters: Option, + run_as_admin: Option, + umu_game_id: Option, + umu_store: Option, + umu_proton_path: Option, + umu_wine_prefix: Option, +) -> Result<(), String> { + // Launching can first install umu-launcher (a multi-megabyte download) and + // spawns processes, so it must not run on the UI thread: the setup overlay + // has to stay responsive while it happens. + tauri::async_runtime::spawn_blocking(move || { + launch_game_blocking( + app, + game_title, + game_id, + version_directory, + installation_path, + executable_relative_path, + launch_parameters, + run_as_admin, + umu_game_id, + umu_store, + umu_proton_path, + umu_wine_prefix, + ) + }) + .await + .map_err(|error| format!("Launching the game failed: {error}"))? +} + #[cfg_attr(not(target_os = "linux"), allow(unused_variables))] -pub(crate) fn launch_game( +fn launch_game_blocking( app: tauri::AppHandle, game_title: String, + game_id: Option, + version_directory: Option, installation_path: String, executable_relative_path: String, launch_parameters: Option, @@ -577,7 +705,16 @@ pub(crate) fn launch_game( } let working_dir = exe_path.parent().unwrap_or(&root); - let restore_on_exit = load_settings(&app).minimize_on_game_launch; + let settings = load_settings(&app); + let restore_on_exit = settings.minimize_on_game_launch; + + // Every launch gets its own log: written to a rotating file next to the + // app logs and streamed into the "Launch log" window, which also opens + // automatically when the user enabled that in the settings. + let log = crate::launch_log::begin_launch(&app, &game_title, "game"); + if settings.always_show_launch_logs { + let _ = crate::launch_log::open_or_focus_log_window(&app); + } // On Linux a Windows executable cannot run natively; run it through // umu-launcher (Proton/Wine), auto-installing it when missing. This comes @@ -589,6 +726,8 @@ pub(crate) fn launch_game( return crate::umu::launch_with_umu( app, game_title, + version_directory, + game_id, &exe_path, launch_parameters.as_deref(), umu_game_id.as_deref(), @@ -596,6 +735,7 @@ pub(crate) fn launch_game( umu_proton_path.as_deref(), umu_wine_prefix.as_deref(), restore_on_exit, + Some(log), ); } @@ -648,6 +788,7 @@ pub(crate) fn launch_game( err_path, ADMIN_LAUNCH_GRACE, restore_on_exit, + log, ); return Ok(()); } @@ -678,6 +819,7 @@ pub(crate) fn launch_game( err_path, ADMIN_LAUNCH_GRACE, restore_on_exit, + log, ); Ok(()) } @@ -738,6 +880,7 @@ pub(crate) fn launch_game( err_path, LAUNCH_GRACE, restore_on_exit, + log, ); Ok(()) } @@ -928,6 +1071,7 @@ fn spawn_launch_monitor( err_path: PathBuf, grace: Duration, restore_on_exit: bool, + log: std::sync::Arc, ) { thread::spawn(move || { // When the user has enabled "minimize on launch", tuck the gamevault @@ -938,25 +1082,25 @@ fn spawn_launch_monitor( let start = Instant::now(); let mut launched_running = false; + // Streams what the game writes to its output files while it runs, so the + // log is complete even for launches that outlive the grace window. + let mut tail = LaunchFileTail::new(); let status = loop { + tail.read_new_lines(&log, &out_path, &err_path); match child.try_wait() { Ok(Some(status)) => break status, Ok(None) => { - if start.elapsed() >= grace { - // Still running after the grace window — the launch succeeded and - // the game actually ran. If we are restoring on exit, keep watching - // until the process quits so we can bring the window back; - // otherwise stop watching (the process is already detached). + if start.elapsed() >= grace && !launched_running { + // Still running after the grace window — the launch succeeded + // and the game actually ran. launched_running = true; - if !restore_on_exit { - clean_up_launch_logs(&out_path, &err_path); - return; - } + log.push("info", "The game is running."); } - thread::sleep(Duration::from_millis(50)); + thread::sleep(Duration::from_millis(100)); } Err(_) => { + log.finish_failed(&app, None); clean_up_launch_logs(&out_path, &err_path); restore_main_window(&app); return; @@ -966,10 +1110,13 @@ fn spawn_launch_monitor( let stdout = fs::read_to_string(&out_path).unwrap_or_default(); let stderr = fs::read_to_string(&err_path).unwrap_or_default(); - clean_up_launch_logs(&out_path, &err_path); let exit_code = status.code(); let message = format_launch_output(&stderr, &stdout); + // Everything the tailer has not pushed yet (e.g. output written between + // the last poll and the process exit) — read before the files go away. + tail.read_new_lines(&log, &out_path, &err_path); + clean_up_launch_logs(&out_path, &err_path); // Restore the window regardless of whether the game exited cleanly, so // the user is never left with a hidden gamevault window. @@ -981,11 +1128,97 @@ fn spawn_launch_monitor( // running, ignore benign shutdown console output (engine warnings, etc.) // so we don't report a false "exited with an error" alert. if !launched_running { + log.finish_failed(&app, exit_code); emit_game_launch_failed(&app, game_title, exit_code, message); + } else { + log.finish_success(&app, exit_code); } }); } +/// How many bytes of process output are streamed into the log at most. +const MAX_CAPTURED_OUTPUT_BYTES: u64 = 16 * 1024 * 1024; + +/// Follows the `.out`/`.err` files of a running game and pushes new lines into +/// the launch log. The child writes to files instead of pipes (so a chatty game +/// can never block on a full pipe buffer), which is why we tail them. +struct LaunchFileTail { + out_offset: u64, + err_offset: u64, + out_partial: String, + err_partial: String, + captured: u64, +} + +impl LaunchFileTail { + fn new() -> Self { + Self { + out_offset: 0, + err_offset: 0, + out_partial: String::new(), + err_partial: String::new(), + captured: 0, + } + } + + fn read_new_lines( + &mut self, + log: &crate::launch_log::LaunchLog, + out_path: &Path, + err_path: &Path, + ) { + if self.captured >= MAX_CAPTURED_OUTPUT_BYTES { + return; + } + self.read_stream(log, out_path, "out", false); + self.read_stream(log, err_path, "err", true); + } + + fn read_stream( + &mut self, + log: &crate::launch_log::LaunchLog, + path: &Path, + stream: &str, + is_err: bool, + ) { + let (offset, partial) = if is_err { + (&mut self.err_offset, &mut self.err_partial) + } else { + (&mut self.out_offset, &mut self.out_partial) + }; + + let Ok(mut file) = fs::File::open(path) else { + return; + }; + if file.seek(SeekFrom::Start(*offset)).is_err() { + return; + } + let mut chunk = String::new(); + let Ok(read) = file.read_to_string(&mut chunk) else { + return; + }; + if read == 0 { + return; + } + *offset += read as u64; + self.captured = self.captured.saturating_add(read as u64); + + partial.push_str(&chunk); + while let Some(index) = partial.find('\n') { + let line = partial[..index].trim_end_matches('\r').to_string(); + partial.drain(..=index); + if !line.trim().is_empty() { + log.push(stream, &line); + } + } + if partial.len() > 8 * 1024 { + // Defensive: never keep an unbounded partial line around. + let line = std::mem::take(partial); + log.push(stream, &line); + } + } +} + #[cfg(test)] mod tests { use super::validate_external_url; diff --git a/src-tauri/src/installation.rs b/src-tauri/src/installation.rs index 20fb5b1..2539cc8 100644 --- a/src-tauri/src/installation.rs +++ b/src-tauri/src/installation.rs @@ -186,7 +186,14 @@ fn copy_path_with_progress( } #[tauri::command] -pub(crate) fn list_install_executables(extraction_path: String) -> Result, String> { +pub(crate) async fn list_install_executables(extraction_path: String) -> Result, String> { + // Recursively scans the extraction folder for installers: off the UI thread. + tauri::async_runtime::spawn_blocking(move || list_install_executables_blocking(extraction_path)) + .await + .map_err(|error| format!("Listing install executables failed: {error}"))? +} + +fn list_install_executables_blocking(extraction_path: String) -> Result, String> { let root = PathBuf::from(extraction_path); if !root.exists() || !root.is_dir() { return Ok(Vec::new()); @@ -260,10 +267,19 @@ pub(crate) fn copy_installation_files( return Err("Extraction folder does not exist".to_string()); } + // The copy keeps running when the webview is reloaded (F5). Two copies into + // the same folder would fight over the same files, so keep the running one + // and let the UI re-attach to its progress events. + if crate::state::is_install_copy_running(game_id) { + return Ok(()); + } + fs::create_dir_all(&destination) .map_err(|e| format!("Failed to create installation directory: {e}"))?; std::thread::spawn(move || { + // Sizing the source and checking free space can take seconds on large + // games, so it runs on this thread rather than blocking the UI. let total = match compute_directory_size(&source) { Ok(total) => total, Err(error) => { @@ -405,13 +421,36 @@ fn wait_for_installation_populated(path: &Path) -> bool { fn run_installer_via_umu( app: tauri::AppHandle, game_id: i64, + version_directory: Option<&str>, installer_path: &Path, installer_relative: &str, installation_path_resolved: &str, installer_parameters: Option, is_msi: bool, + umu_game_id: Option<&str>, + umu_store: Option<&str>, + umu_proton_path: Option<&str>, + umu_wine_prefix: Option<&str>, ) { + // Installer runs get their own log too: the window is not opened + // automatically (installs take a while), but the output can be opened from + // the settings at any time. + let log = crate::launch_log::begin_launch( + &app, + &format!("Installer: {installer_relative}"), + "installer", + ); + log.push( + "info", + &format!("Installer: {}", installer_path.display()), + ); + if let Some(parameters) = installer_parameters.as_deref() { + log.push("info", &format!("Parameters: {parameters}")); + } + if let Err(error) = crate::umu::ensure_umu_installed(&app, None) { + log.push("err", &error); + log.finish_failed(&app, None); emit_installer_status( &app, game_id, @@ -425,6 +464,8 @@ fn run_installer_via_umu( let Some(umu_run) = crate::umu::find_umu_run() else { let message = "umu-run was not found. Unable to launch Windows installer on Linux.".to_string(); + log.push("err", &message); + log.finish_failed(&app, None); crate::events::emit_umu_status(&app, None, "error", None, Some(message.clone())); emit_installer_status( &app, @@ -449,14 +490,28 @@ fn run_installer_via_umu( } command.arg(installer_path).current_dir(&working_dir); - // Use an isolated per-game Wine prefix. There is no per-game override on the - // installer path, so this resolves to `/` whenever a - // global default base directory is configured (otherwise umu's default). - if let Some(prefix) = - crate::umu::resolve_wine_prefix(&app, None, &format!("game-{}", game_id)) - { + // Use the same isolated per-game prefix as the game launch, so a game is + // installed into the prefix it later runs in. + if let Some(prefix) = crate::umu::resolve_wine_prefix( + &app, + &crate::umu::PrefixContext { + per_game_prefix: umu_wine_prefix, + version_directory, + game_id: Some(game_id), + umu_game_id, + }, + ) { command.env("WINEPREFIX", &prefix); } + if let Some(value) = umu_game_id.map(str::trim).filter(|v| !v.is_empty()) { + command.env("GAMEID", value); + } + if let Some(value) = umu_store.map(str::trim).filter(|v| !v.is_empty()) { + command.env("STORE", value); + } + if let Some(value) = umu_proton_path.map(str::trim).filter(|v| !v.is_empty()) { + command.env("PROTONPATH", value); + } let mut installer_args: Vec = Vec::new(); if is_msi { @@ -508,7 +563,7 @@ fn run_installer_via_umu( let stdout = child.stdout.take(); let stderr = child.stderr.take(); - let handle = crate::umu::spawn_umu_streamers(&app, None, stdout, stderr); + let handle = crate::umu::spawn_umu_streamers(&app, None, stdout, stderr, Some(log.clone())); emit_installer_status( &app, @@ -546,6 +601,11 @@ fn run_installer_via_umu( let exit_code = status.as_ref().ok().and_then(|s| s.code()); if status.map(|s| s.success()).unwrap_or(false) { + log.push( + "info", + &format!("Installer exited with code {}.", exit_code.unwrap_or(0)), + ); + log.finish_success(&app, exit_code); emit_installer_status( &app, game_id, @@ -557,6 +617,14 @@ fn run_installer_via_umu( } else if wait_for_installation_populated(Path::new(installation_path_resolved)) { // Non-zero exit is not a reliable failure (GOG/NSIS wrappers detach a // child that keeps installing); verify the install dir got populated. + log.push( + "info", + &format!( + "Installer exited with code {}, but the installation directory was populated — treating it as success.", + exit_code.unwrap_or(-1) + ), + ); + log.finish_success(&app, exit_code); emit_installer_status( &app, game_id, @@ -566,16 +634,16 @@ fn run_installer_via_umu( None, ); } else { + let message = format!("Installer exited with code {}.", exit_code.unwrap_or(-1)); + log.push("err", &message); + log.finish_failed(&app, exit_code); emit_installer_status( &app, game_id, "error", Some(installer_relative.to_string()), exit_code, - Some(format!( - "Installer exited with code {}.", - exit_code.unwrap_or(-1) - )), + Some(message), ); } } @@ -584,10 +652,15 @@ fn run_installer_via_umu( pub(crate) fn launch_installation_executable( app: tauri::AppHandle, game_id: i64, + version_directory: Option, extraction_path: String, installer_relative_path: String, installation_path: String, installer_parameters: Option, + umu_game_id: Option, + umu_store: Option, + umu_proton_path: Option, + umu_wine_prefix: Option, ) -> Result<(), String> { let extraction_root = PathBuf::from(extraction_path); // Candidate paths are normalized to forward slashes; convert back to the @@ -604,6 +677,14 @@ pub(crate) fn launch_installation_executable( let installation_path_resolved = installation_path.clone(); let installer_relative = installer_relative_path.clone(); + let version_directory = version_directory.clone(); + + // An installer keeps running when the webview is reloaded (F5), and the UI + // cannot see it any more. Starting a second one would run two setup + // processes over the same folder, so keep the running one. + if crate::state::is_installer_running(game_id) { + return Ok(()); + } let (_, saved_installer_parameters) = read_saved_installer_preferences(&extraction_root); let installer_parameters = installer_parameters .map(|value| value.trim().to_string()) @@ -631,11 +712,16 @@ pub(crate) fn launch_installation_executable( run_installer_via_umu( app, game_id, + version_directory.as_deref(), &installer_path, &installer_relative, &installation_path_resolved, installer_parameters.clone(), is_msi, + umu_game_id.as_deref(), + umu_store.as_deref(), + umu_proton_path.as_deref(), + umu_wine_prefix.as_deref(), ); return; } @@ -806,10 +892,15 @@ pub(crate) fn launch_installation_executable( #[cfg(target_os = "linux")] fn run_uninstall_via_umu( app: tauri::AppHandle, + version_directory: Option<&str>, executable: &Path, working_directory: Option<&str>, arguments: Option, is_msi: bool, + umu_game_id: Option<&str>, + umu_store: Option<&str>, + umu_proton_path: Option<&str>, + umu_wine_prefix: Option<&str>, ) -> Result, String> { if let Err(error) = crate::umu::ensure_umu_installed(&app, None) { return Err(error); @@ -832,6 +923,28 @@ fn run_uninstall_via_umu( } } + // The uninstaller must run in the prefix the game was installed into. + if let Some(prefix) = crate::umu::resolve_wine_prefix( + &app, + &crate::umu::PrefixContext { + per_game_prefix: umu_wine_prefix, + version_directory, + game_id: None, + umu_game_id, + }, + ) { + command.env("WINEPREFIX", &prefix); + } + if let Some(value) = umu_game_id.map(str::trim).filter(|v| !v.is_empty()) { + command.env("GAMEID", value); + } + if let Some(value) = umu_store.map(str::trim).filter(|v| !v.is_empty()) { + command.env("STORE", value); + } + if let Some(value) = umu_proton_path.map(str::trim).filter(|v| !v.is_empty()) { + command.env("PROTONPATH", value); + } + let mut child = command .spawn() .map_err(|error| format!("Failed to start uninstall executable: {error}"))?; @@ -850,12 +963,47 @@ fn run_uninstall_via_umu( } #[tauri::command] +pub(crate) async fn launch_uninstall_executable( + app: tauri::AppHandle, + version_directory: Option, + executable_path: String, + working_directory: Option, + argument_list: Option, + umu_game_id: Option, + umu_store: Option, + umu_proton_path: Option, + umu_wine_prefix: Option, +) -> Result, String> { + // The uninstaller is waited for, which takes as long as the user needs in its + // wizard: running that on the UI thread used to freeze the whole app. + tauri::async_runtime::spawn_blocking(move || { + launch_uninstall_executable_blocking( + app, + version_directory, + executable_path, + working_directory, + argument_list, + umu_game_id, + umu_store, + umu_proton_path, + umu_wine_prefix, + ) + }) + .await + .map_err(|error| format!("Uninstall task failed: {error}"))? +} + #[cfg_attr(not(target_os = "linux"), allow(unused_variables))] -pub(crate) fn launch_uninstall_executable( +fn launch_uninstall_executable_blocking( app: tauri::AppHandle, + version_directory: Option, executable_path: String, working_directory: Option, argument_list: Option, + umu_game_id: Option, + umu_store: Option, + umu_proton_path: Option, + umu_wine_prefix: Option, ) -> Result, String> { let executable = PathBuf::from(&executable_path); if !executable.exists() || !executable.is_file() { @@ -873,10 +1021,15 @@ pub(crate) fn launch_uninstall_executable( if crate::umu::is_windows_executable(&executable) { return run_uninstall_via_umu( app, + version_directory.as_deref(), &executable, working_directory.as_deref(), argument_list, is_msi, + umu_game_id.as_deref(), + umu_store.as_deref(), + umu_proton_path.as_deref(), + umu_wine_prefix.as_deref(), ); } diff --git a/src-tauri/src/launch_log.rs b/src-tauri/src/launch_log.rs new file mode 100644 index 0000000..1257d96 --- /dev/null +++ b/src-tauri/src/launch_log.rs @@ -0,0 +1,525 @@ +//! Launch logs. +//! +//! Every game launch (and umu installer run) is recorded here: the full output +//! is written to a rotating log file in the app log directory and kept in a +//! bounded in-memory tail, which the "Launch log" window streams live. The logs +//! are always written, so a crash can be reported with a complete log even when +//! the user never opened the window. + +use serde::Serialize; +use std::collections::VecDeque; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufWriter, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; + +/// Maximum number of lines kept in memory (oldest are dropped). +const MAX_TAIL_LINES: usize = 5_000; +/// Stop writing to the log file after this many bytes. +const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024; +/// Number of log files kept on disk (oldest are deleted). +const MAX_LOG_FILES: usize = 20; +/// Batching interval for the live stream to the log window. +const FLUSH_INTERVAL: Duration = Duration::from_millis(150); + +pub const WINDOW_LABEL: &str = "launch-log"; +pub const LINES_EVENT: &str = "launch-log-lines"; +pub const STATE_EVENT: &str = "launch-log-state"; + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct LaunchLogLine { + pub seq: u64, + /// "out", "err" or "info". + pub stream: String, + pub text: String, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct LaunchLogSnapshot { + pub id: u64, + pub title: String, + pub kind: String, + pub status: String, + pub exit_code: Option, + pub path: Option, + pub truncated: bool, + pub next_seq: u64, + pub lines: Vec, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct LaunchLogEntry { + pub id: u64, + pub title: String, + pub kind: String, + pub status: String, + pub path: Option, + pub started_at: u64, + pub lines: usize, +} + +pub(crate) struct LaunchLog { + id: u64, + title: String, + kind: String, + started_at: u64, + status: Mutex, + exit_code: AtomicI64, + seq: AtomicU64, + truncated: AtomicBool, + finished: AtomicBool, + /// Bytes written to the file so far. + written: AtomicU64, + file: Mutex>>, + path: Mutex>, + lines: Mutex>, + pending: Mutex>, +} + +static CURRENT: OnceLock>>> = OnceLock::new(); +static HISTORY: OnceLock>> = OnceLock::new(); +static NEXT_ID: AtomicU64 = AtomicU64::new(1); + +fn current_store() -> &'static Mutex>> { + CURRENT.get_or_init(|| Mutex::new(None)) +} + +fn history_store() -> &'static Mutex> { + HISTORY.get_or_init(|| Mutex::new(VecDeque::new())) +} + +/// Directory that holds the rotating launch log files. +pub(crate) fn log_directory(app: &AppHandle) -> Option { + let dir = app.path().app_log_dir().ok()?.join("launch-logs"); + fs::create_dir_all(&dir).ok()?; + Some(dir) +} + +fn slugify(value: &str) -> String { + let mut slug: String = value + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { + c + } else { + '-' + } + }) + .collect(); + slug = slug.trim_matches('-').to_string(); + if slug.is_empty() { + "launch".to_string() + } else { + slug.truncate(60); + slug + } +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_secs()) + .unwrap_or(0) +} + +/// Deletes the oldest log files so only the newest `MAX_LOG_FILES` remain. +fn rotate_log_files(dir: &Path) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + let mut files: Vec = entries + .flatten() + .filter(|entry| entry.path().extension().and_then(|e| e.to_str()) == Some("log")) + .map(|entry| entry.path()) + .collect(); + files.sort(); + while files.len() >= MAX_LOG_FILES { + let oldest = files.remove(0); + let _ = fs::remove_file(oldest); + } +} + +/// Starts a new launch log. Any log that is still running is finished first. +pub(crate) fn begin_launch(app: &AppHandle, title: &str, kind: &str) -> Arc { + if let Ok(guard) = current_store().lock() { + if let Some(previous) = guard.as_ref() { + if !previous.finished.load(Ordering::SeqCst) { + previous.finish(app, "finished", None); + } + } + } + + let id = NEXT_ID.fetch_add(1, Ordering::SeqCst); + let path = log_directory(app).map(|dir| { + rotate_log_files(&dir); + dir.join(format!("{}-{}-{}.log", unix_now(), id, slugify(title))) + }); + + let file = path.as_ref().and_then(|path| { + OpenOptions::new() + .create(true) + .append(true) + .open(path) + .ok() + .map(BufWriter::new) + }); + + let log = Arc::new(LaunchLog { + id, + title: title.to_string(), + kind: kind.to_string(), + started_at: unix_now(), + status: Mutex::new("running".to_string()), + exit_code: AtomicI64::new(i64::MIN), + seq: AtomicU64::new(0), + truncated: AtomicBool::new(false), + finished: AtomicBool::new(false), + written: AtomicU64::new(0), + file: Mutex::new(file), + path: Mutex::new(path), + lines: Mutex::new(VecDeque::new()), + pending: Mutex::new(Vec::new()), + }); + + if let Ok(mut guard) = current_store().lock() { + *guard = Some(log.clone()); + } + if let Ok(mut history) = history_store().lock() { + history.push_front(entry_of(&log)); + while history.len() > MAX_LOG_FILES { + history.pop_back(); + } + } + + log.push("info", &format!("=== Launching {} ===", title)); + emit_state(app, &log); + spawn_flusher(app.clone(), log.clone()); + log +} + +fn entry_of(log: &LaunchLog) -> LaunchLogEntry { + LaunchLogEntry { + id: log.id, + title: log.title.clone(), + kind: log.kind.clone(), + status: log + .status + .lock() + .map(|value| value.clone()) + .unwrap_or_else(|_| "running".to_string()), + path: log + .path + .lock() + .ok() + .and_then(|value| value.as_ref().map(|p| p.to_string_lossy().to_string())), + started_at: log.started_at, + lines: log.lines.lock().map(|value| value.len()).unwrap_or(0), + } +} + +fn emit_state(app: &AppHandle, log: &LaunchLog) { + if let Some(snapshot) = snapshot_of(log, MAX_TAIL_LINES) { + let _ = app.emit_to(WINDOW_LABEL, STATE_EVENT, snapshot); + } +} + +/// Emits pending lines in batches while the launch is running. +fn spawn_flusher(app: AppHandle, log: Arc) { + std::thread::spawn(move || { + while !log.finished.load(Ordering::SeqCst) { + std::thread::sleep(FLUSH_INTERVAL); + flush_pending(&app, &log); + } + flush_pending(&app, &log); + }); +} + +fn flush_pending(app: &AppHandle, log: &LaunchLog) { + let batch: Vec = match log.pending.lock() { + Ok(mut pending) => pending.drain(..).collect(), + Err(_) => return, + }; + if batch.is_empty() { + return; + } + let start_seq = batch.first().map(|line| line.seq).unwrap_or(0); + let _ = app.emit_to( + WINDOW_LABEL, + LINES_EVENT, + serde_json::json!({ + "logId": log.id, + "startSeq": start_seq, + "lines": batch, + }), + ); +} + +impl LaunchLog { + /// Appends one line to the in-memory tail, the log file and the live stream. + pub fn push(&self, stream: &str, text: &str) { + if self.finished.load(Ordering::SeqCst) { + return; + } + + let line = LaunchLogLine { + seq: self.seq.fetch_add(1, Ordering::SeqCst), + stream: stream.to_string(), + text: text.to_string(), + }; + + if let Ok(mut lines) = self.lines.lock() { + lines.push_back(line.clone()); + while lines.len() > MAX_TAIL_LINES { + lines.pop_front(); + self.truncated.store(true, Ordering::SeqCst); + } + } + if let Ok(mut pending) = self.pending.lock() { + pending.push(line); + } + + if self.written.load(Ordering::SeqCst) >= MAX_FILE_BYTES { + self.truncated.store(true, Ordering::SeqCst); + return; + } + if let Ok(mut guard) = self.file.lock() { + if let Some(writer) = guard.as_mut() { + let line = format!("[{}] {}\n", stream, text); + if writer.write_all(line.as_bytes()).is_ok() { + self + .written + .fetch_add(line.len() as u64, Ordering::SeqCst); + let _ = writer.flush(); + } + } + } + } + + fn finish(&self, app: &AppHandle, status: &str, exit_code: Option) { + if self.finished.swap(true, Ordering::SeqCst) { + return; + } + + match exit_code { + Some(code) => self.exit_code.store(code as i64, Ordering::SeqCst), + None => self.exit_code.store(i64::MIN, Ordering::SeqCst), + } + if let Ok(mut value) = self.status.lock() { + *value = status.to_string(); + } + + let summary = match exit_code { + Some(code) => format!("=== {} (exit code {}) ===", status, code), + None => format!("=== {} ===", status), + }; + self.push("info", &summary); + + if let Ok(mut guard) = self.file.lock() { + guard.take(); + } + + flush_pending(app, self); + emit_state(app, self); + if let Ok(mut history) = history_store().lock() { + if let Some(entry) = history.iter_mut().find(|entry| entry.id == self.id) { + *entry = entry_of(self); + } + } + } + + pub fn finish_success(&self, app: &AppHandle, exit_code: Option) { + self.finish(app, "finished", exit_code); + } + + pub fn finish_failed(&self, app: &AppHandle, exit_code: Option) { + self.finish(app, "failed", exit_code); + } +} + +fn snapshot_of(log: &LaunchLog, limit: usize) -> Option { + let lines: Vec = log + .lines + .lock() + .ok()? + .iter() + .rev() + .take(limit) + .rev() + .cloned() + .collect(); + let exit_code = match log.exit_code.load(Ordering::SeqCst) { + value if value == i64::MIN => None, + value => Some(value as i32), + }; + + Some(LaunchLogSnapshot { + id: log.id, + title: log.title.clone(), + kind: log.kind.clone(), + status: log + .status + .lock() + .map(|value| value.clone()) + .unwrap_or_else(|_| "running".to_string()), + exit_code, + path: log + .path + .lock() + .ok() + .and_then(|value| value.as_ref().map(|p| p.to_string_lossy().to_string())), + truncated: log.truncated.load(Ordering::SeqCst), + next_seq: log.seq.load(Ordering::SeqCst), + lines, + }) +} + +// ── Window ───────────────────────────────────────────────────────────────── + +/// Opens (or focuses) the launch log window. +pub(crate) fn open_or_focus_log_window(app: &AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window(WINDOW_LABEL) { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + return Ok(()); + } + + WebviewWindowBuilder::new( + app, + WINDOW_LABEL, + WebviewUrl::App("index.html#launch-log".into()), + ) + .title("GameVault — Launch log") + .inner_size(1100.0, 700.0) + .min_inner_size(600.0, 400.0) + .build() + .map(|_| ()) + .map_err(|error| format!("Failed to open the launch log window: {error}")) +} + +// ── Commands ─────────────────────────────────────────────────────────────── + +#[tauri::command] +pub(crate) fn get_launch_log(log_id: Option) -> Option { + let log = current_store().lock().ok().and_then(|guard| guard.clone())?; + if let Some(requested) = log_id { + if requested != log.id { + return None; + } + } + snapshot_of(&log, MAX_TAIL_LINES) +} + +#[tauri::command] +pub(crate) fn list_launch_logs() -> Vec { + history_store() + .lock() + .map(|guard| guard.iter().cloned().collect()) + .unwrap_or_default() +} + +#[tauri::command] +pub(crate) fn open_launch_log_window(app: AppHandle) -> Result<(), String> { + open_or_focus_log_window(&app) +} + +#[tauri::command] +pub(crate) fn open_launch_log_folder(app: AppHandle) -> Result<(), String> { + let dir = log_directory(&app).ok_or_else(|| "Log directory unavailable".to_string())?; + crate::games::open_in_file_explorer(dir.to_string_lossy().to_string()) +} + +#[tauri::command] +pub(crate) fn clear_launch_logs(app: AppHandle) -> Result<(), String> { + if let Some(dir) = log_directory(&app) { + if let Ok(entries) = fs::read_dir(&dir) { + for entry in entries.flatten() { + let _ = fs::remove_file(entry.path()); + } + } + } + if let Ok(mut history) = history_store().lock() { + history.clear(); + } + Ok(()) +} + +/// Reads the tail of a log file (for logs that are no longer in memory). +#[tauri::command] +pub(crate) fn read_launch_log_file(path: String, max_bytes: Option) -> Result { + read_log_tail(Path::new(&path), max_bytes.unwrap_or(256 * 1024)) +} + +/// Reads at most `max_bytes` from the end of a log file, dropping a partial +/// first line (that is what the window shows for older logs). +fn read_log_tail(path: &Path, max_bytes: u64) -> Result { + let mut file = File::open(path).map_err(|error| format!("Failed to open log file: {error}"))?; + let length = file + .metadata() + .map_err(|error| format!("Failed to read log file: {error}"))? + .len(); + let start = length.saturating_sub(max_bytes); + if start > 0 { + file.seek(SeekFrom::Start(start)) + .map_err(|error| format!("Failed to read log file: {error}"))?; + } + let mut buffer = String::new(); + file + .read_to_string(&mut buffer) + .map_err(|error| format!("Failed to read log file: {error}"))?; + if start > 0 { + // Drop the partial first line. + if let Some(index) = buffer.find('\n') { + buffer = buffer[index + 1..].to_string(); + } + } + Ok(buffer) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slugifies_log_file_names() { + assert_eq!(slugify("ReStory - Chill Repairs"), "ReStory---Chill-Repairs"); + assert_eq!(slugify(" "), "launch"); + assert!(slugify(&"x".repeat(200)).len() <= 60); + } + + #[test] + fn rotates_log_files_to_the_newest_ones() { + let dir = std::env::temp_dir().join(format!("gv-log-rotation-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + for index in 0..MAX_LOG_FILES + 5 { + fs::write(dir.join(format!("{:04}-launch.log", index)), "x").unwrap(); + } + rotate_log_files(&dir); + + let remaining = fs::read_dir(&dir).unwrap().count(); + assert_eq!(remaining, MAX_LOG_FILES - 1); + assert!(!dir.join("0000-launch.log").exists()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn reads_the_tail_of_a_log_file() { + let path = std::env::temp_dir().join(format!("gv-log-tail-{}", std::process::id())); + fs::write(&path, "first\nsecond\nthird\n").unwrap(); + + assert_eq!(read_log_tail(&path, 1024).unwrap(), "first\nsecond\nthird\n"); + // A byte limit that cuts into the first line drops that partial line. + assert_eq!(read_log_tail(&path, 8).unwrap(), "third\n"); + + let _ = fs::remove_file(&path); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 94b3cbd..f6772a3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,8 @@ mod util; mod downloads; mod extraction; mod installation; +mod launch_log; +mod tracker_log; mod games; mod fs_commands; mod time_tracker; @@ -286,6 +288,54 @@ async fn download_and_install_app_update( app: AppHandle, channel: UpdateChannel, ) -> Result, String> { + // A reloaded page can ask again while the update task started earlier keeps + // running. Downloading the update twice (and installing it twice) would be a + // mess, so refuse while one is already in flight. + if crate::state::app_update_snapshot() + .map(|snapshot| snapshot.status == "downloading" || snapshot.status == "installing") + .unwrap_or(false) + { + return Err("An update is already being downloaded or installed.".to_string()); + } + + crate::state::set_app_update_snapshot(Some(crate::state::AppUpdateSnapshot { + status: "downloading".to_string(), + version: None, + error: None, + })); + + match run_app_update(app, channel).await { + Ok(version) => { + crate::state::set_app_update_snapshot(Some(crate::state::AppUpdateSnapshot { + status: "finished".to_string(), + version: version.clone(), + error: None, + })); + Ok(version) + } + Err(error) => { + crate::state::set_app_update_snapshot(Some(crate::state::AppUpdateSnapshot { + status: "error".to_string(), + version: None, + error: Some(error.clone()), + })); + Err(error) + } + } +} + +/// State of everything that keeps running in the background (downloads, +/// extractions, installations, umu setup, app updates). +/// +/// The frontend calls this right after loading so it can re-attach to work that +/// is still in flight, and pick up results that finished while its page was +/// reloaded or the app was restarted. +#[tauri::command] +fn get_background_states() -> crate::state::BackgroundStates { + crate::state::background_states() +} + +async fn run_app_update(app: AppHandle, channel: UpdateChannel) -> Result, String> { let Some(update) = resolve_update_for_channel(&app, channel).await? else { return Ok(None); }; @@ -412,6 +462,12 @@ pub fn run() { )?; } + // ── Time tracker diagnostics log ─────────────────────────────────── + // Records every tracker tick, match transition and credit outcome. + // Disabled for now via `tracker_log::ENABLED`; the tracker keeps its + // counters in memory either way. + tracker_log::init(app.handle()); + // ── System tray with Show / Quit menu ────────────────────────────── let show_item = MenuItemBuilder::with_id("show", "Show").build(app)?; @@ -503,6 +559,7 @@ pub fn run() { util::open_devtools, youtube::youtube_embed_base, extraction::extract_archive, + get_background_states, installation::list_install_executables, installation::copy_installation_files, installation::launch_installation_executable, @@ -541,6 +598,13 @@ pub fn run() { time_tracker::delete_offline_time_file, time_tracker::sync_offline_time, time_tracker::debug_tracker_scan, + time_tracker::get_tracker_status, + time_tracker::reset_tracker_ledger, + tracker_log::get_tracker_log, + tracker_log::clear_tracker_log, + tracker_log::open_tracker_log_folder, + tracker_log::read_tracker_log_file, + tracker_log::tracker_log_line, settings::get_start_minimized, settings::set_start_minimized, settings::get_minimize_on_game_launch, @@ -549,6 +613,10 @@ pub fn run() { settings::set_ignore_list, settings::get_default_wine_prefix, settings::set_default_wine_prefix, + settings::get_prerelease_notice_channel, + settings::set_prerelease_notice_channel, + settings::get_always_show_launch_logs, + settings::set_always_show_launch_logs, rendering::get_rendering_diagnostics, rendering::get_webkit_settings, rendering::set_webkit_smooth_scrolling, @@ -559,6 +627,15 @@ pub fn run() { umu::umu_status, umu::install_umu_launcher, umu::resolve_windows_install_path, + umu::resolve_game_wine_prefix, + umu::delete_game_wine_prefix, + umu::list_proton_builds, + launch_log::get_launch_log, + launch_log::list_launch_logs, + launch_log::open_launch_log_window, + launch_log::open_launch_log_folder, + launch_log::clear_launch_logs, + launch_log::read_launch_log_file, is_updater_enabled, check_for_app_update, download_and_install_app_update diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index b2f4fc8..9e3a638 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,9 +1,11 @@ use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use std::time::UNIX_EPOCH; use tauri::Manager; -#[derive(Serialize, Deserialize, Default)] +#[derive(Serialize, Deserialize, Default, Clone)] pub(crate) struct AppSettings { #[serde(default)] pub start_minimized: bool, @@ -25,6 +27,18 @@ pub(crate) struct AppSettings { /// `None` = leave WebKit default. #[serde(default)] pub webkit_hw_accel_policy: Option, + /// Whether the launch log window opens automatically when a game starts. + /// Logs are always recorded; this only controls the window. + #[serde(default)] + pub always_show_launch_logs: bool, + /// Pre-release channel ("unstable" or "early-access") whose one-time launch + /// warning has already been acknowledged on this installation. + /// + /// This is kept in the app settings file instead of the webview's + /// localStorage, because the webview data folder is cleared by the + /// installer on updates - which made the warning reappear after each one. + #[serde(default)] + pub prerelease_notice_channel: Option, } #[derive(Serialize, Clone)] @@ -37,16 +51,55 @@ pub(crate) fn settings_path(app: &tauri::AppHandle) -> PathBuf { app.path().app_data_dir().unwrap().join("gamevault-settings.json") } +/// Last parsed settings, keyed by the file's modification time. +/// +/// The time tracker reads the ignore list every 60 s tick; re-reading and +/// parsing the JSON from disk every time is pure overhead. +static SETTINGS_CACHE: OnceLock>> = OnceLock::new(); + +fn settings_cache() -> &'static Mutex> { + SETTINGS_CACHE.get_or_init(|| Mutex::new(None)) +} + +fn settings_modified_ms(path: &std::path::Path) -> u64 { + std::fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + pub(crate) fn load_settings(app: &tauri::AppHandle) -> AppSettings { let path = settings_path(app); - if path.exists() { - if let Ok(data) = std::fs::read_to_string(&path) { - if let Ok(settings) = serde_json::from_str::(&data) { - return settings; + let modified = settings_modified_ms(&path); + + if modified != 0 { + if let Ok(cache) = settings_cache().lock() { + if let Some((cached_modified, settings)) = cache.as_ref() { + if *cached_modified == modified { + return settings.clone(); + } } } } - AppSettings::default() + + let settings = if path.exists() { + std::fs::read_to_string(&path) + .ok() + .and_then(|data| serde_json::from_str::(&data).ok()) + .unwrap_or_default() + } else { + AppSettings::default() + }; + + if modified != 0 { + if let Ok(mut cache) = settings_cache().lock() { + *cache = Some((modified, settings.clone())); + } + } + + settings } pub(crate) fn save_settings(app: &tauri::AppHandle, settings: &AppSettings) -> Result<(), String> { @@ -55,7 +108,13 @@ pub(crate) fn save_settings(app: &tauri::AppHandle, settings: &AppSettings) -> R std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create config dir: {}", e))?; } let data = serde_json::to_string_pretty(settings).map_err(|e| format!("Failed to serialize: {}", e))?; - std::fs::write(&path, &data).map_err(|e| format!("Failed to write config: {}", e)) + std::fs::write(&path, &data).map_err(|e| format!("Failed to write config: {}", e))?; + + // Update the cache in place so the next reader does not re-parse the file. + if let Ok(mut cache) = settings_cache().lock() { + *cache = Some((settings_modified_ms(&path), settings.clone())); + } + Ok(()) } #[tauri::command] @@ -116,11 +175,28 @@ pub(crate) fn set_ignore_list(app: tauri::AppHandle, ignored: Vec) -> Re save_settings(&app, &settings) } +#[tauri::command] +pub(crate) fn get_prerelease_notice_channel(app: tauri::AppHandle) -> Option { + load_settings(&app).prerelease_notice_channel +} +/// Records the pre-release channel whose warning was acknowledged. `None` +/// clears it, which makes the warning show up again on the next launch. +#[tauri::command] +pub(crate) fn set_prerelease_notice_channel( + app: tauri::AppHandle, + channel: Option, +) -> Result<(), String> { + let mut settings = load_settings(&app); + settings.prerelease_notice_channel = channel + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + save_settings(&app, &settings) +} + #[tauri::command] pub(crate) fn get_default_wine_prefix(app: tauri::AppHandle) -> Option { load_settings(&app).default_wine_prefix } - #[tauri::command] pub(crate) fn set_default_wine_prefix( app: tauri::AppHandle, @@ -133,3 +209,19 @@ pub(crate) fn set_default_wine_prefix( settings.default_wine_prefix = trimmed; save_settings(&app, &settings) } + +/// Whether the launch log window should open automatically on game start. +#[tauri::command] +pub(crate) fn get_always_show_launch_logs(app: tauri::AppHandle) -> bool { + load_settings(&app).always_show_launch_logs +} + +#[tauri::command] +pub(crate) fn set_always_show_launch_logs( + app: tauri::AppHandle, + enabled: bool, +) -> Result<(), String> { + let mut settings = load_settings(&app); + settings.always_show_launch_logs = enabled; + save_settings(&app, &settings) +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index e302866..1a51aaf 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -30,3 +30,325 @@ pub(crate) fn tracker_config() -> &'static Mutex> { pub(crate) fn tracker_stop_tx() -> &'static Mutex>> { TRACKER_STOP_TX.get_or_init(|| Mutex::new(None)) } + +/// Live counters for the native time tracker, surfaced in the diagnostics dump. +/// +/// The tracker credits one minute per matched tick, so `lost_ticks` and the +/// per-game ledgers below are the evidence trail for playtime that ended up +/// lower than what was actually played. +#[derive(Clone, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TrackerRuntimeStats { + pub running: bool, + pub started_at: Option, + pub tick_count: u64, + /// Seconds between expected ticks (the tracker interval). + pub tick_interval_secs: u64, + /// Ticks the interval expected but the loop did not run (sleep, freeze, starvation). + pub lost_ticks: u64, + /// Ticks that fired back-to-back to catch up after a stall. + pub catch_up_ticks: u64, + pub last_tick_at: Option, + pub last_tick_gap_secs: Option, + pub last_tick_duration_ms: Option, + pub last_tick_summary: Option, + pub last_error: Option, + pub consecutive_failures: u64, + /// Number of increments the server rejected with 401/403 (expired token). + pub auth_rejected_count: u64, + /// Consecutive auth rejections since the last successful increment. + pub auth_streak: u64, + /// While set (unix ms in the future) the tracker stores playtime offline + /// instead of asking a server that is rejecting its session. + pub auth_backoff_until: Option, + /// Ticks that were stored offline without a request because of the backoff. + pub skipped_auth_ticks: u64, + /// Last time the frontend was notified about an expired session. + pub last_auth_event_at: Option, + pub stop_reason: Option, + pub log_path: Option, +} + +/// Per-game playtime accounting: what the wall clock suggested versus what the +/// server actually credited, plus the reason for every lost tick. +#[derive(Clone, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GamePlayLedger { + pub game_id: i64, + pub game_title: String, + /// Currently matched to a running process. + pub matched: bool, + /// Seconds since the last time this game was matched. + pub first_matched_at: Option, + pub last_matched_at: Option, + pub matched_ticks: u64, + /// Increments the server accepted. + pub credited_ticks: u64, + /// Increments that failed but were written to the offline file. + pub offline_ticks: u64, + /// Minutes that were never recorded anywhere. + pub lost_ticks: u64, + /// Lost ticks attributed to a tick gap (sleep/resume, freeze, starvation). + pub dropped_ticks: u64, + /// Matches lost although the game was played before (launcher exit, re-exec…). + pub match_flaps: u64, + pub credited_minutes: u64, + pub offline_minutes: u64, + /// Minutes synced from an offline file of an earlier session. + pub replayed_minutes: u64, + pub observed_seconds: u64, + /// Ticks the game was not matched since the last match (grace counter). + pub missing_ticks: u64, + /// Last `minutes_played` the server reported for this game. + pub last_server_minutes: Option, + /// Why the game was not matched/credited on the last tick. + pub last_skip_reason: Option, +} + +static TRACKER_STATS: OnceLock> = OnceLock::new(); +static TRACKER_LEDGER: OnceLock>> = OnceLock::new(); + +pub(crate) fn tracker_stats() -> &'static Mutex { + TRACKER_STATS.get_or_init(|| Mutex::new(TrackerRuntimeStats::default())) +} + +pub(crate) fn tracker_ledger() -> &'static Mutex> { + TRACKER_LEDGER.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Clears the playtime ledger and resets counters for a new tracker session. +pub(crate) fn reset_tracker_stats(started_at: u64, tick_interval_secs: u64) { + if let Ok(mut stats) = tracker_stats().lock() { + let log_path = stats.log_path.clone(); + *stats = TrackerRuntimeStats { + running: true, + started_at: Some(started_at), + tick_interval_secs, + log_path, + ..TrackerRuntimeStats::default() + }; + } + if let Ok(mut ledger) = tracker_ledger().lock() { + ledger.clear(); + } +} + +/// Snapshot of the ledger, sorted by game id for stable output. +pub(crate) fn tracker_ledger_snapshot() -> Vec { + tracker_ledger() + .lock() + .map(|ledger| { + let mut games: Vec = ledger.values().cloned().collect(); + games.sort_by_key(|game| game.game_id); + games + }) + .unwrap_or_default() +} + +/// Minutes that are stored offline and still waiting for a successful sync. +pub(crate) fn pending_offline_minutes() -> u64 { + tracker_ledger() + .lock() + .map(|ledger| ledger.values().map(|game| game.offline_minutes).sum()) + .unwrap_or(0) +} + +/// Latest known state of an extraction, per game. +/// +/// Extraction runs on a detached blocking task that keeps going when the +/// webview is reloaded (F5) or navigated away. Without this registry the UI +/// would lose track of it and show the game as "not extracted" again, even +/// though files are still being written. The frontend queries the registry on +/// startup to re-attach to a running extraction and to pick up results that +/// finished while the page was away. +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ExtractionSnapshot { + pub game_id: i64, + /// `extracting` | `completed` | `needs-password` | `error` + pub status: String, + pub processed: u64, + pub total: Option, + pub progress: Option, + pub current_file: Option, + pub error: Option, +} + +static EXTRACTION_STATES: OnceLock>> = OnceLock::new(); + +fn extraction_states() -> &'static Mutex> { + EXTRACTION_STATES.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub(crate) fn set_extraction_snapshot(snapshot: ExtractionSnapshot) { + if let Ok(mut states) = extraction_states().lock() { + states.insert(snapshot.game_id, snapshot); + } +} + +pub(crate) fn extraction_snapshots() -> Vec { + extraction_states() + .lock() + .map(|states| states.values().cloned().collect()) + .unwrap_or_default() +} + +/// Whether an extraction for this game is currently being processed. +pub(crate) fn is_extraction_running(game_id: i64) -> bool { + extraction_states() + .lock() + .map(|states| { + states + .get(&game_id) + .map(|snapshot| snapshot.status == "extracting") + .unwrap_or(false) + }) + .unwrap_or(false) +} + +/// Latest known state of an installation (file copy or installer run), per game. +/// +/// Like extractions, both install steps run on detached threads, so their state +/// must survive a webview reload for the UI to re-attach to them. +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct InstallationSnapshot { + pub game_id: i64, + /// `copy` for the file copy, `installer` while a setup executable runs. + pub step: String, + /// `copying` | `launching` | `running` | `completed` | `error` + pub status: String, + pub processed: u64, + pub total: Option, + pub progress: Option, + pub current_file: Option, + pub exit_code: Option, + pub error: Option, +} + +static INSTALLATION_STATES: OnceLock>> = OnceLock::new(); + +fn installation_states() -> &'static Mutex> { + INSTALLATION_STATES.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub(crate) fn set_installation_snapshot(snapshot: InstallationSnapshot) { + if let Ok(mut states) = installation_states().lock() { + states.insert(snapshot.game_id, snapshot); + } +} + +pub(crate) fn installation_snapshots() -> Vec { + installation_states() + .lock() + .map(|states| states.values().cloned().collect()) + .unwrap_or_default() +} + +/// Whether a file copy for this game is currently running. +pub(crate) fn is_install_copy_running(game_id: i64) -> bool { + installation_states() + .lock() + .map(|states| { + states + .get(&game_id) + .map(|snapshot| snapshot.step == "copy" && snapshot.status == "copying") + .unwrap_or(false) + }) + .unwrap_or(false) +} + +/// Whether an installer for this game is currently running. +pub(crate) fn is_installer_running(game_id: i64) -> bool { + installation_states() + .lock() + .map(|states| { + states + .get(&game_id) + .map(|snapshot| { + snapshot.step == "installer" + && (snapshot.status == "launching" || snapshot.status == "running") + }) + .unwrap_or(false) + }) + .unwrap_or(false) +} + +/// State of the (single) umu-launcher setup task. +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UmuSnapshot { + /// `installing` | `setup` | `running` | `exit` | `error` + pub status: String, + pub message: Option, + pub game_title: Option, +} + +static UMU_STATE: OnceLock>> = OnceLock::new(); + +fn umu_state() -> &'static Mutex> { + UMU_STATE.get_or_init(|| Mutex::new(None)) +} + +pub(crate) fn set_umu_snapshot(snapshot: Option) { + if let Ok(mut state) = umu_state().lock() { + *state = snapshot; + } +} + +pub(crate) fn umu_snapshot() -> Option { + umu_state().lock().ok().and_then(|state| state.clone()) +} + +/// Whether umu-launcher is currently being downloaded/extracted. +pub(crate) fn is_umu_install_running() -> bool { + umu_snapshot() + .map(|snapshot| snapshot.status == "installing") + .unwrap_or(false) +} + +/// State of the (single) app self-update task. +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AppUpdateSnapshot { + /// `downloading` | `installing` | `finished` | `error` + pub status: String, + pub version: Option, + pub error: Option, +} + +static APP_UPDATE_STATE: OnceLock>> = OnceLock::new(); + +fn app_update_state() -> &'static Mutex> { + APP_UPDATE_STATE.get_or_init(|| Mutex::new(None)) +} + +pub(crate) fn set_app_update_snapshot(snapshot: Option) { + if let Ok(mut state) = app_update_state().lock() { + *state = snapshot; + } +} + +pub(crate) fn app_update_snapshot() -> Option { + app_update_state().lock().ok().and_then(|state| state.clone()) +} + +/// Everything the frontend needs to re-attach to long-running work after a +/// reload or restart. Served by the `get_background_states` command. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BackgroundStates { + pub extractions: Vec, + pub installations: Vec, + pub umu: Option, + pub app_update: Option, +} + +pub(crate) fn background_states() -> BackgroundStates { + BackgroundStates { + extractions: extraction_snapshots(), + installations: installation_snapshots(), + umu: umu_snapshot(), + app_update: app_update_snapshot(), + } +} diff --git a/src-tauri/src/time_tracker.rs b/src-tauri/src/time_tracker.rs index 934a360..f05dc0b 100644 --- a/src-tauri/src/time_tracker.rs +++ b/src-tauri/src/time_tracker.rs @@ -1,15 +1,82 @@ -use crate::games::{list_installed_games, collect_launch_candidates}; +use crate::events::InstalledGameInfo; +use crate::games::{collect_launch_candidates, list_installed_games_blocking}; use crate::settings::load_settings; -use crate::state::{tracker_config, tracker_stop_tx, TrackerConfig}; +use crate::state::{ + pending_offline_minutes, reset_tracker_stats, tracker_config, tracker_ledger, + tracker_ledger_snapshot, tracker_stats, tracker_stop_tx, GamePlayLedger, TrackerConfig, + TrackerRuntimeStats, +}; +use crate::tracker_log; use crate::util::{is_ignored_executable, paths_match}; use serde::Serialize; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant, UNIX_EPOCH}; use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System}; +use tauri::Emitter; use tokio::sync::watch; +/// Seconds between tracker ticks. One matched tick credits exactly one minute, +/// so every skipped tick is a minute of playtime that never reaches the server. +const TICK_INTERVAL_SECS: u64 = 60; +/// A tick that fires earlier than this is a catch-up burst after a stall. +const CATCH_UP_TOLERANCE_SECS: u64 = 5; +/// Backoff after repeated session rejections: retry after 1, 2, 5, then 15 min. +const AUTH_BACKOFF_SECS: [u64; 4] = [60, 120, 300, 900]; +/// Ticks a game may be missing before the match counts as lost. Launchers that +/// hand the game to a new process would otherwise lose a minute per restart. +const MATCH_GRACE_TICKS: u64 = 2; +/// How long a scanned candidate list is reused before walking the folders again. +const CANDIDATE_CACHE_TTL_SECS: u64 = 600; +/// At most one `tracker-auth-expired` event per minute. +const AUTH_EVENT_THROTTLE_MS: u64 = 60_000; +/// Event telling the frontend that the tracker's session was rejected. +const AUTH_EXPIRED_EVENT: &str = "tracker-auth-expired"; + +/// Event payload for [`AUTH_EXPIRED_EVENT`]. +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TrackerAuthExpiredEvent { + pub at: u64, + pub auth_rejected_count: u64, + pub pending_offline_minutes: u64, + pub retry_in_secs: u64, +} + +/// A previously scanned candidate list, reused while the folder is unchanged. +#[derive(Clone)] +struct CachedCandidates { + scan_dir: PathBuf, + dir_modified_ms: u64, + scanned_at_ms: u64, + candidates: Vec, +} + +static CANDIDATE_CACHE: OnceLock>> = OnceLock::new(); + +fn candidate_cache() -> &'static Mutex> { + CANDIDATE_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn dir_modified_ms(path: &Path) -> u64 { + fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +fn is_shortcut(path: &Path) -> bool { + path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("lnk")) + .unwrap_or(false) +} + #[tauri::command] pub(crate) fn start_game_time_tracker( app: tauri::AppHandle, @@ -19,9 +86,19 @@ pub(crate) fn start_game_time_tracker( download_path: Option, download_paths: Option>, ) -> Result<(), String> { + let previous_running = tracker_stats() + .lock() + .map(|stats| stats.running) + .unwrap_or(false); + if let Ok(mut tx) = tracker_stop_tx().lock() { if let Some(sender) = tx.take() { let _ = sender.send(true); + tracker_log::push( + "info", + "lifecycle", + "restarting tracker: stopping the previous loop", + ); } } @@ -35,6 +112,27 @@ pub(crate) fn start_game_time_tracker( }, }; + tracker_log::push_kv( + "info", + "lifecycle", + &[ + ("event", "start".to_string()), + ("server_url", server_url.clone()), + ("user_id", user_id.to_string()), + ("token", tracker_log::redact_token(&access_token)), + ("roots", paths.len().to_string()), + ( + "root_list", + if paths.is_empty() { + "none".to_string() + } else { + paths.join(", ") + }, + ), + ("previous_loop_running", previous_running.to_string()), + ], + ); + let config = TrackerConfig { server_url, user_id, @@ -46,6 +144,11 @@ pub(crate) fn start_game_time_tracker( *cfg = Some(config); } + if let Ok(mut stats) = tracker_stats().lock() { + stats.log_path = tracker_log::log_path(); + } + reset_tracker_stats(tracker_log::now_ms(), TICK_INTERVAL_SECS); + let (stop_tx, stop_rx) = watch::channel(false); if let Ok(mut tx) = tracker_stop_tx().lock() { *tx = Some(stop_tx); @@ -57,7 +160,9 @@ pub(crate) fn start_game_time_tracker( } #[tauri::command] -pub(crate) fn stop_game_time_tracker() -> Result<(), String> { +pub(crate) fn stop_game_time_tracker(reason: Option) -> Result<(), String> { + let reason = reason.unwrap_or_else(|| "unspecified".to_string()); + if let Ok(mut tx) = tracker_stop_tx().lock() { if let Some(sender) = tx.take() { let _ = sender.send(true); @@ -66,31 +171,156 @@ pub(crate) fn stop_game_time_tracker() -> Result<(), String> { if let Ok(mut cfg) = tracker_config().lock() { *cfg = None; } + if let Ok(mut stats) = tracker_stats().lock() { + stats.running = false; + stats.stop_reason = Some(reason.clone()); + } + + tracker_log::push("info", "lifecycle", &format!("tracker stop requested (reason={reason})")); Ok(()) } #[tauri::command] pub(crate) fn update_tracker_auth(access_token: String) -> Result<(), String> { + let mut updated = false; if let Ok(mut cfg) = tracker_config().lock() { if let Some(ref mut c) = *cfg { - c.access_token = access_token; + c.access_token = access_token.clone(); + updated = true; } } + + // A fresh token ends the rejection streak: retry immediately instead of + // waiting out the backoff. + let cleared_backoff = if let Ok(mut stats) = tracker_stats().lock() { + let was_backing_off = stats.auth_backoff_until.is_some(); + stats.auth_backoff_until = None; + stats.auth_streak = 0; + was_backing_off + } else { + false + }; + if cleared_backoff { + tracker_log::push( + "info", + "http", + "auth_backoff_cleared: fresh session token arrived, resuming requests", + ); + } + + tracker_log::push_kv( + "info", + "lifecycle", + &[ + ("event", "auth_updated".to_string()), + ("applied_to_running_tracker", updated.to_string()), + ("token", tracker_log::redact_token(&access_token)), + ], + ); Ok(()) } +/// One entry of the per-game accounting ledger, used for log lines. +#[derive(Clone)] +struct SkipReason { + game_id: i64, + title: String, + reason: String, +} + async fn game_time_tracker_loop(mut stop_rx: watch::Receiver, app: tauri::AppHandle) { - let mut interval = tokio::time::interval(Duration::from_secs(60)); + let mut interval = tokio::time::interval(Duration::from_secs(TICK_INTERVAL_SECS)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Burst); interval.tick().await; + tracker_log::push( + "info", + "lifecycle", + &format!("tracker loop started (interval={TICK_INTERVAL_SECS}s, missed_tick_behavior=burst)"), + ); + + let mut previous_tick_at: Option = None; + let mut in_catch_up_burst = false; + let mut logged_skips: HashMap = HashMap::new(); + let mut logged_candidates: HashMap = HashMap::new(); + let mut logged_backoff_until: Option = None; + loop { tokio::select! { _ = interval.tick() => {}, _ = stop_rx.changed() => { + let reason = tracker_stats() + .lock() + .ok() + .and_then(|stats| stats.stop_reason.clone()) + .unwrap_or_else(|| "stop signal (no reason recorded)".to_string()); + tracker_log::push("info", "lifecycle", &format!("tracker loop stopping: {reason}")); break; } } + let tick_start = Instant::now(); + let now_ms = tracker_log::now_ms(); + let tick_number = match tracker_stats().lock() { + Ok(mut stats) => { + stats.tick_count += 1; + stats.last_tick_at = Some(now_ms); + stats.tick_count + } + Err(_) => 0, + }; + + // ── Tick timing: detect stalls, gaps and catch-up bursts ─────────────── + let mut dropped_ticks = 0u64; + if let Some(previous) = previous_tick_at { + let gap_secs = tick_start.duration_since(previous).as_secs(); + let catch_up = gap_secs + CATCH_UP_TOLERANCE_SECS < TICK_INTERVAL_SECS; + if catch_up && !in_catch_up_burst { + tracker_log::push( + "warn", + "tick", + &format!( + "catch-up burst started: the previous iteration was stalled (gap {gap_secs}s, previous gap {}s)", + tracker_stats() + .lock() + .ok() + .and_then(|stats| stats.last_tick_gap_secs.map(|value| value.to_string())) + .unwrap_or_else(|| "unknown".to_string()) + ), + ); + } + in_catch_up_burst = catch_up; + if catch_up { + if let Ok(mut stats) = tracker_stats().lock() { + stats.catch_up_ticks += 1; + } + } else if gap_secs > TICK_INTERVAL_SECS + CATCH_UP_TOLERANCE_SECS { + dropped_ticks = dropped_ticks_for_gap(gap_secs); + tracker_log::push_kv( + "warn", + "tick", + &[ + ("event", "tick_gap".to_string()), + ("tick", tick_number.to_string()), + ("gap_secs", gap_secs.to_string()), + ("dropped_ticks", dropped_ticks.to_string()), + ( + "hint", + "device sleep/hibernate, app freeze or tick starvation: playtime in this window may be lost" + .to_string(), + ), + ], + ); + } + if let Ok(mut stats) = tracker_stats().lock() { + stats.last_tick_gap_secs = Some(gap_secs); + if dropped_ticks > 0 { + stats.lost_ticks += dropped_ticks; + } + } + } + previous_tick_at = Some(tick_start); + let config = match tracker_config().lock() { Ok(guard) => match guard.clone() { Some(c) => c, @@ -100,24 +330,76 @@ async fn game_time_tracker_loop(mut stop_rx: watch::Receiver, app: tauri:: }; if config.download_paths.is_empty() || config.server_url.is_empty() { + tracker_log::push_kv( + "warn", + "tick", + &[ + ("event", "tick_skipped".to_string()), + ("tick", tick_number.to_string()), + ("reason", "tracker has no roots or no server url".to_string()), + ], + ); continue; } - // Collect installed games from all root paths + // ── Library scan ─────────────────────────────────────────────────────── + let library_start = Instant::now(); let mut installed = Vec::new(); for path in &config.download_paths { - if let Ok(games) = list_installed_games(path.clone()) { - installed.extend(games); + match list_installed_games_blocking(path.clone()) { + Ok(games) => { + if games.is_empty() { + tracker_log::push_kv( + "warn", + "scan", + &[ + ("event", "root_no_games".to_string()), + ("root", path.clone()), + ], + ); + } + installed.extend(games); + } + Err(error) => tracker_log::push_kv( + "error", + "scan", + &[ + ("event", "root_scan_failed".to_string()), + ("root", path.clone()), + ("error", error), + ], + ), } } + let library_ms = library_start.elapsed().as_millis() as u64; if installed.is_empty() { + mark_all_unmatched("no_installed_games"); + tracker_log::push_kv( + "warn", + "tick", + &[ + ("event", "no_installed_games".to_string()), + ("tick", tick_number.to_string()), + ("roots", config.download_paths.len().to_string()), + ("library_ms", library_ms.to_string()), + ], + ); continue; } + // ── Executable candidates per installed game ─────────────────────────── + let ignored = load_settings(&app).ignored_executables; + let candidates_start = Instant::now(); let mut game_exe_map: HashMap> = HashMap::new(); + let mut game_titles: HashMap = HashMap::new(); + let mut skips: HashMap = HashMap::new(); + let mut cache_hits = 0usize; + let mut cache_misses = 0usize; for game in &installed { + game_titles.insert(game.game_id, game.game_title.clone()); + // The configured installation dir may not exist (e.g. no "Installation" // subfolder); fall back to the version dir so the game is still tracked. let configured_install = PathBuf::from(&game.installation_directory); @@ -126,40 +408,169 @@ async fn game_time_tracker_loop(mut stop_rx: watch::Receiver, app: tauri:: scan_dir = PathBuf::from(&game.version_directory); } if !scan_dir.exists() || !scan_dir.is_dir() { + skips.insert( + game.game_id, + SkipReason { + game_id: game.game_id, + title: game.game_title.clone(), + reason: format!( + "version_dir_missing (install='{}', version='{}')", + game.installation_directory, game.version_directory + ), + }, + ); continue; } - let ignored = load_settings(&app).ignored_executables; + // Walking a game folder is expensive (large trees) and used to run on + // every tick for every game. Reuse the last result while the folder is + // unchanged and the cache is still fresh. + let dir_modified = dir_modified_ms(&scan_dir); + let cached = candidate_cache() + .lock() + .ok() + .and_then(|cache| cache.get(&game.game_id).cloned()); + let mut scan_failed = false; + let cache_hit = matches!( + cached.as_ref(), + Some(entry) + if entry.scan_dir == scan_dir + && entry.dir_modified_ms == dir_modified + && now_ms.saturating_sub(entry.scanned_at_ms) < CANDIDATE_CACHE_TTL_SECS * 1000 + ); + let scanned: Vec = if cache_hit { + cache_hits += 1; + cached.map(|entry| entry.candidates).unwrap_or_default() + } else { + cache_misses += 1; + let mut rel_candidates = Vec::new(); + if collect_launch_candidates(&scan_dir, &scan_dir, &mut rel_candidates).is_err() { + scan_failed = true; + } + let scanned: Vec = rel_candidates + .iter() + .map(|rel| scan_dir.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR))) + .collect(); + if let Ok(mut cache) = candidate_cache().lock() { + cache.insert( + game.game_id, + CachedCandidates { + scan_dir: scan_dir.clone(), + dir_modified_ms: dir_modified, + scanned_at_ms: now_ms, + candidates: scanned.clone(), + }, + ); + } + scanned + }; // Always include the exact launcher the user runs (from the per-game - // config), then any executables found by scanning the install dir. + // config), then the scanned executables, de-duplicated. + let found = scanned.len(); let mut abs_paths: Vec = Vec::new(); - if let Some(rel_exe) = read_configured_launch_executable(Path::new(&game.version_directory)) { + let mut seen: HashSet = HashSet::new(); + let configured_launcher = + read_configured_launch_executable(Path::new(&game.version_directory)); + if let Some(rel_exe) = configured_launcher.clone() { let abs = scan_dir.join(rel_exe); - if abs.exists() && !is_ignored_executable(&abs, &ignored) { + if abs.exists() && !is_ignored_executable(&abs, &ignored) && !is_shortcut(&abs) { + seen.insert(abs.to_string_lossy().to_ascii_lowercase()); abs_paths.push(abs); } } - let mut candidates = Vec::new(); - if collect_launch_candidates(&scan_dir, &scan_dir, &mut candidates).is_ok() { - for rel in candidates { - let abs = scan_dir.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR)); - if !is_ignored_executable(&abs, &ignored) { - abs_paths.push(abs); - } + let mut ignored_count = 0usize; + for abs in scanned { + // A shortcut is never the process that runs the game. + if is_shortcut(&abs) { + continue; + } + if is_ignored_executable(&abs, &ignored) { + ignored_count += 1; + continue; + } + let key = abs.to_string_lossy().to_ascii_lowercase(); + if seen.insert(key) { + abs_paths.push(abs); } } - if !abs_paths.is_empty() { - game_exe_map.entry(game.game_id).or_default().extend(abs_paths); + if abs_paths.is_empty() { + let reason = if scan_failed { + format!("launch_candidate_scan_failed in {}", scan_dir.to_string_lossy()) + } else if found > 0 && ignored_count > 0 { + format!( + "all_candidates_ignored ({found} candidates, {ignored_count} ignored by the ignore list)" + ) + } else if configured_launcher.is_some() { + "no_exe_candidates (configured launch executable is missing)".to_string() + } else { + format!("no_exe_candidates (found {found} candidates in {})", scan_dir.to_string_lossy()) + }; + skips.insert( + game.game_id, + SkipReason { + game_id: game.game_id, + title: game.game_title.clone(), + reason, + }, + ); + continue; + } + + if logged_candidates.get(&game.game_id) != Some(&abs_paths.len()) { + logged_candidates.insert(game.game_id, abs_paths.len()); + let mut listed = abs_paths + .iter() + .take(8) + .map(|path| path.to_string_lossy().to_string()) + .collect::>() + .join(" | "); + if abs_paths.len() > 8 { + listed.push_str(&format!(" | … (+{} more)", abs_paths.len() - 8)); + } + tracker_log::push_kv( + "info", + "game", + &[ + ("event", "candidates".to_string()), + ("game", game.game_id.to_string()), + ("title", game.game_title.clone()), + ("count", abs_paths.len().to_string()), + ("cache", if cache_hit { "hit".to_string() } else { "miss".to_string() }), + ("exes", listed), + ], + ); } + + game_exe_map + .entry(game.game_id) + .or_default() + .extend(abs_paths); + } + + let candidates_ms = candidates_start.elapsed().as_millis() as u64; + + for skip in skips.values() { + log_skip_once(&mut logged_skips, skip); } if game_exe_map.is_empty() { + tracker_log::push_kv( + "warn", + "tick", + &[ + ("event", "no_executable_candidates".to_string()), + ("tick", tick_number.to_string()), + ("installed", installed.len().to_string()), + ], + ); continue; } + // ── Process matching ─────────────────────────────────────────────────── + let process_start = Instant::now(); let mut sys = System::new(); // `refresh_processes` alone does not load `cmd()` in sysinfo 0.33 — the // process argv (which is what matches a running game) stays empty. Use @@ -171,44 +582,813 @@ async fn game_time_tracker_loop(mut stop_rx: watch::Receiver, app: tauri:: ); let processes: Vec<&sysinfo::Process> = sys.processes().values().collect(); + let process_ms = process_start.elapsed().as_millis() as u64; + let match_start = Instant::now(); let mut matched_game_ids: Vec = Vec::new(); - for (game_id, exe_paths) in &game_exe_map { - let is_running = exe_paths.iter().any(|game_exe| { - processes - .iter() - .any(|process| process_matches_game(process, game_exe)) - }); - if is_running { + let matching: Vec = processes + .iter() + .filter(|process| exe_paths.iter().any(|game_exe| process_matches_game(process, game_exe))) + .map(|process| describe_process(process)) + .collect(); + let title = game_titles.get(game_id).cloned().unwrap_or_default(); + let reason = if matching.is_empty() { + // Distinguish "the game closed" from "the game is running but our + // candidate paths do not match it" — the second one is a bug report. + if candidate_name_seen(&processes, exe_paths) { + Some("exe_seen_but_unmatched") + } else { + Some("process_gone") + } + } else { + None + }; + + let state = update_ledger( + *game_id, + &title, + !matching.is_empty(), + &matching, + reason, + dropped_ticks, + now_ms, + ); + if !matches!(state, LedgerMatch::Lost) { matched_game_ids.push(*game_id); } } + // Games without executable candidates can never match: record why. + for skip in skips.values() { + update_ledger( + skip.game_id, + &skip.title, + false, + &[], + Some(skip.reason.as_str()), + dropped_ticks, + now_ms, + ); + } + + let match_ms = match_start.elapsed().as_millis() as u64; + if matched_game_ids.is_empty() { + tracker_log::push_kv( + "tick", + "tick", + &[ + ("event", "no_match".to_string()), + ("tick", tick_number.to_string()), + ("installed", installed.len().to_string()), + ("games_with_exe", game_exe_map.len().to_string()), + ("processes", processes.len().to_string()), + ("match_ms", match_ms.to_string()), + ], + ); continue; } + // ── Credit time ──────────────────────────────────────────────────────── let client = reqwest::Client::new(); - for game_id in matched_game_ids { - let url = format!( - "{}/api/progresses/user/{}/game/{}/increment", - config.server_url, config.user_id, game_id + let http_start = Instant::now(); + let backoff_until = tracker_stats() + .lock() + .ok() + .and_then(|stats| stats.auth_backoff_until); + let backing_off = backoff_until.map(|until| until > now_ms).unwrap_or(false); + + if backing_off && logged_backoff_until != backoff_until { + logged_backoff_until = backoff_until; + tracker_log::push_kv( + "warn", + "http", + &[ + ("event", "auth_backoff_active".to_string()), + ( + "retry_in_secs", + (backoff_until.unwrap_or(now_ms).saturating_sub(now_ms) / 1000).to_string(), + ), + ( + "message", + "the session is still rejected: playtime keeps being stored offline, requests are paused" + .to_string(), + ), + ], + ); + } else if !backing_off { + logged_backoff_until = None; + } + + let mut credited = 0u64; + let mut offline = 0u64; + let mut lost = 0u64; + for game_id in &matched_game_ids { + let outcome = if backing_off { + // Do not hammer a server that rejects our session: keep the minute, + // store it offline and try again after the backoff. + if let Ok(mut stats) = tracker_stats().lock() { + stats.skipped_auth_ticks += 1; + } + save_offline_time(&installed, &config, *game_id); + CreditOutcome::Offline { + reason: "auth backoff (session rejected, request skipped)".to_string(), + } + } else { + increment_game_time(&app, &client, &config, *game_id, &installed).await + }; + + match outcome { + CreditOutcome::Credited { .. } => { + credited += 1; + bump_credit_stats(true, false); + } + CreditOutcome::Offline { ref reason } => { + offline += 1; + bump_credit_stats(false, false); + record_failure(reason); + } + CreditOutcome::Lost { ref reason } => { + lost += 1; + bump_credit_stats(false, false); + record_failure(reason); + } + } + record_credit(*game_id, outcome); + } + let http_ms = http_start.elapsed().as_millis() as u64; + + let duration_ms = tick_start.elapsed().as_millis() as u64; + let summary = format!( + "tick {tick_number} | installed={} with_exe={} processes={} matched={} credited={credited} offline={offline} lost={lost} | library={library_ms}ms candidates={candidates_ms}ms cache={cache_hits}/{cache_misses} process={process_ms}ms match={match_ms}ms http={http_ms}ms total={duration_ms}ms", + installed.len(), + game_exe_map.len(), + processes.len(), + matched_game_ids.len(), + ); + tracker_log::push("tick", "tick", &summary); + if let Ok(mut stats) = tracker_stats().lock() { + stats.last_tick_summary = Some(summary); + stats.last_tick_duration_ms = Some(duration_ms); + } + + for (phase, value) in [ + ("library", library_ms), + ("candidates", candidates_ms), + ("process", process_ms), + ("match", match_ms), + ("http", http_ms), + ] { + if value > 1000 { + tracker_log::push_kv( + "warn", + "tick", + &[ + ("event", "slow_phase".to_string()), + ("tick", tick_number.to_string()), + ("phase", phase.to_string()), + ("phase_ms", value.to_string()), + ("total_ms", duration_ms.to_string()), + ], + ); + } + } + + if duration_ms > TICK_INTERVAL_SECS * 1000 { + tracker_log::push_kv( + "warn", + "tick", + &[ + ("event", "tick_starvation".to_string()), + ("tick", tick_number.to_string()), + ("duration_ms", duration_ms.to_string()), + ("interval_ms", (TICK_INTERVAL_SECS * 1000).to_string()), + ], + ); + } + } + + tracker_log::push("info", "lifecycle", "tracker loop exited"); + if let Ok(mut stats) = tracker_stats().lock() { + stats.running = false; + } +} + +/// Records that no game could be matched this tick, e.g. an empty library scan. +fn mark_all_unmatched(reason: &str) { + if let Ok(mut ledger) = tracker_ledger().lock() { + for entry in ledger.values_mut() { + if entry.matched { + entry.matched = false; + } + entry.last_skip_reason = Some(reason.to_string()); + } + } +} + +/// Logs a missing-executable reason once per change instead of every tick. +fn log_skip_once(logged: &mut HashMap, skip: &SkipReason) { + let changed = logged + .get(&skip.game_id) + .map(|previous| previous != &skip.reason) + .unwrap_or(true); + if !changed { + return; + } + logged.insert(skip.game_id, skip.reason.clone()); + tracker_log::push_kv( + "warn", + "game", + &[ + ("event", "unmatched".to_string()), + ("game", skip.game_id.to_string()), + ("title", skip.title.clone()), + ("reason", skip.reason.clone()), + ], + ); +} + +/// What a ledger update means for the current tick. +enum LedgerMatch { + /// Process still running — credit normally. + Matched, + /// Process vanished but the game may still be running (launcher hand-off) — + /// still credit, for at most [`MATCH_GRACE_TICKS`] ticks. + Grace { remaining: u64 }, + /// The game is not running (or never was) — do not credit. + Lost, +} + +/// Keeps the per-game ledger in sync and logs match transitions. +fn update_ledger( + game_id: i64, + title: &str, + matched: bool, + matching_processes: &[String], + fallback_reason: Option<&str>, + dropped_ticks: u64, + now_ms: u64, +) -> LedgerMatch { + let Ok(mut ledger) = tracker_ledger().lock() else { + return if matched { LedgerMatch::Matched } else { LedgerMatch::Lost }; + }; + let entry = ledger.entry(game_id).or_default(); + entry.game_id = game_id; + if !title.is_empty() { + entry.game_title = title.to_string(); + } + + if dropped_ticks > 0 && (matched || entry.matched) { + entry.lost_ticks += dropped_ticks; + entry.dropped_ticks += dropped_ticks; + tracker_log::push_kv( + "warn", + "game", + &[ + ("event", "gap_undercount_risk".to_string()), + ("game", game_id.to_string()), + ("title", entry.game_title.clone()), + ("dropped_ticks", dropped_ticks.to_string()), + ("lost_ticks_total", entry.lost_ticks.to_string()), + ( + "hint", + "the game was matched around the gap, so this playtime was very likely never credited" + .to_string(), + ), + ], + ); + } + + if matched { + if !entry.matched { + if entry.first_matched_at.is_none() { + entry.first_matched_at = Some(now_ms); + tracker_log::push_kv( + "info", + "game", + &[ + ("event", "match_started".to_string()), + ("game", game_id.to_string()), + ("title", entry.game_title.clone()), + ("processes", matching_processes.join(" | ")), + ], + ); + } else { + entry.match_flaps += 1; + let away_secs = entry + .last_matched_at + .map(|last| now_ms.saturating_sub(last) / 1000) + .unwrap_or(0); + tracker_log::push_kv( + "warn", + "game", + &[ + ("event", "match_resumed".to_string()), + ("game", game_id.to_string()), + ("title", entry.game_title.clone()), + ("away_secs", away_secs.to_string()), + ("flaps_total", entry.match_flaps.to_string()), + ("processes", matching_processes.join(" | ")), + ( + "hint", + "playtime during this window was not credited (launcher exited, re-exec or missing candidate)" + .to_string(), + ), + ], + ); + } + entry.matched = true; + } + entry.missing_ticks = 0; + entry.matched_ticks += 1; + entry.last_matched_at = Some(now_ms); + entry.observed_seconds += TICK_INTERVAL_SECS; + entry.last_skip_reason = None; + return LedgerMatch::Matched; + } + + // Not matched on this tick. + let reason = fallback_reason.unwrap_or("no_matching_process"); + entry.last_skip_reason = Some(reason.to_string()); + + if entry.matched { + entry.missing_ticks += 1; + + // A game can briefly disappear from the process list while it keeps + // running (launcher hand-off, protection re-exec). Keep crediting for a + // couple of ticks instead of losing that playtime. + if entry.missing_ticks <= MATCH_GRACE_TICKS { + entry.matched_ticks += 1; + entry.observed_seconds += TICK_INTERVAL_SECS; + tracker_log::push_kv( + "info", + "game", + &[ + ("event", "match_grace".to_string()), + ("game", game_id.to_string()), + ("title", entry.game_title.clone()), + ("tick", entry.missing_ticks.to_string()), + ("grace_ticks", MATCH_GRACE_TICKS.to_string()), + ("reason", reason.to_string()), + ], + ); + return LedgerMatch::Grace { + remaining: MATCH_GRACE_TICKS - entry.missing_ticks, + }; + } + + entry.matched = false; + entry.missing_ticks = 0; + tracker_log::push_kv( + "warn", + "game", + &[ + ("event", "match_lost".to_string()), + ("game", game_id.to_string()), + ("title", entry.game_title.clone()), + ("reason", reason.to_string()), + ("matched_ticks", entry.matched_ticks.to_string()), + ("credited_minutes", entry.credited_minutes.to_string()), + ( + "observed_minutes", + (entry.observed_seconds / TICK_INTERVAL_SECS).to_string(), + ), + ], + ); + log_session_summary(entry, pending_offline_minutes()); + return LedgerMatch::Lost; + } + + LedgerMatch::Lost +} + +/// One decisive line per game session: what was played, what was credited and +/// why the numbers differ. +fn log_session_summary(entry: &GamePlayLedger, pending_offline_minutes: u64) { + let observed = entry.observed_seconds / TICK_INTERVAL_SECS; + let accounted = entry.credited_minutes + entry.offline_minutes + entry.lost_ticks; + + let verdict = if entry.offline_minutes > 0 && entry.credited_minutes == 0 { + format!( + "{} of {} played minutes were stored offline because the server rejected the session; they are credited when the session works again", + entry.offline_minutes, observed + ) + } else if entry.offline_minutes > 0 { + format!( + "{} of {} played minutes were stored offline (rejected session), {} were credited", + entry.offline_minutes, observed, entry.credited_minutes + ) + } else if observed > accounted { + format!( + "{} of {} played minutes could not be credited ({} lost, {} dropped during a tick gap)", + observed - accounted, + observed, + entry.lost_ticks, + entry.dropped_ticks + ) + } else { + "all matched minutes were credited".to_string() + }; + + tracker_log::push_kv( + "info", + "session", + &[ + ("event", "session_summary".to_string()), + ("game", entry.game_id.to_string()), + ("title", entry.game_title.clone()), + ("observed_minutes", observed.to_string()), + ("credited_minutes", entry.credited_minutes.to_string()), + ("offline_minutes", entry.offline_minutes.to_string()), + ("lost_minutes", entry.lost_ticks.to_string()), + ("dropped_minutes", entry.dropped_ticks.to_string()), + ("match_flaps", entry.match_flaps.to_string()), + ( + "replayed_minutes", + entry.replayed_minutes.to_string(), + ), + ( + "server_minutes", + entry + .last_server_minutes + .map(|minutes| minutes.to_string()) + .unwrap_or_else(|| "unknown".to_string()), + ), + ("pending_offline_minutes", pending_offline_minutes.to_string()), + ("verdict", verdict), + ], + ); +} + +/// True when a process runs with the same file name as one of the candidates: +/// the game is probably running, but the full paths did not match. +fn candidate_name_seen(processes: &[&sysinfo::Process], candidates: &[PathBuf]) -> bool { + let names: HashSet = candidates + .iter() + .filter_map(|path| path.file_name()) + .map(|name| name.to_string_lossy().to_ascii_lowercase()) + .collect(); + if names.is_empty() { + return false; + } + + processes.iter().any(|process| { + process + .exe() + .and_then(|exe| exe.file_name()) + .map(|name| names.contains(&name.to_string_lossy().to_ascii_lowercase())) + .unwrap_or(false) + }) +} + +/// Ticks the interval expected but that never ran, from an observed gap. +/// +/// Rounds to the nearest interval and never counts the tick that just ran, so a +/// 3 minute sleep reports 2 lost ticks and stays silent below the tolerance. +pub(crate) fn dropped_ticks_for_gap(gap_secs: u64) -> u64 { + if gap_secs <= TICK_INTERVAL_SECS + CATCH_UP_TOLERANCE_SECS { + 0 + } else { + (gap_secs + TICK_INTERVAL_SECS / 2) / TICK_INTERVAL_SECS - 1 + } +} + +/// Why a single increment was credited, stored offline or dropped. +enum CreditOutcome { + Credited { server_minutes: Option }, + Offline { reason: String }, + Lost { reason: String }, +} + +/// Applies one credit attempt to the per-game ledger. +fn record_credit(game_id: i64, outcome: CreditOutcome) { + let Ok(mut ledger) = tracker_ledger().lock() else { + return; + }; + let entry = ledger.entry(game_id).or_default(); + entry.game_id = game_id; + + match outcome { + CreditOutcome::Credited { server_minutes } => { + entry.credited_ticks += 1; + entry.credited_minutes += 1; + if let Some(minutes) = server_minutes { + let delta = entry.last_server_minutes.map(|previous| minutes - previous); + entry.last_server_minutes = Some(minutes); + tracker_log::push_kv( + "info", + "credit", + &[ + ("event", "credited".to_string()), + ("game", game_id.to_string()), + ("title", entry.game_title.clone()), + ("server_minutes", minutes.to_string()), + ( + "server_delta", + delta + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown".to_string()), + ), + ( + "expected_delta", + "1".to_string(), + ), + ("credited_minutes", entry.credited_minutes.to_string()), + ( + "observed_minutes", + (entry.observed_seconds / TICK_INTERVAL_SECS).to_string(), + ), + ], + ); + } + } + CreditOutcome::Offline { reason } => { + entry.offline_ticks += 1; + entry.offline_minutes += 1; + tracker_log::push_kv( + "warn", + "credit", + &[ + ("event", "credited_offline".to_string()), + ("game", game_id.to_string()), + ("title", entry.game_title.clone()), + ("reason", reason), + ("offline_minutes", entry.offline_minutes.to_string()), + ], ); - let result = client - .put(&url) - .header("Authorization", format!("Bearer {}", config.access_token)) - .header("Accept", "application/json") - .send() - .await; - - if result.is_err() { - save_offline_time(&config.download_paths, config.user_id, game_id); + } + CreditOutcome::Lost { reason } => { + entry.lost_ticks += 1; + tracker_log::push_kv( + "error", + "credit", + &[ + ("event", "credit_lost".to_string()), + ("game", game_id.to_string()), + ("title", entry.game_title.clone()), + ("reason", reason), + ("lost_ticks_total", entry.lost_ticks.to_string()), + ], + ); + } + } +} + +/// Short, single-line process description for log lines. +fn describe_process(process: &sysinfo::Process) -> String { + let exe = process + .exe() + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_else(|| { + let cmd: Vec = process + .cmd() + .iter() + .map(|part| part.to_string_lossy().to_string()) + .collect(); + let joined = cmd.join(" "); + if joined.is_empty() { + "unknown".to_string() + } else { + joined } + }); + format!("{exe} (pid {})", process.pid()) +} + +/// What the tracker should do with an increment response. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CreditDisposition { + /// 2xx: the minute was counted by the server. + Success, + /// 401/403 (expired token), 408/429 (retry later) or 5xx: store offline. + SaveOffline, + /// Anything else (e.g. 404): do not count, do not store offline. + Drop, +} + +pub(crate) fn classify_status(status: u16) -> CreditDisposition { + if (200..300).contains(&status) { + CreditDisposition::Success + } else if matches!(status, 401 | 403 | 408 | 429) || status >= 500 { + CreditDisposition::SaveOffline + } else { + CreditDisposition::Drop + } +} + +/// Extracts `minutes_played` from an increment response, when the server sends it. +pub(crate) fn parse_server_minutes(body: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(body).ok()?; + value.get("minutes_played").and_then(|minutes| minutes.as_i64()) +} + +fn excerpt(text: &str, max: usize) -> String { + let cleaned = text.replace('\n', " ").replace('\r', " ").trim().to_string(); + if cleaned.chars().count() <= max { + return cleaned; + } + let mut short: String = cleaned.chars().take(max).collect(); + short.push('…'); + short +} + +fn bump_credit_stats(success: bool, auth_rejected: bool) { + if let Ok(mut stats) = tracker_stats().lock() { + if success { + stats.consecutive_failures = 0; + stats.last_error = None; + // A working session ends the rejection streak and any backoff. + stats.auth_streak = 0; + stats.auth_backoff_until = None; + } else { + stats.consecutive_failures += 1; + } + if auth_rejected { + stats.auth_rejected_count += 1; } } } +fn record_failure(reason: &str) { + if let Ok(mut stats) = tracker_stats().lock() { + stats.last_error = Some(reason.to_string()); + } +} + +/// The server rejected our session: stop asking for a while, keep storing the +/// minute offline, and (throttled) tell the frontend so it can refresh the +/// token instead of letting the tracker run on an expired one for an hour. +fn apply_auth_backoff(app: &tauri::AppHandle, status: u16) { + let now = tracker_log::now_ms(); + let (streak, retry_in_secs, rejected_total, should_emit) = { + let Ok(mut stats) = tracker_stats().lock() else { + return; + }; + stats.auth_streak += 1; + stats.consecutive_failures += 1; + stats.auth_rejected_count += 1; + + let index = (stats.auth_streak as usize - 1).min(AUTH_BACKOFF_SECS.len() - 1); + let retry_in_secs = AUTH_BACKOFF_SECS[index]; + stats.auth_backoff_until = Some(now + retry_in_secs * 1000); + + let should_emit = stats + .last_auth_event_at + .map(|last| now.saturating_sub(last) >= AUTH_EVENT_THROTTLE_MS) + .unwrap_or(true); + if should_emit { + stats.last_auth_event_at = Some(now); + } + + ( + stats.auth_streak, + retry_in_secs, + stats.auth_rejected_count, + should_emit, + ) + }; + + tracker_log::push_kv( + "warn", + "http", + &[ + ("event", "auth_backoff".to_string()), + ("status", status.to_string()), + ("streak", streak.to_string()), + ("retry_in_secs", retry_in_secs.to_string()), + ( + "message", + "the server rejected the session — playtime is stored offline and requests are paused" + .to_string(), + ), + ], + ); + + if should_emit { + let _ = app.emit( + AUTH_EXPIRED_EVENT, + TrackerAuthExpiredEvent { + at: now, + auth_rejected_count: rejected_total, + pending_offline_minutes: pending_offline_minutes(), + retry_in_secs, + }, + ); + } +} + +/// The access token the tracker is currently configured with. +fn current_access_token() -> Option { + tracker_config() + .lock() + .ok() + .and_then(|config| config.as_ref().map(|config| config.access_token.clone())) +} + +/// Sends one increment request with a given token. +async fn send_increment( + client: &reqwest::Client, + url: &str, + access_token: &str, +) -> Result { + client + .put(url) + .header("Authorization", format!("Bearer {access_token}")) + .header("Accept", "application/json") + .send() + .await +} + +/// Sends one minute increment and reports what happened to it. +/// +/// A rejected session is retried once with a newer token if the frontend +/// refreshed it in the meantime — the tracker's tick usually starts a moment +/// before the token refresh lands, so without this every refresh would waste a +/// minute into the offline file (and delete it again seconds later). +async fn increment_game_time( + app: &tauri::AppHandle, + client: &reqwest::Client, + config: &TrackerConfig, + game_id: i64, + installed: &[InstalledGameInfo], +) -> CreditOutcome { + let url = format!( + "{}/api/progresses/user/{}/game/{}/increment", + config.server_url, config.user_id, game_id + ); + + let response = match send_increment(client, &url, &config.access_token).await { + Ok(response) => response, + Err(error) => { + let reason = format!("network error: {error}"); + save_offline_time(installed, config, game_id); + return CreditOutcome::Offline { reason }; + } + }; + + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + + match classify_status(status.as_u16()) { + CreditDisposition::Success => CreditOutcome::Credited { + server_minutes: parse_server_minutes(&body), + }, + CreditDisposition::SaveOffline => { + if matches!(status.as_u16(), 401 | 403) { + if let Some(fresh) = current_access_token() { + if fresh != config.access_token { + match send_increment(client, &url, &fresh).await { + Ok(retry) if retry.status().is_success() => { + let retry_body = retry.text().await.unwrap_or_default(); + tracker_log::push_kv( + "info", + "http", + &[ + ("event", "auth_retried".to_string()), + ("game", game_id.to_string()), + ("outcome", "credited with the refreshed session".to_string()), + ], + ); + return CreditOutcome::Credited { + server_minutes: parse_server_minutes(&retry_body), + }; + } + Ok(retry) => tracker_log::push_kv( + "warn", + "http", + &[ + ("event", "auth_retried".to_string()), + ("game", game_id.to_string()), + ("outcome", "still rejected".to_string()), + ("status", retry.status().as_u16().to_string()), + ], + ), + Err(error) => tracker_log::push_kv( + "warn", + "http", + &[ + ("event", "auth_retried".to_string()), + ("game", game_id.to_string()), + ("outcome", format!("network error: {error}")), + ], + ), + } + } + } + apply_auth_backoff(app, status.as_u16()); + } + save_offline_time(installed, config, game_id); + CreditOutcome::Offline { + reason: format!("http {} ({})", status.as_u16(), excerpt(&body, 160)), + } + } + CreditDisposition::Drop => CreditOutcome::Lost { + reason: format!("http {} ({})", status.as_u16(), excerpt(&body, 160)), + }, + } +} + pub(crate) fn process_matches_game(process: &sysinfo::Process, game_exe: &Path) -> bool { // Direct binary: the process exe path resolves to the game executable. if let Some(exe) = process.exe() { @@ -247,37 +1427,106 @@ fn read_configured_launch_executable(version_dir: &Path) -> Option { Some(PathBuf::from(exe)) } -fn save_offline_time(download_paths: &[String], user_id: i64, game_id: i64) { - let mut installed = Vec::new(); - for path in download_paths { - if let Ok(games) = list_installed_games(path.to_string()) { - installed.extend(games); - } - } +/// Builds the offline file content, keeping `accumulated_minutes` monotonic. +/// +/// This is the safety net for every minute the server did not accept: the file +/// is only ever incremented and is replayed once a session works again. Older +/// files (written before `updated_at`/`server_url` existed) are still readable, +/// because only `accumulated_minutes` matters for the count. +fn offline_payload( + existing: Option<&str>, + user_id: i64, + game_id: i64, + server_url: &str, + at_ms: u64, +) -> (i64, serde_json::Value) { + let current_minutes = existing + .and_then(|content| serde_json::from_str::(content).ok()) + .and_then(|json| { + json + .get("accumulated_minutes") + .and_then(|value| value.as_i64()) + }) + .unwrap_or(0) + .max(0); - let target = match installed.iter().find(|g| g.game_id == game_id) { - Some(g) => g, - None => return, + let accumulated = current_minutes + 1; + let data = serde_json::json!({ + "user_id": user_id, + "game_id": game_id, + "accumulated_minutes": accumulated, + "updated_at": at_ms / 1000, + "server_url": server_url, + }); + (accumulated, data) +} + +/// Stores one uncredited minute in the game's offline file. Returns the path it +/// wrote to, so the caller can log where the playtime went. +fn save_offline_time( + installed: &[InstalledGameInfo], + config: &TrackerConfig, + game_id: i64, +) -> Option { + let target = match installed.iter().find(|game| game.game_id == game_id) { + Some(game) => game, + None => { + tracker_log::push_kv( + "error", + "offline", + &[ + ("event", "offline_save_failed".to_string()), + ("game", game_id.to_string()), + ( + "reason", + "the game is no longer in the library, so the minute could not be stored offline" + .to_string(), + ), + ], + ); + return None; + } }; let offline_file = PathBuf::from(&target.version_directory).join(".gamevault.offline_time.json"); + let existing = fs::read_to_string(&offline_file).ok(); + let (accumulated, data) = offline_payload( + existing.as_deref(), + config.user_id, + game_id, + &config.server_url, + tracker_log::now_ms(), + ); - let mut current_minutes: i64 = 0; - if offline_file.exists() { - if let Ok(content) = fs::read_to_string(&offline_file) { - if let Ok(json) = serde_json::from_str::(&content) { - current_minutes = json.get("accumulated_minutes").and_then(|v| v.as_i64()).unwrap_or(0); - } + match fs::write(&offline_file, serde_json::to_string(&data).unwrap_or_default()) { + Ok(()) => { + tracker_log::push_kv( + "info", + "offline", + &[ + ("event", "offline_saved".to_string()), + ("game", game_id.to_string()), + ("title", target.game_title.clone()), + ("accumulated_minutes", accumulated.to_string()), + ("path", offline_file.to_string_lossy().to_string()), + ], + ); + Some(offline_file) + } + Err(error) => { + tracker_log::push_kv( + "error", + "offline", + &[ + ("event", "offline_save_failed".to_string()), + ("game", game_id.to_string()), + ("error", error.to_string()), + ("path", offline_file.to_string_lossy().to_string()), + ], + ); + None } } - - let data = serde_json::json!({ - "user_id": user_id, - "game_id": game_id, - "accumulated_minutes": current_minutes + 1 - }); - - let _ = fs::write(&offline_file, serde_json::to_string(&data).unwrap_or_default()); } // ── Offline time tracking commands ──────────────────────────────────────────── @@ -289,10 +1538,23 @@ pub(crate) struct OfflineTimeFile { user_id: i64, game_id: i64, accumulated_minutes: i64, + /// Unix seconds of the last write (0 for files from older builds). + updated_at: i64, } #[tauri::command] -pub(crate) fn get_offline_time_files(selected_root: String) -> Result, String> { +pub(crate) async fn get_offline_time_files( + selected_root: String, +) -> Result, String> { + // Walks the library for offline time files: off the UI thread. + tauri::async_runtime::spawn_blocking(move || get_offline_time_files_blocking(selected_root)) + .await + .map_err(|error| format!("Reading offline time files failed: {error}"))? +} + +fn get_offline_time_files_blocking( + selected_root: String, +) -> Result, String> { let candidate = PathBuf::from(&selected_root).join("GameVault"); let base = if candidate.exists() { candidate } else { PathBuf::from(&selected_root) }; @@ -321,11 +1583,13 @@ fn walk_offline_time_files(dir: &Path, results: &mut Vec) -> st let user_id = json.get("user_id").and_then(|v| v.as_i64()).unwrap_or(0); let game_id = json.get("game_id").and_then(|v| v.as_i64()).unwrap_or(0); let accumulated_minutes = json.get("accumulated_minutes").and_then(|v| v.as_i64()).unwrap_or(0); + let updated_at = json.get("updated_at").and_then(|v| v.as_i64()).unwrap_or(0); results.push(OfflineTimeFile { path: path.to_string_lossy().to_string(), user_id, game_id, accumulated_minutes, + updated_at, }); } } @@ -339,6 +1603,14 @@ pub(crate) fn delete_offline_time_file(path: String) -> Result<(), String> { let p = Path::new(&path); if p.exists() { fs::remove_file(p).map_err(|e| format!("Failed to delete offline time file: {e}"))?; + tracker_log::push_kv( + "info", + "offline", + &[ + ("event", "offline_file_deleted".to_string()), + ("path", path.clone()), + ], + ); } Ok(()) } @@ -350,20 +1622,82 @@ pub(crate) async fn sync_offline_time( user_id: i64, game_id: i64, minutes: i64, + reason: Option, ) -> Result { + let reason = reason.unwrap_or_else(|| "unspecified".to_string()); let url = format!( "{}/api/progresses/user/{}/game/{}/increment/{}", server_url, user_id, game_id, minutes ); let client = reqwest::Client::new(); - let resp = client + let resp = match client .put(&url) .header("Authorization", format!("Bearer {}", access_token)) .header("Accept", "application/json") .send() .await - .map_err(|e| format!("Sync request failed: {e}"))?; - Ok(resp.status().is_success()) + { + Ok(resp) => resp, + Err(error) => { + tracker_log::push_kv( + "warn", + "offline", + &[ + ("event", "offline_replay_failed".to_string()), + ("game", game_id.to_string()), + ("minutes", minutes.to_string()), + ("reason", reason), + ("error", error.to_string()), + ], + ); + return Err(format!("Sync request failed: {error}")); + } + }; + + let status = resp.status(); + let success = status.is_success(); + let body = resp.text().await.unwrap_or_default(); + + tracker_log::push_kv( + if success { "info" } else { "warn" }, + "offline", + &[ + ("event", "offline_replayed".to_string()), + ("game", game_id.to_string()), + ("minutes", minutes.to_string()), + ("user_id", user_id.to_string()), + ("reason", reason.clone()), + ("status", status.as_u16().to_string()), + ("success", success.to_string()), + ( + "message", + if success { + "offline playtime was credited by the server".to_string() + } else { + "offline playtime was rejected and kept for the next attempt".to_string() + }, + ), + ], + ); + + // Keep the ledger in sync: those minutes are no longer waiting offline. + if success { + if let Ok(mut ledger) = tracker_ledger().lock() { + let entry = ledger.entry(game_id).or_default(); + entry.game_id = game_id; + entry.offline_minutes = entry + .offline_minutes + .saturating_sub(minutes.max(0) as u64); + // Replayed minutes belong to an earlier session, so they are counted + // separately and never inflate this session's credited total. + entry.replayed_minutes += minutes.max(0) as u64; + if let Some(server_minutes) = parse_server_minutes(&body) { + entry.last_server_minutes = Some(server_minutes); + } + } + } + + Ok(success) } // ── Debug diagnostics ──────────────────────────────────────────────────────── @@ -408,7 +1742,17 @@ pub(crate) struct DebugProcessMatch { /// Debug helper: replays the exact scan the time tracker performs and reports /// which installed games, executable candidates and process matches it finds. #[tauri::command] -pub(crate) fn debug_tracker_scan( +pub(crate) async fn debug_tracker_scan( + app: tauri::AppHandle, + selected_root: Option, +) -> Result { + // Replays a full library + process scan: off the UI thread. + tauri::async_runtime::spawn_blocking(move || debug_tracker_scan_blocking(app, selected_root)) + .await + .map_err(|error| format!("Tracker scan failed: {error}"))? +} + +fn debug_tracker_scan_blocking( _app: tauri::AppHandle, selected_root: Option, ) -> Result { @@ -428,7 +1772,7 @@ pub(crate) fn debug_tracker_scan( let mut installed = Vec::new(); for root in &roots { - if let Ok(games) = list_installed_games(root.clone()) { + if let Ok(games) = list_installed_games_blocking(root.clone()) { installed.extend(games); } } @@ -518,3 +1862,149 @@ pub(crate) fn debug_tracker_scan( process_matches, }) } + +// ── Runtime status ─────────────────────────────────────────────────────────── + +/// Diagnostics snapshot: loop counters plus the per-game playtime ledger. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TrackerStatus { + pub tracker_running: bool, + pub stats: TrackerRuntimeStats, + pub games: Vec, + pub log_path: Option, + /// Minutes stored offline that still need a successful sync. + pub pending_offline_minutes: u64, +} + +#[tauri::command] +pub(crate) fn get_tracker_status() -> TrackerStatus { + let mut stats = tracker_stats().lock().map(|stats| stats.clone()).unwrap_or_default(); + stats.log_path = tracker_log::log_path(); + + let running = tracker_config().lock().map(|config| config.is_some()).unwrap_or(false) + || stats.running; + stats.running = running; + + TrackerStatus { + tracker_running: running, + stats, + games: tracker_ledger_snapshot(), + log_path: tracker_log::log_path(), + pending_offline_minutes: pending_offline_minutes(), + } +} + +/// Clears the playtime ledger so a fresh measurement can be taken. +#[tauri::command] +pub(crate) fn reset_tracker_ledger() -> Result<(), String> { + if let Ok(mut ledger) = tracker_ledger().lock() { + ledger.clear(); + } + if let Ok(mut stats) = tracker_stats().lock() { + let started_at = tracker_log::now_ms(); + let lost_ticks = stats.lost_ticks; + stats.tick_count = 0; + stats.lost_ticks = 0; + stats.catch_up_ticks = 0; + stats.consecutive_failures = 0; + stats.auth_rejected_count = 0; + stats.started_at = Some(started_at); + tracker_log::push( + "info", + "lifecycle", + &format!("tracker counters reset (previous lost_ticks={lost_ticks})"), + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_increment_responses() { + assert_eq!(classify_status(200), CreditDisposition::Success); + assert_eq!(classify_status(204), CreditDisposition::Success); + // Expired token / server problems: keep the minute offline. + assert_eq!(classify_status(401), CreditDisposition::SaveOffline); + assert_eq!(classify_status(403), CreditDisposition::SaveOffline); + assert_eq!(classify_status(408), CreditDisposition::SaveOffline); + assert_eq!(classify_status(429), CreditDisposition::SaveOffline); + assert_eq!(classify_status(500), CreditDisposition::SaveOffline); + assert_eq!(classify_status(503), CreditDisposition::SaveOffline); + // Provider errors must not create offline time (game gone, bad request). + assert_eq!(classify_status(400), CreditDisposition::Drop); + assert_eq!(classify_status(404), CreditDisposition::Drop); + } + + #[test] + fn parses_server_reported_minutes() { + assert_eq!( + parse_server_minutes(r#"{"minutes_played": 61, "state": "playing"}"#), + Some(61) + ); + assert_eq!(parse_server_minutes(r#"{"minutes_played": null}"#), None); + assert_eq!(parse_server_minutes(""), None); + assert_eq!(parse_server_minutes(""), None); + } + + #[test] + fn estimates_dropped_ticks_from_a_gap() { + // Rounds to the nearest tick and never reports the tick that just ran. + assert_eq!(dropped_ticks_for_gap(60), 0); + assert_eq!(dropped_ticks_for_gap(65), 0); + assert_eq!(dropped_ticks_for_gap(90), 1); + assert_eq!(dropped_ticks_for_gap(180), 2); + assert_eq!(dropped_ticks_for_gap(3_600), 59); + } + + #[test] + fn excerpts_long_bodies() { + assert_eq!(excerpt("short", 20), "short"); + assert_eq!(excerpt("line\nbreak", 40), "line break"); + let trimmed = excerpt(&"x".repeat(30), 10); + assert_eq!(trimmed.chars().count(), 11); + assert!(trimmed.ends_with('…')); + } + + #[test] + fn offline_payload_counts_up_from_legacy_files() { + // A file from an older build only has accumulated_minutes. + let legacy = r#"{"user_id": 1, "game_id": 2341, "accumulated_minutes": 61}"#; + let (accumulated, data) = offline_payload(Some(legacy), 1, 2341, "https://example.test", 1_700_000_000_000); + assert_eq!(accumulated, 62); + assert_eq!(data["accumulated_minutes"].as_i64(), Some(62)); + assert_eq!(data["game_id"].as_i64(), Some(2341)); + assert_eq!(data["user_id"].as_i64(), Some(1)); + assert_eq!(data["updated_at"].as_i64(), Some(1_700_000_000)); + assert_eq!(data["server_url"].as_str(), Some("https://example.test")); + } + + #[test] + fn offline_payload_starts_at_one_and_survives_damage() { + let (first, _) = offline_payload(None, 1, 7, "https://example.test", 0); + assert_eq!(first, 1); + + // Unreadable/garbage content must not reset the counter backwards. + let (from_garbage, _) = offline_payload(Some(""), 1, 7, "https://example.test", 0); + assert_eq!(from_garbage, 1); + + // Negative values (hand-edited file) are clamped, never decreased further. + let negative = r#"{"accumulated_minutes": -5}"#; + let (clamped, _) = offline_payload(Some(negative), 1, 7, "https://example.test", 0); + assert_eq!(clamped, 1); + } + + #[test] + fn auth_backoff_grows_and_caps() { + let retry_for = |streak: usize| AUTH_BACKOFF_SECS[(streak - 1).min(AUTH_BACKOFF_SECS.len() - 1)]; + assert_eq!(retry_for(1), 60); + assert_eq!(retry_for(2), 120); + assert_eq!(retry_for(3), 300); + assert_eq!(retry_for(4), 900); + // Stays capped: an hour-long rejection streak must not grow unbounded. + assert_eq!(retry_for(60), 900); + } +} diff --git a/src-tauri/src/tracker_log.rs b/src-tauri/src/tracker_log.rs new file mode 100644 index 0000000..be76177 --- /dev/null +++ b/src-tauri/src/tracker_log.rs @@ -0,0 +1,489 @@ +//! Time tracker diagnostics log. +//! +//! The game time tracker credits exactly one minute per matched tick, so a +//! playtime that ends up lower than what was actually played is always caused +//! by lost ticks (sleep/resume, freeze, starvation), lost matches (launcher +//! exits, protection re-exec, missing executables) or dropped requests (expired +//! token, server errors, offline). Everything that can lose a minute is +//! recorded here, always on — also in release builds — so a bug report can show +//! the expected-versus-credited timeline per game. +//! +//! The log is written to a rotating file in the app log directory +//! (`tracker-logs/tracker.log`) and kept in a bounded in-memory tail that the +//! settings dump reads. Secrets are never logged: tokens are reduced to a +//! fingerprint via [`redact_token`]. + +use serde::Serialize; +use std::collections::VecDeque; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufWriter, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Manager}; + +/// Maximum number of lines kept in memory (oldest are dropped). +const MAX_TAIL_LINES: usize = 3_000; +/// Rotate the active file once it grows past this size. +const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024; +/// Number of rotated log files kept on disk (oldest are deleted). +const MAX_LOG_FILES: usize = 10; +/// Active log file name inside the tracker log directory. +const ACTIVE_FILE_NAME: &str = "tracker.log"; + +/// Master switch for the always-on tracker log. +/// +/// While this is `false`, no file is written and no line is recorded: the +/// tracker still keeps its counters and the per-game playtime ledger in memory, +/// but nothing is persisted. Flip it back to `true` (and restore the hook's +/// `tracker_log_line` call in `useGameTimeTracker.ts`) to record the full +/// tick-by-tick trace again for diagnostics. +pub(crate) const ENABLED: bool = false; + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct TrackerLogLine { + pub seq: u64, + /// Milliseconds since the unix epoch. + pub at: u64, + /// `info`, `warn`, `error` or `tick`. + pub level: String, + /// Coarse origin, e.g. `lifecycle`, `tick`, `game`, `http`, `ledger`, `app`. + pub category: String, + pub text: String, +} + +pub(crate) struct TrackerLog { + seq: AtomicU64, + written: AtomicU64, + lines: Mutex>, + file: Mutex>>, + path: Mutex>, +} + +static LOG: OnceLock>>> = OnceLock::new(); + +fn store() -> &'static Mutex>> { + LOG.get_or_init(|| Mutex::new(None)) +} + +/// Milliseconds since the unix epoch. +pub(crate) fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +/// Absolute path of the directory holding the tracker logs. +pub(crate) fn log_directory(app: &AppHandle) -> Option { + let dir = app.path().app_log_dir().ok()?.join("tracker-logs"); + fs::create_dir_all(&dir).ok()?; + Some(dir) +} + +/// Starts (or restarts) the tracker log. Safe to call more than once. +pub(crate) fn init(app: &AppHandle) { + if !ENABLED { + return; + } + + let Some(dir) = log_directory(app) else { + return; + }; + + let path = dir.join(ACTIVE_FILE_NAME); + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .ok() + .map(BufWriter::new); + + let written = file + .as_ref() + .and_then(|_| fs::metadata(&path).ok()) + .map(|metadata| metadata.len()) + .unwrap_or(0); + + let log = Arc::new(TrackerLog { + seq: AtomicU64::new(0), + written: AtomicU64::new(written), + lines: Mutex::new(VecDeque::new()), + file: Mutex::new(file), + path: Mutex::new(Some(path)), + }); + + match store().lock() { + Ok(mut guard) => *guard = Some(log.clone()), + Err(_) => return, + } + + prune_old_files(&dir); + push( + "info", + "lifecycle", + &format!("=== tracker log started (v{}) ===", env!("CARGO_PKG_VERSION")), + ); +} + +/// Returns the active log file path, if the logger has been initialized. +pub(crate) fn log_path() -> Option { + store() + .lock() + .ok() + .and_then(|guard| guard.as_ref().cloned()) + .and_then(|log| { + log + .path + .lock() + .ok() + .and_then(|value| value.as_ref().map(|p| p.to_string_lossy().to_string())) + }) +} + +/// Appends one line to the in-memory tail and the log file. +pub(crate) fn push(level: &str, category: &str, text: &str) { + if !ENABLED { + return; + } + + match level { + "error" => log::error!("[tracker:{category}] {text}"), + "warn" => log::warn!("[tracker:{category}] {text}"), + _ => log::info!("[tracker:{category}] {text}"), + } + + let log = match store().lock() { + Ok(guard) => guard.as_ref().cloned(), + Err(_) => None, + }; + let Some(log) = log else { + return; + }; + + let seq = log.seq.fetch_add(1, Ordering::SeqCst); + let at = now_ms(); + let line = TrackerLogLine { + seq, + at, + level: level.to_string(), + category: category.to_string(), + text: text.to_string(), + }; + + if let Ok(mut lines) = log.lines.lock() { + lines.push_back(line.clone()); + while lines.len() > MAX_TAIL_LINES { + lines.pop_front(); + } + } + + let rendered = format_line(&line) + "\n"; + let mut rotate = false; + if let Ok(mut guard) = log.file.lock() { + if let Some(writer) = guard.as_mut() { + if writer.write_all(rendered.as_bytes()).is_ok() && writer.flush().is_ok() { + let total = log + .written + .fetch_add(rendered.len() as u64, Ordering::SeqCst) + + rendered.len() as u64; + rotate = total >= MAX_FILE_BYTES; + } + } + } + if rotate { + rotate_active_file(&log); + } +} + +/// Appends a `key=value` line. Values are quoted when they contain a space. +pub(crate) fn push_kv(level: &str, category: &str, fields: &[(&str, String)]) { + push(level, category, &format_kv(fields)); +} + +/// Renders fields as `key=value` pairs, quoting values that need it. +pub(crate) fn format_kv(fields: &[(&str, String)]) -> String { + fields + .iter() + .map(|(key, value)| { + if value.contains(' ') || value.is_empty() { + format!("{key}=\"{value}\"") + } else { + format!("{key}={value}") + } + }) + .collect::>() + .join(" ") +} + +fn rotate_active_file(log: &Arc) { + let path = match log.path.lock() { + Ok(guard) => guard.clone(), + Err(_) => None, + }; + let Some(path) = path else { + return; + }; + let directory = match path.parent() { + Some(directory) => directory.to_path_buf(), + None => return, + }; + + // Drop the writer first so the file handle is closed before renaming. + if let Ok(mut guard) = log.file.lock() { + *guard = None; + } + + let rotated = directory.join(format!("tracker-{}.log", now_ms() / 1000)); + let renamed = fs::rename(&path, &rotated).is_ok(); + + if let Ok(mut guard) = log.file.lock() { + *guard = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .ok() + .map(BufWriter::new); + } + log.written.store(0, Ordering::SeqCst); + + if renamed { + push( + "info", + "lifecycle", + &format!( + "rotated tracker log to {}", + rotated.file_name().unwrap_or_default().to_string_lossy() + ), + ); + prune_old_files(&directory); + } +} + +fn prune_old_files(directory: &Path) { + let Ok(entries) = fs::read_dir(directory) else { + return; + }; + let mut files: Vec = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path + .file_name() + .map(|name| name.to_string_lossy().starts_with("tracker-")) + .unwrap_or(false) + }) + .collect(); + files.sort(); + while files.len() > MAX_LOG_FILES { + let oldest = files.remove(0); + let _ = fs::remove_file(oldest); + } +} + +/// Formats one line: `2026-09-26 10:15:23.123 [tick] text`. +fn format_line(line: &TrackerLogLine) -> String { + format!( + "{} [{}] {}", + format_timestamp(line.at), + line.level, + line.text + ) +} + +/// Formats unix milliseconds as `YYYY-MM-DD HH:MM:SS.mmm` (UTC). +pub(crate) fn format_timestamp(unix_ms: u64) -> String { + let secs = (unix_ms / 1000) as i64; + let millis = (unix_ms % 1000) as u32; + let days = secs.div_euclid(86_400); + let rest = secs.rem_euclid(86_400); + let (year, month, day) = civil_from_days(days); + format!( + "{year:04}-{month:02}-{day:02} {:02}:{:02}:{:02}.{millis:03}", + rest / 3600, + (rest % 3600) / 60, + rest % 60 + ) +} + +/// Howard Hinnant's civil-from-days algorithm (days since 1970-01-01 → Y/M/D). +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as i64; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = (doy - (153 * mp + 2) / 5 + 1) as u32; + let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if month <= 2 { year + 1 } else { year }, month, day) +} + +/// Never log a raw token: returns a fingerprint with length and last 6 chars. +pub(crate) fn redact_token(token: &str) -> String { + if token.is_empty() { + return "none".to_string(); + } + let tail: String = token + .chars() + .rev() + .take(6) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("", token.chars().count(), tail) +} + +/// Returns the in-memory tail, oldest first. +pub(crate) fn tail(limit: usize) -> Vec { + let log = match store().lock() { + Ok(guard) => guard.as_ref().cloned(), + Err(_) => None, + }; + let Some(log) = log else { + return Vec::new(); + }; + let lines = match log.lines.lock() { + Ok(lines) => lines, + Err(_) => return Vec::new(), + }; + let skip = lines.len().saturating_sub(limit); + lines.iter().skip(skip).cloned().collect() +} + +fn clear(app: &AppHandle) { + let log = match store().lock() { + Ok(guard) => guard.as_ref().cloned(), + Err(_) => None, + }; + if let Some(log) = log.as_ref() { + if let Ok(mut lines) = log.lines.lock() { + lines.clear(); + } + // Close the writer before the file is deleted below. + if let Ok(mut guard) = log.file.lock() { + *guard = None; + } + } + + let Some(directory) = log_directory(app) else { + return; + }; + if let Ok(entries) = fs::read_dir(&directory) { + for entry in entries.flatten() { + let _ = fs::remove_file(entry.path()); + } + } + + if let Some(log) = log { + let path = directory.join(ACTIVE_FILE_NAME); + if let Ok(mut guard) = log.file.lock() { + *guard = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .ok() + .map(BufWriter::new); + } + if let Ok(mut guard) = log.path.lock() { + *guard = Some(path); + } + log.written.store(0, Ordering::SeqCst); + } + push("info", "lifecycle", "tracker log cleared"); +} + +// ── Commands ─────────────────────────────────────────────────────────────── + +#[tauri::command] +pub(crate) fn get_tracker_log(limit: Option) -> Vec { + tail(limit.unwrap_or(MAX_TAIL_LINES).min(MAX_TAIL_LINES)) +} + +#[tauri::command] +pub(crate) fn clear_tracker_log(app: AppHandle) -> Result<(), String> { + clear(&app); + Ok(()) +} + +#[tauri::command] +pub(crate) fn open_tracker_log_folder(app: AppHandle) -> Result<(), String> { + let dir = log_directory(&app).ok_or_else(|| "Log directory unavailable".to_string())?; + crate::games::open_in_file_explorer(dir.to_string_lossy().to_string()) +} + +/// Reads the tail of a tracker log file (for rotated files). +#[tauri::command] +pub(crate) fn read_tracker_log_file(path: String, max_bytes: Option) -> Result { + let mut file = + File::open(&path).map_err(|error| format!("Failed to open tracker log: {error}"))?; + let length = file + .metadata() + .map_err(|error| format!("Failed to read tracker log: {error}"))? + .len(); + let max_bytes = max_bytes.unwrap_or(512 * 1024); + let start = length.saturating_sub(max_bytes); + if start > 0 { + file.seek(SeekFrom::Start(start)) + .map_err(|error| format!("Failed to read tracker log: {error}"))?; + } + let mut buffer = String::new(); + file + .read_to_string(&mut buffer) + .map_err(|error| format!("Failed to read tracker log: {error}"))?; + if start > 0 { + if let Some(index) = buffer.find('\n') { + buffer.drain(..=index); + } + } + Ok(buffer) +} + +/// Lets the frontend record lifecycle decisions in the same log. +#[tauri::command] +pub(crate) fn tracker_log_line(level: String, category: String, message: String) { + push(&level, &format!("app:{category}"), &message); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_unix_millis_as_utc_timestamp() { + assert_eq!(format_timestamp(0), "1970-01-01 00:00:00.000"); + assert_eq!(format_timestamp(1_000), "1970-01-01 00:00:01.000"); + // Well-known epoch: 2020-09-13 12:26:40 UTC + assert_eq!(format_timestamp(1_600_000_000_000), "2020-09-13 12:26:40.000"); + // Leap-day sanity check: 2024-02-29 00:00:00 UTC + assert_eq!(format_timestamp(1_709_164_800_000), "2024-02-29 00:00:00.000"); + } + + #[test] + fn redacts_tokens() { + assert_eq!(redact_token(""), "none"); + let redacted = redact_token("abcdefghijklmnop"); + assert!(!redacted.contains("abcdefghij")); + assert!(redacted.contains("klmnop")); + assert!(redacted.contains("16")); + } + + #[test] + fn formats_key_value_lines() { + let text = format_kv(&[ + ("tick", "7".to_string()), + ("game", "42".to_string()), + ("title", "My Game".to_string()), + ]); + assert_eq!(text, "tick=7 game=42 title=\"My Game\""); + } + + #[test] + fn tail_is_empty_without_logger() { + assert!(tail(10).is_empty()); + } +} diff --git a/src-tauri/src/umu.rs b/src-tauri/src/umu.rs index bf48682..68d93dd 100644 --- a/src-tauri/src/umu.rs +++ b/src-tauri/src/umu.rs @@ -221,6 +221,24 @@ async fn install_umu_launcher_inner(app: &tauri::AppHandle, game_title: Option<& return Ok(()); } + // Another run may already be installing umu-launcher (e.g. it was started + // before the page was reloaded, or by a launch that is still waiting). Wait + // for that one instead of downloading a second copy over it. + if crate::state::is_umu_install_running() { + for _ in 0..2_400 { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + if find_umu_run().is_some() { + return Ok(()); + } + if !crate::state::is_umu_install_running() { + break; + } + } + if find_umu_run().is_some() { + return Ok(()); + } + } + let home = home_dir().ok_or_else(|| "Could not determine home directory".to_string())?; let install_dir = home.join(UMU_INSTALL_DIR); fs::create_dir_all(&install_dir).map_err(|e| format!("Failed to create umu-launcher directory: {e}"))?; @@ -336,8 +354,14 @@ pub(crate) async fn install_umu_launcher( app: tauri::AppHandle, game_title: Option, ) -> Result<(), String> { - match install_umu_launcher_inner(&app, game_title.as_deref()).await { - Ok(()) => Ok(()), + let result = install_umu_launcher_inner(&app, game_title.as_deref()).await; + match result { + Ok(()) => { + // Clear the "installing" marker so a reloaded UI knows there is nothing + // left to re-attach to. + crate::state::set_umu_snapshot(None); + Ok(()) + } Err(error) => { emit_umu_status(&app, game_title.as_deref(), "error", None, Some(error.clone())); Err(error) @@ -367,10 +391,14 @@ pub(crate) fn ensure_umu_installed(app: &tauri::AppHandle, game_title: Option<&s #[cfg(target_os = "linux")] { if find_umu_run().is_some() { + crate::state::set_umu_snapshot(None); return Ok(()); } match tauri::async_runtime::block_on(install_umu_launcher_inner(app, game_title)) { - Ok(()) => Ok(()), + Ok(()) => { + crate::state::set_umu_snapshot(None); + Ok(()) + } Err(error) => { emit_umu_status(app, game_title, "error", None, Some(error.clone())); Err(error) @@ -445,8 +473,10 @@ fn stream_umu_lines( stream: R, app: tauri::AppHandle, game_title: Option, + stream_kind: &'static str, lines: Arc>>, running: Arc, + log: Option>, ) { thread::spawn(move || { let mut reader = BufReader::new(stream); @@ -467,11 +497,18 @@ fn stream_umu_lines( queue.pop_front(); } } + if let Some(log) = log.as_ref() { + log.push(stream_kind, &trimmed); + } // umu prints these right before the game/installer window opens. if trimmed.contains("fsync") || trimmed.contains("Proton: Executable") { running.store(true, Ordering::SeqCst); } - emit_umu_status(&app, game_title.as_deref(), "setup", Some(trimmed), None); + // Once the game runs, Proton's output belongs in the log window only + // (otherwise it would re-open the setup overlay). + if !running.load(Ordering::SeqCst) { + emit_umu_status(&app, game_title.as_deref(), "setup", Some(trimmed), None); + } } Err(_) => break, } @@ -486,6 +523,7 @@ pub(crate) fn spawn_umu_streamers( game_title: Option<&str>, stdout: Option, stderr: Option, + log: Option>, ) -> UmuStreamHandle { let running = Arc::new(AtomicBool::new(false)); let lines: Arc>> = Arc::new(Mutex::new(VecDeque::new())); @@ -497,8 +535,10 @@ pub(crate) fn spawn_umu_streamers( out, app_handle.clone(), game_title.clone(), + "out", lines.clone(), running.clone(), + log.clone(), ); } if let Some(err) = stderr { @@ -506,8 +546,10 @@ pub(crate) fn spawn_umu_streamers( err, app_handle.clone(), game_title.clone(), + "err", lines.clone(), running.clone(), + log.clone(), ); } UmuStreamHandle { running, lines } @@ -563,26 +605,369 @@ pub(crate) fn slugify_prefix_name(name: &str) -> String { } } -/// Resolves the `WINEPREFIX` for a launch. A per-game override (from the game -/// settings dialog) always wins; otherwise, when a global default base -/// directory is configured, an isolated `/` prefix is used so -/// games never share a prefix. Returns `None` to fall back to umu's default. +/// A Proton build that is already installed on the machine. +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProtonBuild { + /// Folder name inside `compatibilitytools.d`, e.g. `GE-Proton9-5`. + pub name: String, + pub path: String, + /// Directory the build was found in. + pub source: String, +} + +/// Directories that can contain Proton builds. umu resolves `PROTONPATH` by +/// name inside `compatibilitytools.d`, so we list what is already installed +/// instead of downloading and managing Proton builds ourselves. +#[cfg(target_os = "linux")] +fn proton_search_dirs() -> Vec { + let Some(home) = home_dir() else { + return Vec::new(); + }; + vec![ + home.join(".local/share/Steam/compatibilitytools.d"), + home.join(".steam/steam/compatibilitytools.d"), + home.join(".steam/root/compatibilitytools.d"), + home.join(".var/app/com.valvesoftware.Steam/data/Steam/compatibilitytools.d"), + home.join(".local/share/umu/compatibilitytools.d"), + ] +} + +#[cfg(target_os = "linux")] +#[tauri::command] +pub(crate) fn list_proton_builds() -> Vec { + let mut builds: Vec = Vec::new(); + + for dir in proton_search_dirs() { + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if name.starts_with('.') || builds.iter().any(|build| build.name == name) { + continue; + } + builds.push(ProtonBuild { + name: name.to_string(), + path: path.to_string_lossy().to_string(), + source: dir.to_string_lossy().to_string(), + }); + } + } + + builds.sort_by_key(|build| build.name.to_lowercase()); + builds +} + +#[cfg(not(target_os = "linux"))] +#[tauri::command] +pub(crate) fn list_proton_builds() -> Vec { + Vec::new() +} + +/// Everything needed to locate the Wine/Proton prefix of one game. +#[cfg(target_os = "linux")] +pub(crate) struct PrefixContext<'a> { + /// Per-game override from the game settings dialog (wins verbatim). + pub per_game_prefix: Option<&'a str>, + /// Version directory (`/GameVault//Versions/`), + /// used to derive the per-game prefix folder name. + pub version_directory: Option<&'a str>, + /// Numeric GameVault game id, used to adopt the legacy `game-` prefix. + pub game_id: Option, + /// umu GAMEID, used to adopt umu's own default prefix location. + pub umu_game_id: Option<&'a str>, +} + +/// Folder name of the game inside the install tree +/// (`...//Versions/`). Falls back to the version folder +/// name when the tree has an unexpected shape. +#[cfg(target_os = "linux")] +pub(crate) fn game_folder_name(version_directory: &Path) -> Option { + let parent = version_directory.parent(); + let versions_folder = parent + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .map(|n| n.eq_ignore_ascii_case("versions")) + .unwrap_or(false); + + let folder = if versions_folder { + parent.and_then(|p| p.parent()).and_then(|p| p.file_name())? + } else { + version_directory.file_name()? + }; + + let name = folder.to_string_lossy().trim().to_string(); + if name.is_empty() { + None + } else { + Some(name) + } +} + +/// Base directory for GameVault-managed prefixes: the configured setting, or +/// `$XDG_DATA_HOME/GameVault/prefixes` when it is empty. +#[cfg(target_os = "linux")] +pub(crate) fn prefix_base_dir(app: &tauri::AppHandle) -> Option { + use tauri::Manager; + + let configured = crate::settings::load_settings(app) + .default_wine_prefix + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + if let Some(configured) = configured { + return Some(PathBuf::from(configured)); + } + + app + .path() + .data_dir() + .ok() + .map(|dir| dir.join("GameVault").join("prefixes")) +} + +/// The prefix candidate paths of a game, most preferred first. +#[cfg(target_os = "linux")] +fn prefix_candidates(base: &Path, context: &PrefixContext<'_>) -> Vec { + let folder = context + .version_directory + .map(Path::new) + .and_then(game_folder_name); + + let mut candidates: Vec = Vec::new(); + if let Some(folder) = folder.as_deref() { + candidates.push(base.join(folder)); + } + if let Some(game_id) = context.game_id { + candidates.push(base.join(format!("game-{game_id}"))); + } + if let Some(folder) = folder.as_deref() { + let slug_path = base.join(slugify_prefix_name(folder)); + if !candidates.contains(&slug_path) { + candidates.push(slug_path); + } + } + // umu's own default location, only when a GAMEID is known (without one umu + // would have used its shared `umu-default` prefix, which we do not adopt). + if let Some(umu_game_id) = context + .umu_game_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if let Some(home) = home_dir() { + candidates.push(home.join("Games").join("umu").join(umu_game_id)); + } + } + candidates +} + +/// Resolves the `WINEPREFIX` for installing, launching and uninstalling one +/// game. All three flows use this, so a game always runs in the prefix it was +/// installed into. +/// +/// Precedence: per-game override > an existing prefix of a legacy layout (so +/// nothing has to be migrated and no save games are lost) > GameVault's +/// canonical `/`. Returns `None` only when no base +/// directory can be resolved at all (then umu's own default applies). #[cfg(target_os = "linux")] pub(crate) fn resolve_wine_prefix( app: &tauri::AppHandle, - per_game_prefix: Option<&str>, - identifier: &str, + context: &PrefixContext<'_>, ) -> Option { - if let Some(value) = per_game_prefix.map(str::trim).filter(|v| !v.is_empty()) { + if let Some(value) = context + .per_game_prefix + .map(str::trim) + .filter(|value| !value.is_empty()) + { return Some(value.to_string()); } - let base = crate::settings::load_settings(app).default_wine_prefix; - if let Some(base) = base.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) { - let slug = slugify_prefix_name(identifier); - let merged = Path::new(&base).join(&slug); - return Some(merged.to_string_lossy().to_string()); + + let base = prefix_base_dir(app)?; + let candidates = prefix_candidates(&base, context); + + if let Some(existing) = candidates.iter().find(|path| path.exists()) { + return Some(existing.to_string_lossy().to_string()); } - None + + candidates + .into_iter() + .next() + .map(|path| path.to_string_lossy().to_string()) +} + +/// True when the directory looks like a Wine/Proton prefix. +#[cfg(target_os = "linux")] +pub(crate) fn is_wine_prefix_dir(path: &Path) -> bool { + path.join("drive_c").is_dir() +} + +/// Info about the Wine/Proton prefix a game would use. +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WinePrefixInfo { + pub path: Option, + pub exists: bool, + pub is_prefix: bool, + /// True when the prefix lives inside GameVault's prefix base directory. + pub managed: bool, + /// Size on disk in bytes (only computed for existing prefixes). + pub size_bytes: Option, +} + +/// Total size of a directory tree in bytes (best effort). +#[cfg(target_os = "linux")] +pub(crate) fn directory_size(path: &Path) -> u64 { + let Ok(entries) = fs::read_dir(path) else { + return 0; + }; + let mut total = 0; + for entry in entries.flatten() { + let Ok(metadata) = entry.metadata() else { + continue; + }; + if metadata.is_dir() { + total += directory_size(&entry.path()); + } else { + total += metadata.len(); + } + } + total +} + +#[cfg(target_os = "linux")] +#[tauri::command] +pub(crate) fn resolve_game_wine_prefix( + app: tauri::AppHandle, + version_directory: Option, + game_id: Option, + umu_game_id: Option, + per_game_prefix: Option, +) -> WinePrefixInfo { + let context = PrefixContext { + per_game_prefix: per_game_prefix.as_deref(), + version_directory: version_directory.as_deref(), + game_id, + umu_game_id: umu_game_id.as_deref(), + }; + let path = resolve_wine_prefix(&app, &context); + let target = path.as_deref().map(PathBuf::from); + let exists = target.as_deref().map(|p| p.is_dir()).unwrap_or(false); + + WinePrefixInfo { + exists, + is_prefix: target + .as_deref() + .map(is_wine_prefix_dir) + .unwrap_or(false), + managed: target + .as_deref() + .map(|p| is_managed_prefix_path(&app, p)) + .unwrap_or(false), + size_bytes: target.as_deref().filter(|_| exists).map(directory_size), + path, + } +} + +#[cfg(not(target_os = "linux"))] +#[tauri::command] +pub(crate) fn resolve_game_wine_prefix( + _version_directory: Option, + _game_id: Option, + _umu_game_id: Option, + _per_game_prefix: Option, +) -> WinePrefixInfo { + WinePrefixInfo { + path: None, + exists: false, + is_prefix: false, + managed: false, + size_bytes: None, + } +} + +/// True when `path` is a prefix GameVault manages, i.e. it lives inside the +/// configured prefix base directory (or is exactly the per-game override). +#[cfg(target_os = "linux")] +fn is_managed_prefix_path(app: &tauri::AppHandle, path: &Path) -> bool { + prefix_base_dir(app) + .map(|base| path != base && path.starts_with(&base)) + .unwrap_or(false) +} + +/// Validates that a Wine/Proton prefix may be deleted: the path must be +/// absolute, must be inside GameVault's prefix base directory (never the base +/// itself) or exactly the per-game override, and must look like a prefix. +#[cfg(target_os = "linux")] +pub(crate) fn check_deletable_prefix( + base: Option<&Path>, + per_game_override: Option<&Path>, + target: &Path, +) -> Result<(), String> { + if !target.is_absolute() { + return Err("Refusing to delete a relative path.".to_string()); + } + + let inside_base = base + .map(|base| target != base && target.starts_with(base)) + .unwrap_or(false); + let is_override = per_game_override == Some(target); + if !inside_base && !is_override { + return Err(format!( + "Refusing to delete '{}': it is not managed by GameVault.", + target.display() + )); + } + + if !is_wine_prefix_dir(target) { + return Err(format!( + "Refusing to delete '{}': it does not look like a Wine/Proton prefix.", + target.display() + )); + } + + Ok(()) +} + +/// Deletes a Wine/Proton prefix. Only prefixes GameVault manages are accepted: +/// the path must live inside the prefix base directory or be exactly the +/// per-game override, and it must actually look like a Wine prefix. +#[cfg(target_os = "linux")] +#[tauri::command] +pub(crate) fn delete_game_wine_prefix( + app: tauri::AppHandle, + path: String, + per_game_prefix: Option, +) -> Result<(), String> { + let target = PathBuf::from(path.trim()); + let override_path = per_game_prefix + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + let base = prefix_base_dir(&app); + + check_deletable_prefix(base.as_deref(), override_path.as_deref(), &target)?; + + if !target.is_dir() { + return Ok(()); + } + + fs::remove_dir_all(&target) + .map_err(|error| format!("Failed to delete the Wine/Proton prefix: {error}")) +} + +#[cfg(not(target_os = "linux"))] +#[tauri::command] +pub(crate) fn delete_game_wine_prefix( + _path: String, + _per_game_prefix: Option, +) -> Result<(), String> { + Err("Wine/Proton prefixes are only managed on Linux".to_string()) } /// Spawn `umu-run [args]` and hand the child to the umu launch monitor. @@ -592,6 +977,8 @@ pub(crate) fn resolve_wine_prefix( pub(crate) fn launch_with_umu( app: tauri::AppHandle, game_title: String, + game_version_directory: Option, + game_id: Option, exe_path: &Path, launch_parameters: Option<&str>, umu_game_id: Option<&str>, @@ -599,6 +986,7 @@ pub(crate) fn launch_with_umu( umu_proton_path: Option<&str>, umu_wine_prefix: Option<&str>, restore_on_exit: bool, + log: Option>, ) -> Result<(), String> { let umu_run = match find_umu_run() { Some(path) => path, @@ -626,7 +1014,15 @@ pub(crate) fn launch_with_umu( if let Some(value) = umu_proton_path.map(str::trim).filter(|v| !v.is_empty()) { command.env("PROTONPATH", value); } - if let Some(prefix) = resolve_wine_prefix(&app, umu_wine_prefix, &game_title) { + if let Some(prefix) = resolve_wine_prefix( + &app, + &PrefixContext { + per_game_prefix: umu_wine_prefix, + version_directory: game_version_directory.as_deref(), + game_id, + umu_game_id, + }, + ) { command.env("WINEPREFIX", &prefix); } @@ -643,7 +1039,14 @@ pub(crate) fn launch_with_umu( match command.spawn() { Ok(child) => { - spawn_umu_launch_monitor(app, game_title, exe_path.to_path_buf(), child, restore_on_exit); + spawn_umu_launch_monitor( + app, + game_title, + exe_path.to_path_buf(), + child, + restore_on_exit, + log, + ); Ok(()) } Err(error) => { @@ -664,6 +1067,7 @@ pub(crate) fn spawn_umu_launch_monitor( exe_path: PathBuf, mut child: Child, restore_on_exit: bool, + launch_log: Option>, ) { thread::spawn(move || { if restore_on_exit { @@ -672,7 +1076,13 @@ pub(crate) fn spawn_umu_launch_monitor( let stdout = child.stdout.take(); let stderr = child.stderr.take(); - let handle = spawn_umu_streamers(&app, Some(&game_title), stdout, stderr); + let handle = spawn_umu_streamers( + &app, + Some(&game_title), + stdout, + stderr, + launch_log.clone(), + ); let start = Instant::now(); let mut reported_running = false; @@ -727,9 +1137,15 @@ pub(crate) fn spawn_umu_launch_monitor( }; if !reported_running { + if let Some(launch_log) = launch_log.as_ref() { + launch_log.finish_failed(&app, exit_code); + } emit_umu_status(&app, Some(&game_title), "error", None, Some(message.clone())); emit_game_launch_failed(&app, game_title, exit_code, message); } else { + if let Some(launch_log) = launch_log.as_ref() { + launch_log.finish_success(&app, exit_code); + } emit_umu_status(&app, Some(&game_title), "exit", None, None); } }); @@ -769,8 +1185,7 @@ mod tests { #[cfg(target_os = "linux")] #[test] - fn maps_other_paths_to_z_drive() { - let home = Path::new("/home/yelo"); + fn maps_other_paths_to_z_drive() { let home = Path::new("/home/yelo"); assert_eq!( to_windows_install_path_with_home("/media/data/game/Setup.exe", Some(home)), "Z:\\media\\data\\game\\Setup.exe" @@ -781,4 +1196,91 @@ mod tests { "Z:\\home\\other\\game\\Setup.exe" ); } + + #[cfg(target_os = "linux")] + #[test] + fn derives_the_game_folder_name_from_the_install_tree() { + assert_eq!( + game_folder_name(Path::new( + "/games/GameVault/ReStory - Chill Electronics Repairs/Versions/1.0" + )), + Some("ReStory - Chill Electronics Repairs".to_string()) + ); + // Unknown layout: fall back to the folder name itself. + assert_eq!( + game_folder_name(Path::new("/games/SomeGame")), + Some("SomeGame".to_string()) + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn prefers_the_game_folder_then_legacy_prefixes() { + let base = Path::new("/prefixes"); + let context = PrefixContext { + per_game_prefix: None, + version_directory: Some("/root/GameVault/Foo/Versions/1.0"), + game_id: Some(42), + umu_game_id: Some("umu-9000"), + }; + + let candidates = prefix_candidates(base, &context); + assert_eq!(candidates[0], base.join("Foo")); + assert_eq!(candidates[1], base.join("game-42")); + assert!(candidates + .iter() + .any(|path| path.ends_with("Games/umu/umu-9000"))); + assert_eq!( + candidates + .iter() + .filter(|path| *path == &base.join("Foo")) + .count(), + 1 + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn does_not_adopt_umus_shared_default_prefix() { + let context = PrefixContext { + per_game_prefix: None, + version_directory: Some("/root/GameVault/Foo/Versions/1.0"), + game_id: None, + umu_game_id: None, + }; + + assert_eq!( + prefix_candidates(Path::new("/prefixes"), &context), + vec![PathBuf::from("/prefixes/Foo")] + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn guards_prefix_deletion() { + let base = std::env::temp_dir().join(format!("gv-test-base-{}", std::process::id())); + let prefix = base.join("Foo"); + let outside = std::env::temp_dir().join(format!("gv-test-outside-{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + let _ = fs::remove_dir_all(&outside); + fs::create_dir_all(prefix.join("drive_c")).unwrap(); + fs::create_dir_all(outside.join("drive_c")).unwrap(); + + // A prefix inside the base may be deleted. + assert!(check_deletable_prefix(Some(&base), None, &prefix).is_ok()); + // The base directory itself may not. + assert!(check_deletable_prefix(Some(&base), None, &base).is_err()); + // Anything outside the base needs to be the exact per-game override. + assert!(check_deletable_prefix(Some(&base), None, &outside).is_err()); + assert!(check_deletable_prefix(Some(&base), Some(&outside), &outside).is_ok()); + // Relative paths are always refused. + assert!(check_deletable_prefix(Some(&base), None, Path::new("Foo")).is_err()); + // Folders without drive_c are not prefixes. + let not_a_prefix = base.join("NotAPrefix"); + fs::create_dir_all(¬_a_prefix).unwrap(); + assert!(check_deletable_prefix(Some(&base), None, ¬_a_prefix).is_err()); + + let _ = fs::remove_dir_all(&base); + let _ = fs::remove_dir_all(&outside); + } } diff --git a/src/components/GameCard.tsx b/src/components/GameCard.tsx index 0bfd22e..46dffd6 100644 --- a/src/components/GameCard.tsx +++ b/src/components/GameCard.tsx @@ -331,6 +331,8 @@ const GameCard = memo(function GameCard({ await invoke("launch_game", { gameTitle: localGame.metadata?.title || localGame.title || "Game", + gameId: game.id, + versionDirectory: installedInfo.versionDirectory, installationPath: installedInfo.installationDirectory, executableRelativePath: launchExe, launchParameters: launchParams || null, diff --git a/src/components/PrereleaseNotice.tsx b/src/components/PrereleaseNotice.tsx new file mode 100644 index 0000000..66994af --- /dev/null +++ b/src/components/PrereleaseNotice.tsx @@ -0,0 +1,48 @@ +import { useEffect, useRef } from "react"; +import { useAlertDialog } from "@/context/AlertDialogContext"; +import { + markPrereleaseNoticeSeen, + prereleaseChannelOfBuild, + prereleaseNoticeContent, + shouldShowPrereleaseNotice, +} from "@/utils/prereleaseNotice"; + +/** + * Warns the user the first time a pre-release build (Early Access / unstable) + * is launched. Renders nothing — it only queues a warning dialog, which the + * provider holds on screen until it is acknowledged. + */ +export function PrereleaseNotice() { + const { showAlert } = useAlertDialog(); + const shownRef = useRef(false); + + useEffect(() => { + // Effects run twice in React StrictMode; the dialog must only be queued once. + if (shownRef.current) return; + + const channel = prereleaseChannelOfBuild(); + if (!channel) return; + + shownRef.current = true; + + void (async () => { + // The acknowledgement lives in the app's settings file (desktop builds), + // so it survives updates and the warning stays a one-time thing. + if (!(await shouldShowPrereleaseNotice(channel))) return; + + await showAlert({ + tone: "warning", + affirmativeText: "I understand", + ...prereleaseNoticeContent(channel, __APP_VERSION__), + }); + + // Only remember the warning once it was actually acknowledged, so a user + // who closes the app before reading it still sees it on the next launch. + await markPrereleaseNoticeSeen(channel); + })(); + }, [showAlert]); + + return null; +} + +export default PrereleaseNotice; diff --git a/src/components/admin/GameSettings.tsx b/src/components/admin/GameSettings.tsx index 347cdd0..5083b67 100644 --- a/src/components/admin/GameSettings.tsx +++ b/src/components/admin/GameSettings.tsx @@ -62,27 +62,29 @@ interface Props { // No cool object binding in react, so we manually pick fields for custom metadata. But at least this is type save type CustomMetadataForm = { [ - K in keyof Pick< - GameMetadata, - | "title" - | "description" - | "notes" - | "average_playtime" - | "age_rating" - | "release_date" - | "rating" - | "early_access" - | "launch_executable" - | "launch_parameters" - | "installer_executable" - | "installer_parameters" - | "uninstaller_executable" - | "uninstaller_parameters" - | "url_websites" - | "url_trailers" - | "url_gameplays" - | "url_screenshots" - > + K in + | keyof Pick< + GameMetadata, + | "title" + | "description" + | "notes" + | "average_playtime" + | "age_rating" + | "release_date" + | "rating" + | "early_access" + | "launch_executable" + | "launch_parameters" + | "installer_executable" + | "installer_parameters" + | "uninstaller_executable" + | "uninstaller_parameters" + | "url_websites" + | "url_trailers" + | "url_gameplays" + | "url_screenshots" + > + | UmuMetadataFields ]: string; } & { sort_title: string; @@ -92,6 +94,18 @@ type CustomMetadataForm = { developers: string; }; +/** + * umu-launcher defaults for Linux clients (GAMEID/STORE/PROTONPATH). + * + * Declared locally so the form keeps compiling with API clients generated + * from a server that does not ship these metadata fields yet; the values are + * sent through the untyped `updateDto` in `saveCustomMetadata`. + */ +type UmuMetadataFields = "umu_game_id" | "umu_store" | "umu_proton_path"; + +type GameMetadataWithUmu = GameMetadata & + Partial>; + type InstalledGameInfo = { gameId: number; gameTitle: string; @@ -230,6 +244,9 @@ export function GameSettings({ installer_parameters: "", uninstaller_executable: "", uninstaller_parameters: "", + umu_game_id: "", + umu_store: "", + umu_proton_path: "", url_websites: "", genres: "", tags: "", @@ -276,6 +293,12 @@ export function GameSettings({ const [umuStore, setUmuStore] = useState(""); const [umuProtonPath, setUmuProtonPath] = useState(""); const [umuWinePrefix, setUmuWinePrefix] = useState(""); + const [protonBuilds, setProtonBuilds] = useState< + { name: string; path: string; source: string }[] + >([]); + const [resolvedPrefixPath, setResolvedPrefixPath] = useState( + null, + ); const [loadingLaunchOptions, setLoadingLaunchOptions] = useState(false); const launchOptionsLoadedRef = useRef(false); @@ -553,6 +576,63 @@ export function GameSettings({ }; }, [activeTab, installedGame, checkUmuStatus]); + // List the Proton builds the user already has installed (compatibilitytools.d), + // so PROTONPATH does not have to be typed from memory. + useEffect(() => { + if (!isTauriApp()) return; + let cancelled = false; + (async () => { + try { + const { invoke } = await import("@tauri-apps/api/core"); + const builds = + await invoke<{ name: string; path: string; source: string }[]>( + "list_proton_builds", + ); + if (!cancelled) setProtonBuilds(builds); + } catch { + if (!cancelled) setProtonBuilds([]); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + // Show where this game's Wine/Proton prefix actually lives (Linux/umu), so + // the per-game override field does not have to guess a path. + useEffect(() => { + if (!isTauriApp() || !installedGame?.versionDirectory) { + setResolvedPrefixPath(null); + return; + } + let cancelled = false; + (async () => { + try { + const { invoke } = await import("@tauri-apps/api/core"); + const info = await invoke<{ path: string | null }>( + "resolve_game_wine_prefix", + { + versionDirectory: installedGame.versionDirectory, + gameId: installedGame.gameId, + umuGameId: umuGameId || null, + perGamePrefix: umuWinePrefix || null, + }, + ); + if (!cancelled) setResolvedPrefixPath(info?.path ?? null); + } catch { + if (!cancelled) setResolvedPrefixPath(null); + } + })(); + return () => { + cancelled = true; + }; + }, [ + installedGame?.versionDirectory, + installedGame?.gameId, + umuGameId, + umuWinePrefix, + ]); + const handleMakeExecutable = useCallback(async () => { if (!installedGame) return; setMakingExecutable(true); @@ -702,20 +782,13 @@ export function GameSettings({ current = {}; } - const next: GameVaultConfig = { - gameid: current.gameid, - versionid: current.versionid, - gametype: current.gametype, - downloadfinished: Boolean(current.downloadfinished), - extractionfinished: Boolean(current.extractionfinished), + // Spread the existing config so unrelated settings (launch-as-admin, umu + // overrides) survive the uninstall; rebuilding it from a whitelist used + // to drop them silently. + const next = { + ...current, installationfinished: installationFinished, - downloadprogress: - typeof current.downloadprogress === "string" - ? current.downloadprogress - : "", - launchexecutable: current.launchexecutable, - launchparameters: current.launchparameters, - }; + } as GameVaultConfig; await invoke("fs_write_text_file", { path: configPath, @@ -778,6 +851,87 @@ export function GameSettings({ [showAlert], ); + /** Reads the local launch/umu overrides of an installed version. */ + const readLocalUmuConfig = useCallback(async (versionDirectory: string) => { + const { invoke } = await import("@tauri-apps/api/core"); + const { join } = await import("@tauri-apps/api/path"); + const configPath = await join( + versionDirectory, + ".gamevault.game.config.json", + ); + if (!(await invoke("fs_path_exists", { path: configPath }))) { + return {}; + } + try { + const raw = JSON.parse( + await invoke("fs_read_text_file", { path: configPath }), + ); + const readString = (value: unknown) => + typeof value === "string" && value.trim() ? value : undefined; + return { + umuGameId: readString(raw.umugameid), + umuStore: readString(raw.umustore), + umuProtonPath: readString(raw.umuprotonpath), + umuWinePrefix: readString(raw.umuwineprefix), + }; + } catch { + return {}; + } + }, []); + + /** + * Offers to delete the game's Wine/Proton prefix (Linux, umu/Proton). + * + * Opt-in and deliberate: prefixes can contain local save games and game + * settings, so "keep" is the pre-focused answer, and only prefixes inside + * GameVault's prefix directory are offered at all. + */ + const offerPrefixCleanup = useCallback( + async (versionDirectory: string, gameId: number) => { + if (!isTauriApp()) return; + try { + const { invoke } = await import("@tauri-apps/api/core"); + const local = await readLocalUmuConfig(versionDirectory); + const info = await invoke<{ + path: string | null; + exists: boolean; + isPrefix: boolean; + managed: boolean; + sizeBytes: number | null; + }>("resolve_game_wine_prefix", { + versionDirectory, + gameId, + umuGameId: local.umuGameId ?? null, + perGamePrefix: local.umuWinePrefix ?? null, + }); + if (!info?.path || !info.exists || !info.isPrefix || !info.managed) { + return; + } + + const size = info.sizeBytes ? ` (${formatBytes(info.sizeBytes)})` : ""; + const keepPrefix = await showAlert({ + title: "Delete the Wine/Proton prefix too?", + description: + `This game was installed into its own Wine/Proton prefix:\n${info.path}${size}\n\n` + + "Local save games, game settings and installed runtime files can live inside it. " + + "Keep it unless you want the disk space back.", + affirmativeText: "Keep prefix", + negativeText: "Delete prefix", + tone: "warning", + }); + if (keepPrefix) return; + + await invoke("delete_game_wine_prefix", { + path: info.path, + perGamePrefix: local.umuWinePrefix ?? null, + }); + } catch (error) { + console.warn("Wine/Proton prefix cleanup skipped:", error); + } + }, + [readLocalUmuConfig, showAlert], + ); + const handleUninstallGame = useCallback(async () => { if (!installedGame) return; @@ -816,6 +970,11 @@ export function GameSettings({ await pruneEmptyVersionFolders(installedGame.versionDirectory); // Offer to delete any leftover files the uninstaller left behind. await cleanupLeftoverFiles(installedGame.versionDirectory); + // ... and the (optional) Wine/Proton prefix of the game. + await offerPrefixCleanup( + installedGame.versionDirectory, + installedGame.gameId, + ); setInstalledGame(await findInstalledGame()); onUninstalled?.(); await showAlert({ @@ -861,16 +1020,28 @@ export function GameSettings({ setUninstalling(true); try { const { invoke } = await import("@tauri-apps/api/core"); + const local = await readLocalUmuConfig( + installedGame.versionDirectory, + ); await invoke("launch_uninstall_executable", { + versionDirectory: installedGame.versionDirectory, executablePath, workingDirectory: installedGame.installationDirectory, argumentList: workingGame.metadata?.uninstaller_parameters || null, + umuGameId: local.umuGameId ?? null, + umuStore: local.umuStore ?? null, + umuProtonPath: local.umuProtonPath ?? null, + umuWinePrefix: local.umuWinePrefix ?? null, }); await updateInstallationFinishedFlag( installedGame.versionDirectory, false, ); await cleanupLeftoverFiles(installedGame.versionDirectory); + await offerPrefixCleanup( + installedGame.versionDirectory, + installedGame.gameId, + ); setInstalledGame(await findInstalledGame()); onUninstalled?.(); await showAlert({ @@ -1030,7 +1201,8 @@ export function GameSettings({ } catch (e: any) { await showAlert({ title: "Unable to delete game file", - description: e?.message || "The server could not delete this game file.", + description: + e?.message || "The server could not delete this game file.", affirmativeText: "OK", }); } finally { @@ -1728,7 +1900,7 @@ export function GameSettings({ }; const applyWatermark = (field: keyof CustomMetadataForm) => { - const metadata = workingGame.metadata; + const metadata = workingGame.metadata as GameMetadataWithUmu | undefined; let value: any = ""; switch (field) { @@ -1788,6 +1960,15 @@ export function GameSettings({ case "uninstaller_parameters": value = metadata?.uninstaller_parameters || ""; break; + case "umu_game_id": + value = metadata?.umu_game_id || ""; + break; + case "umu_store": + value = metadata?.umu_store || ""; + break; + case "umu_proton_path": + value = metadata?.umu_proton_path || ""; + break; case "url_websites": value = Array.isArray(metadata?.url_websites) ? metadata.url_websites.join(", ") @@ -1834,7 +2015,7 @@ export function GameSettings({ }; const getWatermark = (field: keyof CustomMetadataForm): string => { - const metadata = workingGame.metadata; + const metadata = workingGame.metadata as GameMetadataWithUmu | undefined; switch (field) { case "title": @@ -1882,6 +2063,12 @@ export function GameSettings({ return metadata?.uninstaller_executable || ""; case "uninstaller_parameters": return metadata?.uninstaller_parameters || ""; + case "umu_game_id": + return metadata?.umu_game_id || ""; + case "umu_store": + return metadata?.umu_store || ""; + case "umu_proton_path": + return metadata?.umu_proton_path || ""; case "url_websites": return Array.isArray(metadata?.url_websites) ? metadata.url_websites.join(", ") @@ -1958,6 +2145,12 @@ export function GameSettings({ if (customMetadata.uninstaller_parameters) updateDto.uninstaller_parameters = customMetadata.uninstaller_parameters; + if (customMetadata.umu_game_id) + updateDto.umu_game_id = customMetadata.umu_game_id; + if (customMetadata.umu_store) + updateDto.umu_store = customMetadata.umu_store; + if (customMetadata.umu_proton_path) + updateDto.umu_proton_path = customMetadata.umu_proton_path; if (customMetadata.url_websites) updateDto.url_websites = customMetadata.url_websites @@ -2037,7 +2230,8 @@ export function GameSettings({ } catch (e: any) { await showAlert({ title: "Unable to save custom metadata", - description: e?.message || "The server could not save your custom metadata.", + description: + e?.message || "The server could not save your custom metadata.", affirmativeText: "OK", }); } finally { @@ -2100,7 +2294,8 @@ export function GameSettings({ } catch (e: any) { await showAlert({ title: "Unable to wipe custom metadata", - description: e?.message || "The server could not wipe your custom metadata.", + description: + e?.message || "The server could not wipe your custom metadata.", affirmativeText: "OK", }); } finally { @@ -3195,6 +3390,115 @@ export function GameSettings({ + {/* umu-launcher defaults (Linux) */} +
+

+ umu-launcher (Linux) +

+

+ Applied by Linux clients when running this game + through umu-launcher/Proton. Leave empty for umu's + defaults. +

+ +
+ +
+ + setCustomMetadata({ + ...customMetadata, + umu_game_id: e.target.value, + }) + } + placeholder={ + getWatermark("umu_game_id") || "umu-12345" + } + className="pr-10" + /> + {getWatermark("umu_game_id") && ( + + )} +
+
+ +
+ +
+ + setCustomMetadata({ + ...customMetadata, + umu_store: e.target.value, + }) + } + placeholder={getWatermark("umu_store") || "egs"} + className="pr-10" + /> + {getWatermark("umu_store") && ( + + )} +
+
+ +
+ +
+ + setCustomMetadata({ + ...customMetadata, + umu_proton_path: e.target.value, + }) + } + placeholder={ + getWatermark("umu_proton_path") || + "GE-Proton9-5" + } + className="pr-10" + /> + {getWatermark("umu_proton_path") && ( + + )} +
+
+
+ {/* Installer Executable */}
@@ -3836,11 +4169,70 @@ export function GameSettings({ onChange={(e: any) => setUmuWinePrefix(e.target.value) } - placeholder="~/.local/share/GameVault/umu" + placeholder="Automatic (per game)" />

- Where the Wine prefix lives. Defaults to - $HOME/Games/umu/<GAMEID>. + Leave empty to let GameVault use its own + folder per game (named like the game's install + folder) inside the Wine/Proton Prefix + Directory from the settings. + {resolvedPrefixPath && ( + <> + {" "} + Currently:{" "} + {" "} + + + )}

diff --git a/src/components/downloads/launch-defaults.test.ts b/src/components/downloads/launch-defaults.test.ts new file mode 100644 index 0000000..f8f5440 --- /dev/null +++ b/src/components/downloads/launch-defaults.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { mergeLaunchDefaults } from "./launch-defaults"; + +describe("mergeLaunchDefaults", () => { + it("fills empty options from the server defaults", () => { + const merged = mergeLaunchDefaults( + {}, + { + launchExecutable: "Game.exe", + launchParameters: "-novid", + umuGameId: "umu-12345", + umuStore: "egs", + umuProtonPath: "GE-Proton9-5", + }, + ["Game.exe", "Launcher.exe"], + ); + + expect(merged).toEqual({ + launchexecutable: "Game.exe", + launchparameters: "-novid", + umugameid: "umu-12345", + umustore: "egs", + umuprotonpath: "GE-Proton9-5", + }); + }); + + it("still adopts the umu defaults when a launch executable is already set", () => { + // Regression: the old blanket guard returned early and skipped everything. + const merged = mergeLaunchDefaults( + { launchexecutable: "MyChoice.exe" }, + { umuGameId: "umu-12345", umuStore: "egs" }, + [], + ); + + expect(merged).toEqual({ + launchexecutable: "MyChoice.exe", + umugameid: "umu-12345", + umustore: "egs", + }); + }); + + it("never overwrites values the user configured", () => { + const current = { + launchexecutable: "MyChoice.exe", + launchparameters: "-windowed", + umugameid: "umu-own", + umustore: "steam", + umuprotonpath: "GE-Proton8-27", + }; + + expect( + mergeLaunchDefaults( + current, + { + launchExecutable: "Server.exe", + launchParameters: "-server", + umuGameId: "umu-server", + umuStore: "egs", + umuProtonPath: "GE-Proton9-5", + }, + ["Server.exe"], + ), + ).toBeNull(); + }); + + it("auto-detects an executable without any server defaults", () => { + // Keeps the existing "pick the first candidate" behavior when the server + // does not define a launch executable. + expect(mergeLaunchDefaults({}, {}, ["First.exe", "Other.exe"])).toEqual({ + launchexecutable: "First.exe", + }); + }); + + it("ignores blank and missing defaults", () => { + expect(mergeLaunchDefaults({}, { launchParameters: " " }, [])).toBeNull(); + expect( + mergeLaunchDefaults({}, { launchExecutable: " ", umuStore: "" }, []), + ).toBeNull(); + }); +}); diff --git a/src/components/downloads/launch-defaults.ts b/src/components/downloads/launch-defaults.ts new file mode 100644 index 0000000..1f49344 --- /dev/null +++ b/src/components/downloads/launch-defaults.ts @@ -0,0 +1,80 @@ +import { pickPreferredExecutable } from "./install-utils"; + +/** + * Server-provided launch defaults for a game. + * + * The server exposes these as custom metadata; Linux clients additionally use + * the umu-launcher fields (GAMEID/STORE/PROTONPATH). + */ +export interface LaunchDefaults { + /** Default launch executable. */ + launchExecutable?: string; + /** Default launch parameters. */ + launchParameters?: string; + /** umu-launcher GAMEID. */ + umuGameId?: string; + /** umu-launcher STORE. */ + umuStore?: string; + /** umu-launcher PROTONPATH (Proton build folder name). */ + umuProtonPath?: string; +} + +/** Local `.gamevault.game.config.json` shape (only the launch-related keys). */ +export type LocalLaunchConfig = Record & { + launchexecutable?: string; + launchparameters?: string; + umugameid?: string; + umustore?: string; + umuprotonpath?: string; +}; + +function trimmed(value: string | undefined): string | undefined { + const result = value?.trim(); + return result ? result : undefined; +} + +/** + * Fills unset launch options from the server defaults. + * + * Everything the user configured locally always wins: + * - the executable/parameters pair is only auto-filled while both are empty + * (a manually chosen executable means the user owns the parameters too), + * - each umu override is filled independently. + * + * Returns the updated config, or `null` when there is nothing to change (so + * callers can skip the file write). + */ +export function mergeLaunchDefaults( + current: LocalLaunchConfig, + defaults: LaunchDefaults, + executableOptions: string[], +): LocalLaunchConfig | null { + const next: LocalLaunchConfig = { ...current }; + + if (!next.launchexecutable && !next.launchparameters) { + const resolvedExe = pickPreferredExecutable( + executableOptions, + trimmed(defaults.launchExecutable), + ); + if (resolvedExe) next.launchexecutable = resolvedExe; + + const resolvedParams = trimmed(defaults.launchParameters); + if (resolvedParams) next.launchparameters = resolvedParams; + } + + const umuFields: [keyof LocalLaunchConfig, string | undefined][] = [ + ["umugameid", defaults.umuGameId], + ["umustore", defaults.umuStore], + ["umuprotonpath", defaults.umuProtonPath], + ]; + for (const [key, value] of umuFields) { + const resolved = trimmed(value); + if (!next[key] && resolved) next[key] = resolved; + } + + const changed = + Object.keys(next).length !== Object.keys(current).length || + Object.entries(next).some(([key, value]) => current[key] !== value); + + return changed ? next : null; +} diff --git a/src/context/AlertDialogContext.tsx b/src/context/AlertDialogContext.tsx index 6ecdd3f..82dade5 100644 --- a/src/context/AlertDialogContext.tsx +++ b/src/context/AlertDialogContext.tsx @@ -261,10 +261,9 @@ export const GlobalAlertDialogBridge: React.FC = () => { window.showAlertDialog = showAlert; // Surface game launch failures captured in the Tauri backend (a game that - // exits immediately with console output, e.g. a missing prerequisite) as a - // clear error dialog instead of silently doing nothing. The wording makes - // it obvious the *game process* closed — not GameVault — and shows the - // game's own output in a console-style block. + // exits immediately with console output, e.g. a missing prerequisite). The + // full log is shown in the launch log window (which opens automatically); + // only if that fails, fall back to the dialog so a crash is never silent. useEffect(() => { if (!isTauriApp()) return; let unlisten: (() => void) | undefined; @@ -280,12 +279,19 @@ export const GlobalAlertDialogBridge: React.FC = () => { const { gameTitle, exitCode, message } = event.payload; const codeInfo = exitCode != null ? ` with exit code ${exitCode}` : ""; - showAlert({ - title: `${gameTitle} exited with an error`, - description: `The game process closed${codeInfo}. It printed the following output:`, - log: message, - affirmativeText: "OK", - }); + void (async () => { + try { + const { openLaunchLogWindow } = await import("@/utils/launchLog"); + await openLaunchLogWindow(); + } catch { + await showAlert({ + title: `${gameTitle} exited with an error`, + description: `The game process closed${codeInfo}. It printed the following output:`, + log: message, + affirmativeText: "OK", + }); + } + })(); }); } catch { // Non-Tauri environments won't have the IPC bridge; ignore. diff --git a/src/context/AppUpdaterContext.tsx b/src/context/AppUpdaterContext.tsx index a5b83ba..d8b84c0 100644 --- a/src/context/AppUpdaterContext.tsx +++ b/src/context/AppUpdaterContext.tsx @@ -179,6 +179,80 @@ export function AppUpdaterProvider({ }; }, []); + // Re-attach to an update that is already downloading or installing. + // + // The update keeps running in the backend when the webview is reloaded, but + // its progress listener is gone. Restore the status text, and keep + // `runningRef` set so the auto-check (and a manual check) can't start a + // second download of the same update on top of it. + useEffect(() => { + if (!isTauriApp()) return; + let cancelled = false; + + void (async () => { + try { + const { invoke } = await import("@tauri-apps/api/core"); + const states = await invoke<{ + appUpdate?: { + status: string; + version?: string | null; + error?: string | null; + } | null; + }>("get_background_states"); + const snapshot = states?.appUpdate; + if (cancelled || !snapshot) return; + + if ( + snapshot.status === "downloading" || + snapshot.status === "installing" + ) { + runningRef.current = true; + setIsInstalling(true); + setStatusText( + snapshot.status === "installing" + ? "Installing update..." + : "Downloading update...", + ); + + // The backend keeps streaming progress; follow it so the status text + // stays accurate while the update finishes in the background. + const { listen } = await import("@tauri-apps/api/event"); + const unlisten = await listen( + APP_UPDATER_EVENT, + (event) => { + if (cancelled) return; + switch (event.payload.event) { + case "Installing": + setStatusText("Installing update..."); + break; + case "Finished": + setIsInstalling(false); + setStatusText( + "Update installed. Restart GameVault to finish.", + ); + break; + default: + break; + } + }, + ); + if (cancelled) unlisten(); + } else if (snapshot.status === "finished") { + setAvailableVersion(null); + setStatusText("Update installed. Restart GameVault to finish."); + } else if (snapshot.status === "error" && snapshot.error) { + setErrorText(snapshot.error); + } + } catch { + // Older builds without the background state command; ignore. + } + })(); + + return () => { + cancelled = true; + }; + }, []); + const checkForUpdates = useCallback( async ({ manual = false }: CheckForUpdatesOptions = {}) => { if (runningRef.current) { diff --git a/src/context/DownloadContext.tsx b/src/context/DownloadContext.tsx index bb843a3..c0e12c0 100644 --- a/src/context/DownloadContext.tsx +++ b/src/context/DownloadContext.tsx @@ -13,6 +13,7 @@ import { useServerStatus } from "@/hooks/useServerStatus"; import { isTauriApp } from "@/utils/tauri"; import { onGameUpdated } from "@/utils/gameUpdates"; import { getServerNamespace, resolveApiMediaBlob } from "@/utils/mediaCache"; +import { computeTaskbarIndicator } from "@/utils/taskbarIndicator"; import { getRootPaths } from "@/utils/rootPaths"; import { computeSpeedBps, @@ -23,10 +24,8 @@ import { getSkipAutoResumeIds, setSkipAutoResume, } from "@/utils/downloadFormat"; -import { - pickPreferredInstaller, - pickPreferredExecutable, -} from "@/components/downloads/install-utils"; +import { pickPreferredInstaller } from "@/components/downloads/install-utils"; +import { mergeLaunchDefaults } from "@/components/downloads/launch-defaults"; import type { GameVaultConfig } from "@/models/gamevaultconfig"; import type { GameMetadata } from "@/api/models/GameMetadata"; import type { @@ -78,6 +77,50 @@ export interface ActiveDownload { export type SimulatedDownloadKind = "downloading" | "paused" | "error" | "aborted" | "completed" | "installing"; +/** + * Extraction state as reported by the Rust backend. Extraction runs on a + * detached backend task, so it outlives page reloads and navigations. + */ +interface ExtractionStatePayload { + gameId: number; + status: "extracting" | "completed" | "needs-password" | "error"; + processed?: number; + total?: number | null; + progress?: number | null; + currentFile?: string | null; + error?: string | null; +} + +/** Installation state (file copy or installer run) as reported by the backend. */ +interface InstallationStatePayload { + gameId: number; + step: "copy" | "installer"; + status: "copying" | "launching" | "running" | "completed" | "error"; + progress?: number | null; + currentFile?: string | null; + exitCode?: number | null; + error?: string | null; +} + +/** + * Snapshot of every long-running task the backend keeps going when the webview + * is reloaded or the route changes. Used to re-attach the UI on startup. + */ +interface BackgroundStatesPayload { + extractions?: ExtractionStatePayload[]; + installations?: InstallationStatePayload[]; + umu?: { + status: string; + message?: string | null; + gameTitle?: string | null; + } | null; + appUpdate?: { + status: string; + version?: string | null; + error?: string | null; + } | null; +} + interface DownloadContextValue { downloads: Record; startDownload: (params: { @@ -123,7 +166,6 @@ const DEFAULT_GAME_VAULT_CONFIG: GameVaultConfig = { downloadprogress: "", }; - type StartDownloadParams = { gameId: number; versionId: number; @@ -188,6 +230,11 @@ export function DownloadProvider({ children }: { children: ReactNode }) { samples.shift(); }; + // Keep the latest downloads reachable from long-lived callbacks (event + // listeners, startup recovery) without re-creating them on every update. + const downloadsRef = useRef>({}); + downloadsRef.current = downloads; + const updateDownload = useCallback( (gameId: number, patch: Partial) => { setDownloads((prev) => { @@ -198,7 +245,6 @@ export function DownloadProvider({ children }: { children: ReactNode }) { }, [], ); - // ── Simulated (debug) downloads ──────────────────────────────────────────── // Fake cards are keyed by negative game IDs so they are excluded from // real backend polling (downloadGameIdsKey filters gameId > 0). @@ -351,37 +397,53 @@ export function DownloadProvider({ children }: { children: ReactNode }) { } } - // Never overwrite a user-configured launch executable - if (current.launchexecutable) return; - - const metaExe = (d.gameMetadata as any)?.launch_executable as - string | undefined; - const metaParams = (d.gameMetadata as any)?.launch_parameters as - string | undefined; + // Never overwrite the executable/parameters pair the user configured; the + // umu defaults below are filled independently, because they are separate + // launch options (and were not server-provided before). + const needsExecutableDefaults = !current.launchexecutable; + + const metadata = d.gameMetadata as + | (Record & { + launch_executable?: string; + launch_parameters?: string; + umu_game_id?: string; + umu_store?: string; + umu_proton_path?: string; + }) + | undefined; // Same source as the launch-executable Listbox (already excludes ignored // executables and is sorted), used to resolve and validate candidates. - const { executables: exeList } = await invoke<{ executables: string[] }>( - "list_launch_executables", + let exeList: string[] = []; + if (needsExecutableDefaults) { + ({ executables: exeList } = await invoke<{ executables: string[] }>( + "list_launch_executables", + { + installationPath: d.installationDirectory, + }, + )); + } + + // Prefer the metadata launch executable, falling back to auto-detecting the + // first available one (restoring the legacy client's auto-select behavior), + // and adopt the server's umu-launcher defaults for Linux. + const merged = mergeLaunchDefaults( + current, { - installationPath: d.installationDirectory, + launchExecutable: metadata?.launch_executable, + launchParameters: metadata?.launch_parameters, + umuGameId: metadata?.umu_game_id, + umuStore: metadata?.umu_store, + umuProtonPath: metadata?.umu_proton_path, }, + exeList, ); - // Prefer the metadata launch executable, falling back to auto-detecting the - // first available one (restoring the legacy client's auto-select behavior). - const resolvedExe = pickPreferredExecutable(exeList, metaExe); - - const resolvedParams = - metaParams && metaParams.trim() ? metaParams.trim() : undefined; - - if (!resolvedExe && !resolvedParams) return; + if (!merged) return; - if (resolvedExe) current.launchexecutable = resolvedExe; - if (resolvedParams !== undefined) current.launchparameters = resolvedParams; await invoke("fs_write_text_file", { path: configPath, - content: JSON.stringify(current, null, 2), + content: JSON.stringify(merged, null, 2), }); }, []); @@ -696,6 +758,14 @@ export function DownloadProvider({ children }: { children: ReactNode }) { ((gameId: number, installerRelativePath: string) => Promise) | null >(null); const pendingAutoResumeRef = useRef([]); + /** Games whose finished extraction was already handled in this page session. */ + const extractionCompletionHandledRef = useRef>(new Set()); + /** Games whose finished installation was already handled in this page session. */ + const installationCompletionHandledRef = useRef>(new Set()); + /** Set after render so the startup recovery can re-attach to extractions. */ + const reconcileBackgroundStatesRef = useRef< + ((recovered: Record) => Promise) | null + >(null); const startDownload = useCallback( async ({ @@ -1486,12 +1556,194 @@ export function DownloadProvider({ children }: { children: ReactNode }) { [downloads], ); + // Extraction runs in the Rust backend on a detached task, so it keeps going + // when the webview is reloaded (F5) or the route changes. These helpers keep + // the UI in sync with that task instead of losing track of it. + + /** + * Reads the backend's background task state (downloads outlive the page too, + * but they are re-attached through their own listener below). + */ + const fetchBackgroundStates = + useCallback(async (): Promise => { + if (!isTauriApp()) return {}; + try { + const { invoke } = await import("@tauri-apps/api/core"); + return await invoke("get_background_states"); + } catch (error) { + console.debug("Could not read background states:", error); + return {}; + } + }, []); + + const isExtractionRunning = useCallback( + async (gameId: number) => { + const states = await fetchBackgroundStates(); + return (states.extractions ?? []).some( + (state) => + Number(state?.gameId) === gameId && state.status === "extracting", + ); + }, + [fetchBackgroundStates], + ); + + // Handles a finished extraction. Idempotent per game, because both the + // invoke result and the terminal `extract-progress` event report it. + const handleExtractionCompleted = useCallback( + async (gameId: number) => { + if (extractionCompletionHandledRef.current.has(gameId)) return; + extractionCompletionHandledRef.current.add(gameId); + const d = downloadsRef.current[gameId]; + + updateDownload(gameId, { + extractionStatus: "completed", + extractionProgress: 100, + extractionCurrentFile: undefined, + extractionError: undefined, + extractionPasswordRequired: false, + }); + + if (d?.versionDirectory) { + try { + await writeVersionConfig(d.versionDirectory, { + extractionfinished: true, + }); + } catch (error) { + console.warn("Failed to persist extraction state:", error); + } + } + + // Start installation only after extraction has actually completed. + try { + if ( + typeof localStorage !== "undefined" && + localStorage.getItem("tauri_auto_install") === "1" + ) { + const gameType = d?.gameType; + const isPortable = + gameType === "WINDOWS_PORTABLE" || + gameType === "LINUX_PORTABLE" || + gameType === "WINDOWS_SOFTWARE" || + gameType === "LINUX_SOFTWARE"; + const isSetup = gameType === "WINDOWS_SETUP"; + + if (isPortable) { + await copyInstallationFilesRef.current?.(gameId); + } else if (isSetup) { + const candidates = + (await listInstallExecutablesRef.current?.(gameId)) ?? []; + if (candidates.length > 0) { + const preferredInstaller = pickPreferredInstaller( + candidates, + (d?.gameMetadata as GameMetadata | undefined) + ?.installer_executable, + ); + await launchInstallationExecutableRef.current?.( + gameId, + preferredInstaller, + ); + } + } + } + } catch (e) { + console.error("Auto-install failed:", e); + } + }, + [updateDownload, writeVersionConfig], + ); + + /** + * Subscribes to `extract-progress` for a game, replacing any previous + * subscription. Needed both when starting an extraction and when re-attaching + * to one that is still running after the page was reloaded. + */ + const attachExtractionListener = useCallback( + async (gameId: number) => { + if (!isTauriApp()) return; + const { listen } = await import("@tauri-apps/api/event"); + + const existing = tauriExtractUnlistenRef.current[gameId]; + if (existing) { + existing(); + delete tauriExtractUnlistenRef.current[gameId]; + } + + const detach = () => { + const stop = tauriExtractUnlistenRef.current[gameId]; + if (stop) { + stop(); + delete tauriExtractUnlistenRef.current[gameId]; + } + }; + + const unlisten = await listen("extract-progress", (event) => { + const payload = event.payload; + if (!payload || payload.gameId !== gameId) return; + + if (payload.status === "extracting") { + updateDownload(gameId, { + extractionStatus: "extracting", + extractionProgress: + typeof payload.progress === "number" + ? Math.max(0, Math.min(100, payload.progress)) + : null, + extractionCurrentFile: + typeof payload.currentFile === "string" && payload.currentFile + ? payload.currentFile + : undefined, + }); + return; + } + + // Every other status is terminal for this extraction. + detach(); + + if (payload.status === "completed") { + void handleExtractionCompleted(gameId); + } else if (payload.status === "needs-password") { + updateDownload(gameId, { + extractionStatus: "needs-password", + extractionProgress: null, + extractionPasswordRequired: true, + extractionError: + (typeof payload.error === "string" && payload.error) || + "Archive password required.", + }); + } else if (payload.status === "error") { + updateDownload(gameId, { + extractionStatus: "error", + extractionProgress: null, + extractionError: + (typeof payload.error === "string" && payload.error) || + "Extraction failed.", + }); + } + }); + + tauriExtractUnlistenRef.current[gameId] = unlisten; + }, + [handleExtractionCompleted, updateDownload], + ); + + // Re-attaching to installations is set up further down, next to the install + // flow itself (see `reconcileBackgroundStates`). + const extractArchive = useCallback( async (gameId: number, password?: string) => { if (!isTauriApp()) return; const d = downloads[gameId]; if (!d?.downloadedFilePath || !d.extractionDirectory) return; + // An extraction started earlier may still be running in the backend (the + // page may have been reloaded with F5 mid-extraction). Re-sync with it + // instead of starting a second task over the same files. + if (await isExtractionRunning(gameId)) { + await reconcileBackgroundStatesRef.current?.({ [gameId]: d }); + return; + } + + extractionCompletionHandledRef.current.delete(gameId); + updateDownload(gameId, { extractionStatus: "extracting", extractionProgress: 0, @@ -1502,51 +1754,7 @@ export function DownloadProvider({ children }: { children: ReactNode }) { try { const { invoke } = await import("@tauri-apps/api/core"); - const { listen } = await import("@tauri-apps/api/event"); - - if (tauriExtractUnlistenRef.current[gameId]) { - tauriExtractUnlistenRef.current[gameId](); - delete tauriExtractUnlistenRef.current[gameId]; - } - - const unlisten = await listen("extract-progress", (event) => { - const payload = event.payload; - if (!payload || payload.gameId !== gameId) return; - - if (payload.status === "extracting") { - updateDownload(gameId, { - extractionStatus: "extracting", - extractionProgress: - typeof payload.progress === "number" - ? Math.max(0, Math.min(100, payload.progress)) - : null, - extractionCurrentFile: - typeof payload.currentFile === "string" && payload.currentFile - ? payload.currentFile - : undefined, - }); - return; - } - - if (payload.status === "needs-password") { - updateDownload(gameId, { - extractionStatus: "needs-password", - extractionPasswordRequired: true, - extractionError: - (typeof payload.error === "string" && payload.error) || - "Archive password required.", - }); - } else if (payload.status === "error") { - updateDownload(gameId, { - extractionStatus: "error", - extractionError: - (typeof payload.error === "string" && payload.error) || - "Extraction failed.", - }); - } - }); - - tauriExtractUnlistenRef.current[gameId] = unlisten; + await attachExtractionListener(gameId); const result = await invoke<{ success: boolean; @@ -1560,54 +1768,7 @@ export function DownloadProvider({ children }: { children: ReactNode }) { }); if (result.success) { - if (d.versionDirectory) { - await writeVersionConfig(d.versionDirectory, { - extractionfinished: true, - }); - } - updateDownload(gameId, { - extractionStatus: "completed", - extractionProgress: 100, - extractionCurrentFile: undefined, - extractionError: undefined, - extractionPasswordRequired: false, - }); - - // Start installation only after extraction has actually completed. - try { - if ( - typeof localStorage !== "undefined" && - localStorage.getItem("tauri_auto_install") === "1" - ) { - const gameType = d.gameType; - const isPortable = - gameType === "WINDOWS_PORTABLE" || - gameType === "LINUX_PORTABLE" || - gameType === "WINDOWS_SOFTWARE" || - gameType === "LINUX_SOFTWARE"; - const isSetup = gameType === "WINDOWS_SETUP"; - - if (isPortable) { - await copyInstallationFilesRef.current?.(gameId); - } else if (isSetup) { - const candidates = - (await listInstallExecutablesRef.current?.(gameId)) ?? []; - if (candidates.length > 0) { - const preferredInstaller = pickPreferredInstaller( - candidates, - (d.gameMetadata as GameMetadata | undefined) - ?.installer_executable, - ); - await launchInstallationExecutableRef.current?.( - gameId, - preferredInstaller, - ); - } - } - } - } catch (e) { - console.error("Auto-install failed:", e); - } + await handleExtractionCompleted(gameId); return; } @@ -1621,26 +1782,36 @@ export function DownloadProvider({ children }: { children: ReactNode }) { return; } + // The backend refuses to extract the same game twice at once. That is + // not a failure of this call: the running extraction reports its own + // outcome through the still-attached listener. + if (await isExtractionRunning(gameId)) return; + updateDownload(gameId, { extractionStatus: "error", extractionProgress: null, extractionError: result.message || "Extraction failed.", }); } catch (err) { - updateDownload(gameId, { - extractionStatus: "error", - extractionProgress: null, - extractionError: String(err), - }); - } finally { const stop = tauriExtractUnlistenRef.current[gameId]; if (stop) { stop(); delete tauriExtractUnlistenRef.current[gameId]; } + updateDownload(gameId, { + extractionStatus: "error", + extractionProgress: null, + extractionError: String(err), + }); } }, - [downloads, updateDownload, writeVersionConfig], + [ + attachExtractionListener, + downloads, + handleExtractionCompleted, + isExtractionRunning, + updateDownload, + ], ); extractArchiveRef.current = extractArchive; @@ -1743,12 +1914,113 @@ export function DownloadProvider({ children }: { children: ReactNode }) { [updateDownload, deleteDownloadCard], ); + /** + * Completes an installation: persists the flag, applies the default launch + * config, caches the game and cleans up the source files. + * + * Idempotent per game, because the live events and the startup recovery both + * report the same result. + */ + const finalizeInstallation = useCallback( + async (gameId: number, card?: ActiveDownload) => { + if (installationCompletionHandledRef.current.has(gameId)) return; + const d = downloadsRef.current[gameId] ?? card; + if (!d) return; + installationCompletionHandledRef.current.add(gameId); + + if (d.versionDirectory) { + try { + await writeVersionConfig(d.versionDirectory, { + installationfinished: true, + }); + await applyDefaultLaunchConfig(d); + } catch (error) { + console.warn("Failed to persist installation state:", error); + } + } + + updateDownload(gameId, { + installationStatus: "completed", + installationProgress: 100, + installationCurrentFile: undefined, + installationError: undefined, + }); + + // Cache game data for offline use + cacheInstalledGameData(gameId); + + // Auto-delete source files. Deletion is deferred and retried so that + // processes spawned by the install (e.g. quicksfv.exe) aren't left + // pointing at files we are about to remove. + void autoDeleteSourceFiles(gameId, d); + }, + [ + applyDefaultLaunchConfig, + autoDeleteSourceFiles, + cacheInstalledGameData, + updateDownload, + writeVersionConfig, + ], + ); + + /** + * Handles an installer that exited. The exit code alone is not a reliable + * success signal (many installers, e.g. NSIS-based ones, return 0 even when + * the user cancels), so the installation folder is checked for launchable + * executables first. + */ + const handleInstallerCompleted = useCallback( + async (gameId: number, exitCode: number | null, card?: ActiveDownload) => { + if (installationCompletionHandledRef.current.has(gameId)) return; + const d = downloadsRef.current[gameId] ?? card; + if (!d) return; + + let installVerified = false; + if (d.installationDirectory) { + try { + const { invoke } = await import("@tauri-apps/api/core"); + const { executables: installedExecutables } = await invoke<{ + executables: string[]; + }>("list_launch_executables", { + installationPath: d.installationDirectory, + }); + installVerified = installedExecutables.length > 0; + } catch { + installVerified = false; + } + } + + if (!installVerified) { + installationCompletionHandledRef.current.add(gameId); + updateDownload(gameId, { + installationStatus: "error", + installationCurrentFile: undefined, + installationError: + "The installer appeared to exit successfully but no game " + + "executable was found in the installation folder. The " + + "install may have been canceled or failed, so no source " + + "files were deleted.", + installationExitCode: exitCode, + }); + return; + } + + updateDownload(gameId, { installationExitCode: exitCode ?? 0 }); + await finalizeInstallation(gameId, card); + }, + [finalizeInstallation, updateDownload], + ); + const copyInstallationFiles = useCallback( - async (gameId: number) => { + async (gameId: number, card?: ActiveDownload) => { if (!isTauriApp()) return; - const d = downloads[gameId]; + const d = downloads[gameId] ?? card; if (!d?.extractionDirectory || !d.installationDirectory) return; + // A fresh copy (or a re-attach to one that is still running) resets the + // "already handled" marker for this game. + installationCompletionHandledRef.current.delete(gameId); + if (d.versionDirectory) { await writeVersionConfig(d.versionDirectory, { installationfinished: false, @@ -1795,25 +2067,7 @@ export function DownloadProvider({ children }: { children: ReactNode }) { } if (payload.status === "completed") { - if (d.versionDirectory) { - await writeVersionConfig(d.versionDirectory, { - installationfinished: true, - }); - await applyDefaultLaunchConfig(d); - } - updateDownload(gameId, { - installationStatus: "completed", - installationProgress: 100, - installationCurrentFile: undefined, - installationError: undefined, - }); - // Cache game data for offline use - cacheInstalledGameData(gameId); - - // Auto-delete source files for portable games. Deletion is - // deferred and retried so that processes spawned by the install - // aren't left pointing at files we are about to remove. - void autoDeleteSourceFiles(gameId, d); + void finalizeInstallation(gameId); } else if (payload.status === "error") { updateDownload(gameId, { installationStatus: "error", @@ -1852,14 +2106,18 @@ export function DownloadProvider({ children }: { children: ReactNode }) { }); } }, - [downloads, updateDownload, autoDeleteSourceFiles], + [downloads, finalizeInstallation, updateDownload, writeVersionConfig], ); copyInstallationFilesRef.current = copyInstallationFiles; const launchInstallationExecutable = useCallback( - async (gameId: number, installerRelativePath: string) => { + async ( + gameId: number, + installerRelativePath: string, + card?: ActiveDownload, + ) => { if (!isTauriApp()) return; - const d = downloads[gameId]; + const d = downloads[gameId] ?? card; if (!d?.extractionDirectory || !d.installationDirectory) return; if (d.versionDirectory) { @@ -1876,6 +2134,10 @@ export function DownloadProvider({ children }: { children: ReactNode }) { installationExitCode: null, }); + // A fresh installer run (or a re-attach to a running one) resets the + // "already handled" marker for this game. + installationCompletionHandledRef.current.delete(gameId); + try { const { invoke } = await import("@tauri-apps/api/core"); const { listen } = await import("@tauri-apps/api/event"); @@ -1916,67 +2178,10 @@ export function DownloadProvider({ children }: { children: ReactNode }) { } if (payload.status === "completed") { - // The installer reported exit code 0, but that is not a reliable - // success signal: many installers (e.g. NSIS-based ones) return 0 - // even when the user cancels the setup. Verify the install - // directory actually got populated with launchable executables - // before treating the install as successful. - let installVerified = false; - try { - const { invoke } = await import("@tauri-apps/api/core"); - const { executables: installedExecutables } = await invoke<{ - executables: string[]; - }>("list_launch_executables", { - installationPath: d.installationDirectory, - }); - installVerified = installedExecutables.length > 0; - } catch { - installVerified = false; - } - - if (!installVerified) { - updateDownload(gameId, { - installationStatus: "error", - installationCurrentFile: undefined, - installationError: - "The installer appeared to exit successfully but no game " + - "executable was found in the installation folder. The " + - "install may have been canceled or failed, so no source " + - "files were deleted.", - installationExitCode: - typeof payload.exitCode === "number" - ? payload.exitCode - : null, - }); - const stop = tauriInstallerUnlistenRef.current[gameId]; - if (stop) { - stop(); - delete tauriInstallerUnlistenRef.current[gameId]; - } - return; - } - - if (d.versionDirectory) { - await writeVersionConfig(d.versionDirectory, { - installationfinished: true, - }); - await applyDefaultLaunchConfig(d); - } - updateDownload(gameId, { - installationStatus: "completed", - installationCurrentFile: undefined, - installationError: undefined, - installationExitCode: - typeof payload.exitCode === "number" ? payload.exitCode : 0, - }); - // Cache game data for offline use - cacheInstalledGameData(gameId); - - // Auto-delete source files for setup games. Deletion is deferred - // and retried so that processes spawned by the installer (e.g. - // quicksfv.exe) aren't left pointing at files we are about to - // remove. - void autoDeleteSourceFiles(gameId, d); + void handleInstallerCompleted( + gameId, + typeof payload.exitCode === "number" ? payload.exitCode : null, + ); } else if (payload.status === "error") { updateDownload(gameId, { installationStatus: "error", @@ -2000,12 +2205,43 @@ export function DownloadProvider({ children }: { children: ReactNode }) { tauriInstallerUnlistenRef.current[gameId] = unlisten; + // The installer must run in the same prefix as the launch, including a + // per-game override the user configured in the game settings. + let umuWinePrefix: string | null = null; + try { + const { join } = await import("@tauri-apps/api/path"); + const configPath = d.versionDirectory + ? await join(d.versionDirectory, ".gamevault.game.config.json") + : null; + if ( + configPath && + (await invoke("fs_path_exists", { path: configPath })) + ) { + const raw = JSON.parse( + await invoke("fs_read_text_file", { path: configPath }), + ); + if ( + typeof raw?.umuwineprefix === "string" && + raw.umuwineprefix.trim() + ) { + umuWinePrefix = raw.umuwineprefix.trim(); + } + } + } catch { + umuWinePrefix = null; + } + await invoke("launch_installation_executable", { gameId, + versionDirectory: d.versionDirectory, extractionPath: d.extractionDirectory, installerRelativePath, installationPath: d.installationDirectory, installerParameters: d.gameMetadata?.installer_parameters ?? null, + umuGameId: (d.gameMetadata as any)?.umu_game_id ?? null, + umuStore: (d.gameMetadata as any)?.umu_store ?? null, + umuProtonPath: (d.gameMetadata as any)?.umu_proton_path ?? null, + umuWinePrefix, }); } catch (err) { const stop = tauriInstallerUnlistenRef.current[gameId]; @@ -2020,10 +2256,178 @@ export function DownloadProvider({ children }: { children: ReactNode }) { }); } }, - [downloads, updateDownload, autoDeleteSourceFiles], + [downloads, handleInstallerCompleted, updateDownload, writeVersionConfig], ); launchInstallationExecutableRef.current = launchInstallationExecutable; + /** + * Re-syncs the cards with the backend after startup. + * + * Extractions and installations (file copy / installer run) keep running in + * the backend when the webview is reloaded (F5), and their progress listeners + * are gone afterwards. This re-attaches to whatever is still running and + * applies results that finished while the page was away. + */ + const reconcileBackgroundStates = useCallback( + async (recovered: Record) => { + if (!isTauriApp()) return; + const gameIds = Object.keys(recovered).map(Number); + if (!gameIds.length) return; + const states = await fetchBackgroundStates(); + + // The recovered cards may not have made it into state yet, so merge into + // the recovered card as a fallback instead of losing the update. + const patch = (gameId: number, values: Partial) => { + setDownloads((prev) => { + const existing = prev[gameId] ?? recovered[gameId]; + if (!existing) return prev; + return { ...prev, [gameId]: { ...existing, ...values } }; + }); + }; + + // ── Extractions ────────────────────────────────────────────────────── + for (const state of states.extractions ?? []) { + const gameId = Number(state?.gameId || 0); + if (!gameId || !gameIds.includes(gameId)) continue; + const card = recovered[gameId]; + + if (state.status === "extracting") { + patch(gameId, { + extractionStatus: "extracting", + extractionProgress: + typeof state.progress === "number" ? state.progress : null, + extractionCurrentFile: state.currentFile || undefined, + extractionError: undefined, + extractionPasswordRequired: false, + }); + await attachExtractionListener(gameId); + continue; + } + + if (state.status === "completed") { + // Discovered after the fact: restore the state and persist the flag, + // but never re-run the auto-install for an extraction that finished + // before this page was loaded. + extractionCompletionHandledRef.current.add(gameId); + patch(gameId, { + extractionStatus: "completed", + extractionProgress: 100, + extractionCurrentFile: undefined, + extractionError: undefined, + extractionPasswordRequired: false, + }); + if (card?.versionDirectory) { + try { + await writeVersionConfig(card.versionDirectory, { + extractionfinished: true, + }); + } catch (error) { + console.warn("Failed to persist extraction state:", error); + } + } + continue; + } + + if (state.status === "needs-password") { + patch(gameId, { + extractionStatus: "needs-password", + extractionProgress: null, + extractionPasswordRequired: true, + extractionError: state.error || "Archive password required.", + }); + continue; + } + + if (state.status === "error") { + // Never downgrade an extraction the version config says finished. + if (card?.extractionStatus === "completed") continue; + patch(gameId, { + extractionStatus: "error", + extractionProgress: null, + extractionError: state.error || "Extraction failed.", + }); + } + } + + // ── Installations (file copy / installer run) ───────────────────────── + for (const state of states.installations ?? []) { + const gameId = Number(state?.gameId || 0); + if (!gameId || !gameIds.includes(gameId)) continue; + const card = recovered[gameId]; + // Already installed according to the version config / install folder: + // there is nothing left to recover. + if (card?.installationStatus === "completed") continue; + + if (state.step === "copy") { + if (state.status === "copying") { + // Re-attach: the running copy keeps streaming progress, and the + // backend ignores the start request while a copy is already going. + await copyInstallationFiles(gameId, card); + patch(gameId, { + installationProgress: + typeof state.progress === "number" ? state.progress : null, + installationCurrentFile: state.currentFile || undefined, + }); + continue; + } + if (state.status === "completed") { + await finalizeInstallation(gameId, card); + continue; + } + if (state.status === "error") { + patch(gameId, { + installationStatus: "error", + installationProgress: null, + installationError: state.error || "Installation copy failed.", + }); + } + continue; + } + + if (state.status === "launching" || state.status === "running") { + // Re-attach to the running installer. The backend ignores the start + // request while an installer for this game is already running. + await launchInstallationExecutable( + gameId, + state.currentFile || "", + card, + ); + patch(gameId, { + installationCurrentFile: state.currentFile || undefined, + }); + continue; + } + if (state.status === "completed") { + await handleInstallerCompleted( + gameId, + typeof state.exitCode === "number" ? state.exitCode : null, + card, + ); + continue; + } + if (state.status === "error") { + patch(gameId, { + installationStatus: "error", + installationCurrentFile: undefined, + installationError: state.error || "Installer exited with an error.", + installationExitCode: + typeof state.exitCode === "number" ? state.exitCode : null, + }); + } + } + }, + [ + attachExtractionListener, + copyInstallationFiles, + fetchBackgroundStates, + finalizeInstallation, + handleInstallerCompleted, + launchInstallationExecutable, + writeVersionConfig, + ], + ); + reconcileBackgroundStatesRef.current = reconcileBackgroundStates; + const setSpeedLimitKB = useCallback((v: number) => { const val = Math.max(0, v || 0); setSpeedLimitKBState(val); @@ -2149,6 +2553,13 @@ export function DownloadProvider({ children }: { children: ReactNode }) { if (!mounted || !Object.keys(recovered).length) return; + setDownloads((prev) => ({ ...recovered, ...prev })); + + // Extraction keeps running in the backend while the page is reloaded + // (F5). Re-attach to extractions that are still running, and pick up + // results that finished while this page was away. + void reconcileBackgroundStatesRef.current?.(recovered); + // Auto-resume any download interrupted by the app exiting, unless the // user intentionally stopped it (paused/cancelled) before quitting. const skipAutoResume = getSkipAutoResumeIds(); @@ -2159,8 +2570,6 @@ export function DownloadProvider({ children }: { children: ReactNode }) { toResume.push(dl); } - setDownloads((prev) => ({ ...recovered, ...prev })); - if (toResume.length) { pendingAutoResumeRef.current = toResume; setAutoResumeIds(toResume.map((dl) => dl.gameId)); @@ -2287,6 +2696,11 @@ export function DownloadProvider({ children }: { children: ReactNode }) { // active downloads and drives the native indicator so the user can see // progress (and when something needs their attention) even when GameVault // is in the background. + // + // Only downloads that still have work to do count: cards are kept in the + // list after they finish (download, extraction and installation), so the + // finished ones must not keep reporting the last phase's 100%. When nothing + // is left, the indicator is cleared instead of being pinned at "done". useEffect(() => { if (!isTauriApp()) return; @@ -2300,68 +2714,25 @@ export function DownloadProvider({ children }: { children: ReactNode }) { } }; - const active = Object.values(downloads).filter( + // Synthetic cards (e.g. simulated downloads) never drive the indicator. + const usable = Object.values(downloads).filter( (d) => Number.isFinite(d.gameId) && d.gameId > 0, ); - if (active.length === 0) { + const { status, progress } = computeTaskbarIndicator(usable); + + if (status === "none") { lastTaskbarRef.current = null; void send("clear_taskbar_progress"); return; } - const needsAction = active.some( - (d) => - d.status === "error" || - d.extractionStatus === "error" || - d.extractionStatus === "needs-password" || - d.extractionPasswordRequired || - d.installationStatus === "error", - ); - const paused = active.some((d) => d.status === "paused"); - - // Average progress across the currently-active phase of each download. - const progresses = active.map((d) => { - if ( - d.extractionStatus === "extracting" && - typeof d.extractionProgress === "number" - ) { - return d.extractionProgress; - } - if ( - d.installationStatus === "copying" && - typeof d.installationProgress === "number" - ) { - return d.installationProgress; - } - if (typeof d.progress === "number") return d.progress; - return 0; - }); - const progress = - progresses.reduce((sum, p) => sum + p, 0) / progresses.length; - - // Only invoke when the indicator state actually changes (round progress - // to whole percent to avoid spamming the native command). - const rounded = Math.round(progress); - - const status: "error" | "paused" | "indeterminate" | "normal" = needsAction - ? "error" - : paused - ? "paused" - : rounded === 0 && - active.some( - (d) => - d.status === "downloading" || - d.extractionStatus === "extracting" || - d.installationStatus === "copying", - ) - ? "indeterminate" - : "normal"; - - const key = `${status}:${rounded}`; + // Only invoke when the indicator state actually changes (progress is + // already rounded to whole percent to avoid spamming the native command). + const key = `${status}:${progress}`; if (lastTaskbarRef.current === key) return; lastTaskbarRef.current = key; - void send("set_taskbar_progress", { status, progress: rounded / 100 }); + void send("set_taskbar_progress", { status, progress: progress / 100 }); }, [downloads]); const value: DownloadContextValue = { diff --git a/src/context/UmuContext.tsx b/src/context/UmuContext.tsx index 061b329..7e81b0b 100644 --- a/src/context/UmuContext.tsx +++ b/src/context/UmuContext.tsx @@ -56,8 +56,11 @@ export function UmuProvider({ children }: { children: React.ReactNode }) { line?: string | null; message?: string | null; }>("umu-status", (event) => { - const { phase: nextPhase, line, gameTitle: nextGameTitle } = - event.payload; + const { + phase: nextPhase, + line, + gameTitle: nextGameTitle, + } = event.payload; if (nextPhase === "installing" || nextPhase === "setup") { setPhase(nextPhase as UmuPhase); if (nextGameTitle) setGameTitle(nextGameTitle); @@ -82,27 +85,73 @@ export function UmuProvider({ children }: { children: React.ReactNode }) { }; }, []); - const checkUmuStatus = useCallback(async (): Promise => { - if (!isTauriApp()) return null; - try { - const { invoke } = await import("@tauri-apps/api/core"); - return await invoke("umu_status"); - } catch { - return null; - } - }, []); + // Re-attach to a umu setup that is still running in the backend. + // + // umu-launcher setup (download + extract) keeps going when the webview is + // reloaded, but the overlay above only reacts to live events and would be + // gone. Ask the backend what it is doing and show it again; the ongoing + // events keep the overlay updated from there on. + useEffect(() => { + if (!isTauriApp()) return; + let cancelled = false; + + void (async () => { + try { + const { invoke } = await import("@tauri-apps/api/core"); + const states = await invoke<{ + umu?: { + status: string; + message?: string | null; + gameTitle?: string | null; + } | null; + }>("get_background_states"); + const snapshot = states?.umu; + if (cancelled || !snapshot) return; + if (snapshot.status !== "installing" && snapshot.status !== "setup") { + return; + } + + setPhase(snapshot.status as UmuPhase); + if (snapshot.gameTitle) setGameTitle(snapshot.gameTitle); + if (snapshot.message) { + setLines([snapshot.message].slice(-MAX_OVERLAY_LINES)); + } + setVisible(true); + } catch { + // Non-Tauri environments won't have the IPC bridge; ignore. + } + })(); - const installUmu = useCallback(async (gameTitle?: string): Promise => { - if (!isTauriApp()) return false; - try { - const { invoke } = await import("@tauri-apps/api/core"); - await invoke("install_umu_launcher", { gameTitle: gameTitle ?? null }); - return true; - } catch { - return false; - } + return () => { + cancelled = true; + }; }, []); + const checkUmuStatus = + useCallback(async (): Promise => { + if (!isTauriApp()) return null; + try { + const { invoke } = await import("@tauri-apps/api/core"); + return await invoke("umu_status"); + } catch { + return null; + } + }, []); + + const installUmu = useCallback( + async (gameTitle?: string): Promise => { + if (!isTauriApp()) return false; + try { + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("install_umu_launcher", { gameTitle: gameTitle ?? null }); + return true; + } catch { + return false; + } + }, + [], + ); + return ( {children} diff --git a/src/hooks/useGameTimeTracker.ts b/src/hooks/useGameTimeTracker.ts index d1b3fae..c291a76 100644 --- a/src/hooks/useGameTimeTracker.ts +++ b/src/hooks/useGameTimeTracker.ts @@ -1,15 +1,190 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useAuth } from "@/context/AuthContext"; import { useOnlineStatus } from "@/context/OfflineContext"; import { isTauriApp } from "@/utils/tauri"; import { getRootPaths } from "@/utils/rootPaths"; +/** + * Game time tracker lifecycle controller. + * + * The actual tracking runs in the Rust backend (`src-tauri/src/time_tracker.rs`): + * it polls every 60 seconds, matches running processes to installed games and + * credits one minute per matched game. This hook only starts/stops that native + * loop and replays playtime that was recorded offline. + * + * Every decision and every previously swallowed `invoke` failure is written to + * the native tracker log, because the loop credits exactly one minute per tick: + * a playtime that ends up lower than what was played is always caused by a + * missed tick, a lost process match or a rejected request, and the log has to + * show which one it was. + */ + +interface OfflineTimeFile { + path: string; + userId: number; + gameId: number; + accumulatedMinutes: number; + /** Unix seconds of the last write; 0 for files from older builds. */ + updatedAt: number; +} + +/** Payload of the native `tracker-auth-expired` event. */ +interface TrackerAuthExpiredPayload { + at: number; + authRejectedCount: number; + pendingOfflineMinutes: number; + retryInSecs: number; +} + +/** Only the part of the native tracker status this hook needs. */ +interface TrackerStatusSummary { + pendingOfflineMinutes: number; +} + +const AUTH_EXPIRED_EVENT = "tracker-auth-expired"; + +/** + * How often the hook refreshes the session and hands it to the native tracker. + * + * Must stay well below the server's access-token lifetime (a few minutes), + * otherwise the tracker always runs one tick on a token that just expired. + */ +const KEEP_ALIVE_MS = 60 * 1000; +/** How often pending offline playtime is retried in the background. */ +const PENDING_RETRY_MS = 5 * 60 * 1000; + +/** + * Mirrors a frontend lifecycle line to the console. + * + * The native tracker log is disabled (`tracker_log::ENABLED` in + * `src-tauri/src/tracker_log.rs`), so nothing is sent over IPC. Re-add the + * `tracker_log_line` invoke here when the native log is switched back on. + */ +async function logTrackerLine( + level: "info" | "warn" | "error", + category: string, + message: string, +): Promise { + if (level === "error") { + console.error(`[tracker:${category}]`, message); + } else if (level === "warn") { + console.warn(`[tracker:${category}]`, message); + } else { + console.debug(`[tracker:${category}]`, message); + } +} + +/** + * Invokes a tracker command and records failures. + * + * These calls used to be `.catch(() => {})`, which hid a tracker that never + * started or never stopped. + */ +async function invokeTracker( + command: string, + args?: Record, +): Promise { + try { + const { invoke } = await import("@tauri-apps/api/core"); + return await invoke(command, args); + } catch (error) { + await logTrackerLine("error", "invoke", `${command} failed: ${String(error)}`); + return null; + } +} + +/** + * Replays every offline time file and logs what was found, credited or kept. + * + * `reason` ends up in the log so a failed replay can be traced back to startup + * or a reconnect. + */ +async function reconcileOfflineTime(options: { + serverUrl: string; + accessToken: string; + reason: string; +}): Promise { + const rootPaths = getRootPaths(); + if (!rootPaths.length) { + await logTrackerLine( + "warn", + "offline", + `${options.reason}: no root paths configured, skipping offline replay`, + ); + return; + } + + let files = 0; + let creditedMinutes = 0; + let pendingMinutes = 0; + let oldestFileMinutes = 0; + + for (const root of rootPaths) { + const offlineFiles = + (await invokeTracker("get_offline_time_files", { + selectedRoot: root.path, + })) ?? []; + + for (const file of offlineFiles) { + files += 1; + + if (file.updatedAt) { + const ageMinutes = Math.max( + 0, + Math.round(Date.now() / 1000 - file.updatedAt) / 60, + ); + oldestFileMinutes = Math.max(oldestFileMinutes, Math.round(ageMinutes)); + } + + if (!file.accumulatedMinutes || file.accumulatedMinutes <= 0) { + await invokeTracker("delete_offline_time_file", { path: file.path }); + continue; + } + + const success = await invokeTracker("sync_offline_time", { + serverUrl: options.serverUrl, + accessToken: options.accessToken, + userId: file.userId, + gameId: file.gameId, + minutes: file.accumulatedMinutes, + reason: options.reason, + }); + + if (success) { + creditedMinutes += file.accumulatedMinutes; + await invokeTracker("delete_offline_time_file", { path: file.path }); + } else { + pendingMinutes += file.accumulatedMinutes; + } + } + } + + const age = oldestFileMinutes > 0 ? `, oldest file ${oldestFileMinutes} min old` : ""; + await logTrackerLine( + pendingMinutes > 0 ? "warn" : "info", + "offline", + `${options.reason}: ${files} offline file(s), credited ${creditedMinutes} min, ${pendingMinutes} min kept for the next attempt${age}`, + ); +} + export function useGameTimeTracker() { - const { serverUrl, user, auth } = useAuth(); + const { serverUrl, user, auth, getAccessToken } = useAuth(); const { onReconnect } = useOnlineStatus(); const startedRef = useRef<{ serverUrl: string; userId: number } | null>(null); const syncInFlightRef = useRef(false); const initialSyncDoneRef = useRef(false); + /** Token last handed to the native tracker (avoids duplicate pushes). */ + const pushedTokenRef = useRef(null); + + /** + * Hands a (possibly refreshed) token to the native tracker — but only when it + * actually changed, so the log does not fill up with duplicate auth lines. + */ + const pushToken = useCallback((token: string) => { + if (pushedTokenRef.current === token) return; + pushedTokenRef.current = token; + void invokeTracker("update_tracker_auth", { accessToken: token }); + }, []); // Start / restart tracker as soon as credentials are available. We key the // "started" state on server+user so a token refresh (handled by @@ -21,52 +196,83 @@ export function useGameTimeTracker() { const accessToken = auth?.access_token; const downloadPaths = getRootPaths().map((p) => p.path); - if (!serverUrl || !userId || !accessToken || !downloadPaths.length) { - // If tracker was running but credentials are gone (logout), stop it + if (!serverUrl || !userId || !accessToken) { if (startedRef.current) { + // Logout or a genuinely missing session: stop the native loop. startedRef.current = null; - import("@tauri-apps/api/core").then(({ invoke }) => { - invoke("stop_game_time_tracker").catch(() => {}); + void logTrackerLine( + "info", + "lifecycle", + "stopping tracker: credentials are gone (logout or missing session)", + ); + void invokeTracker("stop_game_time_tracker", { + reason: "credentials unavailable (logout or missing session)", }); + } else { + void logTrackerLine("info", "lifecycle", "tracker idle: waiting for credentials"); + } + return; + } + + if (!downloadPaths.length) { + if (startedRef.current) { + // Root paths can be momentarily empty (storage still loading). The + // native loop re-reads the library every tick, so keep it running + // instead of tearing down a session that is tracking a running game. + void logTrackerLine( + "warn", + "lifecycle", + "root paths are empty but the session is valid — keeping the tracker running", + ); + } else { + void logTrackerLine( + "warn", + "lifecycle", + "tracker idle: no download root paths configured", + ); } return; } const started = startedRef.current; - if ( - started && - started.serverUrl === serverUrl && - started.userId === userId - ) { + if (started && started.serverUrl === serverUrl && started.userId === userId) { // Same session (token may have refreshed); leave the loop running. return; } // New session or a changed server/user — (re)start with fresh credentials. if (started) { - import("@tauri-apps/api/core").then(({ invoke }) => { - invoke("stop_game_time_tracker").catch(() => {}); - }); + void logTrackerLine( + "info", + "lifecycle", + `restarting tracker for a new session (server=${serverUrl}, user=${userId}, roots=${downloadPaths.length})`, + ); + } else { + void logTrackerLine( + "info", + "lifecycle", + `starting tracker (server=${serverUrl}, user=${userId}, roots=${downloadPaths.length})`, + ); } + startedRef.current = { serverUrl, userId }; - import("@tauri-apps/api/core").then(({ invoke }) => { - invoke("start_game_time_tracker", { - serverUrl, - userId, - accessToken, - downloadPath: null, - downloadPaths, - }).catch(() => {}); + pushedTokenRef.current = accessToken; + void invokeTracker("start_game_time_tracker", { + serverUrl, + userId, + accessToken, + downloadPath: null, + downloadPaths, }); }, [serverUrl, user?.id, auth?.access_token]); // Stop the tracker on unmount. useEffect(() => { return () => { + if (!startedRef.current) return; startedRef.current = null; - import("@tauri-apps/api/core").then(({ invoke }) => { - invoke("stop_game_time_tracker").catch(() => {}); - }); + void logTrackerLine("info", "lifecycle", "stopping tracker: hook unmounted"); + void invokeTracker("stop_game_time_tracker", { reason: "hook unmounted" }); }; }, []); @@ -75,11 +281,8 @@ export function useGameTimeTracker() { if (!isTauriApp() || !startedRef.current) return; const accessToken = auth?.access_token; if (!accessToken) return; - - import("@tauri-apps/api/core").then(({ invoke }) => { - invoke("update_tracker_auth", { accessToken }).catch(() => {}); - }); - }, [auth?.access_token]); + pushToken(accessToken); + }, [auth?.access_token, pushToken]); // Sync any lingering offline time on startup (handles case where user // went offline, closed the app, then restarted while online) @@ -89,105 +292,218 @@ export function useGameTimeTracker() { if (initialSyncDoneRef.current) return; initialSyncDoneRef.current = true; - (async () => { + void reconcileOfflineTime({ + serverUrl, + accessToken: auth.access_token, + reason: "startup", + }); + }, [serverUrl, auth?.access_token]); + + // Sync offline time when coming back online + useEffect(() => { + if (!isTauriApp()) return; + + const unregister = onReconnect(async () => { + if (syncInFlightRef.current) return; + syncInFlightRef.current = true; + try { - const rootPaths = getRootPaths(); - if (!rootPaths.length) return; - - const { invoke } = await import("@tauri-apps/api/core"); - - for (const root of rootPaths) { - const files = await invoke("get_offline_time_files", { - selectedRoot: root.path, - }).catch(() => [] as any[]); - - for (const file of files) { - if (!file.accumulatedMinutes || file.accumulatedMinutes <= 0) { - await invoke("delete_offline_time_file", { - path: file.path, - }).catch(() => {}); - continue; + await reconcileOfflineTime({ + serverUrl, + accessToken: auth?.access_token || "", + reason: "reconnect", + }); + } finally { + syncInFlightRef.current = false; + } + }); + + return unregister; + }, [onReconnect, serverUrl, auth?.access_token]); + + // The native tracker tells us when the server rejects its session. Without + // this, the tracker kept using an expired token for as long as the UI made + // no authenticated request (an hour of playtime ended up offline). + useEffect(() => { + if (!isTauriApp()) return; + + let unlisten: (() => void) | undefined; + let disposed = false; + + void (async () => { + try { + const { listen } = await import("@tauri-apps/api/event"); + const stop = await listen( + AUTH_EXPIRED_EVENT, + async (event) => { + const { authRejectedCount, pendingOfflineMinutes, retryInSecs } = + event.payload; + await logTrackerLine( + "warn", + "auth", + `server rejected the session (${authRejectedCount} rejection(s), ${pendingOfflineMinutes} min stored offline, next retry in ${retryInSecs}s) — requesting a fresh session`, + ); + + // Refreshes the token when it is near expiry (single-flight). + const token = await getAccessToken(); + if (!token) { + await logTrackerLine( + "error", + "auth", + "no fresh session available — playtime keeps being stored offline", + ); + return; } + pushToken(token); + + if (syncInFlightRef.current) return; + syncInFlightRef.current = true; try { - const success = await invoke("sync_offline_time", { + await reconcileOfflineTime({ serverUrl, - accessToken: auth.access_token, - userId: file.userId, - gameId: file.gameId, - minutes: file.accumulatedMinutes, + accessToken: token, + reason: "auth-refresh", }); - - if (success) { - await invoke("delete_offline_time_file", { - path: file.path, - }).catch(() => {}); - } - } catch { - // Retry on next reconnect + } finally { + syncInFlightRef.current = false; } - } + }, + ); + + if (disposed) { + stop(); + } else { + unlisten = stop; } - } catch { - // Silently fail + } catch (error) { + await logTrackerLine( + "error", + "auth", + `could not subscribe to ${AUTH_EXPIRED_EVENT}: ${String(error)}`, + ); } })(); - }, [serverUrl, auth?.access_token]); - // Sync offline time when coming back online + return () => { + disposed = true; + unlisten?.(); + }; + }, [getAccessToken, pushToken, serverUrl]); + + // Keep the native tracker's session copy fresh. Token refresh is otherwise + // lazy (nothing asks for a new token while the UI sits idle), and a token + // that is only refreshed at its expiry moment is always rejected once before + // it lands — so this refreshes well ahead of the deadline. useEffect(() => { if (!isTauriApp()) return; - const unregister = onReconnect(async () => { + const id = window.setInterval(() => { + if (!startedRef.current) return; + void getAccessToken().then((token) => { + if (token) { + pushToken(token); + } + }); + }, KEEP_ALIVE_MS); + + return () => window.clearInterval(id); + }, [getAccessToken, pushToken]); + + // Retry playtime that is waiting offline, without needing an app restart. + useEffect(() => { + if (!isTauriApp()) return; + + const id = window.setInterval(async () => { if (syncInFlightRef.current) return; - syncInFlightRef.current = true; + const status = await invokeTracker("get_tracker_status"); + if (!status || status.pendingOfflineMinutes <= 0) return; + + const token = await getAccessToken(); + if (!token) return; + pushToken(token); + + syncInFlightRef.current = true; try { - const rootPaths = getRootPaths(); - if (!rootPaths.length) return; - - const { invoke } = await import("@tauri-apps/api/core"); - - for (const root of rootPaths) { - const files = await invoke("get_offline_time_files", { - selectedRoot: root.path, - }).catch(() => [] as any[]); - - for (const file of files) { - if (!file.accumulatedMinutes || file.accumulatedMinutes <= 0) { - // Delete empty files - await invoke("delete_offline_time_file", { - path: file.path, - }).catch(() => {}); - continue; - } + await reconcileOfflineTime({ + serverUrl, + accessToken: token, + reason: "periodic-retry", + }); + } finally { + syncInFlightRef.current = false; + } + }, PENDING_RETRY_MS); - try { - const success = await invoke("sync_offline_time", { - serverUrl, - accessToken: auth?.access_token || "", - userId: file.userId, - gameId: file.gameId, - minutes: file.accumulatedMinutes, - }); + return () => window.clearInterval(id); + }, [getAccessToken, pushToken, serverUrl]); - if (success) { - await invoke("delete_offline_time_file", { - path: file.path, - }).catch(() => {}); - } - } catch { - // Retry on next reconnect - } - } - } - } catch { - // Silently fail — retry on next reconnect + // The browser reporting "online" is the earliest signal that the server is + // reachable again — replay immediately instead of waiting for the poller. + useEffect(() => { + if (!isTauriApp()) return; + + const handleOnline = async () => { + if (syncInFlightRef.current) return; + + const token = await getAccessToken(); + if (!token) return; + pushToken(token); + + syncInFlightRef.current = true; + try { + await reconcileOfflineTime({ + serverUrl, + accessToken: token, + reason: "browser-online", + }); } finally { syncInFlightRef.current = false; } - }); + }; - return unregister; - }, [onReconnect, serverUrl, auth?.access_token]); + window.addEventListener("online", handleOnline); + return () => window.removeEventListener("online", handleOnline); + }, [getAccessToken, pushToken, serverUrl]); + + // Record window visibility and connectivity next to the tracker ticks, so a + // sleep/resume or an offline window lines up with the tick gaps in the log. + useEffect(() => { + if (!isTauriApp()) return; + + const handleVisibility = () => { + void logTrackerLine("info", "app", `window visibility: ${document.visibilityState}`); + }; + const handleFocus = () => { + void logTrackerLine("info", "app", "window focused"); + }; + const handleBlur = () => { + void logTrackerLine("info", "app", "window blurred"); + }; + const handleOnline = () => { + void logTrackerLine("info", "app", "browser reports online"); + }; + const handleOffline = () => { + void logTrackerLine( + "warn", + "app", + "browser reports offline — playtime is stored offline until the connection returns", + ); + }; + + document.addEventListener("visibilitychange", handleVisibility); + window.addEventListener("focus", handleFocus); + window.addEventListener("blur", handleBlur); + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + return () => { + document.removeEventListener("visibilitychange", handleVisibility); + window.removeEventListener("focus", handleFocus); + window.removeEventListener("blur", handleBlur); + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, []); } diff --git a/src/main.tsx b/src/main.tsx index eacbc99..2f24f8a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,6 +5,7 @@ import { MotionConfig } from "motion/react"; import { Login } from "./components/Login"; import { Register } from "./components/Register"; import { PageLoader } from "./components/PageLoader"; +import { PrereleaseNotice } from "./components/PrereleaseNotice"; import { AlertDialogProvider, GlobalAlertDialogBridge, @@ -37,102 +38,125 @@ import { applyTheme, getStoredTheme } from "./utils/theme"; import { applyZoom, getStoredZoom, registerZoomHotkeys } from "./utils/zoom"; import { registerReloadHotkey } from "./utils/reload"; import { registerStreamerModeHotkey } from "./utils/streamerMode"; -import { - registerExternalLinkHandler, - isTauriApp, -} from "./utils/tauri"; +import { registerExternalLinkHandler, isTauriApp } from "./utils/tauri"; import { isAnalyticsEnabled } from "./utils/analytics"; import { startMediaCacheMaintenance } from "./utils/mediaCache"; import * as Swetrix from "swetrix"; +import { LaunchLogWindow } from "./pages/LaunchLogWindow"; -// Apply stored theme immediately to prevent flash of wrong theme -applyTheme(getStoredTheme()); -// Apply persisted zoom level (native webview zoom in Tauri, CSS zoom on web) -void applyZoom(getStoredZoom()); -void startMediaCacheMaintenance(); +// The launch log window boots standalone: it renders its own document without +// the app providers, router, analytics or global hotkeys. +const isLaunchLogWindow = window.location.hash === "#launch-log"; -// Reflect the user's locale on so native form controls (e.g. the -// filters) render dates in the locale the computer uses -// instead of the statically-declared "en". -if (typeof document !== "undefined" && typeof navigator !== "undefined") { - document.documentElement.lang = navigator.language || "en"; -} +if (isLaunchLogWindow) { + createRoot(document.getElementById("root")!).render(); +} else { + // Apply stored theme immediately to prevent flash of wrong theme + applyTheme(getStoredTheme()); + // Apply persisted zoom level (native webview zoom in Tauri, CSS zoom on web) + void applyZoom(getStoredZoom()); + void startMediaCacheMaintenance(); -(window as any).global = window; + // Reflect the user's locale on so native form controls (e.g. the + // filters) render dates in the locale the computer uses + // instead of the statically-declared "en". + if (typeof document !== "undefined" && typeof navigator !== "undefined") { + document.documentElement.lang = navigator.language || "en"; + } -if (isAnalyticsEnabled()) { - Swetrix.init("dBl2xaaJ9x3M", { preloadSessionReplay: true, apiURL: "https://analytics.platform.phalco.de/log" }); - Swetrix.trackViews(); - Swetrix.trackErrors(); -} + (window as any).global = window; -// Ctrl/Cmd + +/-/0 zoom hotkeys (browsers already handle these natively) -if (isTauriApp()) { - registerZoomHotkeys(); - // F5 reloads the app in the Tauri webview (packaged builds have no native F5) - registerReloadHotkey(); - // Ctrl/Cmd+Shift+O toggles OPSEC/streamer mode - registerStreamerModeHotkey(); - // Open every external link (target=_blank, http(s), mailto, tel) through - // the native OS opener instead of being swallowed by the webview. - registerExternalLinkHandler(); -} + if (isAnalyticsEnabled()) { + Swetrix.init("dBl2xaaJ9x3M", { + preloadSessionReplay: true, + apiURL: "https://analytics.platform.phalco.de/log", + }); + Swetrix.trackViews(); + Swetrix.trackErrors(); + } + + // Ctrl/Cmd + +/-/0 zoom hotkeys (browsers already handle these natively) + if (isTauriApp()) { + registerZoomHotkeys(); + // F5 reloads the app in the Tauri webview (packaged builds have no native F5) + registerReloadHotkey(); + // Ctrl/Cmd+Shift+O toggles OPSEC/streamer mode + registerStreamerModeHotkey(); + // Open every external link (target=_blank, http(s), mailto, tel) through + // the native OS opener instead of being swallowed by the webview. + registerExternalLinkHandler(); + } -createRoot(document.getElementById("root")!).render( - - - - - - - - - - - - - }> - - }> - } /> - } /> - + createRoot(document.getElementById("root")!).render( + + + + + + + + + + + + + + }> + + }> + } /> + } /> + - }> - } /> - } /> - } /> - } /> - } - /> - } /> - - - - } - /> - + }> + } + /> + } + /> + } + /> + } + /> + } + /> + } /> + + + + } + /> + - } /> - - - - - - - - - - - - - , -); + } /> + + + + + + + + + + + + + , + ); +} diff --git a/src/pages/GameView.tsx b/src/pages/GameView.tsx index 9c4cb47..8c0df6e 100644 --- a/src/pages/GameView.tsx +++ b/src/pages/GameView.tsx @@ -537,6 +537,8 @@ export default function GameView() { await invoke("launch_game", { gameTitle: title || game?.title || "Game", + gameId: installedInfo.gameId, + versionDirectory: installedInfo.versionDirectory, installationPath: installedInfo.installationDirectory, executableRelativePath: launchExe, launchParameters: launchParams || null, diff --git a/src/pages/LaunchLogWindow.tsx b/src/pages/LaunchLogWindow.tsx new file mode 100644 index 0000000..00ca5d6 --- /dev/null +++ b/src/pages/LaunchLogWindow.tsx @@ -0,0 +1,259 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + LINES_EVENT, + STATE_EVENT, + appendLogBatch, + bufferFromSnapshot, + clearLaunchLogs, + formatExitCode, + getLaunchLog, + listLaunchLogs, + logText, + openLaunchLogFolder, + readLaunchLogFile, + type LaunchLogEntry, + type LaunchLogLine, + type LaunchLogLinesBatch, + type LaunchLogSnapshot, +} from "@/utils/launchLog"; + +/** + * Standalone window that streams the current launch log (umu/Proton output + * included) and lets the user open earlier logs. Booted directly from + * `main.tsx` when the window was opened with `#launch-log`. + */ +export function LaunchLogWindow() { + const [snapshot, setSnapshot] = useState(null); + const [lines, setLines] = useState([]); + const [nextSeq, setNextSeq] = useState(0); + const [follow, setFollow] = useState(true); + const [entries, setEntries] = useState([]); + const [history, setHistory] = useState(null); + + const bufferRef = useRef({ lines, nextSeq }); + bufferRef.current = { lines, nextSeq }; + const logIdRef = useRef(null); + const preRef = useRef(null); + + const refreshHistory = useCallback(async () => { + try { + setEntries(await listLaunchLogs()); + } catch { + // Not running inside Tauri — nothing to list. + } + }, []); + + // Initial snapshot. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const current = await getLaunchLog(); + if (cancelled || !current) return; + logIdRef.current = current.id; + setSnapshot(current); + const buffer = bufferFromSnapshot(current); + setLines(buffer.lines); + setNextSeq(buffer.nextSeq); + bufferRef.current = buffer; + } catch { + // ignore + } + void refreshHistory(); + })(); + return () => { + cancelled = true; + }; + }, [refreshHistory]); + + // Live stream. + useEffect(() => { + let unlistenLines: (() => void) | undefined; + let unlistenState: (() => void) | undefined; + (async () => { + try { + const { listen } = await import("@tauri-apps/api/event"); + unlistenLines = await listen( + LINES_EVENT, + (event) => { + if ( + logIdRef.current !== null && + event.payload.logId !== logIdRef.current + ) { + return; + } + const merged = appendLogBatch(bufferRef.current, event.payload); + bufferRef.current = merged; + setLines(merged.lines); + setNextSeq(merged.nextSeq); + }, + ); + unlistenState = await listen( + STATE_EVENT, + (event) => { + // A new launch started while the window is open: switch to it. + if (event.payload.id !== logIdRef.current) { + const buffer = bufferFromSnapshot(event.payload); + logIdRef.current = event.payload.id; + bufferRef.current = buffer; + setLines(buffer.lines); + setNextSeq(buffer.nextSeq); + setHistory(null); + void refreshHistory(); + } + setSnapshot(event.payload); + }, + ); + } catch { + // ignore + } + })(); + return () => { + unlistenLines?.(); + unlistenState?.(); + }; + }, [refreshHistory]); + + // Keep the view pinned to the bottom while following. + useEffect(() => { + if (!follow) return; + const element = preRef.current; + if (element) element.scrollTop = element.scrollHeight; + }, [lines, follow]); + + const shownText = useMemo( + () => (history !== null ? history : logText(lines)), + [history, lines], + ); + + const statusText = snapshot ? formatExitCode(snapshot) : "no launch yet"; + + return ( +
+
+
+

+ {snapshot ? snapshot.title : "Launch log"} +

+

+ {statusText} + {snapshot?.path ? ` — ${snapshot.path}` : ""} + {snapshot?.truncated ? " — older lines were dropped" : ""} +

+
+ +
+ + + + + +
+
+ + {entries.length > 1 && ( +
+ Recent launches: + + {history !== null && ( + + )} +
+ )} + +
+        {shownText || "No output yet."}
+      
+
+ ); +} + +export default LaunchLogWindow; diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 3d0d0f0..edfa7d1 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -58,6 +58,7 @@ import { updateRootPath, updateRootPathLabel, } from "@/utils/rootPaths"; +import { openLaunchLogWindow } from "@/utils/launchLog"; import { FolderArrowDownIcon, ComputerDesktopIcon, @@ -324,6 +325,14 @@ const SETTINGS_SEARCH_INDEX: SearchableSetting[] = [ keywords: ["ignore", "hidden", "executables", "skip", "setup"], desktopOnly: true, }, + { + id: "games-launch-logs", + title: "Launch logs", + description: "Open the log window automatically and browse launch logs", + category: "games", + keywords: ["log", "logs", "debug", "crash", "proton", "wine", "umu"], + desktopOnly: true, + }, { id: "games-wine-prefix", title: "Wine/Proton Prefix Directory", @@ -694,6 +703,8 @@ export default function Settings() { } }, ); + const [alwaysShowLaunchLogs, setAlwaysShowLaunchLogs] = + useState(false); const [autoExtract, setAutoExtract] = useState(() => { try { return localStorage.getItem(AUTO_EXTRACT_KEY) === "1"; @@ -958,6 +969,35 @@ export default function Settings() { } }, [minimizeOnGameLaunch]); + // Load and persist the "always show launch logs" setting (desktop only). + useEffect(() => { + if (!isTauri) return; + (async () => { + try { + const { invoke } = await import("@tauri-apps/api/core"); + setAlwaysShowLaunchLogs( + await invoke("get_always_show_launch_logs"), + ); + } catch (e) { + console.error("Failed to load launch log preference:", e); + } + })(); + }, [isTauri]); + + useEffect(() => { + if (!isTauri) return; + (async () => { + try { + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("set_always_show_launch_logs", { + enabled: alwaysShowLaunchLogs, + }); + } catch (e) { + console.error("Failed to sync launch log preference:", e); + } + })(); + }, [alwaysShowLaunchLogs, isTauri]); + // Persist auto-flow settings to localStorage useEffect(() => { try { @@ -1988,6 +2028,46 @@ export default function Settings() { + + + + setAlwaysShowLaunchLogs(v)} + /> + + + + + + + {isLinux && (