From ad8d619249f75f31fa9ca1f55963e1fcb5afd335 Mon Sep 17 00:00:00 2001 From: Alper Alkan Date: Tue, 15 Sep 2026 21:45:59 +0200 Subject: [PATCH 1/9] fix(appimage): bundle full gstreamer plugin set for media playback --- .github/workflows/deploy.yml | 14 +++++++++----- CHANGELOG.md | 7 +++---- README.md | 2 ++ 3 files changed, 14 insertions(+), 9 deletions(-) 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/CHANGELOG.md b/CHANGELOG.md index 15cc33d..aa85b35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,17 +55,16 @@ - 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 +--- + ## 17.0.0 ### Changes 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`. From 46f928a14870f6cb8faef2cb184ef181f509280e Mon Sep 17 00:00:00 2001 From: Alper Alkan Date: Tue, 15 Sep 2026 22:40:54 +0200 Subject: [PATCH 2/9] feat(updater): warn on first launch of Early Access and unstable builds --- CHANGELOG.md | 3 + src/components/PrereleaseNotice.tsx | 41 ++++++++++++++ src/main.tsx | 23 +++++--- src/utils/prereleaseNotice.test.ts | 70 ++++++++++++++++++++++++ src/utils/prereleaseNotice.ts | 85 +++++++++++++++++++++++++++++ 5 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 src/components/PrereleaseNotice.tsx create mode 100644 src/utils/prereleaseNotice.test.ts create mode 100644 src/utils/prereleaseNotice.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aa85b35..9b1a77b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,9 @@ --- +- 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. +- 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. + ## 17.0.0 ### Changes diff --git a/src/components/PrereleaseNotice.tsx b/src/components/PrereleaseNotice.tsx new file mode 100644 index 0000000..e2a508f --- /dev/null +++ b/src/components/PrereleaseNotice.tsx @@ -0,0 +1,41 @@ +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 || !shouldShowPrereleaseNotice(channel)) return; + + shownRef.current = true; + void showAlert({ + tone: "warning", + affirmativeText: "I understand", + ...prereleaseNoticeContent(channel, __APP_VERSION__), + }).then(() => { + // 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. + markPrereleaseNoticeSeen(channel); + }); + }, [showAlert]); + + return null; +} + +export default PrereleaseNotice; diff --git a/src/main.tsx b/src/main.tsx index eacbc99..f1a5dc9 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,10 +38,7 @@ 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"; @@ -61,7 +59,10 @@ if (typeof document !== "undefined" && typeof navigator !== "undefined") { (window as any).global = window; if (isAnalyticsEnabled()) { - Swetrix.init("dBl2xaaJ9x3M", { preloadSessionReplay: true, apiURL: "https://analytics.platform.phalco.de/log" }); + Swetrix.init("dBl2xaaJ9x3M", { + preloadSessionReplay: true, + apiURL: "https://analytics.platform.phalco.de/log", + }); Swetrix.trackViews(); Swetrix.trackErrors(); } @@ -89,6 +90,7 @@ createRoot(document.getElementById("root")!).render( + }> @@ -99,8 +101,15 @@ createRoot(document.getElementById("root")!).render( }> - } /> - } /> + } + /> + } + /> } /> } /> { + localStorage.clear(); +}); + +describe("isPrereleaseChannel", () => { + it("treats early-access and unstable as pre-release channels", () => { + expect(isPrereleaseChannel("early-access")).toBe(true); + expect(isPrereleaseChannel("unstable")).toBe(true); + }); + + it("treats stable as a regular channel", () => { + expect(isPrereleaseChannel("stable")).toBe(false); + }); +}); + +describe("prereleaseChannelOfBuild", () => { + it("returns null for builds without a pre-release channel", () => { + // Tests run with __BUILD_CHANNEL__ = "dev", which is not a pre-release. + expect(prereleaseChannelOfBuild()).toBeNull(); + }); +}); + +describe("shouldShowPrereleaseNotice", () => { + it("shows the notice until that channel has been acknowledged", () => { + expect(shouldShowPrereleaseNotice("unstable")).toBe(true); + + markPrereleaseNoticeSeen("unstable"); + expect(shouldShowPrereleaseNotice("unstable")).toBe(false); + // The other pre-release channel has its own, unrelated warning. + expect(shouldShowPrereleaseNotice("early-access")).toBe(true); + expect(localStorage.getItem(PRERELEASE_NOTICE_KEY)).toBe("unstable"); + }); +}); + +describe("prereleaseNoticeContent", () => { + it("warns unstable users about development builds", () => { + const { title, description } = prereleaseNoticeContent( + "unstable", + "17.1.0-unstable.42.1", + ); + expect(title).toContain("unstable"); + expect(description).toContain("17.1.0-unstable.42.1"); + expect(description).toContain("developers and testers"); + expect(description).toContain("additional setup steps may be necessary"); + expect(description).toContain("Stable update channel"); + }); + + it("warns Early Access users about the program", () => { + const { title, description } = prereleaseNoticeContent( + "early-access", + "17.1.0-ea.7.2", + ); + expect(title).toContain("Early Access"); + expect(description).toContain("17.1.0-ea.7.2"); + expect(description).toContain("early access program"); + expect(description).toContain("additional setup steps may be necessary"); + expect(description).toContain("Stable update channel"); + }); +}); diff --git a/src/utils/prereleaseNotice.ts b/src/utils/prereleaseNotice.ts new file mode 100644 index 0000000..49398c1 --- /dev/null +++ b/src/utils/prereleaseNotice.ts @@ -0,0 +1,85 @@ +import { defaultChannelForBuild, type UpdateChannel } from "./updater"; + +/** Build channels that are explicitly not meant for everyday use. */ +export type PrereleaseChannel = "early-access" | "unstable"; + +/** + * Remembers which pre-release build the user has already been warned about, so + * the warning is only shown once per channel. + */ +export const PRERELEASE_NOTICE_KEY = "gv_prerelease_notice_channel"; + +export function isPrereleaseChannel( + channel: UpdateChannel, +): channel is PrereleaseChannel { + return channel === "early-access" || channel === "unstable"; +} + +/** + * The pre-release channel this build was created for, or `null` for stable + * builds (and for local dev/web builds, which have no channel at all). + */ +export function prereleaseChannelOfBuild(): PrereleaseChannel | null { + const channel = defaultChannelForBuild(); + return isPrereleaseChannel(channel) ? channel : null; +} + +/** Whether the first-launch warning for `channel` still has to be shown. */ +export function shouldShowPrereleaseNotice( + channel: PrereleaseChannel, +): boolean { + try { + return localStorage.getItem(PRERELEASE_NOTICE_KEY) !== channel; + } catch { + // Storage unavailable (private mode / blocked): showing the warning once + // extra is harmless, so fail open. + return true; + } +} + +/** Acknowledges the warning for `channel` so it isn't shown again. */ +export function markPrereleaseNoticeSeen(channel: PrereleaseChannel): void { + try { + localStorage.setItem(PRERELEASE_NOTICE_KEY, channel); + } catch { + // localStorage unavailable + } +} + +export interface PrereleaseNoticeContent { + title: string; + description: string; +} + +const SHARED_ADVICE = + "Keep a backup of anything important, report issues on GitHub, and switch back to the Stable update channel in Settings → About → Updates if you need a reliable build."; + +/** + * Warning copy for the first launch of a pre-release build. The wording makes + * it clear that these builds are for testers/developers, that extra setup may + * be required and that problems are expected. + */ +export function prereleaseNoticeContent( + channel: PrereleaseChannel, + version: string, +): PrereleaseNoticeContent { + if (channel === "unstable") { + return { + title: "You are running an unstable build", + description: + `You are using GameVault v${version}, an unstable development build that tracks the development branch. ` + + "It is meant for developers and testers who are comfortable troubleshooting, not for everyday use: " + + "additional setup steps may be necessary, features can change or disappear at any time, and crashes, " + + `broken downloads or lost settings/progress are expected rather than rare. ${SHARED_ADVICE}`, + }; + } + + return { + title: "You are running an Early Access build", + description: + `You are using GameVault v${version}, an Early Access build that is still being worked on. ` + + "It is meant for members of the early access program and other testers who are comfortable troubleshooting, " + + "not for everyday use: additional setup steps may be necessary, parts of the app can behave unexpectedly, " + + `and you will run into issues more often than on a stable release. ${SHARED_ADVICE}`, + }; +} From febe7a542505b0c03a3d6352166f009fab071be3 Mon Sep 17 00:00:00 2001 From: Alper Alkan Date: Wed, 16 Sep 2026 23:07:26 +0200 Subject: [PATCH 3/9] fix(reliability): keep background work running across refreshes and off the UI thread - Move library scans, disk-usage, launch-executable listing, installation and uninstall commands onto blocking threads so the app no longer freezes while scanning large game folders, uninstalling games or launching Windows games through umu-launcher. - Report final extraction state as an event and guard against duplicate extractions, so a refresh mid-extraction can re-attach instead of restarting. - Track live download tasks per game and refuse to start a second task over a still-running download. - Track extraction, installation, umu setup and app update state in-process and expose it through a new `get_background_states` command so the frontend can re-attach after a webview reload. - Let the frontend re-attach to running extractions, installations, umu setup and app updates on startup, and make completion handling idempotent so downloads, extractions, installations and updates are never duplicated. --- CHANGELOG.md | 2 + src-tauri/src/downloads.rs | 92 +++- src-tauri/src/events.rs | 49 ++ src-tauri/src/extraction.rs | 85 +++- src-tauri/src/fs_commands.rs | 55 ++- src-tauri/src/games.rs | 77 +++- src-tauri/src/installation.rs | 42 +- src-tauri/src/lib.rs | 49 ++ src-tauri/src/state.rs | 200 ++++++++ src-tauri/src/time_tracker.rs | 33 +- src-tauri/src/umu.rs | 34 +- src/context/AppUpdaterContext.tsx | 74 +++ src/context/DownloadContext.tsx | 743 ++++++++++++++++++++++-------- src/context/UmuContext.tsx | 89 +++- 14 files changed, 1370 insertions(+), 254 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b1a77b..a8a74c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,8 @@ - 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. - 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. ## 17.0.0 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..792a01a 100644 --- a/src-tauri/src/extraction.rs +++ b/src-tauri/src/extraction.rs @@ -784,6 +784,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 +838,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..469be4b 100644 --- a/src-tauri/src/games.rs +++ b/src-tauri/src/games.rs @@ -24,7 +24,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 +183,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 { @@ -492,7 +516,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,8 +593,41 @@ pub(crate) fn make_script_executable( } #[tauri::command] +pub(crate) async fn launch_game( + app: tauri::AppHandle, + game_title: String, + 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, + 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, installation_path: String, diff --git a/src-tauri/src/installation.rs b/src-tauri/src/installation.rs index 20fb5b1..e340790 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) => { @@ -604,6 +620,13 @@ pub(crate) fn launch_installation_executable( let installation_path_resolved = installation_path.clone(); let installer_relative = installer_relative_path.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()) @@ -850,8 +873,23 @@ fn run_uninstall_via_umu( } #[tauri::command] +pub(crate) async fn launch_uninstall_executable( + app: tauri::AppHandle, + executable_path: String, + working_directory: Option, + argument_list: 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, executable_path, working_directory, argument_list) + }) + .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, executable_path: String, working_directory: Option, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 94b3cbd..fb774f8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -286,6 +286,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); }; @@ -503,6 +551,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, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index e302866..d62b2d7 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -30,3 +30,203 @@ pub(crate) fn tracker_config() -> &'static Mutex> { pub(crate) fn tracker_stop_tx() -> &'static Mutex>> { TRACKER_STOP_TX.get_or_init(|| Mutex::new(None)) } + +/// 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..4df30dc 100644 --- a/src-tauri/src/time_tracker.rs +++ b/src-tauri/src/time_tracker.rs @@ -1,4 +1,4 @@ -use crate::games::{list_installed_games, collect_launch_candidates}; +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::util::{is_ignored_executable, paths_match}; @@ -106,7 +106,7 @@ async fn game_time_tracker_loop(mut stop_rx: watch::Receiver, app: tauri:: // Collect installed games from all root paths let mut installed = Vec::new(); for path in &config.download_paths { - if let Ok(games) = list_installed_games(path.clone()) { + if let Ok(games) = list_installed_games_blocking(path.clone()) { installed.extend(games); } } @@ -250,7 +250,7 @@ fn read_configured_launch_executable(version_dir: &Path) -> Option { 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()) { + if let Ok(games) = list_installed_games_blocking(path.to_string()) { installed.extend(games); } } @@ -292,7 +292,18 @@ pub(crate) struct OfflineTimeFile { } #[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) }; @@ -408,7 +419,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 +449,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); } } diff --git a/src-tauri/src/umu.rs b/src-tauri/src/umu.rs index bf48682..7f9dfec 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) 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..b0fc347 100644 --- a/src/context/DownloadContext.tsx +++ b/src/context/DownloadContext.tsx @@ -78,6 +78,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 +167,6 @@ const DEFAULT_GAME_VAULT_CONFIG: GameVaultConfig = { downloadprogress: "", }; - type StartDownloadParams = { gameId: number; versionId: number; @@ -188,6 +231,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 +246,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). @@ -696,6 +743,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 +1541,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 +1739,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 +1753,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 +1767,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 +1899,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 +2052,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 +2091,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 +2119,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 +2163,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", @@ -2020,10 +2210,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 +2507,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 +2524,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)); 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} From ac275f1f95cd0d07ae94585c6d92f90f96d29535 Mon Sep 17 00:00:00 2001 From: Alper Alkan Date: Thu, 17 Sep 2026 13:50:55 +0200 Subject: [PATCH 4/9] fix(updater): show pre-release warning only once per installation --- CHANGELOG.md | 2 +- src-tauri/src/lib.rs | 2 + src-tauri/src/settings.rs | 27 +++++++++ src/components/PrereleaseNotice.tsx | 23 ++++--- src/utils/prereleaseNotice.test.ts | 94 ++++++++++++++++++++++++++--- src/utils/prereleaseNotice.ts | 77 ++++++++++++++++++++--- 6 files changed, 199 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8a74c4..61385c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,10 +65,10 @@ --- -- 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. - 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. ## 17.0.0 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fb774f8..cb43628 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -598,6 +598,8 @@ 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, rendering::get_rendering_diagnostics, rendering::get_webkit_settings, rendering::set_webkit_smooth_scrolling, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index b2f4fc8..2b144bc 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -25,6 +25,14 @@ pub(crate) struct AppSettings { /// `None` = leave WebKit default. #[serde(default)] pub webkit_hw_accel_policy: Option, + /// 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)] @@ -116,6 +124,25 @@ 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 diff --git a/src/components/PrereleaseNotice.tsx b/src/components/PrereleaseNotice.tsx index e2a508f..66994af 100644 --- a/src/components/PrereleaseNotice.tsx +++ b/src/components/PrereleaseNotice.tsx @@ -21,18 +21,25 @@ export function PrereleaseNotice() { if (shownRef.current) return; const channel = prereleaseChannelOfBuild(); - if (!channel || !shouldShowPrereleaseNotice(channel)) return; + if (!channel) return; shownRef.current = true; - void showAlert({ - tone: "warning", - affirmativeText: "I understand", - ...prereleaseNoticeContent(channel, __APP_VERSION__), - }).then(() => { + + 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. - markPrereleaseNoticeSeen(channel); - }); + await markPrereleaseNoticeSeen(channel); + })(); }, [showAlert]); return null; diff --git a/src/utils/prereleaseNotice.test.ts b/src/utils/prereleaseNotice.test.ts index d6551e6..c6783ac 100644 --- a/src/utils/prereleaseNotice.test.ts +++ b/src/utils/prereleaseNotice.test.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { PRERELEASE_NOTICE_KEY, isPrereleaseChannel, @@ -9,8 +9,37 @@ import { shouldShowPrereleaseNotice, } from "./prereleaseNotice"; +const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); + +/** Pretend the page runs inside the desktop app's webview. */ +function simulateDesktopApp(): void { + (window as any).__TAURI_INTERNALS__ = {}; +} + +/** + * Minimal stand-in for the native settings file: it remembers the acknowledged + * channel across an update that wipes the webview's localStorage. + */ +function mockNativeSettings(initial: string | null) { + let stored = initial; + invokeMock.mockImplementation( + (command: string, args?: { channel?: string }) => { + if (command === "set_prerelease_notice_channel") { + stored = args?.channel ?? null; + return Promise.resolve(undefined); + } + return Promise.resolve(stored); + }, + ); + return { read: () => stored }; +} + beforeEach(() => { localStorage.clear(); + invokeMock.mockReset(); + delete (window as any).__TAURI_INTERNALS__; }); describe("isPrereleaseChannel", () => { @@ -31,18 +60,69 @@ describe("prereleaseChannelOfBuild", () => { }); }); -describe("shouldShowPrereleaseNotice", () => { - it("shows the notice until that channel has been acknowledged", () => { - expect(shouldShowPrereleaseNotice("unstable")).toBe(true); +describe("shouldShowPrereleaseNotice (web build)", () => { + it("shows the notice until that channel has been acknowledged", async () => { + expect(await shouldShowPrereleaseNotice("unstable")).toBe(true); - markPrereleaseNoticeSeen("unstable"); - expect(shouldShowPrereleaseNotice("unstable")).toBe(false); + await markPrereleaseNoticeSeen("unstable"); + expect(await shouldShowPrereleaseNotice("unstable")).toBe(false); // The other pre-release channel has its own, unrelated warning. - expect(shouldShowPrereleaseNotice("early-access")).toBe(true); + expect(await shouldShowPrereleaseNotice("early-access")).toBe(true); expect(localStorage.getItem(PRERELEASE_NOTICE_KEY)).toBe("unstable"); }); }); +describe("shouldShowPrereleaseNotice (desktop app)", () => { + it("stays acknowledged when an update wipes the webview storage", async () => { + simulateDesktopApp(); + const native = mockNativeSettings("unstable"); + + // The app settings file survived the update, the webview data did not. + expect(localStorage.getItem(PRERELEASE_NOTICE_KEY)).toBeNull(); + expect(await shouldShowPrereleaseNotice("unstable")).toBe(false); + expect(native.read()).toBe("unstable"); + }); + + it("keeps warning about a channel that was never acknowledged", async () => { + simulateDesktopApp(); + mockNativeSettings("unstable"); + + expect(await shouldShowPrereleaseNotice("early-access")).toBe(true); + }); + + it("writes the acknowledgement into the app settings", async () => { + simulateDesktopApp(); + const native = mockNativeSettings(null); + + expect(await shouldShowPrereleaseNotice("early-access")).toBe(true); + + await markPrereleaseNoticeSeen("early-access"); + expect(native.read()).toBe("early-access"); + expect(invokeMock).toHaveBeenCalledWith("set_prerelease_notice_channel", { + channel: "early-access", + }); + expect(await shouldShowPrereleaseNotice("early-access")).toBe(false); + }); + + it("adopts an acknowledgement recorded by an older build", async () => { + simulateDesktopApp(); + localStorage.setItem(PRERELEASE_NOTICE_KEY, "unstable"); + const native = mockNativeSettings(null); + + expect(await shouldShowPrereleaseNotice("unstable")).toBe(false); + expect(native.read()).toBe("unstable"); + }); + + it("falls back to localStorage when the native settings fail", async () => { + simulateDesktopApp(); + localStorage.setItem(PRERELEASE_NOTICE_KEY, "unstable"); + invokeMock.mockRejectedValue(new Error("no native settings")); + + expect(await shouldShowPrereleaseNotice("unstable")).toBe(false); + expect(await shouldShowPrereleaseNotice("early-access")).toBe(true); + }); +}); + describe("prereleaseNoticeContent", () => { it("warns unstable users about development builds", () => { const { title, description } = prereleaseNoticeContent( diff --git a/src/utils/prereleaseNotice.ts b/src/utils/prereleaseNotice.ts index 49398c1..a3c146d 100644 --- a/src/utils/prereleaseNotice.ts +++ b/src/utils/prereleaseNotice.ts @@ -1,3 +1,4 @@ +import { isTauriApp } from "./tauri"; import { defaultChannelForBuild, type UpdateChannel } from "./updater"; /** Build channels that are explicitly not meant for everyday use. */ @@ -6,6 +7,12 @@ export type PrereleaseChannel = "early-access" | "unstable"; /** * Remembers which pre-release build the user has already been warned about, so * the warning is only shown once per channel. + * + * Desktop builds keep the acknowledgement in the app's settings file + * (`gamevault-settings.json`), which survives app updates. The webview's + * localStorage does not survive them, so older desktop builds showed the + * warning again after every update. The localStorage key is still used by the + * web build, as a fallback, and to migrate existing acknowledgements. */ export const PRERELEASE_NOTICE_KEY = "gv_prerelease_notice_channel"; @@ -24,21 +31,16 @@ export function prereleaseChannelOfBuild(): PrereleaseChannel | null { return isPrereleaseChannel(channel) ? channel : null; } -/** Whether the first-launch warning for `channel` still has to be shown. */ -export function shouldShowPrereleaseNotice( - channel: PrereleaseChannel, -): boolean { +function readLocalAcknowledgement(): string | null { try { - return localStorage.getItem(PRERELEASE_NOTICE_KEY) !== channel; + return localStorage.getItem(PRERELEASE_NOTICE_KEY); } catch { - // Storage unavailable (private mode / blocked): showing the warning once - // extra is harmless, so fail open. - return true; + // Storage unavailable (private mode / blocked) + return null; } } -/** Acknowledges the warning for `channel` so it isn't shown again. */ -export function markPrereleaseNoticeSeen(channel: PrereleaseChannel): void { +function writeLocalAcknowledgement(channel: string): void { try { localStorage.setItem(PRERELEASE_NOTICE_KEY, channel); } catch { @@ -46,6 +48,61 @@ export function markPrereleaseNoticeSeen(channel: PrereleaseChannel): void { } } +/** + * The pre-release channel whose warning was already acknowledged on this + * installation, or `null` if it still has to be shown. + */ +export async function readAcknowledgedPrereleaseChannel(): Promise< + string | null +> { + const local = readLocalAcknowledgement(); + if (!isTauriApp()) return local; + + try { + const { invoke } = await import("@tauri-apps/api/core"); + const stored = await invoke("get_prerelease_notice_channel"); + if (stored) return stored; + } catch { + // Native settings unavailable: fall back to the webview copy. + return local; + } + + // Desktop builds from before this setting existed recorded the + // acknowledgement in the webview's localStorage. Adopt it so the update that + // introduces the setting doesn't show the warning one last time. + if (local) await writeAcknowledgedPrereleaseChannel(local); + return local; +} + +async function writeAcknowledgedPrereleaseChannel( + channel: string, +): Promise { + // Keep the webview copy in sync as a fallback for older builds. + writeLocalAcknowledgement(channel); + if (!isTauriApp()) return; + + try { + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("set_prerelease_notice_channel", { channel }); + } catch { + // localStorage still holds the acknowledgement. + } +} + +/** Whether the first-launch warning for `channel` still has to be shown. */ +export async function shouldShowPrereleaseNotice( + channel: PrereleaseChannel, +): Promise { + return (await readAcknowledgedPrereleaseChannel()) !== channel; +} + +/** Acknowledges the warning for `channel` so it isn't shown again. */ +export async function markPrereleaseNoticeSeen( + channel: PrereleaseChannel, +): Promise { + await writeAcknowledgedPrereleaseChannel(channel); +} + export interface PrereleaseNoticeContent { title: string; description: string; From 49fdb4c1f5c699ed58c2928c1e1f3cfa7afc0e08 Mon Sep 17 00:00:00 2001 From: Alper Alkan Date: Thu, 17 Sep 2026 19:50:31 +0200 Subject: [PATCH 5/9] feat(linux): inherit umu presets, unify Wine prefixes and add launch logs umu-launcher defaults (GAMEID, STORE, PROTONPATH) can now be defined once per game as custom metadata on the server; newly downloaded games inherit them automatically and existing ones can adopt them with one click. Fixed the Wine/Proton prefix handling: installer, launch and uninstall now resolve the same per-game prefix (named after the game's install folder), a per-game override applies to all three flows, existing prefixes are reused instead of moved, and games no longer fall back to umu's shared default prefix without a GAMEID. Uninstalling offers to delete the prefix (opt-in, showing path and size), the prefix field shows the real path with copy/open actions, and uninstall no longer drops the umu and run-as-admin settings. Added launch logs: one rotating log file per launch plus a log window streaming the full Proton/Wine/umu output, opened automatically when a launch fails and optionally on every start. Installer runs are logged too. The Proton (PROTONPATH) field now lists the builds installed in compatibilitytools.d, and CI runs the Rust test suite. --- .github/workflows/test.yml | 29 + CHANGELOG.md | 9 + src-tauri/capabilities/launch-log.json | 13 + src-tauri/src/games.rs | 136 ++++- src-tauri/src/installation.rs | 139 ++++- src-tauri/src/launch_log.rs | 525 ++++++++++++++++++ src-tauri/src/lib.rs | 12 + src-tauri/src/settings.rs | 22 +- src-tauri/src/umu.rs | 512 ++++++++++++++++- src/components/GameCard.tsx | 2 + src/components/admin/GameSettings.tsx | 476 ++++++++++++++-- .../downloads/launch-defaults.test.ts | 80 +++ src/components/downloads/launch-defaults.ts | 80 +++ src/context/AlertDialogContext.tsx | 26 +- src/context/DownloadContext.tsx | 95 +++- src/main.tsx | 205 +++---- src/pages/GameView.tsx | 2 + src/pages/LaunchLogWindow.tsx | 259 +++++++++ src/pages/Settings.tsx | 82 ++- src/utils/launchLog.test.ts | 112 ++++ src/utils/launchLog.ts | 134 +++++ 21 files changed, 2732 insertions(+), 218 deletions(-) create mode 100644 src-tauri/capabilities/launch-log.json create mode 100644 src-tauri/src/launch_log.rs create mode 100644 src/components/downloads/launch-defaults.test.ts create mode 100644 src/components/downloads/launch-defaults.ts create mode 100644 src/pages/LaunchLogWindow.tsx create mode 100644 src/utils/launchLog.test.ts create mode 100644 src/utils/launchLog.ts 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 61385c9..c0f441f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,10 +65,19 @@ --- +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/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/games.rs b/src-tauri/src/games.rs index 469be4b..903250e 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}; @@ -596,6 +597,8 @@ pub(crate) fn make_script_executable( 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, @@ -612,6 +615,8 @@ pub(crate) async fn launch_game( launch_game_blocking( app, game_title, + game_id, + version_directory, installation_path, executable_relative_path, launch_parameters, @@ -630,6 +635,8 @@ pub(crate) async 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, @@ -646,7 +653,16 @@ fn launch_game_blocking( } 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 @@ -658,6 +674,8 @@ fn launch_game_blocking( return crate::umu::launch_with_umu( app, game_title, + version_directory, + game_id, &exe_path, launch_parameters.as_deref(), umu_game_id.as_deref(), @@ -665,6 +683,7 @@ fn launch_game_blocking( umu_proton_path.as_deref(), umu_wine_prefix.as_deref(), restore_on_exit, + Some(log), ); } @@ -717,6 +736,7 @@ fn launch_game_blocking( err_path, ADMIN_LAUNCH_GRACE, restore_on_exit, + log, ); return Ok(()); } @@ -747,6 +767,7 @@ fn launch_game_blocking( err_path, ADMIN_LAUNCH_GRACE, restore_on_exit, + log, ); Ok(()) } @@ -807,6 +828,7 @@ fn launch_game_blocking( err_path, LAUNCH_GRACE, restore_on_exit, + log, ); Ok(()) } @@ -997,6 +1019,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 @@ -1007,25 +1030,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; @@ -1035,10 +1058,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. @@ -1050,11 +1076,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 e340790..2539cc8 100644 --- a/src-tauri/src/installation.rs +++ b/src-tauri/src/installation.rs @@ -421,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, @@ -441,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, @@ -465,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 { @@ -524,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, @@ -562,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, @@ -573,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, @@ -582,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), ); } } @@ -600,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 @@ -620,6 +677,7 @@ 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 @@ -654,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; } @@ -829,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); @@ -855,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}"))?; @@ -875,14 +965,29 @@ 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, executable_path, working_directory, argument_list) + 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}"))? @@ -891,9 +996,14 @@ pub(crate) async fn launch_uninstall_executable( #[cfg_attr(not(target_os = "linux"), allow(unused_variables))] 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() { @@ -911,10 +1021,15 @@ fn launch_uninstall_executable_blocking( 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 cb43628..ff64d8d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod util; mod downloads; mod extraction; mod installation; +mod launch_log; mod games; mod fs_commands; mod time_tracker; @@ -600,6 +601,8 @@ pub fn run() { 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, @@ -610,6 +613,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 2b144bc..56820cc 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -25,6 +25,10 @@ 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. /// @@ -128,7 +132,6 @@ pub(crate) fn set_ignore_list(app: tauri::AppHandle, ignored: Vec) -> Re 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] @@ -147,7 +150,6 @@ pub(crate) fn set_prerelease_notice_channel( 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, @@ -160,3 +162,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/umu.rs b/src-tauri/src/umu.rs index 7f9dfec..68d93dd 100644 --- a/src-tauri/src/umu.rs +++ b/src-tauri/src/umu.rs @@ -473,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); @@ -495,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, } @@ -514,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())); @@ -525,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 { @@ -534,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 } @@ -591,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. @@ -620,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>, @@ -627,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, @@ -654,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); } @@ -671,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) => { @@ -692,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 { @@ -700,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; @@ -755,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); } }); @@ -797,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" @@ -809,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/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/DownloadContext.tsx b/src/context/DownloadContext.tsx index b0fc347..5c48373 100644 --- a/src/context/DownloadContext.tsx +++ b/src/context/DownloadContext.tsx @@ -23,10 +23,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 { @@ -398,37 +396,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), }); }, []); @@ -2190,12 +2204,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]; diff --git a/src/main.tsx b/src/main.tsx index f1a5dc9..2f24f8a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -42,106 +42,121 @@ 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(); + } -createRoot(document.getElementById("root")!).render( - - - - - - - - - - - - - - }> - - }> - } /> - } /> - + // 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( + + + + + + + + + + + + + + }> + + }> + } /> + } /> + - } /> - - - - - - - - - - - - - , -); + }> + } + /> + } + /> + } + /> + } + /> + } + /> + } /> + + + + } + /> + + + } /> + + + + + + + + + + + + + , + ); +} 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 && ( + + + + + + + + + + + + + + + )} diff --git a/src/utils/trackerLog.test.ts b/src/utils/trackerLog.test.ts new file mode 100644 index 0000000..52de183 --- /dev/null +++ b/src/utils/trackerLog.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { + formatLedgerGame, + formatPlaytimeLedger, + formatTrackerDump, + formatTrackerLine, + formatTrackerStats, + ledgerShortfall, + logText, + type GamePlayLedger, + type TrackerLogLine, + type TrackerRuntimeStats, + type TrackerStatus, +} from "./trackerLog"; + +function game(overrides: Partial = {}): GamePlayLedger { + return { + gameId: 1, + gameTitle: "Some Game", + matched: true, + firstMatchedAt: null, + lastMatchedAt: null, + matchedTicks: 0, + creditedTicks: 0, + offlineTicks: 0, + lostTicks: 0, + droppedTicks: 0, + matchFlaps: 0, + creditedMinutes: 0, + offlineMinutes: 0, + observedSeconds: 0, + lastServerMinutes: null, + lastSkipReason: null, + ...overrides, + }; +} + +function stats(overrides: Partial = {}): TrackerRuntimeStats { + return { + running: true, + startedAt: null, + tickCount: 0, + tickIntervalSecs: 60, + lostTicks: 0, + catchUpTicks: 0, + lastTickAt: null, + lastTickGapSecs: null, + lastTickDurationMs: null, + lastTickSummary: null, + lastError: null, + consecutiveFailures: 0, + authRejectedCount: 0, + stopReason: null, + logPath: null, + ...overrides, + }; +} + +function line(seq: number, text = `line ${seq}`): TrackerLogLine { + return { seq, at: 1_600_000_000_000 + seq * 1000, level: "tick", category: "tick", text }; +} + +describe("ledgerShortfall", () => { + it("reports the minutes the tracker saw but never credited", () => { + // One hour observed, 15 minutes credited: the reported 1h -> 15min case. + const played = game({ + observedSeconds: 60 * 60, + creditedMinutes: 15, + }); + + expect(ledgerShortfall(played)).toBe(45); + }); + + it("counts offline minutes as credited and never goes negative", () => { + const stored = game({ observedSeconds: 60 * 20, creditedMinutes: 15, offlineMinutes: 5 }); + expect(ledgerShortfall(stored)).toBe(0); + }); +}); + +describe("formatLedgerGame", () => { + it("includes observed, credited and lost minutes with the last reason", () => { + const text = formatLedgerGame( + game({ + observedSeconds: 60 * 60, + creditedMinutes: 15, + lostTicks: 45, + droppedTicks: 40, + matchFlaps: 3, + lastSkipReason: "no_matching_process", + }), + ); + + expect(text).toContain("observed=60min"); + expect(text).toContain("credited=15min"); + expect(text).toContain("lost=45min"); + expect(text).toContain("dropped=40min"); + expect(text).toContain("flaps=3"); + expect(text).toContain("SHORTFALL=45min"); + expect(text).toContain('last_reason="no_matching_process"'); + }); + + it("omits the shortfall when everything was accounted for", () => { + expect(formatLedgerGame(game({ observedSeconds: 60, creditedMinutes: 1 }))).not.toContain( + "SHORTFALL", + ); + }); +}); + +describe("formatPlaytimeLedger", () => { + it("explains an empty ledger", () => { + expect(formatPlaytimeLedger([])).toContain("no games tracked yet"); + }); + + it("lists the biggest shortfall first", () => { + const text = formatPlaytimeLedger([ + game({ gameId: 1, observedSeconds: 60 * 5, creditedMinutes: 5 }), + game({ gameId: 2, observedSeconds: 60 * 60, creditedMinutes: 15 }), + ]); + + expect(text.split("\n")).toHaveLength(2); + expect(text.indexOf("#2")).toBeLessThan(text.indexOf("#1")); + }); +}); + +describe("formatTrackerStats", () => { + it("summarises the loop counters", () => { + const text = formatTrackerStats( + stats({ tickCount: 42, lostTicks: 3, authRejectedCount: 2, stopReason: "hook unmounted" }), + ); + + expect(text).toContain("running=yes"); + expect(text).toContain("interval=60s"); + expect(text).toContain("ticks=42"); + expect(text).toContain("lost_ticks=3"); + expect(text).toContain("auth_rejected=2"); + expect(text).toContain("stop_reason=hook unmounted"); + }); +}); + +describe("formatTrackerLine", () => { + it("prefixes the UTC time and level", () => { + // 2020-09-13 12:26:40 UTC + expect(formatTrackerLine(line(0, "hello"))).toBe("12:26:40.000 [tick] hello"); + }); + + it("joins lines for copy/paste", () => { + expect(logText([line(0, "a"), line(1, "b")])).toBe( + "12:26:40.000 [tick] a\n12:26:41.000 [tick] b", + ); + }); +}); + +describe("formatTrackerDump", () => { + const status: TrackerStatus = { + trackerRunning: true, + stats: stats({ tickCount: 7, lastTickSummary: "tick 7 | matched=1" }), + games: [game({ observedSeconds: 60 * 60, creditedMinutes: 15, lostTicks: 45 })], + logPath: "C:/logs/tracker-logs/tracker.log", + }; + + it("combines status, ledger and log tail", () => { + const dump = formatTrackerDump({ + status, + lines: [line(0, "match_started")], + generatedAt: "2026-09-26T00:00:00.000Z", + }); + + expect(dump).toContain("generated: 2026-09-26T00:00:00.000Z"); + expect(dump).toContain("log file: C:/logs/tracker-logs/tracker.log"); + expect(dump).toContain("ticks=7"); + expect(dump).toContain("SHORTFALL=45min"); + expect(dump).toContain("match_started"); + }); + + it("includes the scan snapshot when provided", () => { + const dump = formatTrackerDump({ + status, + lines: [], + scan: { + trackerRunning: true, + roots: ["D:/Games"], + totalProcesses: 120, + allProcesses: [], + games: [], + processMatches: [{ gameId: 4, matched: false, matchingProcesses: [] }], + }, + }); + + expect(dump).toContain("roots=D:/Games"); + expect(dump).toContain("#4 matched=false"); + }); + + it("degrades gracefully without a tracker status", () => { + const dump = formatTrackerDump({ status: null, lines: [] }); + + expect(dump).toContain("tracker status: unavailable"); + expect(dump).toContain("(empty)"); + }); +}); diff --git a/src/utils/trackerLog.ts b/src/utils/trackerLog.ts new file mode 100644 index 0000000..515efb2 --- /dev/null +++ b/src/utils/trackerLog.ts @@ -0,0 +1,275 @@ +/** + * Tracker log & playtime diagnostics helpers. + * + * The Rust time tracker credits exactly one minute per matched tick and records + * every tick, match transition and credit outcome in a rotating log file + * (`tracker-logs/tracker.log`). These helpers read that log, the per-game + * playtime ledger and the one-shot scan report, and format them for a bug + * report. + */ + +export interface TrackerLogLine { + seq: number; + /** Milliseconds since the unix epoch. */ + at: number; + /** "info", "warn", "error" or "tick". */ + level: string; + /** e.g. "lifecycle", "tick", "game", "credit", "offline", "app". */ + category: string; + text: string; +} + +export interface TrackerRuntimeStats { + running: boolean; + startedAt: number | null; + tickCount: number; + tickIntervalSecs: number; + /** Ticks the interval expected but that never ran (sleep, freeze, starvation). */ + lostTicks: number; + catchUpTicks: number; + lastTickAt: number | null; + lastTickGapSecs: number | null; + lastTickDurationMs: number | null; + lastTickSummary: string | null; + lastError: string | null; + consecutiveFailures: number; + /** Increments the server rejected with 401/403 (expired token). */ + authRejectedCount: number; + stopReason: string | null; + logPath: string | null; +} + +export interface GamePlayLedger { + gameId: number; + gameTitle: string; + matched: boolean; + firstMatchedAt: number | null; + lastMatchedAt: number | null; + matchedTicks: number; + creditedTicks: number; + offlineTicks: number; + lostTicks: number; + /** Lost ticks attributed to a tick gap (sleep/resume, freeze, starvation). */ + droppedTicks: number; + matchFlaps: number; + creditedMinutes: number; + offlineMinutes: number; + observedSeconds: number; + lastServerMinutes: number | null; + lastSkipReason: string | null; +} + +export interface TrackerStatus { + trackerRunning: boolean; + stats: TrackerRuntimeStats; + games: GamePlayLedger[]; + logPath: string | null; +} + +export interface TrackerDebugProcess { + pid: number; + exe: string | null; + cmd: string; +} + +export interface TrackerDebugGame { + gameId: number; + gameTitle: string; + installationDirectory: string; + versionDirectory: string; + exeCandidates: string[]; +} + +export interface TrackerDebugMatch { + gameId: number; + matched: boolean; + matchingProcesses: string[]; +} + +export interface TrackerDebugReport { + trackerRunning: boolean; + roots: string[]; + totalProcesses: number; + allProcesses: TrackerDebugProcess[]; + games: TrackerDebugGame[]; + processMatches: TrackerDebugMatch[]; +} + +/** Lines kept when formatting a log tail for a bug report. */ +export const MAX_DUMP_LINES = 400; + +async function invokeCommand( + command: string, + args?: Record, +): Promise { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke(command, args); +} + +/** `10:15:23.123 [warn] text` — enough to line ticks up with a session. */ +export function formatTrackerLine(line: TrackerLogLine): string { + const time = new Date(line.at).toISOString().slice(11, 23); + return `${time} [${line.level}] ${line.text}`; +} + +export function logText(lines: TrackerLogLine[]): string { + return lines.map(formatTrackerLine).join("\n"); +} + +/** Minutes the tracker saw (wall clock) but never credited for this game. */ +export function ledgerShortfall(game: GamePlayLedger): number { + const observed = Math.floor(game.observedSeconds / 60); + return Math.max(0, observed - game.creditedMinutes - game.offlineMinutes); +} + +export function formatLedgerGame(game: GamePlayLedger): string { + const observed = Math.floor(game.observedSeconds / 60); + const parts = [ + `#${game.gameId} ${game.gameTitle || "(unknown)"}`, + `observed=${observed}min`, + `credited=${game.creditedMinutes}min`, + `offline=${game.offlineMinutes}min`, + `lost=${game.lostTicks}min`, + `dropped=${game.droppedTicks}min`, + `flaps=${game.matchFlaps}`, + ]; + const shortfall = ledgerShortfall(game); + if (shortfall > 0) { + parts.push(`SHORTFALL=${shortfall}min`); + } + if (game.lastSkipReason) { + parts.push(`last_reason="${game.lastSkipReason}"`); + } + return parts.join(" | "); +} + +/** Human-readable playtime accounting table, one line per game. */ +export function formatPlaytimeLedger(games: GamePlayLedger[]): string { + if (!games.length) { + return "(no games tracked yet — nothing was matched since the tracker started)"; + } + const lines = games + .slice() + .sort((a, b) => ledgerShortfall(b) - ledgerShortfall(a)); + return lines.map(formatLedgerGame).join("\n"); +} + +/** Compact summary of the tracker loop counters. */ +export function formatTrackerStats(stats: TrackerRuntimeStats): string { + const lastTick = stats.lastTickAt + ? new Date(stats.lastTickAt).toISOString() + : "never"; + return [ + `running=${stats.running ? "yes" : "no"}`, + `interval=${stats.tickIntervalSecs}s`, + `ticks=${stats.tickCount}`, + `lost_ticks=${stats.lostTicks}`, + `catch_up_ticks=${stats.catchUpTicks}`, + `consecutive_failures=${stats.consecutiveFailures}`, + `auth_rejected=${stats.authRejectedCount}`, + `last_tick=${lastTick}`, + `last_tick_gap=${stats.lastTickGapSecs ?? "unknown"}s`, + `stop_reason=${stats.stopReason ?? "none"}`, + `last_error=${stats.lastError ?? "none"}`, + ].join(" | "); +} + +/** + * Full diagnostics block for the clipboard: loop counters, the per-game + * playtime accounting and the tail of the tracker log. + */ +export function formatTrackerDump(options: { + status: TrackerStatus | null; + lines: TrackerLogLine[]; + scan?: TrackerDebugReport | null; + /** Overrides "now" for deterministic output (tests). */ + generatedAt?: string; +}): string { + const { status, lines, scan } = options; + const sections: string[] = [ + "=== GameVault time tracker diagnostics ===", + `generated: ${options.generatedAt ?? new Date().toISOString()}`, + ]; + + if (!status) { + sections.push("tracker status: unavailable (tracker log not initialized)"); + } else { + sections.push(`log file: ${status.logPath ?? "unknown"}`); + sections.push(`stats: ${formatTrackerStats(status.stats)}`); + sections.push(`last tick summary: ${status.stats.lastTickSummary ?? "none"}`); + sections.push(""); + sections.push("--- playtime ledger (observed vs credited) ---"); + sections.push(formatPlaytimeLedger(status.games)); + } + + if (scan) { + sections.push(""); + sections.push("--- scan snapshot ---"); + sections.push( + `roots=${scan.roots.join(", ") || "none"} | processes=${scan.totalProcesses} | installed=${scan.games.length}`, + ); + for (const match of scan.processMatches) { + sections.push( + `#${match.gameId} matched=${match.matched} processes=${match.matchingProcesses.join(" ; ") || "-"}`, + ); + } + } + + sections.push(""); + sections.push(`--- tracker log tail (last ${Math.min(lines.length, MAX_DUMP_LINES)} lines) ---`); + sections.push(lines.length ? logText(lines.slice(-MAX_DUMP_LINES)) : "(empty)"); + + return sections.join("\n"); +} + +// ── Commands ─────────────────────────────────────────────────────────────── + +export function getTrackerLog(limit?: number): Promise { + return invokeCommand("get_tracker_log", { + limit: limit ?? null, + }); +} + +export function getTrackerStatus(): Promise { + return invokeCommand("get_tracker_status"); +} + +export function resetTrackerLedger(): Promise { + return invokeCommand("reset_tracker_ledger"); +} + +export function clearTrackerLog(): Promise { + return invokeCommand("clear_tracker_log"); +} + +export function openTrackerLogFolder(): Promise { + return invokeCommand("open_tracker_log_folder"); +} + +export function readTrackerLogFile( + path: string, + maxBytes?: number, +): Promise { + return invokeCommand("read_tracker_log_file", { + path, + maxBytes: maxBytes ?? null, + }); +} + +/** Records a frontend line in the same native tracker log. */ +export function trackerLogLine( + level: "info" | "warn" | "error", + category: string, + message: string, +): Promise { + return invokeCommand("tracker_log_line", { level, category, message }); +} + +/** Replays one tracker scan and reports what it found right now. */ +export function debugTrackerScan( + selectedRoot?: string, +): Promise { + return invokeCommand("debug_tracker_scan", { + selectedRoot: selectedRoot ?? null, + }); +} From 993db3f897a573b7e2c16a5cda21cb7f3378b29d Mon Sep 17 00:00:00 2001 From: Yelo420 Date: Wed, 30 Sep 2026 00:10:37 +0200 Subject: [PATCH 9/9] Fixed multiple playtime tracking issues to ensure uninterrupted minute logging, seamless offline syncing, and improved background performance. --- CHANGELOG.md | 5 + src-tauri/src/games.rs | 62 ++- src-tauri/src/lib.rs | 8 +- src-tauri/src/settings.rs | 61 ++- src-tauri/src/state.rs | 21 + src-tauri/src/time_tracker.rs | 694 ++++++++++++++++++++++++++++---- src-tauri/src/tracker_log.rs | 17 + src/hooks/useGameTimeTracker.ts | 226 ++++++++++- src/pages/Settings.tsx | 135 ------- src/utils/trackerLog.test.ts | 199 --------- src/utils/trackerLog.ts | 275 ------------- 11 files changed, 988 insertions(+), 715 deletions(-) delete mode 100644 src/utils/trackerLog.test.ts delete mode 100644 src/utils/trackerLog.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 20554f2..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. diff --git a/src-tauri/src/games.rs b/src-tauri/src/games.rs index 903250e..a129eca 100644 --- a/src-tauri/src/games.rs +++ b/src-tauri/src/games.rs @@ -396,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; } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a4493b9..f6772a3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -462,10 +462,10 @@ pub fn run() { )?; } - // ── Time tracker diagnostics log (always on, also in release) ────── - // Records every tracker tick, match transition and credit outcome so a - // playtime that ends up lower than what was actually played can be - // traced from the log. + // ── 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 ────────────────────────────── diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 56820cc..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, @@ -49,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> { @@ -67,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] diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 0ba46d8..1a51aaf 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -56,6 +56,15 @@ pub(crate) struct TrackerRuntimeStats { 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, } @@ -85,7 +94,11 @@ pub(crate) struct GamePlayLedger { 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. @@ -132,6 +145,14 @@ pub(crate) fn tracker_ledger_snapshot() -> Vec { .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 diff --git a/src-tauri/src/time_tracker.rs b/src-tauri/src/time_tracker.rs index 1685abc..f05dc0b 100644 --- a/src-tauri/src/time_tracker.rs +++ b/src-tauri/src/time_tracker.rs @@ -1,17 +1,21 @@ +use crate::events::InstalledGameInfo; use crate::games::{collect_launch_candidates, list_installed_games_blocking}; use crate::settings::load_settings; use crate::state::{ - reset_tracker_stats, tracker_config, tracker_ledger, tracker_ledger_snapshot, tracker_stats, - tracker_stop_tx, GamePlayLedger, TrackerConfig, TrackerRuntimeStats, + 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, Instant}; +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, @@ -19,6 +23,59 @@ use tokio::sync::watch; 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( @@ -132,6 +189,25 @@ pub(crate) fn update_tracker_auth(access_token: String) -> Result<(), String> { 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", @@ -167,6 +243,7 @@ async fn game_time_tracker_loop(mut stop_rx: watch::Receiver, app: tauri:: 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! { @@ -266,7 +343,7 @@ async fn game_time_tracker_loop(mut stop_rx: watch::Receiver, app: tauri:: } // ── Library scan ─────────────────────────────────────────────────────── - let scan_start = Instant::now(); + let library_start = Instant::now(); let mut installed = Vec::new(); for path in &config.download_paths { match list_installed_games_blocking(path.clone()) { @@ -294,7 +371,7 @@ async fn game_time_tracker_loop(mut stop_rx: watch::Receiver, app: tauri:: ), } } - let scan_ms = scan_start.elapsed().as_millis() as u64; + let library_ms = library_start.elapsed().as_millis() as u64; if installed.is_empty() { mark_all_unmatched("no_installed_games"); @@ -305,7 +382,7 @@ async fn game_time_tracker_loop(mut stop_rx: watch::Receiver, app: tauri:: ("event", "no_installed_games".to_string()), ("tick", tick_number.to_string()), ("roots", config.download_paths.len().to_string()), - ("scan_ms", scan_ms.to_string()), + ("library_ms", library_ms.to_string()), ], ); continue; @@ -313,9 +390,12 @@ async fn game_time_tracker_loop(mut stop_rx: watch::Receiver, app: tauri:: // ── 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()); @@ -342,39 +422,87 @@ async fn game_time_tracker_loop(mut stop_rx: watch::Receiver, app: tauri:: continue; } + // 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(); + 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(); - let mut scan_failed = false; - if collect_launch_candidates(&scan_dir, &scan_dir, &mut candidates).is_err() { - scan_failed = true; - } - let found = candidates.len(); let mut ignored_count = 0usize; - for rel in candidates { - let abs = scan_dir.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR)); + 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; } - abs_paths.push(abs); + let key = abs.to_string_lossy().to_ascii_lowercase(); + if seen.insert(key) { + abs_paths.push(abs); + } } 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 == found { - format!("all_candidates_ignored ({found} ignored by the ignore list)") + } 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 { @@ -410,6 +538,7 @@ async fn game_time_tracker_loop(mut stop_rx: watch::Receiver, app: tauri:: ("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), ], ); @@ -421,6 +550,8 @@ async fn game_time_tracker_loop(mut stop_rx: watch::Receiver, app: tauri:: .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); } @@ -439,6 +570,7 @@ async fn game_time_tracker_loop(mut stop_rx: watch::Receiver, app: tauri:: } // ── 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 @@ -450,6 +582,7 @@ 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(); @@ -459,20 +592,31 @@ async fn game_time_tracker_loop(mut stop_rx: watch::Receiver, app: tauri:: .filter(|process| exe_paths.iter().any(|game_exe| process_matches_game(process, game_exe))) .map(|process| describe_process(process)) .collect(); - if !matching.is_empty() { - matched_game_ids.push(*game_id); - } - let title = game_titles.get(game_id).cloned().unwrap_or_default(); - update_ledger( + 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, - None, + 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. @@ -508,11 +652,53 @@ async fn game_time_tracker_loop(mut stop_rx: watch::Receiver, app: tauri:: // ── Credit time ──────────────────────────────────────────────────────── let client = reqwest::Client::new(); + 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 = increment_game_time(&client, &config, *game_id).await; + 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; @@ -531,10 +717,11 @@ async fn game_time_tracker_loop(mut stop_rx: watch::Receiver, app: tauri:: } 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} | scan={scan_ms}ms match={match_ms}ms total={duration_ms}ms", + "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(), @@ -545,6 +732,29 @@ async fn game_time_tracker_loop(mut stop_rx: watch::Receiver, app: tauri:: 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", @@ -599,6 +809,17 @@ fn log_skip_once(logged: &mut HashMap, skip: &SkipReason) { ); } +/// 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, @@ -608,9 +829,9 @@ fn update_ledger( fallback_reason: Option<&str>, dropped_ticks: u64, now_ms: u64, -) { +) -> LedgerMatch { let Ok(mut ledger) = tracker_ledger().lock() else { - return; + return if matched { LedgerMatch::Matched } else { LedgerMatch::Lost }; }; let entry = ledger.entry(game_id).or_default(); entry.game_id = game_id; @@ -679,14 +900,46 @@ fn update_ledger( } 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; - } else if entry.matched { + 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; - let reason = fallback_reason.unwrap_or("no_matching_process"); - entry.last_skip_reason = Some(reason.to_string()); + entry.missing_ticks = 0; tracker_log::push_kv( "warn", "game", @@ -703,7 +956,90 @@ fn update_ledger( ), ], ); + 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. @@ -862,6 +1198,9 @@ fn bump_credit_stats(success: bool, auth_rejected: bool) { 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; } @@ -877,37 +1216,113 @@ fn record_failure(reason: &str) { } } -fn bump_auth_rejected() { - bump_credit_stats(false, true); - tracker_log::push( +/// 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", - "the server rejected the session token (401/403) — playtime is stored offline until the session refreshes", + &[ + ("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 = client - .put(&url) - .header("Authorization", format!("Bearer {}", config.access_token)) - .header("Accept", "application/json") - .send() - .await; - let response = match response { + 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(&config.download_paths, config.user_id, game_id); + save_offline_time(installed, config, game_id); return CreditOutcome::Offline { reason }; } }; @@ -921,9 +1336,49 @@ async fn increment_game_time( }, CreditDisposition::SaveOffline => { if matches!(status.as_u16(), 401 | 403) { - bump_auth_rejected(); + 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(&config.download_paths, config.user_id, game_id); + save_offline_time(installed, config, game_id); CreditOutcome::Offline { reason: format!("http {} ({})", status.as_u16(), excerpt(&body, 160)), } @@ -972,18 +1427,49 @@ fn read_configured_launch_executable(version_dir: &Path) -> Option { Some(PathBuf::from(exe)) } +/// 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 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(download_paths: &[String], user_id: i64, game_id: i64) -> Option { - let mut installed = Vec::new(); - for path in download_paths { - if let Ok(games) = list_installed_games_blocking(path.to_string()) { - installed.extend(games); - } - } - - let target = match installed.iter().find(|g| g.game_id == game_id) { - Some(g) => g, +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", @@ -1003,22 +1489,14 @@ fn save_offline_time(download_paths: &[String], user_id: i64, game_id: i64) -> O }; let offline_file = PathBuf::from(&target.version_directory).join(".gamevault.offline_time.json"); - - 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); - } - } - } - - let accumulated = current_minutes + 1; - let data = serde_json::json!({ - "user_id": user_id, - "game_id": game_id, - "accumulated_minutes": accumulated - }); + 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(), + ); match fs::write(&offline_file, serde_json::to_string(&data).unwrap_or_default()) { Ok(()) => { @@ -1060,6 +1538,8 @@ 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] @@ -1103,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, }); } } @@ -1140,7 +1622,9 @@ 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 @@ -1162,6 +1646,7 @@ pub(crate) async fn sync_offline_time( ("event", "offline_replay_failed".to_string()), ("game", game_id.to_string()), ("minutes", minutes.to_string()), + ("reason", reason), ("error", error.to_string()), ], ); @@ -1171,6 +1656,8 @@ pub(crate) async fn sync_offline_time( 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", @@ -1179,6 +1666,7 @@ pub(crate) async fn sync_offline_time( ("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()), ( @@ -1191,6 +1679,24 @@ pub(crate) async fn sync_offline_time( ), ], ); + + // 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) } @@ -1367,6 +1873,8 @@ pub(crate) struct TrackerStatus { 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] @@ -1383,6 +1891,7 @@ pub(crate) fn get_tracker_status() -> TrackerStatus { stats, games: tracker_ledger_snapshot(), log_path: tracker_log::log_path(), + pending_offline_minutes: pending_offline_minutes(), } } @@ -1459,4 +1968,43 @@ mod tests { 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 index 00865d0..be76177 100644 --- a/src-tauri/src/tracker_log.rs +++ b/src-tauri/src/tracker_log.rs @@ -32,6 +32,15 @@ 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 { @@ -76,6 +85,10 @@ pub(crate) fn log_directory(app: &AppHandle) -> Option { /// 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; }; @@ -132,6 +145,10 @@ pub(crate) fn log_path() -> Option { /// 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}"), diff --git a/src/hooks/useGameTimeTracker.ts b/src/hooks/useGameTimeTracker.ts index a98ea03..c291a76 100644 --- a/src/hooks/useGameTimeTracker.ts +++ b/src/hooks/useGameTimeTracker.ts @@ -1,4 +1,4 @@ -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"; @@ -24,9 +24,42 @@ interface OfflineTimeFile { userId: number; gameId: number; accumulatedMinutes: number; + /** Unix seconds of the last write; 0 for files from older builds. */ + updatedAt: number; } -/** Mirrors a frontend lifecycle line into the native tracker log. */ +/** 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, @@ -39,12 +72,6 @@ async function logTrackerLine( } else { console.debug(`[tracker:${category}]`, message); } - try { - const { invoke } = await import("@tauri-apps/api/core"); - await invoke("tracker_log_line", { level, category, message }); - } catch { - // Diagnostics must never break the tracker itself. - } } /** @@ -90,6 +117,7 @@ async function reconcileOfflineTime(options: { let files = 0; let creditedMinutes = 0; let pendingMinutes = 0; + let oldestFileMinutes = 0; for (const root of rootPaths) { const offlineFiles = @@ -100,6 +128,14 @@ async function reconcileOfflineTime(options: { 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; @@ -111,6 +147,7 @@ async function reconcileOfflineTime(options: { userId: file.userId, gameId: file.gameId, minutes: file.accumulatedMinutes, + reason: options.reason, }); if (success) { @@ -122,19 +159,32 @@ async function reconcileOfflineTime(options: { } } + 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`, + `${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 @@ -206,6 +256,7 @@ export function useGameTimeTracker() { } startedRef.current = { serverUrl, userId }; + pushedTokenRef.current = accessToken; void invokeTracker("start_game_time_tracker", { serverUrl, userId, @@ -230,13 +281,8 @@ export function useGameTimeTracker() { if (!isTauriApp() || !startedRef.current) return; const accessToken = auth?.access_token; if (!accessToken) return; - - void invokeTracker("update_tracker_auth", { accessToken }).then((result) => { - if (result !== null) { - void logTrackerLine("info", "lifecycle", "auth token refreshed — tracker kept running"); - } - }); - }, [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) @@ -275,6 +321,152 @@ export function useGameTimeTracker() { 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 { + await reconcileOfflineTime({ + serverUrl, + accessToken: token, + reason: "auth-refresh", + }); + } finally { + syncInFlightRef.current = false; + } + }, + ); + + if (disposed) { + stop(); + } else { + unlisten = stop; + } + } catch (error) { + await logTrackerLine( + "error", + "auth", + `could not subscribe to ${AUTH_EXPIRED_EVENT}: ${String(error)}`, + ); + } + })(); + + 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 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; + + 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 { + await reconcileOfflineTime({ + serverUrl, + accessToken: token, + reason: "periodic-retry", + }); + } finally { + syncInFlightRef.current = false; + } + }, PENDING_RETRY_MS); + + return () => window.clearInterval(id); + }, [getAccessToken, pushToken, serverUrl]); + + // 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; + } + }; + + 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(() => { diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 636cecf..edfa7d1 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -59,15 +59,6 @@ import { updateRootPathLabel, } from "@/utils/rootPaths"; import { openLaunchLogWindow } from "@/utils/launchLog"; -import { - debugTrackerScan, - formatPlaytimeLedger, - formatTrackerDump, - getTrackerLog, - getTrackerStatus, - openTrackerLogFolder, - resetTrackerLedger, -} from "@/utils/trackerLog"; import { FolderArrowDownIcon, ComputerDesktopIcon, @@ -1237,7 +1228,6 @@ export default function Settings() { version: __APP_VERSION__, system: systemInfo, rendering: await getRenderingDiagnostics().catch(() => null), - tracker: await collectTrackerDump(), settings, }, null, @@ -1275,79 +1265,6 @@ export default function Settings() { } }; - /** - * Tracker status, playtime ledger and log tail for bug reports. - * - * The tracker credits one minute per matched tick, so the ledger shows what - * the wall clock suggested versus what was actually credited, plus the reason - * for every minute that was lost. - */ - const collectTrackerDump = async (lineCount = 400) => { - if (!isTauri) return null; - try { - const status = await getTrackerStatus(); - const lines = await getTrackerLog(lineCount); - return { - status, - ledger: formatPlaytimeLedger(status.games), - text: formatTrackerDump({ status, lines }), - }; - } catch (error) { - return { error: String(error) }; - } - }; - - const handleCopyTrackerDiagnostics = async () => { - try { - const status = await getTrackerStatus(); - const lines = await getTrackerLog(); - const scan = await debugTrackerScan().catch(() => null); - const dump = formatTrackerDump({ status, lines, scan }); - await navigator.clipboard.writeText(dump); - await showAlert({ - title: "Tracker diagnostics copied to clipboard", - description: - "Includes the playtime ledger (observed vs credited) and the tracker log tail.", - tone: "success", - }); - } catch (error) { - await showAlert({ - title: "Couldn't copy tracker diagnostics", - description: String(error), - tone: "danger", - }); - } - }; - - const handleResetTrackerLedger = async () => { - try { - await resetTrackerLedger(); - await showAlert({ - title: "Tracker counters reset", - description: "Playtime accounting starts fresh with the next tick.", - tone: "success", - }); - } catch (error) { - await showAlert({ - title: "Couldn't reset tracker counters", - description: String(error), - tone: "danger", - }); - } - }; - - const handleOpenTrackerLogFolder = async () => { - try { - await openTrackerLogFolder(); - } catch (error) { - await showAlert({ - title: "Couldn't open the tracker log folder", - description: String(error), - tone: "danger", - }); - } - }; - const handleOpenDevTools = async () => { try { const { invoke } = await import("@tauri-apps/api/core"); @@ -2583,58 +2500,6 @@ export default function Settings() { - - - - - - - - - - - - - - - )} diff --git a/src/utils/trackerLog.test.ts b/src/utils/trackerLog.test.ts deleted file mode 100644 index 52de183..0000000 --- a/src/utils/trackerLog.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - formatLedgerGame, - formatPlaytimeLedger, - formatTrackerDump, - formatTrackerLine, - formatTrackerStats, - ledgerShortfall, - logText, - type GamePlayLedger, - type TrackerLogLine, - type TrackerRuntimeStats, - type TrackerStatus, -} from "./trackerLog"; - -function game(overrides: Partial = {}): GamePlayLedger { - return { - gameId: 1, - gameTitle: "Some Game", - matched: true, - firstMatchedAt: null, - lastMatchedAt: null, - matchedTicks: 0, - creditedTicks: 0, - offlineTicks: 0, - lostTicks: 0, - droppedTicks: 0, - matchFlaps: 0, - creditedMinutes: 0, - offlineMinutes: 0, - observedSeconds: 0, - lastServerMinutes: null, - lastSkipReason: null, - ...overrides, - }; -} - -function stats(overrides: Partial = {}): TrackerRuntimeStats { - return { - running: true, - startedAt: null, - tickCount: 0, - tickIntervalSecs: 60, - lostTicks: 0, - catchUpTicks: 0, - lastTickAt: null, - lastTickGapSecs: null, - lastTickDurationMs: null, - lastTickSummary: null, - lastError: null, - consecutiveFailures: 0, - authRejectedCount: 0, - stopReason: null, - logPath: null, - ...overrides, - }; -} - -function line(seq: number, text = `line ${seq}`): TrackerLogLine { - return { seq, at: 1_600_000_000_000 + seq * 1000, level: "tick", category: "tick", text }; -} - -describe("ledgerShortfall", () => { - it("reports the minutes the tracker saw but never credited", () => { - // One hour observed, 15 minutes credited: the reported 1h -> 15min case. - const played = game({ - observedSeconds: 60 * 60, - creditedMinutes: 15, - }); - - expect(ledgerShortfall(played)).toBe(45); - }); - - it("counts offline minutes as credited and never goes negative", () => { - const stored = game({ observedSeconds: 60 * 20, creditedMinutes: 15, offlineMinutes: 5 }); - expect(ledgerShortfall(stored)).toBe(0); - }); -}); - -describe("formatLedgerGame", () => { - it("includes observed, credited and lost minutes with the last reason", () => { - const text = formatLedgerGame( - game({ - observedSeconds: 60 * 60, - creditedMinutes: 15, - lostTicks: 45, - droppedTicks: 40, - matchFlaps: 3, - lastSkipReason: "no_matching_process", - }), - ); - - expect(text).toContain("observed=60min"); - expect(text).toContain("credited=15min"); - expect(text).toContain("lost=45min"); - expect(text).toContain("dropped=40min"); - expect(text).toContain("flaps=3"); - expect(text).toContain("SHORTFALL=45min"); - expect(text).toContain('last_reason="no_matching_process"'); - }); - - it("omits the shortfall when everything was accounted for", () => { - expect(formatLedgerGame(game({ observedSeconds: 60, creditedMinutes: 1 }))).not.toContain( - "SHORTFALL", - ); - }); -}); - -describe("formatPlaytimeLedger", () => { - it("explains an empty ledger", () => { - expect(formatPlaytimeLedger([])).toContain("no games tracked yet"); - }); - - it("lists the biggest shortfall first", () => { - const text = formatPlaytimeLedger([ - game({ gameId: 1, observedSeconds: 60 * 5, creditedMinutes: 5 }), - game({ gameId: 2, observedSeconds: 60 * 60, creditedMinutes: 15 }), - ]); - - expect(text.split("\n")).toHaveLength(2); - expect(text.indexOf("#2")).toBeLessThan(text.indexOf("#1")); - }); -}); - -describe("formatTrackerStats", () => { - it("summarises the loop counters", () => { - const text = formatTrackerStats( - stats({ tickCount: 42, lostTicks: 3, authRejectedCount: 2, stopReason: "hook unmounted" }), - ); - - expect(text).toContain("running=yes"); - expect(text).toContain("interval=60s"); - expect(text).toContain("ticks=42"); - expect(text).toContain("lost_ticks=3"); - expect(text).toContain("auth_rejected=2"); - expect(text).toContain("stop_reason=hook unmounted"); - }); -}); - -describe("formatTrackerLine", () => { - it("prefixes the UTC time and level", () => { - // 2020-09-13 12:26:40 UTC - expect(formatTrackerLine(line(0, "hello"))).toBe("12:26:40.000 [tick] hello"); - }); - - it("joins lines for copy/paste", () => { - expect(logText([line(0, "a"), line(1, "b")])).toBe( - "12:26:40.000 [tick] a\n12:26:41.000 [tick] b", - ); - }); -}); - -describe("formatTrackerDump", () => { - const status: TrackerStatus = { - trackerRunning: true, - stats: stats({ tickCount: 7, lastTickSummary: "tick 7 | matched=1" }), - games: [game({ observedSeconds: 60 * 60, creditedMinutes: 15, lostTicks: 45 })], - logPath: "C:/logs/tracker-logs/tracker.log", - }; - - it("combines status, ledger and log tail", () => { - const dump = formatTrackerDump({ - status, - lines: [line(0, "match_started")], - generatedAt: "2026-09-26T00:00:00.000Z", - }); - - expect(dump).toContain("generated: 2026-09-26T00:00:00.000Z"); - expect(dump).toContain("log file: C:/logs/tracker-logs/tracker.log"); - expect(dump).toContain("ticks=7"); - expect(dump).toContain("SHORTFALL=45min"); - expect(dump).toContain("match_started"); - }); - - it("includes the scan snapshot when provided", () => { - const dump = formatTrackerDump({ - status, - lines: [], - scan: { - trackerRunning: true, - roots: ["D:/Games"], - totalProcesses: 120, - allProcesses: [], - games: [], - processMatches: [{ gameId: 4, matched: false, matchingProcesses: [] }], - }, - }); - - expect(dump).toContain("roots=D:/Games"); - expect(dump).toContain("#4 matched=false"); - }); - - it("degrades gracefully without a tracker status", () => { - const dump = formatTrackerDump({ status: null, lines: [] }); - - expect(dump).toContain("tracker status: unavailable"); - expect(dump).toContain("(empty)"); - }); -}); diff --git a/src/utils/trackerLog.ts b/src/utils/trackerLog.ts deleted file mode 100644 index 515efb2..0000000 --- a/src/utils/trackerLog.ts +++ /dev/null @@ -1,275 +0,0 @@ -/** - * Tracker log & playtime diagnostics helpers. - * - * The Rust time tracker credits exactly one minute per matched tick and records - * every tick, match transition and credit outcome in a rotating log file - * (`tracker-logs/tracker.log`). These helpers read that log, the per-game - * playtime ledger and the one-shot scan report, and format them for a bug - * report. - */ - -export interface TrackerLogLine { - seq: number; - /** Milliseconds since the unix epoch. */ - at: number; - /** "info", "warn", "error" or "tick". */ - level: string; - /** e.g. "lifecycle", "tick", "game", "credit", "offline", "app". */ - category: string; - text: string; -} - -export interface TrackerRuntimeStats { - running: boolean; - startedAt: number | null; - tickCount: number; - tickIntervalSecs: number; - /** Ticks the interval expected but that never ran (sleep, freeze, starvation). */ - lostTicks: number; - catchUpTicks: number; - lastTickAt: number | null; - lastTickGapSecs: number | null; - lastTickDurationMs: number | null; - lastTickSummary: string | null; - lastError: string | null; - consecutiveFailures: number; - /** Increments the server rejected with 401/403 (expired token). */ - authRejectedCount: number; - stopReason: string | null; - logPath: string | null; -} - -export interface GamePlayLedger { - gameId: number; - gameTitle: string; - matched: boolean; - firstMatchedAt: number | null; - lastMatchedAt: number | null; - matchedTicks: number; - creditedTicks: number; - offlineTicks: number; - lostTicks: number; - /** Lost ticks attributed to a tick gap (sleep/resume, freeze, starvation). */ - droppedTicks: number; - matchFlaps: number; - creditedMinutes: number; - offlineMinutes: number; - observedSeconds: number; - lastServerMinutes: number | null; - lastSkipReason: string | null; -} - -export interface TrackerStatus { - trackerRunning: boolean; - stats: TrackerRuntimeStats; - games: GamePlayLedger[]; - logPath: string | null; -} - -export interface TrackerDebugProcess { - pid: number; - exe: string | null; - cmd: string; -} - -export interface TrackerDebugGame { - gameId: number; - gameTitle: string; - installationDirectory: string; - versionDirectory: string; - exeCandidates: string[]; -} - -export interface TrackerDebugMatch { - gameId: number; - matched: boolean; - matchingProcesses: string[]; -} - -export interface TrackerDebugReport { - trackerRunning: boolean; - roots: string[]; - totalProcesses: number; - allProcesses: TrackerDebugProcess[]; - games: TrackerDebugGame[]; - processMatches: TrackerDebugMatch[]; -} - -/** Lines kept when formatting a log tail for a bug report. */ -export const MAX_DUMP_LINES = 400; - -async function invokeCommand( - command: string, - args?: Record, -): Promise { - const { invoke } = await import("@tauri-apps/api/core"); - return invoke(command, args); -} - -/** `10:15:23.123 [warn] text` — enough to line ticks up with a session. */ -export function formatTrackerLine(line: TrackerLogLine): string { - const time = new Date(line.at).toISOString().slice(11, 23); - return `${time} [${line.level}] ${line.text}`; -} - -export function logText(lines: TrackerLogLine[]): string { - return lines.map(formatTrackerLine).join("\n"); -} - -/** Minutes the tracker saw (wall clock) but never credited for this game. */ -export function ledgerShortfall(game: GamePlayLedger): number { - const observed = Math.floor(game.observedSeconds / 60); - return Math.max(0, observed - game.creditedMinutes - game.offlineMinutes); -} - -export function formatLedgerGame(game: GamePlayLedger): string { - const observed = Math.floor(game.observedSeconds / 60); - const parts = [ - `#${game.gameId} ${game.gameTitle || "(unknown)"}`, - `observed=${observed}min`, - `credited=${game.creditedMinutes}min`, - `offline=${game.offlineMinutes}min`, - `lost=${game.lostTicks}min`, - `dropped=${game.droppedTicks}min`, - `flaps=${game.matchFlaps}`, - ]; - const shortfall = ledgerShortfall(game); - if (shortfall > 0) { - parts.push(`SHORTFALL=${shortfall}min`); - } - if (game.lastSkipReason) { - parts.push(`last_reason="${game.lastSkipReason}"`); - } - return parts.join(" | "); -} - -/** Human-readable playtime accounting table, one line per game. */ -export function formatPlaytimeLedger(games: GamePlayLedger[]): string { - if (!games.length) { - return "(no games tracked yet — nothing was matched since the tracker started)"; - } - const lines = games - .slice() - .sort((a, b) => ledgerShortfall(b) - ledgerShortfall(a)); - return lines.map(formatLedgerGame).join("\n"); -} - -/** Compact summary of the tracker loop counters. */ -export function formatTrackerStats(stats: TrackerRuntimeStats): string { - const lastTick = stats.lastTickAt - ? new Date(stats.lastTickAt).toISOString() - : "never"; - return [ - `running=${stats.running ? "yes" : "no"}`, - `interval=${stats.tickIntervalSecs}s`, - `ticks=${stats.tickCount}`, - `lost_ticks=${stats.lostTicks}`, - `catch_up_ticks=${stats.catchUpTicks}`, - `consecutive_failures=${stats.consecutiveFailures}`, - `auth_rejected=${stats.authRejectedCount}`, - `last_tick=${lastTick}`, - `last_tick_gap=${stats.lastTickGapSecs ?? "unknown"}s`, - `stop_reason=${stats.stopReason ?? "none"}`, - `last_error=${stats.lastError ?? "none"}`, - ].join(" | "); -} - -/** - * Full diagnostics block for the clipboard: loop counters, the per-game - * playtime accounting and the tail of the tracker log. - */ -export function formatTrackerDump(options: { - status: TrackerStatus | null; - lines: TrackerLogLine[]; - scan?: TrackerDebugReport | null; - /** Overrides "now" for deterministic output (tests). */ - generatedAt?: string; -}): string { - const { status, lines, scan } = options; - const sections: string[] = [ - "=== GameVault time tracker diagnostics ===", - `generated: ${options.generatedAt ?? new Date().toISOString()}`, - ]; - - if (!status) { - sections.push("tracker status: unavailable (tracker log not initialized)"); - } else { - sections.push(`log file: ${status.logPath ?? "unknown"}`); - sections.push(`stats: ${formatTrackerStats(status.stats)}`); - sections.push(`last tick summary: ${status.stats.lastTickSummary ?? "none"}`); - sections.push(""); - sections.push("--- playtime ledger (observed vs credited) ---"); - sections.push(formatPlaytimeLedger(status.games)); - } - - if (scan) { - sections.push(""); - sections.push("--- scan snapshot ---"); - sections.push( - `roots=${scan.roots.join(", ") || "none"} | processes=${scan.totalProcesses} | installed=${scan.games.length}`, - ); - for (const match of scan.processMatches) { - sections.push( - `#${match.gameId} matched=${match.matched} processes=${match.matchingProcesses.join(" ; ") || "-"}`, - ); - } - } - - sections.push(""); - sections.push(`--- tracker log tail (last ${Math.min(lines.length, MAX_DUMP_LINES)} lines) ---`); - sections.push(lines.length ? logText(lines.slice(-MAX_DUMP_LINES)) : "(empty)"); - - return sections.join("\n"); -} - -// ── Commands ─────────────────────────────────────────────────────────────── - -export function getTrackerLog(limit?: number): Promise { - return invokeCommand("get_tracker_log", { - limit: limit ?? null, - }); -} - -export function getTrackerStatus(): Promise { - return invokeCommand("get_tracker_status"); -} - -export function resetTrackerLedger(): Promise { - return invokeCommand("reset_tracker_ledger"); -} - -export function clearTrackerLog(): Promise { - return invokeCommand("clear_tracker_log"); -} - -export function openTrackerLogFolder(): Promise { - return invokeCommand("open_tracker_log_folder"); -} - -export function readTrackerLogFile( - path: string, - maxBytes?: number, -): Promise { - return invokeCommand("read_tracker_log_file", { - path, - maxBytes: maxBytes ?? null, - }); -} - -/** Records a frontend line in the same native tracker log. */ -export function trackerLogLine( - level: "info" | "warn" | "error", - category: string, - message: string, -): Promise { - return invokeCommand("tracker_log_line", { level, category, message }); -} - -/** Replays one tracker scan and reports what it found right now. */ -export function debugTrackerScan( - selectedRoot?: string, -): Promise { - return invokeCommand("debug_tracker_scan", { - selectedRoot: selectedRoot ?? null, - }); -}