diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f0a5d20..2b48d9ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,15 +47,21 @@ jobs: - name: Test Home calendar and recent games run: corepack pnpm test:home + - name: Test application logging and independent diagnostic toggles + run: corepack pnpm test:logging + - name: Test asynchronous stores and artwork cache run: corepack pnpm test:regressions - name: Test dropdowns, rendering, and shared artwork cache - run: corepack pnpm test:select && corepack pnpm test:visual && corepack pnpm test:artwork-cache + run: corepack pnpm test:select && corepack pnpm test:visual && corepack pnpm test:artwork-cache && corepack pnpm test:artwork-work && corepack pnpm test:details-cache - name: Test source management and deep links run: corepack pnpm test:sources + - name: Test catalog filters and store controls + run: corepack pnpm test:catalog + - name: Test library artwork on cold startup run: corepack pnpm test:artwork @@ -211,6 +217,45 @@ jobs: LEGIO_WINDOWS_TESTS: "1" run: 'cargo test --manifest-path src-tauri/Cargo.toml --locked --lib app_profile::tests::' + - name: Test Windows application logging + if: runner.os == 'Windows' + shell: bash + env: + LEGIO_WINDOWS_TESTS: "1" + run: 'cargo test --manifest-path src-tauri/Cargo.toml --locked --lib application_log::tests::' + + - name: Test Windows artwork conversion and blur + if: runner.os == 'Windows' + shell: bash + env: + LEGIO_WINDOWS_TESTS: "1" + run: 'cargo test --manifest-path src-tauri/Cargo.toml --locked --lib image_trim::tests::' + + - name: Test Windows artwork scheduling, metadata and cache publication + if: runner.os == 'Windows' + shell: bash + env: + LEGIO_WINDOWS_TESTS: "1" + run: | + cargo test --manifest-path src-tauri/Cargo.toml --locked --lib artwork_ + cargo test --manifest-path src-tauri/Cargo.toml --locked --lib steam_details::tests:: + cargo test --manifest-path src-tauri/Cargo.toml --locked --lib steam_assets::tests:: + + - name: Test release artwork command stack usage on Windows + if: runner.os == 'Windows' + shell: bash + env: + LEGIO_WINDOWS_TESTS: "1" + CARGO_PROFILE_RELEASE_DEBUG: "1" + run: 'cargo test --manifest-path src-tauri/Cargo.toml --locked --release --lib commands::tests::' + + - name: Test Windows WebView diagnostics + if: runner.os == 'Windows' + shell: bash + env: + LEGIO_WINDOWS_TESTS: "1" + run: 'cargo test --manifest-path src-tauri/Cargo.toml --locked --lib webview_diagnostics::tests::' + - name: Test Windows Steam discovery if: runner.os == 'Windows' shell: bash @@ -247,5 +292,52 @@ jobs: run: cargo test --manifest-path src-tauri/Cargo.toml --locked --lib finalize_install::tests::recovers_after_database_commit_fails_and_database_reopens - name: Build Tauri application + if: runner.os != 'Windows' || github.event_name != 'pull_request' shell: bash run: corepack pnpm tauri build --debug --no-bundle + + - name: Build Windows test installer + if: runner.os == 'Windows' && github.event_name == 'pull_request' + shell: bash + env: + CARGO_PROFILE_RELEASE_DEBUG: "1" + run: 'corepack pnpm tauri build --bundles nsis --config ''{"bundle":{"createUpdaterArtifacts":false}}''' + + - name: Record Windows installer checksums and revision + if: runner.os == 'Windows' && github.event_name == 'pull_request' + shell: pwsh + env: + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + $directory = "src-tauri/target/release/bundle/nsis" + $installers = @(Get-ChildItem "$directory/*.exe") + if ($installers.Count -eq 0) { throw "Windows installer was not produced" } + $installers | ForEach-Object { + $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $($_.Name)" + } | Set-Content "$directory/SHA256SUMS.txt" -Encoding utf8 + $builtRevision = git rev-parse HEAD + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + @("Built revision: $builtRevision", "PR head: $env:PR_HEAD_SHA", "Test installer. Updater signing is disabled; this is not a published release.") | + Set-Content "$directory/build-info.txt" -Encoding utf8 + + - name: Upload Windows test installer + if: runner.os == 'Windows' && github.event_name == 'pull_request' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: legio-windows-pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + path: | + src-tauri/target/release/bundle/nsis/*.exe + src-tauri/target/release/bundle/nsis/SHA256SUMS.txt + src-tauri/target/release/bundle/nsis/build-info.txt + if-no-files-found: error + retention-days: 14 + + - name: Upload matching Windows native symbols + if: runner.os == 'Windows' && github.event_name == 'pull_request' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: legio-windows-symbols-pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + path: src-tauri/target/release/legio.pdb + if-no-files-found: error + retention-days: 14 diff --git a/README.md b/README.md index e6e3961c..558e2408 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ Get the latest version from [Releases](https://github.com/fraa2a/Legio/releases) Steam must be installed for Steam integration and for games that require it. +Linux uses WebKitGTK's Skia compositor on runtime versions 2.54 and newer. +See [Linux rendering diagnostics and acceptance](docs/releases/0.2.0-linux-skia-test.md) +for backend verification, focused comparisons and runtime packaging limits. + Manual Proton and GE-Proton games without Online Fix use [umu-launcher](https://github.com/Open-Wine-Components/umu-launcher). umu is a runtime dependency for these games, not for native Linux games, Wine launches or games launched through Steam. diff --git a/docs/releases/0.2.0-artwork-rendering-verification.md b/docs/releases/0.2.0-artwork-rendering-verification.md new file mode 100644 index 00000000..3b84aa19 --- /dev/null +++ b/docs/releases/0.2.0-artwork-rendering-verification.md @@ -0,0 +1,45 @@ +# Legio 0.2.0 artwork and rendering verification + +The user approved both plans and inline execution with one independent final review. The PR remains draft until the affected Windows machine passes acceptance. No TaxLauncher migration or release merge is included. + +## Independent review and corrections + +The single read-only review reported no Critical or Minor findings and five Important findings. Each was accepted and addressed in one root fix pass: + +1. Pin the already validated screenshot URL throughout download and publication. A controlled metadata-reordering resolver test returned screenshot B instead of the expected A before correction. +2. Treat recoverable corrupt metadata as a cache miss at the shared cache boundary and reach the existing repair path. The corrupt-cache regression failed before correction; an additional native artwork test checks that a missing header waits for metadata recovery and then displays its cached bytes. +3. Retain metadata requests per live consumer. Offscreen/unmounted rows release demand; the final consumer cancels the native request. Both a shared-consumer test and a mounted Store-row visibility test failed before correction. +4. Retain bounded recent cancellation IDs so cancellation before native registration cannot be forgotten. The cancel-before-begin test failed before correction. +5. Queue ordinary image conversion and IPC phase logs without waiting for log I/O. A subprocess holds the actual logger lock while converting and assembling artwork; it timed out before correction and completes after correction. + +The reviewer could not certify Windows Dither composition, live blur correctness, native latency or artifact provenance from source and simulated graphics. The affected-machine visual and performance protocol is in `0.2.0-windows-test.md`; installer provenance is verified during artifact handoff. TaxLauncher migration and the other developer's transparency report are explicitly deferred. + +## Verification + +All frontend CI test scripts, Svelte/TypeScript check, ESLint, production frontend build and release-manifest test passed locally. The final full native suite passed 452 tests with zero failures; format and all-target/all-feature Clippy with warnings denied passed. CI artifact provenance is recorded in the PR after the Windows build. The first Windows CI run exposed an open SQLite handle during fixture cleanup; tests now explicitly close their temporary database connection before deleting the directory. Three pre-existing native external/local-game tests remain ignored; no new ignored regressions were introduced. + +Before the final native cleanup and added integration test, the full native suite passed 451 tests with zero failures. The five original review regressions were observed failing before their corrections. No numerical performance gain or native compositor fix is claimed from mocks. + +## Execution decisions + +- Task 1: Ruling: use installed Node CLI directly and existing CARGO_HOME/target directory - local pnpm wrapper is unavailable - risk: equivalent tools must still match CI invocations. +- Task 3: Ruling: resolve missing store-asset metadata natively after the disk-cache lookup - a frontend metadata-first request would block an existing persistent cover on missing localized metadata - risk: native metadata dependency needs the same cancellation and stack-budget checks as assets; these are retained. +- Task 3: Ruling: update the existing 72-hour expiry regression to 119/121 hours - the approved freshness policy is five days - risk: intentional compatibility change, with exact five-day boundary covered separately. +- Task 4: Ruling: extend the visibility helper with continuous observation for Store/Library tiles - one-shot observation retained queued requests after scrolling away - risk: offscreen cold artwork consumers now unmount and resume from cache on re-entry; sidebar keeps its existing one-shot behavior. +- Task 4: Ruling: preserve frontend successful-fetch age instead of using refreshAt for retry backoff - native retry state already enforces five minutes and changing refreshAt falsifies freshness - risk: diagnostics must read retry failures separately from asset age. +- Task 5: Ruling: Run the rendering implementation before final integrated verification because existing navigation tests encode the native snapshot behavior that the approved rendering plan replaces. Timing work and all integrated checks remain required before the final review. Cost if wrong: later detection of integration failures. +- Task 5: Ruling: Keep the existing individual Tauri argument contract and narrowly allow argument-count lint on its commands and private adapters. An IPC schema change solely for lint would break the approved service interface. Factor metadata lock type into a named alias. Cost if wrong: signatures remain verbose. +- Task 5: Ruling: Add A/B/A reopening regressions to integrated verification and replace abandoned pending work. Reusing an aborted page request loses the new opening freshness check. Cost if wrong: reduced deduplication for obsolete requests, with live shared consumers still retained. +- Task 5: Ruling: Windows performance measurements remain pending the affected machine; record the exact cold/warm, logging and phase-measurement protocol in the acceptance document rather than claiming a speedup from simulated tests. Cost if wrong: a native latency bottleneck may still need a follow-up fix. +- Task 5: Ruling: Update legacy regressions to assert metadata-independent artwork keys and non-destructive ForceRefresh. The old reset and timestamp-key expectations contradict the approved cache contract. Cost if wrong: incompatible callers need explicit screenshot identity, covered by gallery/viewer tests. +- Final: Ruling: Use a FIFO queue of at most 64 native artwork log records instead of durable codec and IPC checkpoints. Blocking log I/O conflicts with the approved nonblocking artwork contract. Panic and explicit crash diagnostics retain their separate durable paths. Cost if wrong: pending image phase records may be lost on host termination; external Windows crash dumps remain necessary. +- Final: Ruling: Retain cancellation IDs for five minutes with at most 1024 entries. This bounds memory while absorbing cancellation-before-registration interleavings. Cost if wrong: a command delayed beyond five minutes or more than 1024 intervening cancellations may outlive its demand. +- Final: Ruling: Replace strict shared-Promise object identity in the old metadata regression with one underlying native fetch and equivalent results. Shared consumer cancellation requires distinct lifetime wrappers. Cost if wrong: callers depending on Promise identity must use the service result instead. +- Task 3: Ruling: Add a dedicated stateful GL test script to the existing visual CI command. The original always-success GL proxy could not validate resource generations or allocation failures. Cost if wrong: extra test maintenance. +- Task 3: Ruling: Windows compositor correctness remains pending user acceptance; this host exposes only an in-app Linux browser. Preserve the existing shaders and add lifecycle recovery, without claiming a corruption-specific diagnosis from fake GL tests. Cost if wrong: native corruption may require a further focused fix. +- Task 4: Ruling: Start the single read-only review against the committed range plus a saved working-tree diff while the corrected native test build completes. The implementation is stable, and final acceptance still requires a green full suite. Cost if wrong: a native failure requires the root fix pass before publication, with no second review. +- Final: Ruling: Windows Dither/compositor corruption and live blur visual correctness remain acceptance checks on the affected Windows/WebView2 environment. Source and mock graphics checks cannot certify native composition. Cost if wrong: the Windows graphics issue may require a follow-up fix before merge. +- Final: Ruling: Windows cold/warm latency and logging overhead remain unmeasured until the user's Windows run. The implementation establishes limits and priorities; it does not substantiate a numerical speedup. Cost if wrong: native loading may still have another bottleneck. +- Final: Ruling: Installer, matching symbols and provenance are subsequent required work and will be checked against the final PR head before handoff. Cost if wrong: stale artifacts would invalidate acceptance. +- Final: Ruling: TaxLauncher migration and the other developer's transparency issue remain deferred as the user instructed. Cost if wrong: those paths remain unsupported until their separate follow-up. +- Final: Ruling: Update the existing draft PR as already authorized and retain the checkout for Windows acceptance instead of repeating the integration-choice menu. No merge is authorized. Cost if wrong: remote draft changes are visible before acceptance but cannot trigger the versioned release. diff --git a/docs/releases/0.2.0-linux-skia-test.md b/docs/releases/0.2.0-linux-skia-test.md new file mode 100644 index 00000000..635e8059 --- /dev/null +++ b/docs/releases/0.2.0-linux-skia-test.md @@ -0,0 +1,70 @@ +# Linux Skia transition and acceptance + +WebKitGTK 2.54 and newer use Skia composition by default in Legio, including Hyprland. Legio no longer changes the process environment to select TextureMapper. Transparent native windows, blur, artwork, WebGL backgrounds and foreground fades remain supported. + +The legacy GTK full-window damage handler is installed only for transparent windows using a pre-2.54 runtime or an explicit TextureMapper preference. The normal 2.54+ Skia path leaves GTK invalidation and focus redraws to the renderer. + +## Native diagnostics + +Enable application logging in Settings, fully exit Legio and start it again. Application logging and network logging remain independently disabled by default. `app.txt` contains a `linux_renderer` entry with: + +- Loaded WebKitGTK version, rather than the build-time pkg-config version. +- Actual GDK display type: `GdkWaylandDisplay` or `GdkX11Display`. +- Read-back Skia and damage-composition preferences, explicit overrides, native alpha and legacy GTK damage handling. +- Hardware acceleration policy and explicit DMA-BUF/shared-memory switches. + +Preferences and acceleration policy do not prove which GPU executed a frame. WebKit's internal fallback or a driver issue can still affect presentation. Do not describe this record as GPU telemetry. Configuration failures produce `linux_rendering_configuration` records; a terminated WebProcess produces a durable `linux_web_process_terminated` record. These observers do not guarantee a log for every host/GPU failure. + +## Controlled comparisons + +Use the same binary, game, cached artwork, settings, display scaling and driver for each comparison. Fully quit between runs, including the tray instance, because an existing instance retains its original renderer and environment. The normal run needs no overrides. + +For Legio, compare explicit native Wayland (`GDK_BACKEND=wayland`) with XWayland (`GDK_BACKEND=x11`) while using the same Skia default. Explicit `WEBKIT_USE_SKIA_FOR_COMPOSITION=0` remains an engine-level compatibility comparison where TextureMapper is still available. Clear that switch for normal Skia use. + +Do not use a live feature setter to test damage composition: WebKit 2.54 latches compositor settings when it constructs the renderer. A getter can report an updated preference without changing the already-created compositor. Legio's diagnostics therefore read settings without writing them. The former proposed `LEGIO_WEBKIT_DAMAGE_COMPOSITING` switch is unsupported and produces a warning if supplied. + +For damage, transport and native alpha comparisons, use the standalone probe in `tools/linux-rendering/probe.c`. It constructs an ephemeral WebView with preferences set before WebPage creation, uses local synthetic artwork and blur, and does not load a Legio profile. Build with WebKitGTK 2.54+ development headers: + +```sh +cc -std=c11 -Wall -Wextra -Werror tools/linux-rendering/probe.c -o /tmp/legio-rendering-probe $(pkg-config --cflags --libs webkit2gtk-4.1) +``` + +Clear inherited `WEBKIT_USE_SKIA_FOR_COMPOSITION`, `WEBKIT_DISABLE_DMABUF_RENDERER`, `WEBKIT_DMABUF_RENDERER_FORCE_SHM`, `WEBKIT_SKIA_ENABLE_CPU_RENDERING`, `WEBKIT_SHOW_DAMAGE` and `LEGIO_WEBKIT_DAMAGE_COMPOSITING` between comparisons, unless the row explicitly sets one. The probe rejects conflicting composition overrides. The damage overlay can force full repainting and mask a bug. Shared-memory transport is a diagnostic comparison with a potential performance cost. + +| Probe run | Environment and arguments | Purpose | +| --- | --- | --- | +| Native Wayland | `GDK_BACKEND=wayland /tmp/legio-rendering-probe` | Skia with native damage tracking | +| XWayland | `GDK_BACKEND=x11 /tmp/legio-rendering-probe` | Change only the display backend | +| Full composition | `GDK_BACKEND=wayland /tmp/legio-rendering-probe --full-composition` | Keep Skia, disable damage-limited composition before renderer creation | +| Shared memory | `GDK_BACKEND=wayland WEBKIT_DMABUF_RENDERER_FORCE_SHM=1 /tmp/legio-rendering-probe` | Compare buffer transport independently | +| Opaque presentation | `GDK_BACKEND=wayland /tmp/legio-rendering-probe --opaque` | Compare opaque versus transparent content presentation | +| Explicit compatibility | `GDK_BACKEND=wayland /tmp/legio-rendering-probe --texture-mapper` | Compare TextureMapper where the engine still provides it | + +Append `--check` for a nonvisual initialization check that reads preferences and constructs the ephemeral view without opening a window. It does not validate pixels or GPU execution. The synthetic probe is a reduction aid; a passing probe cannot prove that Legio's complete game page is unaffected. + +Open a game page containing hero artwork and blurred panels. Repeat window focus changes and workspace changes at least 20 times, then minimize/restore, resize, open/close fullscreen artwork and navigate through Library, Store and Settings. Repeat with Dither, another background and no background. Check that no surface disappears, text remains sharp, the sidebar stays visible and idle GPU use does not grow unexpectedly. Record the failing comparison and the corresponding `linux_renderer` entry. + +No additional startup navigation or process restart is added to Legio. Its normal Skia selection comes from WebKit's initial engine configuration, before Tauri setup. Explicit engine environment overrides are reported separately from the read-back preferences, because an environment override can affect the WebProcess without changing the UI-process settings object. + +## Runtime distribution boundary + +The current deb/rpm/AppImage release pipeline builds on Ubuntu 22.04 and does not establish a controlled WebKitGTK 2.54 runtime. Upgrading application code or shipping an ordinary package does not upgrade a user's system engine. Legacy engines continue to start and are identified in the log. + +A controlled runtime must pin the engine and required libraries, include its matching WebProcess/network helpers, and be tested as an assembled package. It must use a maintained 2.54+ version with an update/backport policy, verify the actual loaded version and pass the comparisons above on affected hardware. Do not mix an arbitrary WebKit library with host helpers or describe an LD_LIBRARY_PATH override alone as isolation. Building and distributing that package remains separate from this application transition; no existing release packaging was silently replaced. + +## Verification limits + +Policy tests cover version boundaries, native alpha and explicit compatibility requests. The native snapshot test exercises the installed WebKit API directly, including missing-feature handling. Probe initialization checks cover Skia, full composition and explicit TextureMapper preferences set before view creation. They do not establish that the Hyprland rendering defect is fixed. Acceptance on the affected machine remains required before release. + +## Implementation verification, 2026-10-12 + +- `cargo test --all-features --locked`: 456 passed, 0 failed, 4 ignored. The ignored native feature test was then run explicitly and passed; the other ignored tests predate this change. +- Native preference snapshot on loaded WebKitGTK 2.54.1: passed with GTK initialized. +- `cargo clippy --all-targets --all-features --locked -- -D warnings`: passed. +- Build with `tauri/custom-protocol`, embedding the existing frontend: passed. +- Probe compiled with C11, `-Wall -Wextra -Werror`. Initialization passed for Skia/default damage, Skia/full composition, and explicit TextureMapper on XWayland; Skia/full composition also passed on native Wayland. Conflicting composition override rejection passed. +- Final review found that live feature writes could misreport renderer behavior. Those writes were removed. The added rejection test failed before the correction and passed afterward; the complete suite was rerun after that correction. + +The first sandboxed suite run could not bind localhost fixture servers, and the first native test lacked GTK initialization. Neither result is used as successful verification. The final suite had access to its local fixtures and isolated test profiles; the final native test initialized GTK and had display access. + +These checks verify code and initialization, not the disappearance symptom under repeated focus changes on affected hardware. No release merge or production runtime update is implied by this verification. diff --git a/docs/releases/0.2.0-windows-artwork-crash.md b/docs/releases/0.2.0-windows-artwork-crash.md new file mode 100644 index 00000000..e3578bad --- /dev/null +++ b/docs/releases/0.2.0-windows-artwork-crash.md @@ -0,0 +1,31 @@ +# Windows artwork stack overflow + +## Evidence + +The user-provided Windows Error Reporting report and ProcDump capture identify exception `0xc00000fd` in `legio.exe` 0.2.0.0, PE timestamp `0x6acacb3b`, at RVA `0x14779d7`. This is the installer built from PR head `68c7e5bfcb436443a009ba1ed8974a11e2e901d1`. + +The faulting instruction is the stack probe's page write. Its caller is requesting a frame of `0x3d8c8` (252,104) bytes. At that point the thread already uses `0xe2ab0` (928,432) bytes of its 1 MiB stack reservation. + +The capture contains the executable image and AMD64 unwind metadata. Unwinding from the exception context yields the following application frames before returning through the WebView2 callback: + +| Return/fault RVA | Function start RVA | Observation | +| --- | --- | --- | +| `0x14779d7` | `0x14779a0` | Stack probe faults while reserving the next frame. | +| `0x54a3be` | `0x54a3b0` | Probe caller reserves 252,104 bytes before copying and boxing a 252,024-byte future. | +| `0x5eff67` | `0x5efe30` | Runtime dispatch copies that future through a 504,152-byte frame. | +| `0x340eaf` | `0x340e20` | IPC response scheduling uses a 252,088-byte frame. | +| `0x46d26` | `0x45ce0` | Generated command dispatcher calls the scheduling function from the `get_steam_asset` branch. | + +The dispatcher branch compares the command bytes against `get_steam_asset` immediately before the observed call. This ties the native stack to the artwork command without relying on unrelated nearest-export names or symbols from a different build. No repeated application frame appears in this trace. The observed failure is excessive nested stack allocation during command scheduling. + +Tauri 2.12.1's `InvokeResolver::respond_async_serialized` boxes the task under `debug_assertions`, but passes it by value into its scheduling helper in release builds. This explains why development builds and isolated image codec tests did not exercise the failing release dispatch path. + +The image command future has not been polled at the fault. The image decode checkpoints, JavaScript error handlers and Rust panic hook therefore cannot report this exception. In-process logging cannot survive termination of its own host process. + +## Regression coverage and acceptance + +`get_steam_asset` and `prefetch_steam_hero` pin the asset-loading future in a heap allocation before awaiting it. Their enclosing command futures carry the small pinned pointer instead of embedding the complete asset state machine. This reduces the values copied while Tauri schedules the command on the UI thread. + +The artwork and hero-prefetch command futures must remain within a 4 KiB inline budget. The unit test measures the real command future types with the production Wry runtime, without constructing a mock app or issuing network requests. Windows CI runs this check in the release profile in addition to the existing image conversion tests. + +The affected Windows machine must still pass populated Library and Store tests, with cold and warm caches and logging both disabled and enabled, before release acceptance. ProcDump is an external diagnostic collector; application logging remains optional and defaults to off. diff --git a/docs/releases/0.2.0-windows-test.md b/docs/releases/0.2.0-windows-test.md new file mode 100644 index 00000000..6950d2c8 --- /dev/null +++ b/docs/releases/0.2.0-windows-test.md @@ -0,0 +1,95 @@ +# Legio 0.2.0 Windows acceptance test + +The release PR adds Store filters, optional application logging, the native Windows artwork dispatch fix, prioritized artwork loading with persistent cache revalidation, stable dialog blur and foreground page fades, Dither lifecycle recovery, and window/calendar layout fixes. The Windows CI job produces an NSIS installer before merge. Keep the PR open until the Windows acceptance test succeeds. + +## Obtain the installer + +1. Open the latest CI run for the release PR. +2. Wait for `Tauri smoke (Windows)` to succeed. +3. Download the artifact named `legio-windows-pr--` and extract it. +4. Check `build-info.txt` against the PR head revision. It records both the PR head and the merge revision compiled by CI. +5. Verify the installer with `SHA256SUMS.txt`, then run the `.exe` installer. + +This installer uses the production application profile. Back up an existing Legio profile before testing. Updater artifact signing is disabled for this test build; it is not a published release. The release workflow continues to sign updater artifacts after merge. + +## Store checks + +- Confirm the defaults are `Most relevant` and `All`. +- Search for an exact title and a partial title. Confirm the closest matches come first. +- Compare alphabetical ascending and descending ordering, including when the search is empty. +- Add a valid download source containing verified and unverified games. Confirm `Available only` includes both and excludes unavailable games. Confirm `Verified only` includes only games with at least one verified release. +- Confirm a title with several releases appears once and its releases remain selectable on the detail page. +- Check a source title that is not yet in the catalog cache. It must appear and be searchable. +- Load several pages, then change either filter. Confirm the list restarts at 20 games and the selected filters remain active when loading more. +- Switch filters quickly while searching. Confirm older requests do not replace the current results. +- Remove or refresh a source. Confirm availability filters use the updated source state. +- With no sources installed, confirm the availability filters show an empty state and both controls remain usable. +- Test cached searches offline. Confirm a failed refresh retains the local results and offers a retry. +- Check both Italian and English labels and keyboard navigation for both menus. + +## Windows regression checks + +- Start and restart the installed app; confirm settings and library persist. +- Open Library and Store repeatedly, with an empty library and existing games, in both windowed and maximized modes. Test with application logging off and on. The reported Windows crash remains an acceptance blocker until diagnosed and retested. +- Add, launch and stop an existing game; confirm the launcher finds its executable and records playtime. +- Exercise a small download through a valid source, including extraction and finalization. +- Check Steam detection, tray behavior, artwork and dialogs. +- Confirm the native window cannot be resized below 960x540 logical pixels. Below 1280x720, the whole interface scales proportionally to fit, reaching 75% at the minimum and returning to 100% at 1280x720 or larger. Test non-proportional resizing and Windows display scaling, including moving between monitors with different scaling. +- At the minimum, check Settings, dropdown alignment, pointer targets, fullscreen artwork and keyboard focus. Keep a dialog open while resizing and maximizing/restoring. +- Confirm Library, Store, game details and Settings do not create horizontal or vertical document scrollbars. Long page content should scroll within its intended content area. Check expanded and collapsed sidebars on Windows and Linux. +- Confirm calendar cells remain square for five-week and six-week months. Check Italian/English and both sidebar states. +- At the minimum size, scroll Home to reach recent games. Confirm calendar controls, news scrolling and Settings remain reachable with five recent games. + +## Application and network logging + +- On a new profile, both `Save application logs` and `Save network logs` are off. Missing preferences also default to off; explicitly saved choices are preserved. +- Enable application logging in Settings, then restart Legio. Confirm `app.txt` appears in the folder shown in Settings. Enable/disable network logging independently. +- Navigate to Home, Library, Store and a game detail. Confirm application records contain page categories, version, platform and session information without game identifiers or titles. +- Confirm `app.txt` records startup, navigation, native command failures, renderer errors, unhandled rejections and Rust panics when enabled. Shutdown records require exiting Legio completely, rather than minimizing to the tray. +- Disable application logging and confirm subsequent navigation and errors do not append application records. Existing files remain available. The logger retains at most `app.txt` and `app.previous.txt`, each bounded to 256 KiB. +- Check that private paths, URLs and credentials are redacted. Technical event names, error types and numeric source locations remain useful for diagnosis. + +To diagnose the reported Library crash, enable application logging, restart Legio, open Library and send `app.txt` plus `app.previous.txt` if present. For the installed Windows profile, the log folder is `%LOCALAPPDATA%\dev.fraa2a.legio\logs`; development builds use a separate `.dev` identifier. + +The supplied RestartManager event 10001 is an informational session event. It does not identify the faulting module. If the process exits with no recorded Rust panic or JavaScript error, also collect the nearby `Application Error` event 1000 or `Windows Error Reporting` event 1001, including faulting application/module and exception code. The native Windows observers record WebView2 runtime version, process failures (kind, reason and exception/exit code when supported), and browser process exits. These records bypass renderer reporting. A fatal fault in Legio itself, forced termination or a system shutdown can still stop the process before an error record is written. Native image and IPC checkpoints use a bounded asynchronous queue so logging cannot block conversion or publication. Navigation records are sent asynchronously; logging does not delay the foreground fade or page swap. Pending image records can be lost when the host terminates; panic and explicit crash diagnostics remain separate. The last persisted phase narrows the failure location but does not prove its cause. + +The supplied native dump identifies stack exhaustion while dispatching `get_steam_asset` on the Windows UI thread, before the image future is polled. See [the crash analysis](0.2.0-windows-artwork-crash.md). Retest populated Library and Store with cold and warm artwork caches and animation effects enabled. A successful empty Library test alone does not cover the failing path. + +If a native crash recurs, retain `Report.wer` and the `.dmp` referenced by Windows Error Reporting event 1001. If the temporary dump is gone, extract [Microsoft ProcDump](https://learn.microsoft.com/en-us/sysinternals/downloads/procdump), close Legio, open PowerShell in the extracted folder and run: + +```powershell +New-Item -ItemType Directory -Force "$env:USERPROFILE\Desktop\LegioDumps" +.\procdump64.exe -ma -e -w legio.exe "$env:USERPROFILE\Desktop\LegioDumps" +``` + +Leave ProcDump running, start Legio normally and reproduce the crash. ProcDump runs outside Legio and records the unhandled native exception. Keep dumps private because they contain process memory. The separate `legio-windows-symbols-pr--` CI artifact contains the matching native PDB for the test installer; symbols from another revision cannot reliably resolve its addresses. + +The previous JSONL files remain untouched as evidence. New records go only to the two TXT files. Windows CI tests image conversion/blur and WebView diagnostic records in addition to the logger. The installer remains a diagnostic build until the affected Windows machine passes the acceptance test. + +## Release sequence + +1. Complete all required PR checks and this Windows acceptance test. +2. After explicit acceptance, squash merge the release PR. The increase to `0.2.0` triggers the existing release workflow. +3. Verify the Windows release installer and updater manifest are published successfully. +4. Prepare and test the separate TaxLauncher migration update. It must back up and convert legacy data and browser preferences, preconfigure `https://source.taxphobia.top/store.json`, install and verify Legio, then uninstall TaxLauncher while preserving games and backups. + +TaxLauncher migration and its update publication are outside this release PR. + + +## Artwork cache, priority and timing acceptance + +- Use the same games, visible rows, window size and network connection for each comparison. Measure cold-cache and warm-cache startup with logging off, then repeat with application logging on. Record time to the first visible cover, selected-game hero/header/logo, and all visible covers. +- Open a game while Store or Library is still loading covers. Its metadata and essential artwork must get priority. At most five network operations and two image conversions are admitted globally; ordinary work leaves capacity for the opened game. These limits include metadata, Steam artwork metadata and image transfers. +- Open games A, B and A quickly while work is queued. The final A opening must retain its own live demand, without showing B's artwork or errors. +- After three days, metadata revalidates only when the game page opens. After five days, artwork revalidates on opening. List views keep cached content and do not poll expiry. A future cache timestamp is revalidated on opening. +- Test expired caches online and offline. Previous metadata and artwork must remain visible during refresh and after failure. A new image is decoded before it replaces the old generation. A failed refresh must not advance the successful-fetch age; retries use a separate five-minute backoff. Explicit refresh remains available and does not delete the cache first. +- Enabled application logs include bounded `artwork` summaries. Native fields are `request_id`, `resource`, `queue_wait_ms`, `metadata_ms`, `transfer_ms`, `conversion_ms` and `outcome`. The frontend adds `publication_ms` covering display decode and cache publication acknowledgment. Native stage times include preempted attempts; queue wait is separate. Records do not include game names, paths or URLs. A disabled logger produces no new artwork records and log writes do not gate image display. +- Windows latency measurements are pending. Queue and cache tests establish behavior, not a measured speedup over Steam or Hydra. + +## Blur, foreground fade and Dither acceptance + +- With panels at 15% opacity and 6 px blur, repeat navigation with the sidebar expanded and collapsed. Inspect frames during the 105 ms exit and 105 ms entrance, not only settled pages. Sidebar logo, background and panel blur must remain stable. +- Repeat with Dither animated, Dither frozen, particles, and no effect; also test Windows reduced-motion settings. Reduced motion must bypass the timed fade. +- Drag dialog blur repeatedly while Settings opens, stays open and closes. Text remains sharp; the background surface changes live. Check Escape, Cancel and reopening during close, including keyboard focus. +- Resize, maximize, minimize and restore with frozen and animated Dither. It retains the configured background color until a valid frame exists and rebuilds GPU resources after context restoration. Unchanged Dither settings must not redraw a frozen background when another appearance setting changes. +- Native Dither corruption is still an acceptance item. Shaders are unchanged: lifecycle tests do not prove Windows compositor correctness. If corruption recurs, send `app.txt` and a screenshot before/during/after the fade, together with the WebView2 runtime already recorded by native diagnostics. For a specifically requested diagnostic capture, compare one canvas image with the presented window; a correct canvas with a corrupt window points beyond the shader. Do not capture continuously. diff --git a/docs/releases/screenshots/application-logging.png b/docs/releases/screenshots/application-logging.png new file mode 100644 index 00000000..010742c8 Binary files /dev/null and b/docs/releases/screenshots/application-logging.png differ diff --git a/docs/releases/screenshots/home-fullscreen.png b/docs/releases/screenshots/home-fullscreen.png new file mode 100644 index 00000000..a384cd6c Binary files /dev/null and b/docs/releases/screenshots/home-fullscreen.png differ diff --git a/docs/releases/screenshots/home-minimum.png b/docs/releases/screenshots/home-minimum.png new file mode 100644 index 00000000..d22bfe0c Binary files /dev/null and b/docs/releases/screenshots/home-minimum.png differ diff --git a/docs/releases/screenshots/store-availability.jpg b/docs/releases/screenshots/store-availability.jpg new file mode 100644 index 00000000..be5e1173 Binary files /dev/null and b/docs/releases/screenshots/store-availability.jpg differ diff --git a/docs/releases/screenshots/store-sort.jpg b/docs/releases/screenshots/store-sort.jpg new file mode 100644 index 00000000..6ae67f50 Binary files /dev/null and b/docs/releases/screenshots/store-sort.jpg differ diff --git a/docs/superpowers/plans/2026-10-11-artwork-priority-and-cache.md b/docs/superpowers/plans/2026-10-11-artwork-priority-and-cache.md new file mode 100644 index 00000000..062ea16a --- /dev/null +++ b/docs/superpowers/plans/2026-10-11-artwork-priority-and-cache.md @@ -0,0 +1,101 @@ +# Artwork Priority and Cache Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bound artwork work, prioritize the opened game, and refresh cached content only on game-page opening without blanking existing content. + +**Architecture:** A Rust scheduler controls network and conversion admission while existing caches and resource locks retain deduplication. Frontend services manage consumer lifetime, declare selection before page animation, and publish decoded replacement generations. Cache reads are immediate and independent of scheduled refresh. + +**Tech Stack:** Rust 1.98.1, Tokio, existing reqwest/SQLite/image/WebP/UUID dependencies, Tauri 2, Svelte 5, Node 22 test scripts. + +**Spec:** [Approved design](../specs/2026-10-11-artwork-priority-cache-and-rendering-design.md). + +## Global Constraints + +- Remain on `feat/store-filters-release-0.2.0`, version `0.2.0`; PR #183 remains draft until Windows acceptance. +- Maximum five artwork-related network operations, with four available to ordinary work; maximum two conversions, with one ordinary admission slot. +- Metadata freshness is three days; artwork freshness is five days; stale existing entries refresh only on game-page opening or explicit refresh. +- Preserve cached content until a validated replacement is ready; use five-minute failed-refresh backoff for automatic retries. +- Rust owns scheduling and disk operations; Tauri commands remain thin and artwork futures boxed, with a 4096-byte release-profile size budget. +- Application/network logs remain independent, opt-in and disabled by default. No sensitive paths/URLs, per-frame noise, new dependencies or unsafe code. +- Preserve existing cache formats and bounded storage policies. No TaxLauncher migration or release merge in this plan. Git metadata/documentation use English and no em/en dashes. + +## Review Focus + +- Rapid game A/B/A selection while the urgent network slot is occupied: the latest game starts, canceled transfers leave no partial entry, and reusable work resumes. Task 1. +- Two consumers share an image but only one remains mounted during replacement: the remaining old-generation URL stays valid until its release. Task 4. +- WebView reload or abandoned publication: a staged replacement must not erase the last valid persistent image or leak capacity/files. Tasks 1 and 3. +- A metadata URL or language changes before artwork expiry: metadata updates without prematurely replacing artwork, while screenshots remain keyed to valid resource identities. Tasks 2 and 4. +- Cache pruning, corrupt entries or disk-write failure during refresh: limits remain bounded and the last usable image survives; warnings remain actionable. Task 3. + +## File Map and Execution Order + +Create `src-tauri/src/artwork_scheduler.rs` for admission, selection and request lifetime; `src-tauri/src/artwork_publication.rs` for staged replacement ownership; `src/lib/services/artwork-work.ts` for IPC consumer/selection lifetime; and `scripts/test_artwork_work.mjs` for that service's behavioral tests. Modify existing asset/details services rather than moving the whole pipeline. Register native state and commands in `src-tauri/src/lib.rs` and `commands.rs`. + +Modify `steam_details.rs`, `steam_assets.rs`, `steam_pics.rs`, and the frontend details/image services for refresh policy and integration. Extract the metadata service into `src/lib/services/steam-metadata.ts` so image requests can resolve metadata without importing the metadata store and introducing a circular dependency. Update `SteamArtwork.svelte`, `GameDetailView.svelte`, `StoreGameCard.svelte`, `ArtworkTile.svelte`, `ScreenshotGallery.svelte`, `ArtworkViewer.svelte`, `library-artwork.ts` and `App.svelte` only where they declare demand or display lifecycle. Extend existing scripts and CI test invocation. Implement Tasks 1-5 in order; execute the independent rendering plan separately afterward. + +## Task 1: Native Admission and Consumer Lifetime + +**Files:** Create `src-tauri/src/artwork_scheduler.rs`, `src/lib/services/artwork-work.ts`, `scripts/test_artwork_work.mjs`; modify `src-tauri/src/lib.rs`, `commands.rs`, `package.json` and CI test invocation. Native tests live in the new Rust module. + +**Interfaces:** Define `WorkOrigin { Visible, Decorative, Prefetch }`, `WorkStage { Network, Conversion }`, and serializable `WorkError { Cancelled, Failed(String) }`. `ArtworkScheduler::begin(&self, request_id: String, app_id: u32, origin: WorkOrigin) -> Result` returns a clonable handle whose last drop unregisters it; identical live request IDs share the handle, while reuse with a different app/origin is rejected. `select_game(&self, app_id: Option) -> Result<(), WorkError>` changes priority; `cancel(&self, request_id: &str) -> Result<(), WorkError>` cancels a consumerless request. `run_network(&self, work: &WorkHandle, operation: impl FnMut() -> F) -> Result` accepts `F: Future>`. `acquire_conversion(&self, work: &WorkHandle) -> Result` returns an RAII permit held through `spawn_blocking` completion. Register `set_artwork_game` and `cancel_artwork_request` commands accepting app ID/request ID and scheduler state. Export frontend `ArtworkWork { requestId: string; origin: "visible" | "decorative" | "prefetch"; release(): void }` and `createArtworkWork(appId: number, origin: ArtworkWork["origin"]): ArtworkWork`; release is idempotent and forwards cancellation through the dedicated service. + +- [ ] **Write failing Rust tests** named `ordinary_network_leaves_urgent_capacity`, `selection_promotes_pending_work`, `selection_preempts_obsolete_urgent_transfer`, `conversion_reserves_capacity_without_preempting_codec`, and `cancelled_waiter_releases_queue_and_capacity`. Use controlled futures/notifications, not wall-clock sleeps. Assert four ordinary transfers start while the fifth waits; selecting another app starts its urgent transfer with the four still blocked, total never exceeds five; ordinary conversion admission is one and total is two; queued urgent work precedes ordinary work; A/B/A changes release obsolete urgent network capacity without duplicating the same live request. Drop/cancel all handles and assert no admission remains held. Add a frontend test asserting one cancellation IPC call after repeated release of the same work object. +- [ ] **Run the red tests:** `cargo test --manifest-path src-tauri/Cargo.toml --locked --lib artwork_scheduler::tests::` and `node --conditions=browser --test scripts/test_artwork_work.mjs`. Expect compilation/import failure for missing interfaces, then assertion failures until behavior exists. +- [ ] **Implement the interfaces** using existing Tokio notification/synchronization primitives, per-stage FIFO ticket queues, current-game promotion, and RAII cleanup. Never hold the state mutex across await. Cancel/restart an obsolete urgent network operation through its repeatable closure when it still has demand; abandon it if its handle is canceled. Do not cancel a running blocking codec; prevent further obsolete admissions while it finishes. Selection commands are synchronous and perform no network/codec work. +- [ ] **Run the tests green** plus `node --conditions=browser --test scripts/test_artwork_work.mjs` and `cargo fmt --manifest-path src-tauri/Cargo.toml --check`. All scheduler assertions must pass, including cancellation after admission and before operation completion. Register the frontend script in CI. +- [ ] **Commit:** `feat: prioritize artwork work with bounded native admission`. + +## Task 2: Metadata Reads and Page-Open Freshness + +**Files:** Modify `src-tauri/src/steam_details.rs`, `commands.rs`, `lib.rs`, `src/lib/services/steam-details.ts`, `src/lib/stores/steam-details.ts`; create `src/lib/services/steam-metadata.ts` and `scripts/test_steam_details_cache.mjs`; update `package.json` and `.github/workflows/ci.yml` for its test command. + +**Interfaces:** Define serialized `DetailsPolicy { CacheOrFetch, RefreshIfExpired, ForceRefresh }` with snake_case wire values; export the matching TypeScript string union and existing metadata types from `steam-metadata.ts`, re-exporting them from the old service for compatibility. Native `get_details_localized_with_policy(app: AppHandle, network: &NetworkState, app_id: u32, policy: DetailsPolicy, language: LanguagePreference, work: &WorkHandle) -> Result` replaces the boolean entry point while preserving its old wrapper for internal callers/tests until converted. A managed `DetailsFetchState` deduplicates native in-flight work by app ID/language and removes unused locks. The `get_steam_details` command receives `policy`, `requestId` and `origin`, creates the Task 1 handle, and boxes the service future. Frontend `getSteamDetails(appId: number, policy: DetailsPolicy, work: ArtworkWork): Promise` belongs to `steam-metadata.ts`. Store `loadSteamDetails(appId: number, policy: DetailsPolicy, work?: ArtworkWork): Promise` creates/releases work when none is supplied. `ensureSteamDetails(appId: number): void` uses `cache_or_fetch`; `refreshSteamDetailsOnOpen(appId: number, work: ArtworkWork): Promise` reads cached state first, then invokes `refresh_if_expired`. `ArtworkWork` comes from Task 1. + +- [ ] **Write failing tests** `stale_list_metadata_returns_without_fetch`, `page_open_refreshes_at_three_day_boundary`, `failed_refresh_preserves_previous_and_does_not_advance_age`, `future_timestamp_revalidates_only_on_open`, and frontend `localized_refresh_does_not_invalidate_artwork`. With an injected test clock/fetch closure, assert no network at `3*86400-1` seconds, refresh at `3*86400` only on page opening, and no refresh from repeated list reads or elapsed timers. Missing/corrupt metadata still fetches. Cache keys remain app ID plus resolved language. A failed refresh retains previous fields and timestamp; rapid reopen observes the 300000 ms automatic backoff, while explicit refresh retries immediately. +- [ ] **Run red:** `cargo test --manifest-path src-tauri/Cargo.toml --locked --lib steam_details::tests::` and `node --conditions=browser --test scripts/test_steam_details_cache.mjs`. New assertions must fail against the old one-day/blocking-refresh behavior. +- [ ] **Implement cache-first policy** with a three-day threshold, scheduled metadata network work through Task 1, and independent read/refresh publication in the frontend store. A cached result must reach the store before refresh is awaited. Preserve existing localized data and status while refreshing. Recheck the cache after acquiring a shared fetch lock; a concurrent refresh that waited for another successful refresh reuses its result rather than forcing another fetch. Missing/corrupt entries are fetched under the same lock. Route structured cancellation as control flow rather than an error for an obsolete consumer. The metadata service deduplicates compatible IPC requests and can be imported by the image service without importing the store. +- [ ] **Run green**, then existing `scripts/test_regressions.mjs` and frontend check. Add the new script to the existing regressions/CI path rather than leaving it unexecuted. +- [ ] **Commit:** `feat: refresh cached game metadata only on page opening`. + +## Task 3: Safe Artwork Replacement and Scheduled Native Stages + +**Files:** Create `src-tauri/src/artwork_publication.rs`; modify `steam_assets.rs`, `steam_pics.rs`, `commands.rs`, `lib.rs`; keep codec transforms in `image_trim.rs`. Extend native cache/asset tests. + +**Interfaces:** Define `AssetDemand { request_id: String, origin: WorkOrigin, publication: PublicationMode }`, `PublicationMode { Display, NativeOnly }`, and `PublicationOutcome { Displayed, Abandoned, Rejected }`. `get_asset_for_request(app: AppHandle, network: &NetworkState, request: AssetRequest, demand: AssetDemand) -> Result` uses `AssetRequest { app_id: u32, asset: AssetKind, index: Option, full: bool, policy: AssetPolicy, screenshot_identity: Option }`, with `AssetPolicy { CacheOrFetch, RefreshIfExpired, ForceRefresh }`. A screenshot identity is its selected validated metadata URL, checked against cached metadata and hashed with existing SHA256 for the native key; it is not logged. Non-screenshot identities remain app/asset/profile and do not change with metadata timestamps. Preserve existing `get_asset` as a small adapter for native callers/tests, then use the new entry point in commands. Add nullable `replacementToken: string` to the image-response metadata. `finish_steam_asset_update(token: String, outcome: PublicationOutcome) -> Result` accepts only an opaque registered UUID, never a frontend path; `PublicationResult { cache_warning: Option }` acknowledges native persistence. Use snake_case enum wire values and camelCase fields consistently. + +- [ ] **Write failing tests** `cached_artwork_ignores_expiry_outside_game_open`, `page_open_refreshes_at_five_day_boundary`, `rejected_replacement_preserves_disk_generation`, `abandoned_valid_replacement_finishes_as_prefetch`, `staged_replacement_survives_write_failure`, and `nested_blur_does_not_hold_network_or_conversion_capacity`. Assert the five-day boundary at `5*86400` seconds, no fetch from stale list/prefetch reads, and old bytes remain readable before successful publication. Wrong/duplicate tokens cannot replace another asset. Simulate restart cleanup and prune with pending publication; assert last usable entries survive, staged files stay bounded, and rejected data never becomes fresh. +- [ ] **Run red:** `cargo test --manifest-path src-tauri/Cargo.toml --locked --lib steam_assets::tests::` and `cargo test --manifest-path src-tauri/Cargo.toml --locked --lib artwork_publication::tests::`. Exercise local deterministic HTTP fixtures and controlled conversion work, not the ignored live Steam test. +- [ ] **Implement five-day cache policy and staging.** Return existing bytes without waiting for admission or PICS. For screenshots, perform only the cached metadata identity check first; accept a legacy index-based cache entry only when its stored URL matches that identity. Gate each actual CDN/details/PICS network operation with the same work handle; gate each codec independently and release permits before nested dependencies. PICS login/product-info/logout is one bounded operation. Preserve the resource lock's successful deduplication but never memoize cancellation as a failed asset for other live consumers. Keep the previous disk entry until the Display outcome succeeds; NativeOnly/Abandoned validated output commits directly. Use temporary files plus atomic replacement and existing storage limits. Maintain registered staging ownership; discard orphan stages on startup while preserving committed entries. Never reset all artwork before refresh. +- [ ] **Run green** including `image_trim::tests::` and the command future-size test. Expand that test for changed command arguments and the new publication command; all artwork command futures remain at most 4096 bytes. Confirm a nested hero/blur request can progress under saturated ordinary work. +- [ ] **Commit:** `feat: publish artwork refreshes without discarding cached images`. + +## Task 4: Frontend Demand, Selection and Image Generations + +**Files:** Extend `src/lib/services/artwork-work.ts`, `scripts/test_artwork_work.mjs` from Task 1; modify `src/lib/services/steam-details.ts`, `src/lib/stores/steam-details.ts`, `library-artwork.ts`, `App.svelte`, `SteamArtwork.svelte`, `GameDetailView.svelte`, `StoreGameCard.svelte`, `ArtworkTile.svelte`, `ScreenshotGallery.svelte`, `ArtworkViewer.svelte`, existing artwork-cache/library tests, package scripts and CI. + +**Interfaces:** Consume Task 1's `ArtworkWork` and `createArtworkWork`. Add `startArtworkSelectionTracking(): () => void`; `acquireSteamImage(request: SteamImageRequest, origin: ArtworkWork["origin"]): { image: SteamImage | undefined; promise: Promise; release(): void }`; and `refreshSteamImagesOnOpen(appId: number, work: ArtworkWork): Promise`. Add `screenshotIdentity?: string` to `SteamImageRequest`, populated from metadata by gallery/viewer callers and used only for screenshot keys. Introduce `requestSteamAsset(request: SteamImageRequest, policy: AssetPolicy, work: ArtworkWork, publication: "display" | "native_only"): Promise` for scheduled loads; keep `getSteamAsset` as a compatibility adapter using native-only publication for direct non-display callers. Keep `retainSteamImage`/`peekSteamImage` for existing presentation callers. Selection tracking derives the actual Steam app ID from Store selection or the selected Library game and serializes native selection updates, coalescing obsolete pending values. Begin tracking in `App.svelte` before page entrance work; release on teardown. Extend `SteamAsset` with Task 3's nullable replacement token and export matching asset policy/publication outcome wire unions; pass demand fields only through the service module. + +- [ ] **Write failing behavioral tests** for `cached_image_survives_pending_refresh`, `replacement_waits_for_display_decode`, `releasing_old_generation_does_not_revoke_another_consumer`, `details_timestamp_change_preserves_artwork_identity`, `consumerless_request_cancels_native_work`, `selection_is_declared_before_page_mount`, and `rapid_selection_delivers_latest_native_target`. Mock delayed IPC and `Image.decode()` with controlled promises; assert the old URL remains displayed/unrevoked until replacement succeeds, decode rejection acknowledges Rejected and retains old content, abandonment acknowledges Abandoned, and no `reset_steam_artwork_cache` call occurs. Distinguish actual screenshot identity changes from metadata timestamp-only changes. +- [ ] **Run red:** `node --conditions=browser --test scripts/test_steam_image_cache.mjs scripts/test_artwork_work.mjs scripts/test_steam_details_cache.mjs scripts/test_library_artwork.mjs`. New cases must fail before changing the service. +- [ ] **Implement consumer handles and generation publication.** Share resource work across consumers, decrement the exact captured image generation on release, and cancel native work only when all demand leaves. Remove metadata timestamps from artwork identities while preserving app/asset/index/full/display profile and actual screenshot identity. Retain fallback identities independently. Decode a candidate object URL, acknowledge native staging, then publish revision/state; failed acknowledgment may display validated output with its cache warning while preserving the older disk entry. Remove expiry timers and automatic stale refresh from list consumers. Opening details checks metadata and cached artwork variants exactly once; explicit refresh uses ForceRefresh without destructive reset. Before a missing header/capsule/screenshot request, resolve its metadata dependency through Task 2's CacheOrFetch service; cache hits and independent heroes do not wait for textual metadata. Remove the Library hero's rendering dependency on textual metadata. Declare headers/metadata as visible, list blur as decorative, and recent-game hero work as prefetch; load optional gallery work after essential assets. +- [ ] **Run green** and frontend check/lint. Existing fallback recovery tests must be updated to page-open triggers while continuing to prove shared fallback lifetime. Add all new scripts to CI. Rapid selection must not show a prior game's assets or errors. +- [ ] **Commit:** `feat: retain artwork generations and promote opened game assets`. + +## Task 5: Timing Evidence and Integrated Verification + +**Files:** Modify `src-tauri/src/artwork_scheduler.rs`, asset/details modules, `src/lib/services/application-log.ts`, relevant logging tests, `.github/workflows/ci.yml`, `docs/releases/0.2.0-windows-test.md`. + +**Interfaces:** Use a native operation summary with `request_id`, resource kind, `queue_wait_ms`, `metadata_ms`, `transfer_ms`, `conversion_ms`, and outcome; publication timing is recorded by the frontend service through `logApplicationEvent` with a new `artwork` event. No URLs or filesystem paths. Timing collection must not await log writes before image publication. + +- [ ] **Write failing logging tests** proving disabled logging emits no artwork records; enabled logging emits bounded phase summaries without URLs/paths; a blocked logging sink does not delay cache display or publication. Extend native integrated tests for mixed metadata/PICS/CDN work sharing the five-operation limit and for cache hits while both queues are saturated. +- [ ] **Run red** through the existing logging and artwork test commands, then implement the summaries without per-frame/per-function noise. +- [ ] **Run green and required checks:** `cargo fmt --manifest-path src-tauri/Cargo.toml --check`, `cargo clippy --manifest-path src-tauri/Cargo.toml --locked --all-targets --all-features -- -D warnings`, `cargo test --manifest-path src-tauri/Cargo.toml --locked --all-features`, `corepack pnpm check`, `corepack pnpm lint`, `corepack pnpm build`, and every frontend test script listed in CI. Use the installed Node package CLI directly if the local pnpm wrapper is unavailable. Existing ignored external-service tests stay documented; no new ignored regressions. +- [ ] **Record Windows measurements** with the same games/viewport/network: cold versus warm cache, logging off versus on, first visible cover, essential selected-game artwork, complete visible rows, and phase summaries. Check aged-cache/offline refresh while old content remains visible. An installer produced for measurement is a test artifact; do not claim latency improvements from mocks or queue tests alone. Record pending native checks honestly if the affected Windows environment is only available to the user. +- [ ] **Commit:** `test: verify artwork priority cache lifetime and loading timings`. Proceed to the independent rendering plan; keep release merge pending. + +## Self-Review and Handoff + +Coverage: scheduler/selection/cancellation -> Task 1; metadata policy/localization -> Task 2; persistent generation safety/PICS/codec/cache limits -> Task 3; presentation lifetime/refresh triggers -> Task 4; opt-in timing, integration and Windows performance -> Task 5. All five Review Focus items have named tests in their owning tasks. Native and frontend error contracts must be converted at service/command boundaries, without exposing raw serialized objects as UI messages. + +The user approved both plans and inline execution with one independent whole-branch review. Recommend native execution because the task interfaces are tightly coupled, followed by an independent whole-branch review. Subagent-driven execution remains available with a fresh implementer/reviewer gate per task. diff --git a/docs/superpowers/plans/2026-10-11-stable-blur-and-dither-rendering.md b/docs/superpowers/plans/2026-10-11-stable-blur-and-dither-rendering.md new file mode 100644 index 00000000..a29ccdbe --- /dev/null +++ b/docs/superpowers/plans/2026-10-11-stable-blur-and-dither-rendering.md @@ -0,0 +1,88 @@ +# Stable Blur and Dither Rendering Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep dialog text sharp during live blur changes and retain stable sidebar/background/glass rendering through navigation, with evidence for the native Dither defect. + +**Architecture:** A stationary decorative dialog surface owns blur; foreground entrance/exit is separate. Page transitions animate only safe foreground subtrees, never the document root or an ancestor of blur-bearing content. Dither gains measured lifecycle diagnostics/recovery; any corruption correction follows native reproduction rather than speculative shader changes. + +**Tech Stack:** Svelte 5, CSS, Web Animations API, existing WebGL2 renderer, Node 22/JSDOM tests, Windows Tauri/WebView2 acceptance. + +**Spec:** [Approved design](../specs/2026-10-11-artwork-priority-cache-and-rendering-design.md). + +## Global Constraints + +- Retain version `0.2.0`, existing release branch and draft PR #183. No merge before user Windows acceptance; defer TaxLauncher migration and the other developer's transparency issue. +- Preserve live slider preview, dialog open/close/focus/keyboard handling, Dither controls and visual style, and reduced-motion support. +- Sidebar, logo, title bar and application background remain visually stable during navigation. Glass surfaces retain their background access. +- Do not replace Dither, remove the approved page fade, globally disable hardware acceleration, or add a generic rendering framework/dependency as a workaround. +- Logging remains opt-in, disabled by default, bounded and free of sensitive paths/URLs or per-frame output. +- JSDOM/mocked WebGL checks do not establish native Windows visual correctness. No unsupported GPU cause or fixed claim. +- Reuse CSS/layout conventions; Git metadata/documentation use English and no em/en dashes. Preserve the artwork command stack-overflow regression fix. + +## Review Focus + +- Rapid navigation during exit/entrance with a selected game: latest target wins, obsolete page never reappears, and artwork priority is not delayed by the fade. Task 2. +- Live dialog blur while opening/closing or dismissing with Escape: text remains readable, close completion fires once and focus/lifetime behavior remains correct. Task 1. +- A nested or Tailwind blur surface missed by transition targeting: no animated ancestor cuts off backdrop access. Task 2. +- GL context loss or resize while hidden/frozen: resources recover without an extra frame loop or transparent/invalid output. Task 3. +- WebGL pixels remain correct while the compositor corrupts a snapshot: preserve the evidence and avoid changing shader mathematics as an unsupported fix. Tasks 3 and 4. + +## File Map and Execution Order + +Modify `src/lib/components/ui/Dialog.svelte`, `src/app.css` and `scripts/test_dialog.mjs` for dialog layering. Create `src/lib/utils/foreground-transition.ts` for narrowly scoped DOM animation; modify `MainContainer.svelte`, `motion.ts` and `scripts/test_visual_rendering.mjs`. Modify `DitherBackground.svelte` and `AppBackground.svelte`; create `src/lib/components/layout/dither-renderer.ts` to own the existing GL resource lifecycle rather than duplicating it. Extend `scripts/test_appearance.mjs` and visual tests. Update Windows acceptance documentation and existing CI script coverage. This plan is independently testable; run after the artwork plan for the combined test installer. + +## Task 1: Stationary Dialog Blur and Live Preview + +**Files:** Modify `Dialog.svelte`, `src/app.css`, `scripts/test_dialog.mjs`, `scripts/test_appearance.mjs`; modify `SettingsGroup.svelte` only if nested-surface evidence requires it. + +**Interfaces:** Keep all existing Dialog props and snippet APIs. Add a decorative `div.legio-dialog-surface[aria-hidden=true]` before `div.legio-dialog-content`; expose no new public component props. Keep `data-visible` on the dialog for open/close state. Target the content explicitly by class in close-completion handling rather than assuming `firstElementChild`. + +- [ ] **Write failing tests** `live_blur_targets_surface_not_foreground`, `dialog_close_waits_only_for_content_transition`, and `blur_drag_during_close_notifies_once`. Change blur variables repeatedly while open/closing; assert the dialog/content have no backdrop filter or permanent `will-change: opacity`, the decorative surface owns the live filter, and close does not react to surface or nested transition events. Preserve reopen-during-close and Escape/cancel tests. Do not use source-string assertions as a substitute for mounted lifecycle checks. +- [ ] **Run red:** `node --conditions=browser --test scripts/test_dialog.mjs scripts/test_appearance.mjs`. Expect failures against the old filter owner and first-child close assumption. +- [ ] **Implement stationary layering.** Keep dialog opacity at one; decorative surface is positioned behind the relative foreground, clipped to the dialog radius, and does not receive pointer events or continuously interpolate backdrop-filter. Content retains its existing 180 ms entrance/exit but no permanent promotion after settling. Preserve top-layer visibility behavior and focus. Do not disable the slider or postpone preview until release. Keep nested panel blur changes separate until the layered dialog test identifies whether they remain necessary. +- [ ] **Run green** plus frontend check/lint. In a real browser inspect computed filter ownership during drag; record a native Windows drag/open/close sequence for visual acceptance. If the surface approach reproduces the earlier bloom defect, stop and isolate that failure before proceeding rather than combining more CSS guesses. +- [ ] **Commit:** `fix: isolate live dialog blur from settings foreground`. + +## Task 2: Foreground-Only Page Fades + +**Files:** Create `src/lib/utils/foreground-transition.ts`; modify `MainContainer.svelte`, `motion.ts`, `src/app.css`, `scripts/test_visual_rendering.mjs`. Keep `App.svelte`, Sidebar and background ownership unchanged. + +**Interfaces:** `collectForegroundTargets(root: HTMLElement): HTMLElement[]` returns non-overlapping safe foreground subtrees. `animateForeground(root: HTMLElement, direction: "in" | "out", duration: number): { finished: Promise; cancel(): void }` uses those targets. Total navigation duration stays 210 ms: 105 ms outgoing foreground, swap, 105 ms incoming foreground; reduced motion uses zero. MainContainer keeps a monotonically increasing navigation-request guard and cancels obsolete animation handles. + +- [ ] **Write failing tests** `navigation_keeps_sidebar_and_canvas_nodes`, `foreground_targets_never_contain_backdrop_surface`, `rapid_navigation_never_republishes_obsolete_target`, and `logging_does_not_gate_page_swap`. Mount representative nested `.legio-glass`, `.legio-panel`, a computed Tailwind-like backdrop filter, and plain text/artwork. Assert every animated target has no own/descendant blur surface, no target is an ancestor of another target, stationary surface opacity stays one, and only the latest navigation swaps content. Block logging promises and confirm navigation still completes. Reduced motion must create no timed entrance/exit. +- [ ] **Run red:** `node --conditions=browser --test scripts/test_visual_rendering.mjs`. Existing whole-document snapshot expectations must be replaced by behavior checks for the approved stable-background contract. +- [ ] **Implement safe targeting and transition ownership.** Walk the mounted page once, computing whether a subtree contains protected surfaces. Protect `.legio-glass`/`.legio-panel` even when their current blur is zero, plus elements with non-none computed `backdrop-filter`/`-webkit-backdrop-filter`; keep their ancestors unanimated and descend to eligible foreground children. Animate eligible subtrees via Web Animations API. Do not animate text nodes without an element owner. Remove native document-root snapshot crossfade CSS/calls and the full page-wrapper opacity entrance. Keep visible old surfaces until the exit finishes, swap with `flushSync`, then animate the new foreground. No `view-transition-name` is placed on a common page ancestor. Cancel safely during teardown and rapid navigation; fire diagnostics without awaiting them. The artwork plan declares urgent selection before this page delay. +- [ ] **Run green**, then browser frame inspection with sidebar expanded/collapsed and panel opacity 15%, blur 6 px under Dither, particles and no background. Confirm protected surfaces retain blur before/during/after each phase. If safe targeting would require replacing the renderer or removing fade, follow the spec's focused design-revision gate rather than silently broadening scope. +- [ ] **Commit:** `fix: fade page foreground without snapshotting application chrome`. + +## Task 3: Dither Lifecycle and Corruption Evidence + +**Files:** Create `src/lib/components/layout/dither-renderer.ts`; modify `DitherBackground.svelte`, `AppBackground.svelte`, `scripts/test_visual_rendering.mjs`, `scripts/test_appearance.mjs`; reuse `dither-gl.ts` shader strings unchanged unless native evidence identifies a shader defect. + +**Interfaces:** `createDitherRenderer(canvas: HTMLCanvasElement, onDiagnostic: (event: DitherDiagnostic) => void): DitherRenderer | null`; `DitherRenderer { resize(width: number, height: number): void; draw(frame: DitherFrame): boolean; dispose(): void }`; `DitherFrame { time: number; settings: DitherSettings; accent: string; mouse: [number, number]; ratio: number }`; `DitherDiagnostic { event: "context_lost" | "context_restored" | "framebuffer_incomplete" | "render_failed"; detail: string }`. Renderer owns GL allocation/validation/restoration; the Svelte component retains animation, visibility and pointer scheduling. Diagnostic callbacks forward bounded warnings through the existing application logger. + +- [ ] **Write failing lifecycle tests** `unrelated_blur_preview_does_not_redraw_frozen_dither`, `context_restoration_rebuilds_resources_and_draws_valid_frame`, `incomplete_framebuffer_does_not_publish_invalid_frame`, and `hidden_resize_recovers_without_unbounded_frame_loop`. Use a stateful GL fake that records actual lifecycle calls and resource identities, not an always-success proxy. Assert one initial frozen draw, no extra draw for dialogBlur-only changes, resource recreation after context restoration, no second-pass publication on incomplete framebuffer, and at most one scheduled frame while frozen/hidden. Preserve persistent drawing-buffer coverage. +- [ ] **Run red:** `node --conditions=browser --test scripts/test_visual_rendering.mjs scripts/test_appearance.mjs`. +- [ ] **Implement resource lifecycle and diagnostics.** Reuse the two-pass shaders, check framebuffer completeness after allocation/resize, handle context loss with the appropriate preventDefault/recovery behavior, and tear down all listeners/resources once. Show the configured background while no valid output exists; retain valid output where possible. Compare Dither settings by meaningful values so cloning an unchanged settings object does not restart a frozen renderer. Preserve pointer interaction, pause/resume, DPR limits and reduced motion. No readback or graphics error logging in every animation frame. +- [ ] **Collect native Windows evidence before a corruption-specific patch.** Capture frames before/during/after navigation and dialog dragging; record WebView2/runtime and graphics capability information plus context/framebuffer events. Compare animated/frozen Dither, particles/no effect, and the old native snapshot path against Task 2 foreground fades. For one diagnostic sample use an explicitly requested canvas capture, outside the frame loop, and compare its pixels with the presented window. Correct canvas plus corrupt presentation localizes the defect beyond the shader. If the defect persists and requires a different renderer, stop for the focused design revision required by the spec; otherwise implement the minimal correction supported by this evidence, then repeat the same native sequence. +- [ ] **Run green** for lifecycle/appearance/visual tests and native reproduction. Keep native visual acceptance pending if only the user can supply the affected Windows environment. Do not claim GPU corruption fixed from fake-GL tests. +- [ ] **Commit:** `fix: recover dither rendering and avoid unrelated redraws`; describe any evidence-backed compositor correction concretely in its own commit. + +## Task 4: Combined Review, Windows Installer and Acceptance + +**Files:** Modify `docs/releases/0.2.0-windows-test.md`; amend CI test coverage only if earlier tasks added scripts. Use existing NSIS installer and matching-symbol artifact workflow. + +**Interfaces:** No new product interface. PR #183's final head identifies the installer and PDB; `build-info.txt` and SHA256 sums accompany them. + +- [ ] **Run required verification:** frontend check/lint/build and all CI frontend test scripts; Rust format, Clippy with `--all-targets --all-features -- -D warnings`, all-features tests, and existing release-profile artwork command-future budget checks. Any new native scheduling/publication tests run in Windows CI as well as Linux. Record actual output and intentional external-test skips. +- [ ] **Perform independent whole-branch review** against both plans and the approved spec. Resolve actionable findings before final verification. Compare changed files with the planned scope; retain no temporary reproduction flags or default-on diagnostics. Do not merge the PR. +- [ ] **Update Windows acceptance instructions** with rapid A/B/A game opening under queued artwork, three/five-day stale-cache refresh while offline/online, repeated live blur dragging during dialog lifecycle, Dither animated/frozen with opacity 15%/blur 6 px, sidebar expanded/collapsed, repeated page transitions, resize/fullscreen and reduced motion. Document phase measurements from the artwork plan and any unresolved native graphics evidence. Require during-animation frame checks, not only settled screenshots. +- [ ] **Commit, push the updated draft PR and inspect CI results.** Produce the Windows test installer and matching PDB through the existing workflow. Verify artifact revision and installer checksum, make the installer available to the user, and state which native acceptance checks remain for them. If copying into the shared VM folder, request the normal filesystem escalation rather than changing paths or weakening permissions. +- [ ] **Wait for the user's Windows acceptance.** Keep PR draft and TaxLauncher migration pending; release merge is a subsequent explicitly authorized action after checks and acceptance. + +## Self-Review and Handoff + +Coverage: dialog filter ownership/lifecycle -> Task 1; stable sidebar/background/page animation/backdrop boundaries -> Task 2; Dither lifecycle, unrelated redraws and measured corruption investigation -> Task 3; independent review, checks, installer and native acceptance -> Task 4. Every Review Focus condition has a named test or an explicitly native evidence step. The Dither correction is deliberately evidence-gated rather than a guessed shader rewrite. + +The user approved both plans and inline execution with one independent whole-branch review. Recommend native execution with one independent whole-branch review because the foreground targeting and graphics evidence require continuity of context. Subagent-driven execution provides separate implementation/review gates per task at higher context cost. diff --git a/docs/superpowers/plans/2026-10-12-artwork-cache-and-fade-recovery.md b/docs/superpowers/plans/2026-10-12-artwork-cache-and-fade-recovery.md new file mode 100644 index 00000000..9ae69132 --- /dev/null +++ b/docs/superpowers/plans/2026-10-12-artwork-cache-and-fade-recovery.md @@ -0,0 +1,36 @@ +# Artwork cache and foreground fade recovery + +Release 0.2.0 and the TaxLauncher migration remain suspended. This change prepares a draft PR test installer only. + +## Requirements + +- Keep Library blurred covers, icons, logos, capsules and headers on disk until explicit refresh or reset. +- Refresh textual metadata after three days and sharp detail artwork after five days only when opening game details. Display existing images during replacement. +- Prioritize missing visible artwork before background refreshes, with selected-game visible requests first among visible misses. +- Bound the image pipeline through download, conversion, blur and cache publication. Keep capacity for selected-game demand. +- Stop outgoing-page and offscreen demand while preserving images during the foreground fade. +- Keep native Skia composition and protected glass surfaces stable. + +## Implementation and evidence + +1. Native cache: permanent classes are excluded from age expiry and automatic pruning. A blur derived from an old cached sharp image is persisted. Regression tests cover reopening the disk cache. +2. Scheduler: at most five image/network slots, with one reserved for selected demand; two codec slots without a selected game. Image slots survive conversion and publication. Shared image and metadata owners inherit joining visible demand. Visible misses precede refreshes. +3. Presentation: page and viewport scopes release demand before outgoing fade completes. Library tiles no longer request textual metadata. Global startup hero prefetch is removed. Permanent classes bypass automatic detail-open refresh while explicit refresh still works. +4. Fade: underlying opacity is set to its terminal value before Web Animations. Detached outgoing layers remain transparent. Existing CSS opacity and connected-node cancellation are preserved. +5. Final review: continuous sidebar observation and cancelled metadata negative caching were corrected with failing regression tests followed by passing tests. Metadata ownership is registered before the first asynchronous cache read. + +## Validation limits + +The isolated native WebKitGTK 2.54.1 X11 probe showed monotonic outgoing/incoming alpha after the fix. Wayland opened successfully, but native window readback could not verify pixels. Full application behavior on Hyprland and Windows still needs user acceptance. No claim is made that every driver or compositor configuration is fixed. + +The Windows installer must be built from this PR head, with build-info and SHA256 verified. No merge, release or production update is authorized while testing is pending. + +## Final validation + +- Native suite: 466 passed, four pre-existing ignored live/platform tests; no failures. +- Clippy: all targets and features, warnings denied, passed. +- Svelte check: no errors or warnings. ESLint and production frontend build passed. +- Focused Library rendering tests: 15 passed, including page cancellation, retained bitmap and continuous sidebar observation. +- Image cache tests: 21 passed, including permanent assets and explicit refresh. +- Metadata cancellation regression failed before the fix and passed afterwards. +- The initial sandboxed native run could not bind local test servers or write an installed-profile test path. The rerun with those permissions passed. diff --git a/docs/superpowers/plans/2026-10-12-frame-driven-navigation.md b/docs/superpowers/plans/2026-10-12-frame-driven-navigation.md new file mode 100644 index 00000000..5bcb3ddb --- /dev/null +++ b/docs/superpowers/plans/2026-10-12-frame-driven-navigation.md @@ -0,0 +1,31 @@ +# Frame-driven navigation and unavailable updater platform + +The maintainer confirmed Windows acceptance for 219ece2, then confirmed Hyprland still flashes with every background, including None. They explicitly requested removing the current animation and rebuilding it. The release and TaxLauncher migration remain suspended. + +## Design + +Replace the entire compositor-animation implementation with one requestAnimationFrame clock for each outgoing/incoming phase. Every foreground target receives an ordinary style opacity update in the same paint frame. No Web Animations effects, whole-document snapshots, transforms or extra blur filters are created. Keep the established protected-glass target selection, navigation request guards, demand cancellation and reduced-motion behavior. Cancellation restores original inline opacity and priority for connected nodes; retired outgoing nodes stay transparent. + +The renderer follows normal style invalidation and painting in WebKit's Skia pipeline. This is a compatibility approach, not a claim that WebKit mandates this API or that its underlying regression has been repaired. + +## Updater error + +The installed updater plugin reports TargetNotFound/TargetsNotFound when the feed has no matching platform. Represent that as unavailable and show a readable platform availability message. Do not report the application as current. Clear an earlier available installer when checking again so a failed check cannot install stale content. Preserve real network/manifest errors. + +## Evidence and verification + +- New regression tests failed against the old compositor animation, then passed with the frame clock. +- Tests cover midpoint/end opacity, one clock across targets, protected glass, CSS opacity/priority, cancellation and obsolete navigation. +- Updater regression failed before implementation and passed afterwards, including stale installer invalidation and preserved network errors. +- Independent final review found no important concrete issue. Visible Hyprland acceptance remains required. +- Linux test artifacts use an optimized release build with the embedded production frontend, matching the Windows build profile. The earlier Linux debug artifact is unsuitable for direct codec-performance comparison. +- Diagnostic native sessions use isolated database/cache copies. No original profile is modified. + +## Completed local validation + +- All frontend test scripts passed, including application logging adapted to the explicit frame clock. +- Svelte check: zero errors and warnings. ESLint and production build passed. +- Optimized native Linux application with embedded frontend built successfully. +- A real Hyprland Wayland session was verified through its process/window backend, with background None and an isolated profile. Physical window captures showed one outgoing fade followed by one incoming fade, without a return to the outgoing page in that run. Navigation generated no native compositor animation effects. This is limited local evidence, not acceptance on every configuration. +- The updater settings showed platform unavailability, without the raw fallback-platform error and without claiming the application is current. +- Windows acceptance of this new animation remains separate from the earlier 219ece2 acceptance. diff --git a/docs/superpowers/plans/2026-10-12-linux-skia-transition.md b/docs/superpowers/plans/2026-10-12-linux-skia-transition.md new file mode 100644 index 00000000..b47385a3 --- /dev/null +++ b/docs/superpowers/plans/2026-10-12-linux-skia-transition.md @@ -0,0 +1,16 @@ +# Linux Skia transition + +Approved implementation scope: adopt WebKitGTK's current compositor without rewriting the UI. Windows continues to use WebView2. + +1. Remove the Hyprland startup override that selects TextureMapper. Keep explicit user environment overrides available for diagnosis. +2. Use the loaded WebKit version and per-view preferences to configure and report the Linux renderer. Retain GTK full-window invalidation only for legacy composition on transparent windows. Use a separate native probe for damage-limited composition with settings applied before page creation, leaving the application's engine default unchanged. +3. Read feature APIs dynamically because Tauri's current bindings predate them and older distribution runtimes must remain loadable. Check all symbols and feature availability, report unavailable APIs and features. The native probe validates requests and applies settings before constructing the WebView. +4. Add opt-in native diagnostics for backend, loaded engine, composition settings, transport overrides and WebProcess termination. Record no user paths or display names. +5. Verify policy boundaries and real feature read/write on the installed engine, compile, lint and run the Rust suite. Document affected-machine acceptance and controlled-runtime requirements. Build checks alone do not establish a visual fix. + +Runtime packaging is a separate deliverable: the existing AppImage/deb/rpm pipeline does not by itself pin the host WebKit engine. Do not describe an ordinary package as an isolated runtime. + +## Final review correction + +- Important: live feature updates cannot establish a changed compositor because WebKit 2.54 latches those values at renderer construction. Removed all live writes from Legio. The separate native probe applies settings before WebView creation. `unsupported_live_damage_override_is_reported_instead_of_claimed_applied` failed before implementation and now covers rejection of an unsupported live switch. Native probe validation and final suite results are recorded in the verification document. +- Minor: requested-feature errors were ambiguous. Removing the ineffective production write paths also removes those errors; the standalone probe reports its exact missing feature. No separate polish change was made. diff --git a/docs/superpowers/specs/2026-10-11-artwork-priority-cache-and-rendering-design.md b/docs/superpowers/specs/2026-10-11-artwork-priority-cache-and-rendering-design.md new file mode 100644 index 00000000..7c5ee054 --- /dev/null +++ b/docs/superpowers/specs/2026-10-11-artwork-priority-cache-and-rendering-design.md @@ -0,0 +1,112 @@ +# Artwork priority, cache freshness and stable rendering + +Date: 2026-10-11 + +Status: Written specification approved by the user on 2026-10-11; both implementation plans and inline execution with one independent review were approved. + +Baseline: `feat/store-filters-release-0.2.0` at `733b0a5`, draft PR #183. + +## Purpose and scope + +Make Store and Library feel responsive while images are loading. Opening a game must give its assets priority over list images and speculative prefetch. Previously cached content must remain visible during updates. Correct the reported live dialog-blur fragmentation, navigation flashes and Dither composition defects without regressing readable text or the resolved Windows stack overflow. + +The user approved this direction on 2026-10-11. Work remains on the existing release branch. PR #183 stays draft until the user accepts the Windows installer. TaxLauncher migration and release/merge actions remain subsequent work. The other developer's transparency issue is explicitly deferred. + +## Evidence and limits of the investigation + +- Image requests are deduplicated but have no global network concurrency limit or selected-game promotion. Conversion uses a FIFO semaphore with two permits. +- Store rows request metadata, a header and a blurred hero. A blurred hero can require fetching and converting a sharp hero, then decoding and converting the derived background. Visibility is observed once, so work can accumulate as the user scrolls. +- The Library detail banner currently waits for metadata even when its hero could load independently. +- Metadata already persists in SQLite, with a one-day freshness threshold. Images already persist on disk, with a three-day threshold. Image consumers can trigger automatic refresh from lists and timers. +- Automatic image refresh revokes the old object URL before confirming that the replacement can be displayed. Manual refresh resets persistent artwork before downloading replacements. +- Sidebar is outside the keyed page subtree and does not remount on normal navigation. Native navigation nevertheless crossfades the entire document root, including sidebar, logo and background. +- Dialog blur is applied to the dialog containing its content and interpolated for 180 ms while the slider changes. Its content has persistent `will-change: opacity`, with nested glass panels. +- Dither uses WebGL2; the working particles effect uses a 2D canvas. Dither already requests a persistent drawing buffer and writes opaque pixels. Transparent patches are not intended shader output. There are no context restoration handlers or framebuffer-completeness checks. + +These observations establish scheduling and lifecycle problems. They do not establish which phase dominates Windows loading time or prove the cause of the Dither corruption. Existing JSDOM and mocked graphics tests cannot establish native GPU rendering correctness. The implementation must gather that evidence before selecting a Dither correction. + +## Native scheduling and priorities + +Rust owns scheduling, network limits, conversion limits and persistent cache operations. Svelte declares the current game and active artwork consumers through service modules; it does not implement a second download queue. Tauri commands stay thin. + +Use the following priority order: + +1. Assets and metadata needed by the open game, with banner/cover and logo ahead of its optional gallery work. +2. Primary covers and necessary metadata for visible Store/Library entries. +3. Decorative list backgrounds, speculative prefetch and work with no current visible consumer. + +Allow at most five concurrent artwork-related network operations. Ordinary list/background work can occupy at most four slots; reserve the fifth for the current game. Include Steam metadata resolution, including PICS lookups, in the bounded network work. Do not put unrelated game-package downloads in this scheduler. A lookup that involves several protocol steps occupies one network operation, rather than recursively acquiring permits for its internal steps. + +Keep at most two concurrent image conversions. Admit ordinary work into one conversion slot; reserve the other for the current game. The current game may use available capacity within those global limits. These limits deliberately guarantee urgent capacity rather than increasing the total when the ordinary queue is busy. + +Deduplicate by resource identity and transformation profile. Priority is scheduling information, not part of the cache or deduplication key. Selecting a game promotes its existing queued work and any future stages of work already in progress. A request already transferring is reused, not downloaded again. Opening a different game transfers urgent status to that game; finishing an obsolete request must not replace the new game's UI. If obsolete urgent network work occupies the reserved capacity, cancel enough of that work to admit the new game's request. Requeue canceled resources only if they retain a consumer or prefetch purpose; cancellation must release capacity and must not publish a partially downloaded cache entry. + +Do not interrupt a native image conversion halfway through its codec call. If an obsolete game's conversion already occupies the reserved slot, the newly selected game takes the next available conversion slot ahead of queued work. The global two-conversion bound still applies while obsolete conversions finish. In-progress ordinary transfers can finish and populate the cache while urgent work uses reserved capacity. Unstarted work with no remaining consumer or prefetch purpose is removed. Remaining work resumes as capacity becomes available, with FIFO order within each priority class. + +Acquire capacity at the network and conversion stages, not around an entire recursively composed asset request. Deriving a blurred hero must not hold a permit while waiting for another asset that needs the same permit. Disk/memory cache hits bypass the network and conversion queues. + +## Cache freshness and update triggers + +| Resource | Freshness | Missing entry | Existing entry | +| --- | --- | --- | --- | +| Localized game metadata | 3 days from successful fetch | Fetch when a visible list entry or game page needs it | Read immediately; refresh if expired when the game page opens | +| Steam artwork, including covers, library heroes and derived variants | 5 days from successful fetch | Fetch when needed by a visible consumer or prefetch | Display immediately; refresh if expired when the game page opens | + +The first visit to Store or Library may populate the cache. Merely displaying a cached entry there never refreshes it, even after expiry. There is no periodic refresh timer for unopened games and no refresh solely because the application restarts or a list is scrolled. A page left open beyond the threshold is checked on its next opening, not by a background timer. Explicit user refresh remains available and follows the same safe replacement rules. + +Freshness advances after a successful fetch, not after reading the cache, opening a page or failing a request. Check thresholds using elapsed time in the appropriate stored units. A future timestamp caused by clock changes is eligible for revalidation on opening; it must not hide otherwise valid cached content. + +Preserve localized metadata cache identities. Changing language may fetch missing localized metadata, but must not invalidate language-independent artwork. Updating metadata after three days must not invalidate an image merely by changing `cachedAt`, since its separate five-day policy still applies. + +Retain existing bounded storage policies. Expiry means eligible for refresh, not eligible for immediate deletion. Normal storage eviction and unusable/corrupt cache entries can still result in a cache miss; the always-visible guarantee applies whenever a valid previous entry exists. Existing cache files and database records remain readable without a destructive migration. + +The page-open check covers the cached artwork variants belonging to that game, including those initially loaded by a list. Essential page assets take precedence over optional variants. Concurrent opens/refreshes share existing work. Metadata dependencies for headers and screenshots must be resolved before requesting those resources; a hero does not wait for unrelated textual metadata. + +## Replacement and failures + +Read and publish the last valid metadata/image immediately, even when expired. Fetch and process the replacement separately. For images, complete native validation and effects, then confirm that the replacement can be decoded for display before publishing it. + +Keep the previous displayed image and its object URL retained until the new image is ready and has replaced it. Revoke an old URL only after its remaining consumers release it. A shared consumer still displaying an old generation must remain valid. Do not briefly reset the component to an empty placeholder on metadata changes or refresh start. + +Write validated native replacements through temporary files and atomic replacement. Retain the previous persistent generation until replacement validation succeeds; for an active image consumer, also retain it until display decode succeeds. Native-only prefetch can complete publication after native validation. If all consumers leave before display validation, validated native output can finish as prefetch. Cleanup of superseded persistent generations must respect pending publication; retained object URLs independently protect consumers still showing an older image. Do not call destructive cache reset before automatic or explicit refresh. A failed download, unsupported image, conversion failure or failed display decode preserves the last valid image. A failed metadata refresh preserves the last valid metadata. Report failures through existing error and opt-in logging paths. A failed fetch does not mark stale content fresh. + +Retry failed scheduled updates on a later game-page opening, with a short shared retry backoff to prevent rapid navigation from flooding the service. Preserve the current five-minute retry backoff; it is independent of the three/five-day successful-fetch thresholds. Explicit user refresh can retry immediately. + +## Dialog, navigation and Dither rendering + +Separate the dialog's decorative blur/background surface from the foreground containing text and controls. Blur remains live as the slider moves; do not defer preview until pointer release. Avoid continuously interpolating `backdrop-filter` on an ancestor of the settings content, and remove permanent opacity promotion where it creates an unnecessary backdrop boundary. Preserve dialog opening/closing behavior, keyboard dismissal, focus handling and reduced-motion support. + +Navigation must leave sidebar, logo, title bar and the application background visually stable. Fade the changing page's foreground without fading the entire window or disconnecting glass surfaces from their intended background. Do not simply assign a transition name or opacity animation to a common ancestor of all glass panels: both can create a backdrop root. Keep blur-bearing surfaces stationary relative to the content animation. Reuse the existing layout and CSS conventions; do not add a generic rendering framework. + +For Dither, first isolate shader output from composition: record context loss/restoration and framebuffer status, compare canvas pixels with the presented frame, and compare animated/frozen Dither with particles and no effect. Compare navigation with and without the native snapshot path. Keep diagnostic details in the existing opt-in application log, without per-frame logging. + +Maintain valid output through resize, pause/resume and context recovery. Reinitialize native GL resources after restoration and retain a valid visible frame or the configured background while output is unavailable. Changing an unrelated appearance property must not restart or reschedule a frozen Dither renderer unnecessarily. + +Keep the Dither appearance and its controls. Do not globally disable graphics acceleration or silently replace the selected effect as the fix. Select the smallest correction supported by reproduced evidence. A change that requires replacing the renderer or removing an approved animation requires a focused design revision before implementation. + +## Diagnostics and validation + +Application and network logging remain separate, opt-in and disabled by default. Add useful operation summaries distinguishing queue wait, metadata resolution, transfer, conversion and publication. Use resource kinds and non-sensitive request identifiers. Logging must not become a prerequisite or awaited barrier for drawing an image. Avoid logging URLs, local paths, credentials or graphics output every frame. + +Automated validation must cover: + +- A saturated ordinary queue never exceeds four active network operations; opening a different game starts its urgent request without waiting for the ordinary queue to drain. Overall work never exceeds five network operations or two conversions. +- Queued work for a selected game is promoted, duplicate requests share work, changing games releases obsolete urgent network capacity, obsolete consumers cannot overwrite current UI, nested hero/blur requests do not deadlock, and background work continues after urgent work completes. +- Memory/disk cache hits do not wait for network or conversion capacity. +- Metadata and artwork threshold boundaries, warm restart, localization, clock rollback, and the absence of list/timer-based expired-cache refresh. +- Previous metadata and images remain visible during successful and failed refresh; one consumer's update/release does not revoke another consumer's displayed URL. Explicit refresh does not erase the last usable persistent entry first. +- Sidebar and canvas identity survive navigation, live dialog preview keeps foreground content sharp, and Dither resources recover from simulated context loss. GPU mock tests are explicitly insufficient for visual acceptance. +- The existing release-profile command-future size checks continue passing; preserve boxed asset futures and the Windows stack-overflow regression protection. + +Measure representative cold-cache and warm-cache loading separately on the same Windows installation, with consistent viewport, games and network conditions. Record time to the first visible cover, essential selected-game artwork, and complete visible rows, with logging overhead reported separately. Do not claim a fixed latency or competitor equivalence without measurements. + +Windows manual acceptance includes Store and populated Library navigation, selecting a game while list work is queued, old-cache refresh while offline, repeated dialog-blur dragging, and repeated page fades using Dither, particles and no background. Include the user's reported 15% panel opacity and 6 px panel blur, expanded/collapsed sidebar, resize/fullscreen and reduced motion. Inspect frames during transitions, not only settled screenshots. + +## Handoff and release condition + +After user review of this written specification, prepare the implementation plan and its execution-method handoff according to Superpowers. Implement and verify the two focused work areas separately so scheduling changes do not obscure graphics regressions. Publish a new Windows test installer from the updated draft PR only after relevant checks pass. Keep the release merge and TaxLauncher migration pending the user's Windows acceptance. + +## Rendering references + +- [CSS Filter Effects: backdrop roots](https://drafts.csswg.org/filter-effects-2/#BackdropRoot). +- [CSS View Transitions: rendering consolidation](https://drafts.csswg.org/css-view-transitions-1/#rendering-consolidation). diff --git a/package.json b/package.json index 39bf2c57..1b819ab8 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "check": "svelte-check --tsconfig ./tsconfig.json", "lint": "eslint .", "test:home": "node --experimental-strip-types --test scripts/test_home.mjs", + "test:logging": "node --conditions=browser scripts/test_application_logging.mjs", "tauri": "tauri", "test:regressions": "node --test scripts/test_regressions.mjs", "test:artwork": "node --conditions=browser --test scripts/test_library_artwork.mjs", @@ -21,10 +22,13 @@ "test:feedback": "node --conditions=browser --test scripts/test_feedback.mjs", "test:onboarding": "node --test scripts/test_onboarding_news.mjs", "test:sources": "node --test scripts/test_sources.mjs", + "test:catalog": "node --conditions=browser --test scripts/test_catalog.mjs scripts/test_store_filters.mjs", "test:review": "node --conditions=browser --test scripts/test_review_frontend.mjs", "test:select": "node --conditions=browser --test scripts/test_select.mjs", "test:artwork-cache": "node --conditions=browser --test scripts/test_steam_image_cache.mjs", - "test:visual": "node --conditions=browser --test scripts/test_visual_rendering.mjs" + "test:visual": "node --conditions=browser --test scripts/test_visual_rendering.mjs scripts/test_dither_renderer.mjs", + "test:artwork-work": "node --conditions=browser --test scripts/test_artwork_work.mjs", + "test:details-cache": "node --conditions=browser --test scripts/test_steam_details_cache.mjs" }, "dependencies": { "@tauri-apps/api": "^2.12.1", diff --git a/scripts/test_application_logging.mjs b/scripts/test_application_logging.mjs new file mode 100644 index 00000000..77684e39 --- /dev/null +++ b/scripts/test_application_logging.mjs @@ -0,0 +1,360 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { JSDOM } from "jsdom"; +import { artworkPacket, createModuleLoader, dataModule } from "./test_module_loader.mjs"; + +const dom = new JSDOM(""); +Object.defineProperty(globalThis, "window", { configurable: true, value: dom.window }); +for (const key of ["document", "navigator", "HTMLElement", "HTMLInputElement", "HTMLImageElement", "HTMLMediaElement", "Element", "Node", "Text", "Comment", "Event", "CustomEvent", "MutationObserver", "getComputedStyle"]) { + Object.defineProperty(globalThis, key, { configurable: true, value: dom.window[key] }); +} +const { mount, unmount, flushSync } = await import("svelte"); +const { get } = await import("svelte/store"); +const settle = () => new Promise(resolve => setImmediate(resolve)); +let sequence = 0; + +test("navigation does not wait for logging and ignores superseded requests", async () => { + const previousFrame = globalThis.requestAnimationFrame; const previousCancel = globalThis.cancelAnimationFrame; + const animationFrames = new Map();let frameId = 0; + globalThis.requestAnimationFrame = callback => { animationFrames.set(++frameId,callback);return frameId; }; + globalThis.cancelAnimationFrame = id => animationFrames.delete(id); + const advance = time => { const scheduled=[...animationFrames.values()];animationFrames.clear();for(const callback of scheduled)callback(time); }; + const emptyView = dataModule("export default () => {};"); + const mocks = { "src/lib/utils/motion": dataModule("export const reducedMotion = false; export const pageTransitionDuration = 210;") }; + for (const [feature, view] of [["home", "HomeView"], ["library", "LibraryView"], ["library", "GameDetailView"], ["store", "StoreView"], ["downloads", "DownloadsView"]]) mocks[`src/lib/features/${feature}/${view}.svelte`] = emptyView; + const { loader, logger } = await fixture(mocks); + logger.setApplicationLoggingEnabled(true); + const messages = []; + globalThis.applicationLogInvoke = (_command, args) => { messages.push(args.message); return new Promise(() => {}); }; + const navigation = await import(await loader.load("src/lib/stores/navigation.ts")); + navigation.activeSection.set("home"); navigation.selectedGameId.set(null); + const component = (await import(await loader.load("src/lib/components/layout/MainContainer.svelte"))).default; + const target = document.createElement("div"); document.body.append(target); + const instance = mount(component, { target }); + try { + flushSync(); + navigation.activeSection.set("library"); flushSync(); + navigation.activeSection.set("store"); flushSync(); + advance(0);advance(105); + await settle(); flushSync(); + assert.equal(target.querySelector("[data-page]").dataset.page, "store"); + assert.ok(!messages.includes("DOM update completed: library")); + assert.ok(messages.includes("DOM update completed: store")); + } finally { await unmount(instance); target.remove();globalThis.requestAnimationFrame=previousFrame;globalThis.cancelAnimationFrame=previousCancel; } +}); + +test("navigation checkpoints wait for persistence and tolerate a failed logging transport", async () => { + const { logger } = await fixture(); + logger.setApplicationLoggingEnabled(false); + assert.equal(logger.logNavigationCheckpoint("Snapshot starting: library"), undefined); + logger.setApplicationLoggingEnabled(true); + let acknowledge; + globalThis.applicationLogInvoke = () => new Promise(resolve => { acknowledge = resolve; }); + let finished = false; + const pending = logger.logNavigationCheckpoint("Snapshot starting: library").then(() => { finished = true; }); + await settle(); + assert.equal(finished, false); + acknowledge(); + await pending; + assert.equal(finished, true); + globalThis.applicationLogInvoke = async () => { throw new Error("Unavailable"); }; + const warn = console.warn; + console.warn = () => {}; + const stop = logger.installApplicationLogging(); + try { await logger.logNavigationCheckpoint("Snapshot starting: store"); } + finally { stop(); console.warn = warn; } +}); + +test("Steam image display failures log safe asset context and keep a stable fallback", async () => { + const storeImport = `import { writable } from "${import.meta.resolve("svelte/store")}";`; + const { records, loader, logger } = await fixture({ + "src/lib/i18n": dataModule(storeImport + 'export const language = writable("it"); export const t = value => value;'), + "src/lib/stores/window-activity": dataModule(storeImport + 'export const windowActive = writable(true);'), + "src/lib/utils/motion": dataModule("export const fadeDuration = 0;"), + }); + globalThis.applicationLogInvoke = async (command, args) => { + if (command === "report_application_event") { records.push(args); return; } + if (command === "cancel_artwork_request") return; + assert.equal(command, "get_steam_asset"); + return artworkPacket({ bytes: [1], contentType: "image/webp" }); + }; + logger.setApplicationLoggingEnabled(true); + const component = (await import(await loader.load("src/lib/features/library/SteamArtwork.svelte"))).default; + const target = document.createElement("div"); document.body.append(target); + const instance = mount(component, { target, props: { steamAppId: 917623, asset: "hero_blur", alt: "PRIVATE_GAME_NAME" } }); + try { + for (let index = 0; index < 8; index++) { await settle(); flushSync(); } + target.querySelector("img").dispatchEvent(new Event("error")); + await settle(); flushSync(); + assert.equal(target.querySelector("img"), null); + assert.equal(target.querySelector('[role="status"]'), null, "a rejected image must leave the loading state"); + const images = await import(await loader.load("src/lib/services/steam-details.ts")); + images.steamImageRevision.update(value => value + 1); + await settle(); flushSync(); + assert.equal(target.querySelector("img"), null, "an unrelated cache revision must not retry the same rejected URL"); + assert.ok(records.some(record => record.message === "Artwork display failed; asset: hero_blur")); + assert.ok(!JSON.stringify(records).includes("PRIVATE_GAME_NAME")); + assert.ok(!JSON.stringify(records).includes("917623")); + assert.ok(!JSON.stringify(records).includes("blob:")); + } finally { await unmount(instance); target.remove(); } +}); + +async function fixture(extraMocks = {}) { + const records = []; + globalThis.applicationLogInvoke = async (command, args) => { + assert.equal(command, "report_application_event"); + records.push(args); + }; + const loader = createModuleLoader({ + "@tauri-apps/api/core": dataModule(`export const invoke = (...args) => globalThis.applicationLogInvoke(...args); // fixture ${++sequence}`), + ...extraMocks, + }); + return { records, loader, logger: await import(await loader.load("src/lib/services/application-log.ts")) }; +} + +test("disabled application logging does not forward renderer errors and re-enabling resumes capture", async () => { + const { records, logger } = await fixture(); + const stop = logger.installApplicationLogging(); + try { + logger.setApplicationLoggingEnabled(false); + window.dispatchEvent(new dom.window.ErrorEvent("error", { error: new Error("Not recorded") })); + await settle(); + assert.equal(records.length, 0); + logger.setApplicationLoggingEnabled(true); + window.dispatchEvent(new dom.window.ErrorEvent("error", { error: new TypeError("Cannot read property") })); + await settle(); + assert.equal(records.length, 1); + assert.equal(records[0].event, "renderer_error"); + assert.match(records[0].message, /TypeError: Cannot read property/); + logger.setApplicationLoggingEnabled(false); + logger.logApplicationEvent("info", "navigation", "library"); + await settle(); + assert.equal(records.length, 1); + } finally { stop(); } +}); + +test("local icon display failures record a static warning without the game's identifier", async () => { + const storeImport = `import { writable } from "${import.meta.resolve("svelte/store")}";`; + const { records, loader, logger } = await fixture({ + "src/lib/i18n": dataModule(storeImport + 'export const language = writable("it"); export const t = value => value;'), + "src/lib/stores/window-activity": dataModule(storeImport + 'export const windowActive = writable(true);'), + "src/lib/utils/motion": dataModule("export const fadeDuration = 0;"), + }); + globalThis.applicationLogInvoke = async (command, args) => { + if (command === "report_application_event") { records.push(args); return; } + assert.equal(command, "get_game_icon"); + return artworkPacket({ bytes: [1], contentType: "image/png" }); + }; + logger.setApplicationLoggingEnabled(true); + const warn = console.warn; console.warn = () => {}; + const stop = logger.installApplicationLogging(); + const component = (await import(await loader.load("src/lib/features/library/GameIcon.svelte"))).default; + const target = document.createElement("div"); document.body.append(target); + const instance = mount(component, { target, props: { game: {id:"PRIVATE_GAME_IDENTIFIER",name:"Private game",steamAppId:null} } }); + try { + for (let index = 0; index < 4; index++) { await settle(); flushSync(); } + assert.ok(target.querySelector("img")); + target.querySelector("img").dispatchEvent(new Event("error")); + await settle(); flushSync(); + assert.equal(target.querySelector("img"), null); + assert.ok(records.some(record => record.event === "console" && record.message.includes("local game icon"))); + assert.ok(!JSON.stringify(records).includes("PRIVATE_GAME_IDENTIFIER")); + assert.ok(!JSON.stringify(records).includes("Private game")); + } finally { await unmount(instance); target.remove(); stop(); console.warn = warn; } +}); + +test("unhandled rejection preserves Error stack without serializing arbitrary objects", async () => { + const { records, logger } = await fixture(); + logger.setApplicationLoggingEnabled(true); + const stop = logger.installApplicationLogging(); + try { + const failure = new Error("Library render failed"); + const event = new dom.window.Event("unhandledrejection"); + Object.defineProperty(event, "reason", { value: failure }); + window.dispatchEvent(event); + const privateEvent = new dom.window.Event("unhandledrejection"); + Object.defineProperty(privateEvent, "reason", { value: { username: "PRIVATE_USER", token: "SECRET" } }); + window.dispatchEvent(privateEvent); + await settle(); + assert.equal(records[0].event, "unhandled_rejection"); + assert.match(records[0].stack, /Library render failed/); + assert.ok(!JSON.stringify(records).includes("PRIVATE_USER")); + assert.ok(!JSON.stringify(records).includes("SECRET")); + } finally { stop(); } +}); + +test("console forwarding preserves original output and does not recursively log transport failures", async () => { + const { records, logger } = await fixture(); + logger.setApplicationLoggingEnabled(true); + const previous = console.warn; + const output = []; + console.warn = (...args) => output.push(args); + const stop = logger.installApplicationLogging(); + try { + const privateObject = { token: "PRIVATE_TOKEN" }; + console.warn("Page transition failed", new Error("Snapshot unavailable"), privateObject); + await settle(); + assert.equal(output.length, 1); + assert.equal(output[0][2], privateObject); + assert.equal(records.length, 1); + assert.equal(records[0].level, "warn"); + assert.ok(!JSON.stringify(records).includes("PRIVATE_TOKEN")); + globalThis.applicationLogInvoke = async () => { throw new Error("Transport unavailable"); }; + logger.logApplicationEvent("error", "bootstrap_error", "Failed"); + await settle(); + assert.equal(output.length, 2, "one static warning is emitted without recursively reporting itself"); + } finally { stop(); console.warn = previous; } +}); + +test("renderer records obey native UTF-8 limits and cap an error storm", async () => { + const { records, logger } = await fixture(); + logger.setApplicationLoggingEnabled(true); + const message = "🙂".repeat(2000); + for (let index = 0; index < 100; index++) logger.logApplicationEvent("error", "renderer_error", message, message); + await settle(); + assert.equal(records.length, 50); + for (const record of records) { + assert.ok(Buffer.byteLength(record.message) <= 1024); + assert.ok(Buffer.byteLength(record.stack) <= 4096); + assert.ok(!record.message.includes("�")); + } +}); + +test("routine error storms cannot consume the budget reserved for crash diagnostics", async () => { + const { records, logger } = await fixture(); + logger.setApplicationLoggingEnabled(true); + for (let index = 0; index < 100; index++) logger.logApplicationEvent("warn", "console", "Artwork unavailable"); + logger.logApplicationEvent("error", "renderer_error", "Library render failed"); + await settle(); + assert.equal(records.filter(record => record.event === "console").length, 50); + assert.equal(records.filter(record => record.event === "renderer_error").length, 1); +}); + +test("a native command rejection is recorded without its arguments and preserves the original failure", async () => { + const { records, loader, logger } = await fixture(); + logger.setApplicationLoggingEnabled(true); + const failure = new Error("Database unavailable"); + globalThis.applicationLogInvoke = async (command, args) => { + if (command === "report_application_event") { records.push(args); return; } + assert.equal(command, "list_games"); + throw failure; + }; + const { invoke } = await import(await loader.load("src/lib/services/invoke.ts")); + await assert.rejects(invoke("list_games", { username: "PRIVATE_USER", token: "SECRET" }), error => error === failure); + await settle(); + assert.equal(records.length, 1); + assert.equal(records[0].event, "command_error"); + assert.match(records[0].message, /list_games.*Database unavailable/); + assert.ok(!JSON.stringify(records).includes("PRIVATE_USER")); + assert.ok(!JSON.stringify(records).includes("SECRET")); +}); + +test("logging settings default off and application and network toggles persist independently", async () => { + const { logger } = await fixture(); + logger.setApplicationLoggingEnabled(false); + const storeImport = `import { writable } from "${import.meta.resolve("svelte/store")}";`; + const mocks = { + "@tauri-apps/api/core": dataModule(`export const invoke = (...args) => globalThis.applicationLogInvoke(...args); // settings ${++sequence}`), + "src/lib/i18n": dataModule(storeImport + 'export const language = writable("it"); export const t = value => value;'), + "src/lib/services/appearance": dataModule("export const defaultAppearance = () => ({});"), + "src/lib/stores/downloads": dataModule("export const installedFolder = { load: async () => {} };"), + "src/lib/stores/app-info": dataModule(storeImport + 'export const appInfo = writable({data:{platform:"windows"}});'), + "src/lib/services/game-settings": dataModule("export const getCompatibilityLogsDirectory = async () => null;"), + }; + const settingsLoader = createModuleLoader(mocks); + const { settings, defaultSettings } = await import(await settingsLoader.load("src/lib/stores/settings.ts")); + let persisted = structuredClone(defaultSettings); + globalThis.applicationLogInvoke = async (command, args) => { + if (command === "get_settings") return structuredClone(persisted); + if (command === "save_settings") { persisted = structuredClone(args.settings); return structuredClone(persisted); } + if (command === "get_network_log_status" || command === "get_application_log_status") { + return { enabled: command === "get_network_log_status" ? persisted.diagnosticsEnabled : persisted.applicationLoggingEnabled, directory: "C:\\Logs", lastError: null, droppedRecords: 0, pendingRecords: 0 }; + } + throw new Error(`Unexpected command ${command}`); + }; + await settings.load(); + assert.equal(get(settings).data.applicationLoggingEnabled, false); + assert.equal(get(settings).data.diagnosticsEnabled, false); + const component = (await import(await settingsLoader.load("src/lib/features/settings/AdvancedSettings.svelte"))).default; + const target = document.createElement("div"); document.body.append(target); + const instance = mount(component, { target }); + const render = async () => { await settle(); flushSync(); await settle(); flushSync(); }; + try { + await render(); + const toggle = label => [...target.querySelectorAll("label")].find(node => node.textContent.includes(label))?.querySelector("input"); + const application = toggle("Salva log applicativi"); + const network = toggle("Salva log di rete"); + assert.ok(application); + assert.equal(application.checked, false); + assert.equal(network.checked, false); + application.checked = true; application.dispatchEvent(new Event("change", { bubbles: true })); + await render(); + assert.equal(persisted.applicationLoggingEnabled, true); + assert.equal(persisted.diagnosticsEnabled, false); + network.checked = true; network.dispatchEvent(new Event("change", { bubbles: true })); + await render(); + application.checked = false; application.dispatchEvent(new Event("change", { bubbles: true })); + await render(); + assert.equal(persisted.applicationLoggingEnabled, false); + assert.equal(persisted.diagnosticsEnabled, true); + } finally { await unmount(instance); target.remove(); } +}); + +test("navigation records library checkpoints without disclosing a selected game identifier", async () => { + const { records, logger } = await fixture(); + logger.setApplicationLoggingEnabled(true); + const mocks = { + "@tauri-apps/api/core": dataModule(`export const invoke = (...args) => globalThis.applicationLogInvoke(...args); // navigation ${++sequence}`), + "src/lib/utils/motion": dataModule("export const reducedMotion = true; export const pageTransitionDuration = 0;"), + }; + const emptyView = dataModule("export default () => {};"); + for (const [feature, view] of [["home", "HomeView"], ["library", "LibraryView"], ["library", "GameDetailView"], ["store", "StoreView"], ["downloads", "DownloadsView"]]) { + mocks[`src/lib/features/${feature}/${view}.svelte`] = emptyView; + } + const loader = createModuleLoader(mocks); + const log = await import(await loader.load("src/lib/services/application-log.ts")); + log.setApplicationLoggingEnabled(true); + const navigation = await import(await loader.load("src/lib/stores/navigation.ts")); + const component = (await import(await loader.load("src/lib/components/layout/MainContainer.svelte"))).default; + const target = document.createElement("div"); document.body.append(target); + const instance = mount(component, { target }); + try { + flushSync(); await settle(); flushSync(); + navigation.selectSection("library"); + flushSync(); await settle(); flushSync(); + assert.equal(target.querySelector("[data-page]").dataset.page, "library"); + assert.ok(records.some(record => record.event === "navigation" && record.message.includes("library"))); + navigation.openGame("PRIVATE_GAME_IDENTIFIER"); + flushSync(); await settle(); flushSync(); + assert.ok(records.some(record => record.event === "navigation" && record.message.includes("game_details"))); + assert.ok(!JSON.stringify(records).includes("PRIVATE_GAME_IDENTIFIER")); + } finally { await unmount(instance); target.remove(); } +}); + +test("publishing one cover does not cancel another mounted cover's pending demand", async () => { + const storeImport = `import { writable } from "${import.meta.resolve("svelte/store")}";`; + const { loader, logger } = await fixture({ + "src/lib/i18n": dataModule(storeImport + 'export const language = writable("en"); export const t = value => value;'), + "src/lib/utils/motion": dataModule("export const fadeDuration = 0;"), + }); + logger.setApplicationLoggingEnabled(false); + const waiting = new Map(); const cancelled = []; + globalThis.applicationLogInvoke = (command, args) => { + if (command === "get_steam_asset") return new Promise(resolve => waiting.set(args.steamAppId, { resolve, requestId: args.requestId })); + if (command === "cancel_artwork_request") cancelled.push(args.requestId); + return Promise.resolve(); + }; + const component = (await import(await loader.load("src/lib/features/library/SteamArtwork.svelte"))).default; + const a = document.createElement("div"); const b = document.createElement("div"); document.body.append(a, b); + const first = mount(component, { target: a, props: { steamAppId: 950, asset: "hero" } }); + const second = mount(component, { target: b, props: { steamAppId: 951, asset: "hero" } }); + try { + await settle(); flushSync(); const other = waiting.get(951); + waiting.get(950).resolve(artworkPacket({ bytes: [1], contentType: "image/png", stale: false, refreshAfter: Date.now() + 60_000 })); + for (let i=0; i<5; i++) { await settle(); flushSync(); } + assert.ok(!cancelled.includes(other.requestId)); + other.resolve(artworkPacket({ bytes: [2], contentType: "image/png", stale: false, refreshAfter: Date.now() + 60_000 })); + for (let i=0; i<5; i++) { await settle(); flushSync(); } + assert.ok(b.querySelector("img")); + } finally { await unmount(first); await unmount(second); a.remove(); b.remove(); } +}); diff --git a/scripts/test_artwork_work.mjs b/scripts/test_artwork_work.mjs new file mode 100644 index 00000000..32883ef7 --- /dev/null +++ b/scripts/test_artwork_work.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createModuleLoader, dataModule } from "./test_module_loader.mjs"; +const { load } = createModuleLoader({ "src/lib/i18n": dataModule("export const t = s => s;"), "@tauri-apps/api/core": dataModule("export const invoke = (...args) => globalThis.workInvoke(...args);") }); +test("releasing a consumer cancels exactly once", async () => { + const calls = []; globalThis.workInvoke = async (...args) => { calls.push(args); }; + const { createArtworkWork } = await import(await load("src/lib/services/artwork-work.ts")); + const work = createArtworkWork(42, "visible"); work.release(); work.release(); + assert.deepEqual(calls, [["cancel_artwork_request", { requestId: work.requestId }]]); +}); +let fixtureId = 0; +const selectionFixture = async invoke => { + const id = ++fixtureId; + const { writable } = await import("svelte/store"); + globalThis.selectionGames = writable({ data: [{ id: "library-game", steamAppId: 200 }] }); + globalThis.selectionStore = writable(null); globalThis.selectionLibrary = writable(null); globalThis.selectionSection = writable("store"); + globalThis.workInvoke = invoke; + const loader = createModuleLoader({ + "@tauri-apps/api/core": dataModule("export const invoke = (...args) => globalThis.workInvoke(...args);"), + "src/lib/stores/games.ts": dataModule(`export const games = globalThis.selectionGames; // ${id}`), + "src/lib/stores/navigation.ts": dataModule(`export const selectedStoreGame = globalThis.selectionStore; export const selectedGameId = globalThis.selectionLibrary; export const activeSection = globalThis.selectionSection; // ${id}`), + }); + return import(await loader.load("src/lib/services/artwork-work.ts")); +}; +const settle = () => new Promise(resolve => setImmediate(resolve)); +test("selection_is_declared_before_page_mount", async () => { + const calls = []; const service = await selectionFixture(async (command, args) => { if (command === "set_artwork_game") calls.push(args.steamAppId); }); + const stop = service.startArtworkSelectionTracking(); await settle(); + globalThis.selectionStore.set({ steamAppId: 100, name: "Selected game" }); await settle(); + assert.deepEqual(calls, [null, 100]); + globalThis.selectionSection.set("library"); globalThis.selectionLibrary.set("library-game"); await settle(); + assert.equal(calls.at(-1), 200); stop(); await settle(); assert.equal(calls.at(-1), null); +}); +test("rapid_selection_delivers_latest_native_target", async () => { + const calls = []; const acknowledgements = []; + const service = await selectionFixture((command, args) => { + if (command !== "set_artwork_game") return Promise.resolve(); + calls.push(args.steamAppId); return new Promise(resolve => acknowledgements.push(resolve)); + }); + const stop = service.startArtworkSelectionTracking(); acknowledgements.shift()(); await settle(); + globalThis.selectionStore.set({ steamAppId: 100 }); globalThis.selectionStore.set({ steamAppId: 101 }); globalThis.selectionStore.set({ steamAppId: 102 }); + assert.deepEqual(calls, [null, 100]); acknowledgements.shift()(); await settle(); assert.deepEqual(calls, [null, 100, 102]); + acknowledgements.shift()(); await settle(); stop(); acknowledgements.shift()(); await settle(); +}); +test("continuous visibility releases artwork demand when a tile leaves the viewport", async () => { + let notify; let disconnected = 0; + globalThis.IntersectionObserver = class { constructor(callback) { notify = callback; } observe() {} disconnect() { disconnected++; } }; + try { + const { observeVisibility } = await import(await load("src/lib/utils/visibility.ts")); + const states = []; const stop = observeVisibility({}, visible => states.push(visible), false); + notify([{ isIntersecting: true }]); notify([{ isIntersecting: false }]); + assert.deepEqual(states, [true, false]); assert.equal(disconnected, 0); stop(); assert.equal(disconnected, 1); + } finally { delete globalThis.IntersectionObserver; } +}); diff --git a/scripts/test_catalog.mjs b/scripts/test_catalog.mjs new file mode 100644 index 00000000..aa807bd0 --- /dev/null +++ b/scripts/test_catalog.mjs @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { get } from "svelte/store"; +import { createModuleLoader, dataModule } from "./test_module_loader.mjs"; + +const defaults = { sort: "relevance", availability: "all" }; +const filtered = { sort: "name_desc", availability: "verified" }; +const result = (id, nextOffset = null, total = 1) => ({ + games: [{ steamAppId: id, name: `Game ${id}`, availability: "verified" }], + total, nextOffset, cachedAt: 1, stale: false, sourceCachedAt: 1, sourceStale: false, +}); + +function fixture(name, invoke) { + globalThis[name] = invoke; + return createModuleLoader({ + "@tauri-apps/api/core": dataModule(`export const invoke = (...args) => globalThis.${name}(...args);`), + "src/lib/utils/errors": dataModule("export const toMessage = error => String(error);"), + "src/lib/stores/source": dataModule(`import { writable } from ${JSON.stringify(import.meta.resolve("svelte/store"))}; export const source = writable({ data: { manifest: null, stale: false } });`), + }); +} + +test("catalog sends sorting and availability through local and remote pagination", async () => { + const calls = []; + const { load } = fixture("catalogOptions", async (command, args) => { calls.push([command, args]); return result(1); }); + const service = await import(await load("src/lib/services/catalog.ts")); + await service.searchCatalog("portal", 20, filtered); + await service.refreshCatalog("portal", 50, 40, filtered); + assert.deepEqual(calls, [ + ["search_catalog", { query: "portal", limit: 20, options: filtered }], + ["refresh_catalog", { query: "portal", skip: 50, limit: 40, options: filtered }], + ]); +}); + +test("catalog caches and pending requests are isolated by both filters", async () => { + const requests = []; + const { load } = fixture("catalogCacheOptions", (_command, args) => new Promise(resolve => requests.push({ args, resolve }))); + const service = await import(await load("src/lib/services/catalog.ts")); + const first = service.refreshCatalogCached("Portal", defaults); + const again = service.refreshCatalogCached(" portal ", defaults); + const differentSort = service.refreshCatalogCached("portal", { ...defaults, sort: "name_asc" }); + const differentAvailability = service.refreshCatalogCached("portal", filtered); + assert.equal(first, again); + assert.equal(requests.length, 3); + requests.forEach((request, index) => request.resolve(result(index + 1))); + await Promise.all([first, differentSort, differentAvailability]); + assert.equal(service.cachedCatalogSearch("PORTAL", defaults).games[0].steamAppId, 1); + assert.equal(service.cachedCatalogSearch("portal", { ...defaults, sort: "name_asc" }).games[0].steamAppId, 2); + assert.equal(service.cachedCatalogSearch("portal", filtered).games[0].steamAppId, 3); +}); + +test("changing a source prevents an older refresh from repopulating the cache", async () => { + let finish; + const { load } = fixture("catalogSourceInvalidation", () => new Promise(resolve => { finish = resolve; })); + const service = await import(await load("src/lib/services/catalog.ts")); + const request = service.refreshCatalogCached("portal", filtered); + service.invalidateCatalogAvailability(); + finish(result(1)); + await request; + assert.equal(service.cachedCatalogSearch("portal", filtered), null); +}); + +test("switching filters ignores an older search and keeps filters when loading more", async () => { + const remote = []; + const calls = []; + const { load } = fixture("catalogStoreOptions", async (command, args) => { + calls.push([command, args]); + if (command === "search_catalog") return result(args.limit ? 3 : 1); + return new Promise(resolve => remote.push({ args, resolve })); + }); + const store = await import(await load("src/lib/stores/catalog.ts")); + const first = store.runCatalogSearch("portal", defaults); + await new Promise(setImmediate); + const second = store.runCatalogSearch("portal", filtered); + await new Promise(setImmediate); + assert.equal(remote.length, 2); + remote[1].resolve(result(2, 50, 2)); + await second; + remote[0].resolve(result(99)); + await first; + assert.equal(get(store.catalog).results[0].steamAppId, 2); + await store.loadMoreCatalog("portal", 40); + assert.deepEqual(calls.at(-1), ["search_catalog", { query: "portal", limit: 40, options: filtered }]); + const next = store.loadMoreCatalog("portal", 60); + await new Promise(setImmediate); + assert.equal(remote.at(-1).args.skip, 50); + assert.deepEqual(remote.at(-1).args.options, filtered); + remote.at(-1).resolve(result(4)); + await next; + assert.equal(get(store.catalog).results[0].steamAppId, 4); +}); + +test("refreshing without explicit options preserves the current store selection", async () => { + const calls = []; + const { load } = fixture("catalogHeaderRefresh", async (command, args) => { calls.push([command, args]); return result(1); }); + const store = await import(await load("src/lib/stores/catalog.ts")); + await store.runCatalogSearch("portal", filtered); + await store.runCatalogSearch("portal"); + assert.deepEqual(get(store.catalog).options, filtered); + assert.ok(calls.every(([, args]) => args.options.sort === "name_desc" && args.options.availability === "verified")); +}); + +test("source changes invalidate a local load-more response before the next search starts", async () => { + let finish; + const { load } = fixture("catalogLocalInvalidation", async (command, args) => { + if (command === "search_catalog" && args.limit) return new Promise(resolve => { finish = resolve; }); + return result(1, null, 2); + }); + const store = await import(await load("src/lib/stores/catalog.ts")); + const service = await import(await load("src/lib/services/catalog.ts")); + const { source } = await import(await load("src/lib/stores/source")); + await store.runCatalogSearch("portal", filtered); + const loading = store.loadMoreCatalog("portal", 40); + await new Promise(setImmediate); + service.invalidateCatalogAvailability(); + source.set({ data: { manifest: { verified: [], unverified: [] }, stale: false } }); + finish(result(99)); + await loading; + assert.equal(service.cachedCatalogSearch("portal", filtered), null); + assert.equal(get(store.catalog).results[0].steamAppId, 1); +}); + +test("retry after a failed remote page bypasses cached local results and preserves filters", async () => { + const calls = []; + const { load } = fixture("catalogPageRetry", async (command, args) => { + calls.push([command, args]); + if (command === "refresh_catalog" && args.skip > 0) throw new Error("Offline"); + if (command === "search_catalog" && args.limit) return result(2, 50, 1); + return result(1, 50, 2); + }); + const store = await import(await load("src/lib/stores/catalog.ts")); + await store.runCatalogSearch("portal", filtered); + await store.loadMoreCatalog("portal", 40); + await store.loadMoreCatalog("portal", 60); + assert.match(get(store.catalog).error, /Offline/); + const before = calls.length; + await store.runCatalogSearch("portal", filtered, true); + assert.deepEqual(calls.slice(before), [ + ["search_catalog", { query: "portal", limit: undefined, options: filtered }], + ["refresh_catalog", { query: "portal", skip: 0, limit: undefined, options: filtered }], + ]); + assert.equal(get(store.catalog).error, null); +}); diff --git a/scripts/test_dialog.mjs b/scripts/test_dialog.mjs index 4778ba36..4c053595 100644 --- a/scripts/test_dialog.mjs +++ b/scripts/test_dialog.mjs @@ -77,7 +77,7 @@ test("dialog fades its surface and content without isolating the surface backdro const dialog = document.querySelector("dialog"); try { assert.equal(dom.window.getComputedStyle(dialog).opacity, "1", "opening dialog must not isolate the blurred surface"); - const content = dialog.firstElementChild; + const content = dialog.querySelector(".legio-dialog-content"); assert.equal(dom.window.getComputedStyle(content).opacity, "0"); advanceFrame(); advanceFrame(); @@ -109,7 +109,7 @@ test("closing waits for the content fade and hides the dialog before leaving the advanceFrame(); advanceFrame(); const dialog = document.querySelector("dialog"); - const content = dialog.firstElementChild; + const content = dialog.querySelector(".legio-dialog-content"); [...dialog.querySelectorAll("button")].find(button => button.textContent === "Close").click(); flushSync(); finishOpacityTransition(content.querySelector("button")); @@ -141,9 +141,53 @@ test("reopening during an external close reverses the fade and cancels the pendi [...document.querySelectorAll("button")].find(button => button.textContent === "Reopen").click(); flushSync(); assert.equal(dialog.dataset.visible, "true"); - finishOpacityTransition(dialog.firstElementChild); + finishOpacityTransition(dialog.querySelector(".legio-dialog-content")); await new Promise(resolve => setTimeout(resolve, 260)); assert.equal(dialog.open, true); assert.equal(closed, 0); } finally { await unmount(instance); } }); + + +test("live_blur_targets_surface_not_foreground", async () => { + const instance = mount(component, { target: document.body, props: { onClosed() {} } }); + try { + flushSync(); advanceFrame(); advanceFrame(); flushSync(); + const dialog = document.querySelector("dialog"); + const surface = dialog.querySelector(".legio-dialog-surface"); + assert.ok(surface, "the blur needs an independent decorative surface"); + const content = dialog.querySelector(".legio-dialog-content"); + assert.equal(surface.getAttribute("aria-hidden"), "true"); + for (const blur of ["blur(2px)", "blur(10px)", "blur(0px)"]) { + document.documentElement.style.setProperty("--legio-dialog-blur", blur); + const foreground = dom.window.getComputedStyle(content); + assert.ok(!foreground.getPropertyValue("backdrop-filter") || foreground.getPropertyValue("backdrop-filter") === "none"); + assert.notEqual(foreground.willChange, "opacity"); + assert.equal(dom.window.getComputedStyle(dialog).getPropertyValue("backdrop-filter"), "none"); + assert.equal(dom.window.getComputedStyle(surface).getPropertyValue("backdrop-filter"), "var(--legio-dialog-blur)"); + assert.equal(dom.window.getComputedStyle(surface).pointerEvents, "none"); + } + } finally { await unmount(instance); } +}); + +test("dialog_close_waits_only_for_content_transition and blur_drag_during_close_notifies_once", async () => { + let closed = 0; + const instance = mount(component, { target: document.body, props: { onClosed: () => closed++, persistent: true } }); + try { + flushSync(); advanceFrame(); advanceFrame(); flushSync(); + const dialog = document.querySelector("dialog"); + dialog.dispatchEvent(new Event("cancel", { cancelable: true })); flushSync(); + const surface = dialog.querySelector(".legio-dialog-surface"); + assert.ok(surface); + for (const blur of ["blur(4px)", "blur(8px)"]) { + document.documentElement.style.setProperty("--legio-dialog-blur", blur); + finishOpacityTransition(surface); + assert.equal(closed, 0); assert.equal(dialog.open, true); + } + finishOpacityTransition(dialog.querySelector(".legio-dialog-content")); + assert.equal(closed, 1); assert.equal(dialog.open, false); + finishOpacityTransition(surface); + await new Promise(resolve => setTimeout(resolve, 260)); + assert.equal(closed, 1); + } finally { await unmount(instance); } +}); diff --git a/scripts/test_dither_renderer.mjs b/scripts/test_dither_renderer.mjs new file mode 100644 index 00000000..2faa8e94 --- /dev/null +++ b/scripts/test_dither_renderer.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { JSDOM } from "jsdom"; +import { compile } from "svelte/compiler"; +import { createModuleLoader, dataModule } from "./test_module_loader.mjs"; +const dom = new JSDOM('', { pretendToBeVisual: true }); +const frames = new Map(); let sequence = 0; let resize; +for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLMediaElement", "Element", "Node", "Text", "Comment", "Event", "CustomEvent", "MutationObserver", "getComputedStyle"]) Object.defineProperty(globalThis, key, { configurable: true, value: dom.window[key] }); +globalThis.requestAnimationFrame = callback => { frames.set(++sequence, callback); return sequence; }; +globalThis.cancelAnimationFrame = id => frames.delete(id); +globalThis.ResizeObserver = class { constructor(callback) { resize = callback; } observe() {} disconnect() {} }; +window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); +const { load, resolveImports } = createModuleLoader({ "src/lib/services/application-log": dataModule('export const logApplicationEvent = () => {};') }); +const configuration = { waveSpeed: .05, waveFrequency: 3, waveAmplitude: .3, waveColor: null, backgroundColor: '#000000', colorNum: 4, pixelSize: 2, disableAnimation: true, enableMouseInteraction: false, mouseRadius: 1 }; +function fakeGl() { + let generation = 1; let next = 0; let draws = 0; let valid = true; let program; let deleted = 0; + const constants = new Map(); + const resource = () => ({ generation, id: ++next, deleted: false }); + const check = value => { if (value) { assert.equal(value.generation, generation, 'a restored context cannot reuse old resources'); assert.equal(value.deleted, false); } }; + const api = { + get draws() { return draws; }, get deleted() { return deleted; }, + reset() { generation++; program = undefined; }, invalid() { valid = false; }, + createShader: resource, createProgram: resource, createBuffer: resource, createTexture: resource, createFramebuffer: resource, + deleteShader(value) { if (value) { value.deleted = true; deleted++; } }, deleteProgram(value) { if (value) { value.deleted = true; deleted++; } }, deleteBuffer(value) { if (value) { value.deleted = true; deleted++; } }, deleteTexture(value) { if (value) { value.deleted = true; deleted++; } }, deleteFramebuffer(value) { if (value) { value.deleted = true; deleted++; } }, + getShaderParameter: () => true, getProgramParameter: () => true, getUniformLocation: () => ({}), getExtension: () => null, getParameter: () => 'WebGL2', isContextLost: () => false, + useProgram(value) { check(value); program = value; }, bindBuffer(_type, value) { check(value); }, bindTexture(_type, value) { check(value); }, bindFramebuffer(_type, value) { check(value); }, + checkFramebufferStatus: () => valid ? gl.FRAMEBUFFER_COMPLETE : gl.FRAMEBUFFER_INCOMPLETE_ATTACHMENT, + drawArrays() { check(program); assert.ok(program); assert.equal(valid, true, 'invalid framebuffer cannot be presented'); draws++; }, + }; + const gl = new Proxy(api, { get(target, name) { if (name in target) return Reflect.get(target, name); if (String(name).toUpperCase() === name) { if (!constants.has(name)) constants.set(name, constants.size + 1); return constants.get(name); } return () => {}; } }); + return gl; +} +const advance = () => { const scheduled = [...frames]; frames.clear(); for (const [, callback] of scheduled) callback(100); }; +async function fixture(suppliedGl) { + frames.clear(); + const { createDitherRenderer } = await import(await load('src/lib/components/layout/dither-renderer.ts')); + const canvas = document.createElement('canvas'); const container = document.createElement('div'); container.append(canvas); + let width = 640; container.getBoundingClientRect = () => ({ width, height: 480, left: 0, top: 0 }); + const gl = suppliedGl ?? fakeGl(); let attributes; canvas.getContext = (_type, options) => { attributes = options; return gl; }; + const diagnostics = []; + const renderer = createDitherRenderer(canvas, value => diagnostics.push(value)); + renderer.resize(width, 480); + const frame = { time: 0, settings: configuration, accent: '#ff0000', mouse: [0,0], ratio: 1 }; + return { renderer, canvas, gl, diagnostics, draw: () => renderer.draw(frame), get attributes() { return attributes; }, resize() { width++; renderer.resize(width, 480); } }; +} +test('unrelated_blur_preview_does_not_redraw_frozen_dither and hidden_resize_recovers_without_unbounded_frame_loop', async () => { + const gl = fakeGl(); let attributes; + window.HTMLCanvasElement.prototype.getContext = (_type, options) => { attributes = options; return gl; }; + const source = ` + + `; + const Component = (await import(dataModule(await resolveImports(compile(source, { generate: 'client' }).js.code, 'scripts/dither_wrapper.svelte')))).default; + const { mount, unmount, flushSync } = await import('svelte'); + const target = document.createElement('div'); document.body.append(target); + const instance = mount(Component, { target }); + try { + flushSync(); advance(); flushSync(); assert.equal(gl.draws, 2); assert.equal(frames.size, 0); + target.querySelector('button').click(); flushSync(); advance(); assert.equal(gl.draws, 2); assert.equal(frames.size, 0); + target.querySelectorAll('button')[1].click(); flushSync(); + const container = target.querySelector('.animated-background'); container.getBoundingClientRect = () => ({ width: 800, height: 500, left: 0, top: 0 }); resize(); + assert.equal(frames.size, 0); assert.equal(gl.draws, 2); + target.querySelectorAll('button')[1].click(); flushSync(); assert.equal(frames.size, 1); advance(); + assert.equal(gl.draws, 4); assert.equal(frames.size, 0); + assert.deepEqual(attributes, { alpha: false, antialias: false, preserveDrawingBuffer: true }); + } finally { await unmount(instance); target.remove(); } +}); +test('context_restoration_rebuilds_resources_and_draws_valid_frame', async () => { + const f = await fixture(); try { + assert.equal(f.draw(), true); + const event = new window.Event('webglcontextlost', { cancelable: true }); f.canvas.dispatchEvent(event); + assert.equal(event.defaultPrevented, true); assert.equal(f.draw(), false); + f.gl.reset(); f.canvas.dispatchEvent(new window.Event('webglcontextrestored')); + assert.equal(f.draw(), true); assert.equal(f.gl.draws, 4); + assert.deepEqual(f.diagnostics.map(event => event.event), ['context_lost', 'context_restored']); + } finally { f.renderer.dispose(); } +}); +test('incomplete_framebuffer_keeps_fallback_without_drawing', async () => { + const f = await fixture(); try { + f.gl.invalid(); f.resize(); assert.equal(f.draw(), false); + assert.equal(f.gl.draws, 0); assert.equal(f.diagnostics.at(-1).event, "framebuffer_incomplete"); + } finally { f.renderer.dispose(); assert.ok(f.gl.deleted > 0); } +}); + +test('partial_allocation_failure_disposes_created_resources', async () => { + const gl = fakeGl(); gl.createBuffer = () => null; + const f = await fixture(gl); + try { assert.equal(f.draw(), false); assert.ok(gl.deleted >= 6); assert.equal(f.diagnostics.at(-1).event, "render_failed"); } + finally { f.renderer.dispose(); } +}); +test('loss_between_passes_never_presents_invalid_frame', async () => { + const gl = fakeGl(); let lost = false; const draw = gl.drawArrays; + gl.drawArrays = () => { draw(); lost = true; }; gl.isContextLost = () => lost; + const f = await fixture(gl); + try { assert.equal(f.draw(), false); assert.equal(gl.draws, 1); } + finally { f.renderer.dispose(); } +}); diff --git a/scripts/test_library_artwork.mjs b/scripts/test_library_artwork.mjs index ba10aadd..ef481c27 100644 --- a/scripts/test_library_artwork.mjs +++ b/scripts/test_library_artwork.mjs @@ -13,16 +13,16 @@ const { mount, unmount, flushSync } = await import("svelte"); const { writable } = await import("svelte/store"); const mocks = { "src/lib/stores/window-activity": dataModule(`import { writable } from "${import.meta.resolve("svelte/store")}"; export const windowActive = writable(true);`), - "@tauri-apps/api/core": dataModule("export const invoke = (...args) => globalThis.artworkInvoke(...args);"), + "@tauri-apps/api/core": dataModule('export const invoke = (...args) => args[0] === "cancel_artwork_request" ? (globalThis.cancelledMetadata?.push(args[1].requestId), Promise.resolve()) : globalThis.artworkInvoke(...args);'), "src/lib/i18n": dataModule(`import { writable } from "${import.meta.resolve("svelte/store")}"; export const language = writable("en"); export const t = value => value;`), "src/lib/utils/motion": dataModule("export const fadeDuration = 0;"), }; const { load: moduleUrl, resolveImports } = createModuleLoader(mocks); +const logger = await import(await moduleUrl("src/lib/services/application-log.ts")); +logger.setApplicationLoggingEnabled(false); const requests = []; -let detailsResolve; globalThis.artworkInvoke = (command, args) => { requests.push([command, args]); - if (command === "get_steam_details") return new Promise((resolve) => { detailsResolve = resolve; }); assert.equal(command, "get_steam_asset"); return Promise.resolve(artworkPacket({ bytes: [1], contentType: "image/png", stale: false, cacheWarning: null })); }; @@ -31,7 +31,7 @@ const settle = async () => { }; // Each App ID starts with empty details and image caches. -test("visible library cover loads while details are pending and remains after an empty response", async () => { +test("visible library cover loads without requesting textual metadata", async () => { requests.length = 0; let intersect; globalThis.IntersectionObserver = class { @@ -49,10 +49,10 @@ test("visible library cover loads while details are pending and remains after an assert.equal(requests.length, 0, "offscreen covers must remain lazy"); intersect([{ isIntersecting: true }]); await settle(); - assert.ok(requests.some(([command]) => command === "get_steam_details")); + assert.equal(requests.filter(([command]) => command === "get_steam_details").length, 0); assert.ok(requests.some(([command, args]) => command === "get_steam_asset" && args.steamAppId === 400 && args.asset === "hero_blur")); assert.match(target.querySelector("img")?.src ?? "", /^blob:/); - detailsResolve({ details: null, cachedAt: null, stale: false }); + assert.equal(requests.find(([command]) => command === "get_steam_asset")[1].origin, "visible"); await settle(); assert.match(target.querySelector("img")?.src ?? "", /^blob:/); } finally { @@ -99,7 +99,7 @@ test("stale artwork stays visible during refresh and is replaced when ready", as let completeRefresh; globalThis.artworkInvoke = (command, args) => { calls.push(args); - if (args.refresh) return new Promise(resolve => { completeRefresh = resolve; }); + if (args.policy === "refresh_if_expired") return new Promise(resolve => { completeRefresh = resolve; }); return Promise.resolve(artworkPacket({ bytes: [1], contentType: "image/webp", stale: true, cacheWarning: null, refreshAfter: 0 })); }; const target = document.createElement("div"); @@ -107,12 +107,15 @@ test("stale artwork stays visible during refresh and is replaced when ready", as try { await settle(); const oldUrl = target.querySelector("img").src; + const images = await import(await moduleUrl("src/lib/services/steam-details.ts")); + const opening = images.refreshSteamImagesOnOpen(501, { requestId: "open-501", origin: "visible", release() {} }); + await settle(); assert.equal(calls.length, 2); - assert.equal(calls[1].refresh, true); + assert.equal(calls[1].policy, "refresh_if_expired"); await settle(); assert.equal(target.querySelector("img").src, oldUrl); - completeRefresh(artworkPacket({ bytes: [2], contentType: "image/webp", stale: false, cacheWarning: null, refreshAfter: Date.now() + 72 * 3600000 })); - await settle(); + completeRefresh(artworkPacket({ bytes: [2], contentType: "image/webp", stale: false, cacheWarning: null, refreshAfter: Date.now() + 120 * 3600000 })); + await opening; await settle(); assert.notEqual(target.querySelector("img").src, oldUrl); assert.equal(calls.length, 2); } finally { await unmount(component); } @@ -120,7 +123,7 @@ test("stale artwork stays visible during refresh and is replaced when ready", as test("failed background refresh preserves the cached cover", async () => { const { default: SteamArtwork } = await import(await moduleUrl("src/lib/features/library/SteamArtwork.svelte")); - globalThis.artworkInvoke = (command, args) => args.refresh + globalThis.artworkInvoke = (command, args) => args.policy === "refresh_if_expired" ? Promise.reject(new Error("offline")) : Promise.resolve(artworkPacket({ bytes: [1], contentType: "image/webp", stale: true, cacheWarning: null })); const target = document.createElement("div"); @@ -128,7 +131,9 @@ test("failed background refresh preserves the cached cover", async () => { try { await settle(); assert.match(target.querySelector("img").src, /^blob:/); - assert.match(target.textContent, /offline/); + const images = await import(await moduleUrl("src/lib/services/steam-details.ts")); + await assert.rejects(images.refreshSteamImagesOnOpen(502, { requestId: "open-502", origin: "visible", release() {} })); + await settle(); assert.match(target.textContent, /offline/); } finally { await unmount(component); } }); @@ -263,3 +268,94 @@ test("library renders repeated Steam diagnostics and stays usable after refresh" target.remove(); } }); + +test("Store row releases metadata demand as soon as it leaves view", async () => { + let detailsResolve; + globalThis.cancelledMetadata = []; + requests.length = 0; + globalThis.artworkInvoke = (command, args) => { + requests.push([command, args]); + if (command === "get_steam_details") return new Promise(resolve => { detailsResolve = resolve; }); + if (command === "get_steam_asset") return Promise.resolve(artworkPacket({ bytes: [1], contentType: "image/png", stale: false, cacheWarning: null })); + return Promise.resolve(); + }; + let intersect; + globalThis.IntersectionObserver = class { constructor(callback) { intersect = callback; } observe() {} disconnect() {} }; + const { default: StoreGameCard } = await import(await moduleUrl("src/lib/features/store/StoreGameCard.svelte")); + const target = document.createElement("ul"); document.body.append(target); + const component = mount(StoreGameCard, { target, props: { steamAppId: 824, name: "Portal", status: { availability: "unavailable", entry: null }, onOpen() {} } }); + try { + await settle(); intersect([{ isIntersecting: true }]); await settle(); + const metadata = requests.find(([command, args]) => command === "get_steam_details" && args.steamAppId === 824); + assert.ok(metadata, "visible row starts metadata"); + intersect([{ isIntersecting: false }]); await settle(); + assert.ok(globalThis.cancelledMetadata.includes(metadata[1].requestId), "offscreen row must release native metadata demand"); + detailsResolve({ details: null, cachedAt: null, stale: false }); await settle(); + } finally { + await unmount(component); target.remove(); delete globalThis.IntersectionObserver; delete globalThis.cancelledMetadata; + } +}); + + +test("offscreen library cards do not request branding logos", async () => { + const { default: GameCard } = await import(await moduleUrl("src/lib/features/library/GameCard.svelte")); + const calls = []; + globalThis.IntersectionObserver = class { constructor(callback) { this.callback = callback; } observe() {} disconnect() {} }; + globalThis.artworkInvoke = (command, args) => { calls.push([command, args]); return Promise.resolve(artworkPacket({ bytes: [1], contentType: "image/png", stale: false, cacheWarning: null })); }; + const target = document.createElement("div"); + const component = mount(GameCard, { target, props: { game: { id: "offscreen-logo", name: "Hidden game", steamAppId: 7900 }, launch: undefined, onOpen() {} } }); + try { await settle(); assert.equal(calls.filter(([command]) => command === "get_steam_asset").length, 0); } + finally { await unmount(component); delete globalThis.IntersectionObserver; } +}); + +test("outgoing page preserves its cached image after demand stops", async () => { + globalThis.pageArtworkActive = writable(true); + const calls = []; + globalThis.artworkInvoke = (command, args) => { + calls.push([command, args]); + return Promise.resolve(artworkPacket({ bytes: [1], contentType: "image/png", stale: false, cacheWarning: null })); + }; + const source = ``; + const url = dataModule(await resolveImports(compile(source, { generate: "client" }).js.code, "fixture.svelte")); + const target = document.createElement("div"); + const component = mount((await import(url)).default, { target }); + try { + await settle(); const before = target.querySelector("img").src; + globalThis.pageArtworkActive.set(false); await settle(); + assert.equal(target.querySelector("img").src, before); + assert.equal(calls.filter(([command]) => command === "get_steam_asset").length, 1); + } finally { await unmount(component); } +}); + + +test("outgoing page cancels a pending image before unmount", async () => { + globalThis.pageArtworkActive = writable(true); + globalThis.cancelledMetadata = []; + let complete; + globalThis.artworkInvoke = () => new Promise(resolve => { complete = resolve; }); + const source = ``; + const url = dataModule(await resolveImports(compile(source, { generate: "client" }).js.code, "pending-fixture.svelte")); + const component = mount((await import(url)).default, { target: document.createElement("div") }); + try { + await settle(); assert.equal(typeof complete, "function"); + globalThis.pageArtworkActive.set(false); await settle(); + assert.equal(globalThis.cancelledMetadata.length, 1); + } finally { + complete(artworkPacket({ bytes: [1], contentType: "image/png", stale: false, cacheWarning: null })); + await settle(); await unmount(component); delete globalThis.cancelledMetadata; + } +}); + + +test("sidebar continues observing after its icon becomes visible", async () => { + let intersect; let disconnects = 0; + globalThis.IntersectionObserver = class { constructor(callback) { intersect = callback; } observe() {} disconnect() { disconnects++; } }; + const { default: Item } = await import(await moduleUrl("src/lib/components/layout/SidebarGameItem.svelte")); + const component = mount(Item, { target: document.createElement("ul"), props: { game: { id: "observer", name: "Manual", steamAppId: null }, expanded: false } }); + try { + await settle(); const initial = disconnects; + intersect([{ isIntersecting: true }]); await settle(); + assert.equal(disconnects, initial, "Visibility changes must not disconnect the viewport observer"); + intersect([{ isIntersecting: false }]); await settle(); + } finally { await unmount(component); delete globalThis.IntersectionObserver; } +}); diff --git a/scripts/test_regressions.mjs b/scripts/test_regressions.mjs index d8d468d4..bdcd5902 100644 --- a/scripts/test_regressions.mjs +++ b/scripts/test_regressions.mjs @@ -15,7 +15,7 @@ const localeUrl = dataModule(localeCode); async function loadModule(path, imports = {}) { const source = await readFile(new URL(path, import.meta.url), "utf8"); let code = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText; - for (const [specifier, url] of Object.entries({ "svelte/store": import.meta.resolve("svelte/store"), "../i18n": localeUrl, ...imports })) { + for (const [specifier, url] of Object.entries({ "svelte/store": import.meta.resolve("svelte/store"), "../i18n": localeUrl, "./application-log": dataModule("export const logApplicationEvent = () => {};"), ...imports })) { code = code.replaceAll(`from "${specifier}"`, `from "${url}"`); } return import(dataModule(code)); @@ -53,13 +53,14 @@ test("Steam refresh survives older success and failure without losing cached dat globalThis.legioDetailsMock = () => { const request = deferred(); pending.push(request); return request.promise; }; const service = dataModule("export const getSteamDetails = (...args) => globalThis.legioDetailsMock(...args);"); const { steamDetails, loadSteamDetails } = await loadModule("../src/lib/stores/steam-details.ts", { - "../services/steam-details": service, "../utils/errors": errors, + "../services/steam-metadata": service, "../services/artwork-work": dataModule("export const isArtworkCancelled = () => false; export const createArtworkWork = () => ({ requestId: 'test', origin: 'visible', release() {} });"), "../utils/errors": errors, }); const old = loadSteamDetails(42, false); const fresh = loadSteamDetails(42, true); - assert.equal(loadSteamDetails(42, false), fresh); + const joined = loadSteamDetails(42, false); + assert.equal(pending.length, 2, "another consumer shares the current native request"); pending[1].resolve({ details: { name: "NEW" }, cachedAt: 2, stale: false }); - await fresh; + await Promise.all([fresh, joined]); pending[0].resolve({ details: { name: "OLD" }, cachedAt: 1, stale: true }); await old; assert.equal(get(steamDetails)[42].details.name, "NEW"); @@ -115,9 +116,10 @@ test("language selection preserves regional formatting and interpolates translat assert.equal(t(" Chiudi ", "en"), " Close "); }); -test("Steam image cache shares library artwork across details versions and retains versions for details assets", async () => { +test("Steam image cache preserves artwork identity across metadata timestamps and separates monitor profiles", async () => { let requests = 0; - globalThis.legioInvokeMock = () => { + globalThis.legioInvokeMock = command => { + if (command !== "get_steam_asset") return Promise.resolve(); requests++; return Promise.resolve(artworkPacket({ bytes: [1], contentType: "image/png", stale: false, cacheWarning: null, refreshAfter: Date.now() + 60_000 })); }; @@ -131,20 +133,20 @@ test("Steam image cache shares library artwork across details versions and retai assert.equal(oldImage.url, newImage.url); const firstHeader = await loadSteamImage({ ...request, asset: "header" }); const secondHeader = await loadSteamImage({ ...request, asset: "header", version: 2 }); - assert.equal(requests, 3); - assert.notEqual(firstHeader.url, secondHeader.url); + assert.equal(requests, 2); + assert.equal(firstHeader.url, secondHeader.url); const hdRequest = { ...request, displayWidth: 1920 }; const hd = await loadSteamImage(hdRequest); const full = await loadSteamImage({ ...hdRequest, full: true }); assert.equal(hd.url, full.url, "viewer and banner share the selected monitor profile"); - assert.equal(requests, 3); + assert.equal(requests, 2); const qhd = await loadSteamImage({ ...request, displayWidth: 2560 }); - assert.equal(requests, 4); + assert.equal(requests, 3); assert.notEqual(hd.url, qhd.url); assert.equal((await loadSteamImage(hdRequest)).url, hd.url, "returning to a monitor profile reuses its cache"); const unchangedHeader = await loadSteamImage({ ...request, asset: "header", displayWidth: 2560 }); assert.equal(unchangedHeader.url, firstHeader.url); - assert.equal(requests, 4, "monitor resolution only separates hero variants"); + assert.equal(requests, 3, "monitor resolution only separates hero variants"); URL.revokeObjectURL(qhd.url); URL.revokeObjectURL(oldImage.url); URL.revokeObjectURL(newImage.url); @@ -297,60 +299,6 @@ test("native launch events invalidate stale reads and reduce polling during game } }); -test("recent hero prefetch pauses during games and retries failures without exceeding two workers", async () => { - const games = writable({ data: Array.from({ length: 10 }, (_, index) => ({ steamAppId: index + 1 })) }); - const playtime = writable({ data: [] }); - const active = writable(false); - const pending = writable(false); - const requests = []; - globalThis.legioHeroPrefetch = { games, playtime, active, pending, request: (appId) => { - const response = deferred(); - requests.push({ appId, ...response }); - return response.promise; - } }; - const { load } = createModuleLoader({ - "src/lib/stores/games": dataModule("export const games = globalThis.legioHeroPrefetch.games;"), - "src/lib/stores/playtime": dataModule("export const playtime = globalThis.legioHeroPrefetch.playtime;"), - "src/lib/stores/launch": dataModule("export const hasPendingLaunch = globalThis.legioHeroPrefetch.pending;"), - "src/lib/stores/window-activity": dataModule("export const windowActive = globalThis.legioHeroPrefetch.active;"), - "src/lib/services/steam-details": dataModule("export const prefetchSteamHero = appId => globalThis.legioHeroPrefetch.request(appId);"), - "src/lib/features/home/home-model": dataModule("export const recentGames = games => games.map(game => ({ game }));"), - }); - const settle = () => new Promise((resolve) => setImmediate(resolve)); - const originalWarn = console.warn; - const originalNow = Date.now; - let now = 100000; - console.warn = () => {}; - Date.now = () => now; - try { - const module = await import(await load("src/lib/stores/library-artwork.ts")); - module.startLibraryHeroCaching(); - assert.equal(requests.length, 0); - active.set(true); - assert.deepEqual(requests.map(({ appId }) => appId), [1, 2]); - pending.set(true); - requests[0].reject(new Error("temporary failure")); - requests[1].resolve(); - await settle(); - assert.equal(requests.length, 2, "a game prevents more work when in-flight requests finish"); - active.set(false); - pending.set(false); - assert.equal(requests.length, 2); - now += 30001; - active.set(true); - assert.deepEqual(requests.slice(2).map(({ appId }) => appId), [1, 3]); - active.set(false); - for (const request of requests.slice(2)) request.resolve(); - await settle(); - assert.equal(requests.length, 4); - } finally { - active.set(false); - console.warn = originalWarn; - Date.now = originalNow; - delete globalThis.legioHeroPrefetch; - } -}); - test("hydration waits for startup recovery before loading games and displays failures without a shortcut", async () => { const ready = deferred(); const calls = []; @@ -369,7 +317,6 @@ test("hydration waits for startup recovery before loading games and displays fai "src/lib/stores/settings": fromGlobal("settings"), "src/lib/stores/games": fromGlobal("games"), "src/lib/stores/playtime": fromGlobal("playtime"), - "src/lib/stores/library-artwork": dataModule("export const startLibraryHeroCaching = () => {};"), "src/lib/stores/downloads": dataModule(["downloads", "bandwidthLimit", "installedFolder"].map((name) => `export const ${name} = globalThis.legioBootstrap.${name};`).join("\n") + "export const startDownloadProgressPolling = () => {};"), "src/lib/stores/source-links": dataModule("export const startSourceLinks = async () => {};"), "src/lib/stores/source": dataModule("export const source = globalThis.legioBootstrap.source; export const refreshSource = async () => {};"), @@ -441,12 +388,13 @@ test("download UI polling stops while inactive and catches completed work on res } }); -test("manual artwork refresh renews visible URLs and resets only its Steam App ID", async () => { +test("manual artwork refresh renews its visible URLs while retaining other games", async () => { const calls = []; let revision = 0; globalThis.legioRefreshArtwork = async (command, args) => { calls.push([command, args]); - if (command === "reset_steam_artwork_cache") { revision++; return; } + if (command !== "get_steam_asset") return; + if (args.policy === "force_refresh") revision++; return artworkPacket({ bytes: [revision + 1], contentType: "image/png", stale: false, cacheWarning: null, refreshAfter: Date.now() + 100000 }); }; const { load } = createModuleLoader({ "@tauri-apps/api/core": dataModule("export const invoke = (...args) => globalThis.legioRefreshArtwork(...args);"), "src/lib/i18n": localeUrl }); @@ -460,7 +408,31 @@ test("manual artwork refresh renews visible URLs and resets only its Steam App I await first; assert.notEqual(images.peekSteamImage(request(400)).url, old.url); assert.equal(images.peekSteamImage(request(401)).url, untouched.url); - assert.equal(calls.filter(([command]) => command === "reset_steam_artwork_cache").length, 1); - assert.ok(calls.filter(([command, args]) => command === "get_steam_asset" && args.refresh).every(([, args]) => args.steamAppId === 400)); + assert.equal(calls.filter(([command]) => command === "reset_steam_artwork_cache").length, 0); + assert.ok(calls.filter(([command, args]) => command === "get_steam_asset" && args.policy === "force_refresh").every(([, args]) => args.steamAppId === 400)); retained.release(); }); + + +test("missing updater platform is unavailable, not current, and invalidates a prior installer", async () => { + let checks = 0; let installs = 0; + globalThis.legioUpdaterCheck = async () => { + if (++checks === 1) return { version: "9.0.0", downloadAndInstall: async () => { installs++; } }; + throw 'None of the fallback platforms `["linux-x86_64"]` were found in the response `platforms` object'; + }; + const { load } = createModuleLoader({ + "src/lib/i18n": dataModule("export const t = value => value;"), + "@tauri-apps/plugin-updater": dataModule("export const check = () => globalThis.legioUpdaterCheck();"), + "@tauri-apps/plugin-process": dataModule("export const relaunch = async () => {};"), + "src/lib/services/invoke": dataModule("export const invoke = async command => command === 'is_aur_package' ? false : undefined;"), + }); + const updater = await import(await load("src/lib/services/app-updater.ts")); + try { + await updater.checkForAppUpdate(); assert.equal(get(updater.updateState).version, "9.0.0"); + await updater.checkForAppUpdate(); const state = get(updater.updateState); + assert.equal(state.unavailable, true); assert.equal(state.version, null); assert.equal(state.error, null); + await updater.installAppUpdate();assert.equal(installs, 0); + globalThis.legioUpdaterCheck = async () => { throw new Error("Network unavailable"); }; + await updater.checkForAppUpdate();assert.match(get(updater.updateState).error,/Network unavailable/);assert.equal(get(updater.updateState).unavailable,false); + } finally { delete globalThis.legioUpdaterCheck; } +}); diff --git a/scripts/test_steam_details_cache.mjs b/scripts/test_steam_details_cache.mjs new file mode 100644 index 00000000..8b9e9f7b --- /dev/null +++ b/scripts/test_steam_details_cache.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { get } from "svelte/store"; +import { createModuleLoader, dataModule } from "./test_module_loader.mjs"; +const { load } = createModuleLoader({ + "@tauri-apps/api/core": dataModule("export const invoke = (...args) => globalThis.detailsInvoke(...args);"), + "src/lib/i18n": dataModule(`import { writable } from "${import.meta.resolve("svelte/store")}"; export const language = writable("en"); export const t = s => s;`), +}); +test("cached metadata is visible while page-open refresh is pending", async () => { + const store = await import(await load("src/lib/stores/steam-details.ts")); + let finish; const policies = []; + globalThis.detailsInvoke = async (cmd, args) => { + if (cmd === "cancel_artwork_request") return; + policies.push(args.policy); + if (args.policy === "refresh_if_expired") return new Promise(resolve => { finish = resolve; }); + return { details: { name: "OLD" }, cachedAt: 1, stale: true }; + }; + const work = { requestId: "page", origin: "visible", release() {} }; + const opening = store.refreshSteamDetailsOnOpen(55, work); + for (let i = 0; i < 20; i++) await Promise.resolve(); + assert.equal(get(store.steamDetails)[55].details.name, "OLD"); + assert.equal(get(store.steamDetails)[55].status, "ready"); + finish({ details: { name: "NEW" }, cachedAt: 2, stale: false }); await opening; + assert.equal(get(store.steamDetails)[55].details.name, "NEW"); + assert.deepEqual(policies, ["cache_or_fetch", "refresh_if_expired"]); +}); +test("localized_refresh_does_not_invalidate_artwork", async () => { + const images = await import(await load("src/lib/services/steam-details.ts")); + const store = await import(await load("src/lib/stores/steam-details.ts")); + const { artworkPacket } = await import("./test_module_loader.mjs"); + const request = { steamAppId: 56, asset: "hero", fallbackAsset: null, index: null, full: false, version: 1 }; + const calls = []; + globalThis.detailsInvoke = async cmd => { + calls.push(cmd); + if (cmd === "get_steam_asset") return artworkPacket({ bytes: [1, 2], contentType: "image/png", stale: false, cacheWarning: null, refreshAfter: Date.now()+60_000 }); + if (cmd === "get_steam_details") return { details: { name: "NEW" }, cachedAt: 2, stale: false }; + }; + const first = await images.loadSteamImage(request); + await store.loadSteamDetails(56, "force_refresh"); + assert.equal(images.peekSteamImage(request).url, first.url); + assert.equal(calls.filter(cmd => cmd === "get_steam_asset").length, 1); +}); +test("cancelled metadata work does not replace the current state with an error", async () => { + const store = await import(await load("src/lib/stores/steam-details.ts")); + globalThis.detailsInvoke = async cmd => { if (cmd === "get_steam_details") throw { kind: "cancelled", message: "Artwork request cancelled" }; }; + await assert.rejects(store.loadSteamDetails(57, "cache_or_fetch")); + assert.equal(get(store.steamDetails)[57].error, null); +}); + +test("reopening a game does not reuse cancelled metadata work", async () => { + const store = await import(await load("src/lib/stores/steam-details.ts")); + const { createArtworkWork } = await import(await load("src/lib/services/artwork-work.ts")); + const waiting = []; + globalThis.detailsInvoke = (command) => command === "get_steam_details" ? new Promise(resolve => waiting.push(resolve)) : Promise.resolve(); + const oldWork = createArtworkWork(999, "visible"); + const old = store.loadSteamDetails(999, "refresh_if_expired", oldWork); + oldWork.release(); + const newWork = createArtworkWork(999, "visible"); + const fresh = store.loadSteamDetails(999, "refresh_if_expired", newWork); + try { + assert.equal(waiting.length, 2, "a reopened game needs its own live metadata request"); + waiting[1]({ details: { name: "NEW" }, cachedAt: 2, stale: false }); await fresh; + waiting[0]({ details: { name: "OLD" }, cachedAt: 1, stale: true }); await old; + assert.equal(get(store.steamDetails)[999].details.name, "NEW"); + } finally { newWork.release(); } +}); + +test("visible metadata demand cancels only when its last consumer leaves", async () => { + const store = await import(await load("src/lib/stores/steam-details.ts")); + let finish; let requestId; const cancelled = []; + globalThis.detailsInvoke = (command, args) => { + if (command === "cancel_artwork_request") { cancelled.push(args.requestId); return Promise.resolve(); } + if (command === "get_steam_details") { requestId = args.requestId; return new Promise(resolve => { finish = resolve; }); } + }; + const releaseA = store.ensureSteamDetails(818); + const releaseB = store.ensureSteamDetails(818); + assert.equal(typeof releaseA, "function", "visible metadata must provide lifetime cleanup"); + releaseA(); + assert.ok(!cancelled.includes(requestId), "one remaining consumer still needs this request"); + releaseB(); + assert.ok(cancelled.includes(requestId), "the final offscreen consumer must cancel native demand"); + finish({ details: { name: "OBSOLETE" }, cachedAt: 1, stale: false }); + for (let i=0;i<20;i++) await Promise.resolve(); + assert.equal(get(store.steamDetails)[818].details, null, "abandoned result must not publish"); + assert.equal(get(store.steamDetails)[818].status, "idle", "returning row can retry"); +}); diff --git a/scripts/test_steam_image_cache.mjs b/scripts/test_steam_image_cache.mjs index b7c7183b..64f2e8ad 100644 --- a/scripts/test_steam_image_cache.mjs +++ b/scripts/test_steam_image_cache.mjs @@ -4,9 +4,11 @@ import { artworkPacket, createModuleLoader, dataModule } from "./test_module_loa const { load } = createModuleLoader({ "@tauri-apps/api/core": dataModule("export const invoke = (...args) => globalThis.cacheInvoke(...args);"), - "src/lib/i18n": dataModule(`import { writable } from "${import.meta.resolve("svelte/store")}"; export const language = writable("en");`), + "src/lib/i18n": dataModule(`import { writable } from "${import.meta.resolve("svelte/store")}"; export const language = writable("en"); export const t = s => s;`), }); const images = await import(await load("src/lib/services/steam-details.ts")); +const logging = await import(await load("src/lib/services/application-log.ts")); +logging.setApplicationLoggingEnabled(false); const request = (steamAppId, asset, fallbackAsset = null) => ({ steamAppId, asset, fallbackAsset, index: null, version: null, full: false }); const packet = () => artworkPacket({ bytes: [1, 2], contentType: "image/png", stale: false, cacheWarning: null, refreshAfter: Date.now() + 60_000 }); @@ -24,6 +26,7 @@ test("identical assets share an in-flight download and object URL across fallbac test("a fallback reuses the header cache without substituting it for a caller requiring a hero", async () => { const calls = []; globalThis.cacheInvoke = (_command, args) => { + if (_command === "cancel_artwork_request") return Promise.resolve(); calls.push(args.asset); return args.asset === "hero" ? Promise.reject(new Error("missing hero")) : Promise.resolve(packet()); }; @@ -39,7 +42,8 @@ test("a fallback reuses the header cache without substituting it for a caller re test("manual refresh retries the original asset after a fallback was displayed", async () => { let missing = true; globalThis.cacheInvoke = (command, args) => { - if (command === "reset_steam_artwork_cache") { missing = false; return Promise.resolve(); } + if (command === "cancel_artwork_request") return Promise.resolve(); + if (args.policy === "force_refresh") missing = false; if (args.asset === "hero" && missing) return Promise.reject(new Error("missing hero")); return Promise.resolve(packet()); }; @@ -54,7 +58,7 @@ test("manual refresh retries the original asset after a fallback was displayed", test("manual refresh keeps a missing hero fallback in the shared header cache", async () => { globalThis.cacheInvoke = (command, args) => { - if (command === "reset_steam_artwork_cache") return Promise.resolve(); + if (command === "cancel_artwork_request") return Promise.resolve(); return args.asset === "hero" ? Promise.reject(new Error("missing hero")) : Promise.resolve(packet()); }; const heroRequest = request(906, "hero", "header"); @@ -79,7 +83,7 @@ test("manual refresh does not put current-monitor bytes in an old fallback profi const { artworkDisplayWidth } = await import(await load("src/lib/stores/artwork-display.ts")); const calls = []; globalThis.cacheInvoke = (command, args) => { - if (command === "reset_steam_artwork_cache") return Promise.resolve(); + if (command === "cancel_artwork_request") return Promise.resolve(); calls.push(args.asset); return args.asset === "hero" ? Promise.reject(new Error("missing hero")) : Promise.resolve(packet()); }; @@ -92,10 +96,11 @@ test("manual refresh does not put current-monitor bytes in an old fallback profi } finally { artworkDisplayWidth.set(1920); } }); -test("an expired fallback retries the original asset and publishes its recovered image", async () => { +test("page opening with an expired fallback retries the original asset and publishes its recovered image", async () => { let missing = true; const calls = []; globalThis.cacheInvoke = (_command, args) => { + if (_command === "cancel_artwork_request") return Promise.resolve(); calls.push(args.asset); if (args.asset === "hero" && missing) return Promise.reject(new Error("missing hero")); return Promise.resolve(packet()); @@ -106,10 +111,9 @@ test("an expired fallback retries the original asset and publishes its recovered const now = Date.now; Date.now = () => now() + 61_000; try { - await images.loadSteamImage(heroRequest); - for (let i = 0; i < 4; i++) await new Promise(resolve => setImmediate(resolve)); + await images.refreshSteamImagesOnOpen(909, { requestId: "open-909", origin: "visible", release() {} }); assert.ok(images.peekSteamImage(request(909, "hero"))); - assert.deepEqual(calls, ["hero", "header", "hero"]); + assert.deepEqual(calls, ["hero", "header", "header", "hero"]); } finally { Date.now = now; } }); @@ -140,6 +144,7 @@ test("refreshing a shared header cannot postpone another consumer's primary reco const calls = []; const now = Date.now; globalThis.cacheInvoke = (_command, args) => { + if (_command === "cancel_artwork_request") return Promise.resolve(); calls.push(args.asset); if (args.asset === "hero" && missing) return Promise.reject(new Error("missing hero")); return Promise.resolve(packet()); @@ -149,28 +154,176 @@ test("refreshing a shared header cannot postpone another consumer's primary reco missing = false; Date.now = () => now() + 61_000; try { - await Promise.all([images.loadSteamImage(request(911, "header")), images.loadSteamImage(heroRequest)]); - for (let i = 0; i < 4; i++) await new Promise(resolve => setImmediate(resolve)); + await images.refreshSteamImagesOnOpen(911, { requestId: "open-911", origin: "visible", release() {} }); assert.ok(images.peekSteamImage(request(911, "hero"))); assert.ok(calls.slice(2).includes("hero")); } finally { Date.now = now; } }); -test("retained fallback reports its retry deadline after a failed refresh", async () => { +test("failed page-open refresh preserves the fallback successful-fetch age", async () => { let offline = false; const now = Date.now; globalThis.cacheInvoke = (_command, args) => args.asset === "hero" || offline ? Promise.reject(new Error("offline")) : Promise.resolve(packet()); const heroRequest = request(912, "hero", "header"); await images.loadSteamImage(heroRequest); + const previousAge = images.peekSteamImage(heroRequest).refreshAt; const expired = now() + 61_000; Date.now = () => expired; offline = true; try { - await images.loadSteamImage(heroRequest); - for (let i = 0; i < 4; i++) await new Promise(resolve => setImmediate(resolve)); + await assert.rejects(images.refreshSteamImagesOnOpen(912, { requestId: "open-912", origin: "visible", release() {} })); const retained = images.retainSteamImage(heroRequest); - try { assert.equal(retained.refreshAt, expired + 300_000); } + try { assert.equal(retained.refreshAt, previousAge); } finally { retained.release(); } } finally { Date.now = now; } }); + +const settle = () => new Promise(resolve => setImmediate(resolve)); +const stagedPacket = token => artworkPacket({ bytes: [1, 2], contentType: "image/webp", stale: false, cacheWarning: null, refreshAfter: Date.now() + 432_000_000, replacementToken: token }); +test("cached_image_survives_pending_refresh", async () => { + const imageRequest = request(930, "hero"); const calls = []; let complete; + globalThis.cacheInvoke = (command, args) => { + calls.push(command); + if (command === "get_steam_asset" && args.policy === "force_refresh") return new Promise(resolve => { complete = resolve; }); + return Promise.resolve(packet()); + }; + const old = await images.loadSteamImage(imageRequest); + const refresh = images.refreshSteamImages(930); await settle(); + assert.equal(images.peekSteamImage(imageRequest).url, old.url); + assert.ok(!calls.includes("reset_steam_artwork_cache")); + complete(packet()); await refresh; +}); +test("replacement_waits_for_display_decode", async () => { + const imageRequest = request(931, "hero"); const old = await images.loadSteamImage(imageRequest); + let decode; const acknowledgements = []; + globalThis.Image = class { decode() { return new Promise(resolve => { decode = resolve; }); } }; + globalThis.cacheInvoke = async (command, args) => { + if (command === "get_steam_asset") return stagedPacket("candidate-931"); + if (command === "finish_steam_asset_update") { acknowledgements.push(args); return { cacheWarning: null }; } + }; + try { + const refresh = images.refreshSteamImages(931); await settle(); + assert.equal(images.peekSteamImage(imageRequest).url, old.url); + assert.equal(acknowledgements.length, 0); + decode(); await refresh; + assert.notEqual(images.peekSteamImage(imageRequest).url, old.url); + assert.deepEqual(acknowledgements, [{ token: "candidate-931", outcome: "displayed" }]); + } finally { delete globalThis.Image; } +}); +test("releasing_old_generation_does_not_revoke_another_consumer", async () => { + globalThis.cacheInvoke = async command => command === "finish_steam_asset_update" ? { cacheWarning: null } : packet(); + const imageRequest = request(932, "header"); const old = await images.loadSteamImage(imageRequest); + const a = images.retainSteamImage(imageRequest); const b = images.retainSteamImage(imageRequest); + const revoked = []; const original = URL.revokeObjectURL; URL.revokeObjectURL = url => { revoked.push(url); original(url); }; + try { + await images.refreshSteamImages(932); a.release(); assert.ok(!revoked.includes(old.url)); + b.release(); assert.ok(revoked.includes(old.url)); + } finally { URL.revokeObjectURL = original; } +}); +test("details_timestamp_change_preserves_artwork_identity", async () => { + let calls = 0; globalThis.cacheInvoke = async command => { if (command === "get_steam_asset") { calls++; return packet(); } }; + const first = await images.loadSteamImage({ ...request(933, "header"), version: 1 }); + const second = await images.loadSteamImage({ ...request(933, "header"), version: 2 }); + assert.equal(first.url, second.url); assert.equal(calls, 1); + const screenshot = { ...request(934, "screenshot"), index: 0, screenshotIdentity: "https://cdn.steamstatic.com/a.jpg" }; + await images.loadSteamImage(screenshot); await images.loadSteamImage({ ...screenshot, screenshotIdentity: "https://cdn.steamstatic.com/b.jpg" }); assert.equal(calls, 3); +}); +test("consumerless_request_cancels_native_work", async () => { + const calls = []; let complete; + globalThis.cacheInvoke = (command, args) => { calls.push([command, args]); if (command === "get_steam_asset") return new Promise(resolve => { complete = resolve; }); return Promise.resolve({ cacheWarning: null }); }; + const a = images.acquireSteamImage(request(935, "hero"), "visible"); const b = images.acquireSteamImage(request(935, "hero"), "visible"); + a.release(); assert.equal(calls.filter(([cmd]) => cmd === "cancel_artwork_request").length, 0); + b.release(); assert.equal(calls.filter(([cmd]) => cmd === "cancel_artwork_request").length, 1); + complete(stagedPacket("abandoned-935")); await Promise.allSettled([a.promise, b.promise]); + assert.ok(calls.some(([cmd, args]) => cmd === "finish_steam_asset_update" && args.outcome === "abandoned")); +}); +test("rejected replacement retains the decoded previous image", async () => { + globalThis.cacheInvoke = async () => packet(); const imageRequest = request(936, "hero"); const old = await images.loadSteamImage(imageRequest); + const outcomes = []; globalThis.Image = class { async decode() { throw new Error("decode failed"); } }; + globalThis.cacheInvoke = async (command, args) => { if (command === "get_steam_asset") return stagedPacket("rejected-936"); if (command === "finish_steam_asset_update") { outcomes.push(args.outcome); return { cacheWarning: null }; } }; + try { await assert.rejects(images.refreshSteamImages(936)); assert.equal(images.peekSteamImage(imageRequest).url, old.url); assert.deepEqual(outcomes, ["rejected"]); } finally { delete globalThis.Image; } +}); +test("an opened game checks assets that finish their initial cached read later", async () => { + const policies = []; let update; const controller = new AbortController(); + globalThis.cacheInvoke = async (command, args) => { + if (command !== "get_steam_asset") return { cacheWarning: null }; + policies.push(args.policy); + if (args.policy === "refresh_if_expired") return new Promise(resolve => { update = resolve; }); + return artworkPacket({ bytes: [1, 2], contentType: "image/png", stale: true, cacheWarning: null, refreshAfter: 0 }); + }; + await images.refreshSteamImagesOnOpen(941, { requestId: "open-941", origin: "visible", signal: controller.signal, release() {} }); + const consumer = images.acquireSteamImage(request(941, "hero"), "visible"); + const old = await consumer.promise; await settle(); + assert.deepEqual(policies, ["cache_or_fetch", "refresh_if_expired"]); + assert.equal(images.peekSteamImage(request(941, "hero")).url, old.url); + update(packet()); await settle(); consumer.release(); controller.abort(); +}); + +test("artwork phase logging is opt-in and cannot delay image publication", async () => { + const records = []; + globalThis.cacheInvoke = (command, args) => { + if (command === "report_application_event") { records.push(args); return new Promise(() => {}); } + return Promise.resolve(packet()); + }; + logging.setApplicationLoggingEnabled(false); + await images.loadSteamImage(request(996, "hero")); + assert.equal(records.length, 0); + logging.setApplicationLoggingEnabled(true); + try { + const image = await images.loadSteamImage(request(997, "hero")); + assert.ok(image.url); assert.equal(records.length, 1); + assert.equal(records[0].event, "artwork"); + assert.match(records[0].message, /publication_ms=/); + assert.ok(!records[0].message.includes("blob:") && !records[0].message.includes("https:") && !records[0].message.includes("997")); + } finally { logging.setApplicationLoggingEnabled(false); } +}); + +test("reopening a game replaces cancelled refresh demand", async () => { + const { createArtworkWork } = await import(await load("src/lib/services/artwork-work.ts")); + const waiting = []; + globalThis.cacheInvoke = (command, args) => { + if (command !== "get_steam_asset") return Promise.resolve(); + return args.policy === "cache_or_fetch" ? Promise.resolve(packet()) : new Promise(resolve => waiting.push(resolve)); + }; + await images.loadSteamImage(request(998, "hero")); + const oldWork = createArtworkWork(998, "visible"); const old = images.refreshSteamImagesOnOpen(998, oldWork); + oldWork.release(); + const work = createArtworkWork(998, "visible"); const current = images.refreshSteamImagesOnOpen(998, work); + try { + for (let i=0;i<10;i++) await Promise.resolve(); + assert.equal(waiting.length, 2, "a new opening must not inherit abandoned refresh work"); + waiting[1](packet()); await current; + waiting[0](packet()); await old; + } finally { work.release(); } +}); + +test("explicit refresh waits for an opening check then performs a force fetch", async () => { + const policies = []; let finish; + globalThis.cacheInvoke = (command, args) => { + if (command !== "get_steam_asset") return Promise.resolve(); + policies.push(args.policy); + return args.policy === "refresh_if_expired" ? new Promise(resolve => { finish = resolve; }) : Promise.resolve(packet()); + }; + await images.loadSteamImage(request(995, "hero")); + const opening = images.refreshSteamImagesOnOpen(995, { requestId: "open-force", origin: "visible", release() {} }); + const explicit = images.refreshSteamImages(995); + finish(packet()); await Promise.all([opening, explicit]); + assert.deepEqual(policies, ["cache_or_fetch", "refresh_if_expired", "force_refresh"]); +}); + + +test("opening details never refreshes permanent Library card artwork or icons", async () => { + const calls = []; + globalThis.cacheInvoke = (command, args) => { + if (command === "cancel_artwork_request") return Promise.resolve(); + calls.push(args); + return Promise.resolve(packet()); + }; + for (const asset of ["hero_blur", "client_icon", "library_capsule", "library_header", "logo"]) await images.loadSteamImage(request(7991, asset)); + calls.length = 0; + await images.refreshSteamImagesOnOpen(7991, { requestId: "opening-permanent", origin: "visible", release() {} }); + assert.equal(calls.length, 0); + await images.refreshSteamImages(7991); + assert.equal(calls.filter(args => args.policy === "force_refresh").length, 5); +}); diff --git a/scripts/test_store_filters.mjs b/scripts/test_store_filters.mjs new file mode 100644 index 00000000..92a6b279 --- /dev/null +++ b/scripts/test_store_filters.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { JSDOM } from "jsdom"; +import { writable } from "svelte/store"; +import { createModuleLoader, dataModule } from "./test_module_loader.mjs"; + +const dom = new JSDOM("", { pretendToBeVisual: true }); +for (const key of ["window", "document", "navigator", "Node", "Text", "Comment", "Element", "HTMLElement", "HTMLMediaElement", "Event", "KeyboardEvent", "MouseEvent"]) { + Object.defineProperty(globalThis, key, { configurable: true, value: dom.window[key] }); +} +HTMLElement.prototype.showPopover = function () {}; +const { mount, unmount, flushSync } = await import("svelte"); +const settle = async () => { flushSync(); await new Promise(resolve => setImmediate(resolve)); flushSync(); }; +const searchDelay = async () => { await settle(); await new Promise(resolve => setTimeout(resolve, 330)); await settle(); }; + +test("store controls combine filters and reset pagination when either choice changes", async () => { + const calls = []; + const more = []; + const notifications = []; + globalThis.storeFilterNotifications = notifications; + const results = Array.from({ length: 80 }, (_, index) => ({ steamAppId: index + 1, name: `Game ${index + 1}` })); + globalThis.storeFilterFixture = { + catalog: writable({ results, total: 120, status: "ready", refreshing: false, error: null, nextOffset: null }), + source: writable({ data: { manifest: null, warning: null } }), + query: writable("portal"), + search: (...args) => calls.push(args), more: (...args) => more.push(args), + }; + const stores = import.meta.resolve("svelte/store"); + const { load } = createModuleLoader({ + "src/lib/i18n": dataModule(`import { writable } from ${JSON.stringify(stores)}; export const language = writable("it"); export const t = value => value;`), + "src/lib/stores/catalog": dataModule("export const catalog = globalThis.storeFilterFixture.catalog; export const runCatalogSearch = (...args) => globalThis.storeFilterFixture.search(...args); export const loadMoreCatalog = (...args) => globalThis.storeFilterFixture.more(...args);"), + "src/lib/stores/library-ui": dataModule("export const storeQuery = globalThis.storeFilterFixture.query;"), + "src/lib/stores/source": dataModule(`import { writable } from ${JSON.stringify(stores)}; export const source = globalThis.storeFilterFixture.source; export const sourceRefreshError = writable(null); export const refreshSource = async () => {};`), + "src/lib/stores/navigation": dataModule(`import { writable } from ${JSON.stringify(stores)}; export const selectedStoreGame = writable(null); export const openStoreGame = () => {};`), + "src/lib/stores/toast": dataModule("export const showToast = (...args) => globalThis.storeFilterNotifications.push(args);"), + "src/lib/features/store/StoreGameCard.svelte": dataModule("export default function () {}"), + "src/lib/features/library/GameDetailView.svelte": dataModule("export default function () {}"), + }); + const StoreView = (await import(await load("src/lib/features/store/StoreView.svelte"))).default; + const target = document.createElement("div"); + document.body.append(target); + const instance = mount(StoreView, { target }); + const loadMore = () => [...target.querySelectorAll("button")].find(button => button.textContent.includes("Carica altri 20")).click(); + async function choose(id, label) { + const trigger = target.querySelector(`#${id}`); + assert.ok(trigger, `${id} must be available before a search returns results`); + trigger.click(); await settle(); + [...target.querySelectorAll('[role="option"]')].find(option => option.textContent.trim() === label).click(); + await searchDelay(); + } + try { + await searchDelay(); + assert.deepEqual(calls.at(-1), ["portal", { sort: "relevance", availability: "all" }]); + loadMore(); await settle(); + loadMore(); await settle(); + loadMore(); await settle(); + assert.deepEqual(more, [["portal", 100]]); + await choose("store-sort", "Alfabetico decrescente (Z-A)"); + assert.deepEqual(calls.at(-1), ["portal", { sort: "name_desc", availability: "all" }]); + loadMore(); await settle(); + assert.equal(more.length, 1, "changing sort must restart at 20 visible games"); + loadMore(); await settle(); + await choose("store-availability", "Solo verified"); + assert.deepEqual(calls.at(-1), ["portal", { sort: "name_desc", availability: "verified" }]); + loadMore(); await settle(); + assert.equal(more.length, 1, "changing availability must restart at 20 visible games"); + await choose("store-availability", "Solo disponibili"); + assert.deepEqual(calls.at(-1), ["portal", { sort: "name_desc", availability: "available" }]); + globalThis.storeFilterFixture.source.set({ data: { manifest: { verified: [], unverified: [] }, warning: null } }); + await searchDelay(); + assert.deepEqual(calls.at(-1), ["portal", { sort: "name_desc", availability: "available" }]); + globalThis.storeFilterFixture.catalog.set({ results, total: 120, status: "ready", refreshing: false, error: "Offline", nextOffset: null }); + await settle(); + assert.equal(notifications.at(-1)?.[0], "Offline", "a failed refresh must offer retry while retaining cached games"); + notifications.at(-1)[2].onRetry(); + assert.deepEqual(calls.at(-1), ["portal", { sort: "name_desc", availability: "available" }, true]); + } finally { await unmount(instance); target.remove(); } +}); diff --git a/scripts/test_visual_rendering.mjs b/scripts/test_visual_rendering.mjs index 9865901c..64757112 100644 --- a/scripts/test_visual_rendering.mjs +++ b/scripts/test_visual_rendering.mjs @@ -16,15 +16,56 @@ globalThis.cancelAnimationFrame = id => frames.delete(id); const { mount, unmount, flushSync } = await import("svelte"); const { load } = createModuleLoader({}); -test("page transition fades without moving content or adding a blur filter", async () => { - const motion = await import(await load("src/lib/utils/motion.ts")); - const node = document.createElement("div"); - for (const progress of [0, 0.25, 0.5, 0.75, 1]) { - node.style.cssText = motion.pageTransition(node).css(progress, 1 - progress); - assert.equal(Number(node.style.opacity), progress, "page fade must preserve each opacity frame"); - assert.ok(node.style.transform === "" || node.style.transform === "none", "page fade must not move content"); - assert.ok(node.style.filter === "" || node.style.filter === "none", "page filter must not create a backdrop root"); - } +function advanceFrames(time) { + const scheduled = [...frames.values()]; frames.clear(); + for (const callback of scheduled) callback(time); +} + +test("foreground fade uses one paint-frame clock without compositor animations", async () => { + frames.clear(); + const { animateForeground } = await import(await load("src/lib/utils/foreground-transition.ts")); + const node = document.createElement("div"); document.body.append(node); + node.animate = () => { throw new Error("Native compositor animations must not be used"); }; + try { + const incoming = animateForeground(node, "in", 100); + assert.equal(node.style.opacity, "0"); assert.equal(frames.size, 1); + advanceFrames(0); advanceFrames(50); + assert.equal(Number(node.style.opacity), .5); + advanceFrames(100); await incoming.finished; + assert.equal(Number(node.style.opacity), 1); assert.equal(frames.size, 0); + incoming.cancel(); assert.equal(node.style.opacity, ""); + assert.equal(node.style.filter, ""); assert.equal(node.style.transform, ""); + } finally { node.remove(); frames.clear(); } +}); + +test("paint-frame fade preserves dim foreground and protected glass", async () => { + frames.clear(); + const { animateForeground } = await import(await load("src/lib/utils/foreground-transition.ts")); + const root = document.createElement("div"); root.innerHTML = '
Text
Other
'; + document.body.append(root); const text = root.querySelector("span"); + try { + const outgoing = animateForeground(root, "out", 100); + assert.equal(frames.size, 1, "all foreground targets share one frame callback"); + advanceFrames(0); advanceFrames(50); + assert.equal(Number(text.style.opacity), .225); + assert.equal(root.querySelector(".legio-panel").style.opacity, ""); + advanceFrames(100); await outgoing.finished; + assert.equal(text.style.opacity, "0"); + root.remove(); outgoing.cancel(); assert.equal(text.style.opacity, "0", "retired layers remain hidden"); + } finally { root.remove(); frames.clear(); } +}); + +test("paint-frame cancellation restores opacity and stops future callbacks", async () => { + frames.clear(); + const { animateForeground } = await import(await load("src/lib/utils/foreground-transition.ts")); + const node = document.createElement("div"); node.style.setProperty("opacity", ".45", "important");document.body.append(node); + try { + const outgoing = animateForeground(node, "out", 100);advanceFrames(0);advanceFrames(50); + outgoing.cancel();await outgoing.finished; + assert.equal(Number(node.style.opacity), .45);assert.equal(node.style.getPropertyPriority("opacity"), "important");assert.equal(frames.size, 0); + advanceFrames(100);assert.equal(Number(node.style.opacity), .45); + const instant = animateForeground(node, "in", 0);await instant.finished;assert.equal(frames.size, 0); + } finally { node.remove(); frames.clear(); } }); test("frozen dither requests a persistent drawing buffer without a continuous frame loop", async () => { @@ -35,6 +76,8 @@ test("frozen dither requests a persistent drawing buffer without a continuous fr return new Proxy({}, { get: (_, name) => { if (name === "drawArrays") return () => { draws++; }; if (name === "getExtension") return () => null; + if (name === "isContextLost") return () => false; + if (name === "checkFramebufferStatus") return () => 1; if (String(name).toUpperCase() === name) return 1; return () => true; } }); @@ -62,38 +105,35 @@ test("frozen dither requests a persistent drawing buffer without a continuous fr test("navigation removes previous page content before the new page enters", async () => { - const previousAnimate = HTMLElement.prototype.animate; - HTMLElement.prototype.animate = (_frames, options) => { - const animation = { currentTime: 0, onfinish: null, cancel() {}, effect: {}, playState: "running" }; - if (options.duration === 0) queueMicrotask(() => animation.onfinish?.()); - return animation; - }; + frames.clear(); const emptyView = dataModule("export default () => {};"); const mocks = {}; for (const [feature, view] of [["home", "HomeView"], ["library", "LibraryView"], ["library", "GameDetailView"], ["store", "StoreView"], ["downloads", "DownloadsView"]]) { mocks[`src/lib/features/${feature}/${view}.svelte`] = emptyView; } const { load } = createModuleLoader(mocks); + (await import(await load("src/lib/services/application-log.ts"))).setApplicationLoggingEnabled(false); const navigation = await import(await load("src/lib/stores/navigation.ts")); const MainContainer = (await import(await load("src/lib/components/layout/MainContainer.svelte"))).default; const target = document.createElement("div"); document.body.append(target); const instance = mount(MainContainer, { target }); const settle = async () => { flushSync(); await new Promise(resolve => setImmediate(resolve)); flushSync(); }; + const finishNavigation = async () => { advanceFrames(0);advanceFrames(105);await settle();advanceFrames(0);advanceFrames(105);await settle(); }; try { await settle(); navigation.activeSection.set("library"); - await settle(); + await settle();await finishNavigation(); assert.deepEqual([...target.querySelectorAll("[data-page]")].map(node => node.dataset.page), ["library"]); navigation.selectedGameId.set("first"); await settle(); navigation.selectedGameId.set("second"); - await settle(); + await settle();await finishNavigation(); assert.deepEqual([...target.querySelectorAll("[data-page]")].map(node => node.dataset.page), ["library:second"]); } finally { await unmount(instance); target.remove(); - HTMLElement.prototype.animate = previousAnimate; + frames.clear(); } }); @@ -103,60 +143,62 @@ test("page entrance respects reduced motion", async () => { globalThis.matchMedia = () => ({ matches: true }); try { const motion = await import(await load("src/lib/utils/motion.ts") + "#reduced-motion"); - assert.equal(motion.pageTransition(document.createElement("div")).duration, 0); + assert.equal(motion.pageTransitionDuration, 0); } finally { globalThis.matchMedia = originalMatchMedia; } }); -test("native page fades snapshot the outgoing page and update to the latest navigation target", async () => { + +test("foreground_targets_never_contain_backdrop_surface", async () => { + const { collectForegroundTargets, animateForeground } = await import(await load("src/lib/utils/foreground-transition.ts")); + const root = document.createElement("div"); + root.innerHTML = '

Text

Panel text
Nested glass text
Other text
'; + document.body.append(root); + const targets = collectForegroundTargets(root); + assert.equal(targets.length, 4); + for (const target of targets) { + assert.ok(!target.matches(".legio-panel,.legio-glass,[style]")); + assert.equal(target.querySelector(".legio-panel,.legio-glass,[style]"), null); + for (const other of targets) if (target !== other) assert.equal(target.contains(other), false); + } + try { + frames.clear(); const outgoing = animateForeground(root, "out", 105); + advanceFrames(0);advanceFrames(105);await outgoing.finished; + for (const surface of root.querySelectorAll(".legio-panel,.legio-glass,article")) assert.equal(surface.style.opacity, ""); + outgoing.cancel(); await animateForeground(root, "in", 0).finished;assert.equal(frames.size, 0); + } finally { root.remove(); frames.clear(); } +}); + +test("navigation_keeps_sidebar_and_canvas_nodes and rapid_navigation_never_republishes_obsolete_target", async () => { const previousStart = document.startViewTransition; - const transitions = []; - document.startViewTransition = update => { - let finish; - const finished = new Promise(resolve => { finish = resolve; }); - const transition = { before: document.querySelector("[data-page]")?.dataset.page, update, ready: Promise.resolve(), finished, skipTransition() { this.skipped = true; finish(); } }; - transitions.push(transition); - return transition; - }; - const mocks = {}; + document.startViewTransition = () => { throw new Error("whole document snapshots must not be used"); }; + frames.clear(); + const mocks = { "src/lib/services/application-log": dataModule('export const logApplicationEvent = () => new Promise(() => {}); export const logNavigationCheckpoint = () => new Promise(() => {});') }; const emptyView = dataModule("export default () => {};"); - for (const [feature, view] of [["home", "HomeView"], ["library", "LibraryView"], ["library", "GameDetailView"], ["store", "StoreView"], ["downloads", "DownloadsView"]]) { - mocks[`src/lib/features/${feature}/${view}.svelte`] = emptyView; - } + for (const [feature, view] of [["home", "HomeView"], ["library", "LibraryView"], ["library", "GameDetailView"], ["store", "StoreView"], ["downloads", "DownloadsView"]]) mocks[`src/lib/features/${feature}/${view}.svelte`] = emptyView; const { load } = createModuleLoader(mocks); const navigation = await import(await load("src/lib/stores/navigation.ts")); - navigation.selectedGameId.set(null); - navigation.activeSection.set("home"); + navigation.selectedGameId.set(null); navigation.activeSection.set("home"); const MainContainer = (await import(await load("src/lib/components/layout/MainContainer.svelte"))).default; const target = document.createElement("div"); - document.body.append(target); + const sidebar = document.createElement("aside"); const canvas = document.createElement("canvas"); + target.append(sidebar, canvas); document.body.append(target); const instance = mount(MainContainer, { target }); + const settle = async () => { flushSync(); await new Promise(resolve => setImmediate(resolve)); flushSync(); }; try { - flushSync(); - navigation.activeSection.set("library"); - flushSync(); - assert.equal(transitions.length, 1); - assert.equal(transitions[0].before, "home", "the old page must be present for the browser snapshot"); - navigation.activeSection.set("store"); - flushSync(); - assert.equal(transitions[0].skipped, true); - await transitions[0].update(); - assert.equal(target.querySelector("[data-page]").dataset.page, "home", "an obsolete update must not reappear"); - await transitions[1].update(); - assert.equal(target.querySelector("[data-page]").dataset.page, "store"); - assert.equal(target.querySelector("[data-page]").style.opacity, "", "the live page must retain its normal blur backdrop"); - navigation.activeSection.set("library"); - flushSync(); - const pending = transitions.at(-1); - navigation.activeSection.set("store"); - flushSync(); - assert.equal(pending.skipped, true, "returning to the displayed page must cancel a pending snapshot"); - await pending.update(); - assert.equal(target.querySelector("[data-page]").dataset.page, "store"); - } finally { - await unmount(instance); - target.remove(); - document.startViewTransition = previousStart; - } + await settle(); + target.querySelector("[data-page]").innerHTML = '
Outgoing foreground
'; + navigation.activeSection.set("library"); await settle(); + assert.equal(target.querySelector("[data-page]").dataset.page, "home", "outgoing foreground waits for exit"); + assert.equal(frames.size, 1); + navigation.activeSection.set("store"); await settle(); + advanceFrames(0); await settle(); + assert.equal(target.querySelector("[data-page]").dataset.page, "home", "obsolete exit cannot publish library"); + advanceFrames(200); await settle(); + assert.equal(target.querySelector("[data-page]").dataset.page, "store", "logging_does_not_gate_page_swap"); + assert.equal(target.querySelector("aside"), sidebar); assert.equal(target.querySelector("canvas"), canvas); + assert.equal(sidebar.style.opacity, ""); assert.equal(canvas.style.opacity, ""); + assert.equal(sidebar.style.opacity, "");assert.equal(canvas.style.opacity, ""); + } finally { await unmount(instance); target.remove(); document.startViewTransition = previousStart;frames.clear(); } }); diff --git a/scripts/test_window_activity.mjs b/scripts/test_window_activity.mjs index 8194bf03..912f037f 100644 --- a/scripts/test_window_activity.mjs +++ b/scripts/test_window_activity.mjs @@ -197,6 +197,8 @@ test("static dither redraws on changes without keeping a frame loop alive", asyn dom.window.HTMLCanvasElement.prototype.getContext = () => new Proxy({}, { get: (_, name) => { if (name === "drawArrays") return () => draws++; if (name === "getExtension") return () => null; + if (name === "isContextLost") return () => false; + if (name === "checkFramebufferStatus") return () => 1; if (String(name).toUpperCase() === name) return 1; return () => true; } }); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8754999d..202048a6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2451,14 +2451,16 @@ dependencies = [ [[package]] name = "legio" -version = "0.1.5" +version = "0.2.0" dependencies = [ "base64 0.23.1", "chrono", "compress-tools", + "dirs", "gtk", "image", "libc", + "regex", "reqwest 0.13.5", "rusqlite", "serde", @@ -2475,7 +2477,10 @@ dependencies = [ "tauri-plugin-updater", "tokio", "uuid", + "webkit2gtk", "webp", + "webview2-com", + "windows", "windows-sys 0.61.2", "winreg 0.56.0", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 42ed69fb..3c5ad368 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "legio" -version = "0.1.5" +version = "0.2.0" description = "A desktop game launcher for Windows and Linux" edition = "2024" rust-version = "1.98.1" @@ -16,8 +16,10 @@ tauri-build = { version = "2", features = [] } base64 = "0.23" chrono = { version = "0.4", default-features = false, features = ["std"] } compress-tools = "0.16" +dirs = "7" image = { version = "0.25", default-features = false, features = ["ico", "png", "jpeg", "webp"] } reqwest = { version = "0.13", default-features = false, features = ["rustls"] } +regex = "1" rusqlite = { version = "0.40.2", features = ["bundled"] } serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" @@ -40,9 +42,12 @@ tokio = { version = "1", features = ["test-util"] } [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18" +webkit2gtk = "=2.0.2" libc = "0.2" [target.'cfg(windows)'.dependencies] compress-tools = { version = "0.16", features = ["static"] } +webview2-com = "=0.39.1" +windows = { version = "=0.62.2", default-features = false } winreg = "0.56.0" windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_Globalization", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading"] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 23b5558f..51dadf63 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -57,6 +57,8 @@ fn main() { "set_game_executable", "get_network_status", "get_network_log_status", + "get_application_log_status", + "report_application_event", "get_legio_source", "add_download_source", "remove_download_source", diff --git a/src-tauri/permissions/main.toml b/src-tauri/permissions/main.toml index 310db4c4..d7878835 100644 --- a/src-tauri/permissions/main.toml +++ b/src-tauri/permissions/main.toml @@ -56,6 +56,8 @@ commands.allow = [ "set_game_executable", "get_network_status", "get_network_log_status", + "get_application_log_status", + "report_application_event", "get_legio_source", "add_download_source", "remove_download_source", diff --git a/src-tauri/src/application_log.rs b/src-tauri/src/application_log.rs new file mode 100644 index 00000000..25ebab9a --- /dev/null +++ b/src-tauri/src/application_log.rs @@ -0,0 +1,1280 @@ +use std::{ + fs::{self, OpenOptions}, + io::{self, Write}, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, OnceLock, TryLockError, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use regex::Regex; +use rusqlite::{Connection, OpenFlags, OptionalExtension}; +use serde::{Deserialize, Serialize}; + +const MAX_LOG_BYTES: u64 = 256 * 1024; +const MAX_MESSAGE_BYTES: usize = 1024; +const MAX_STACK_BYTES: usize = 4096; +const REPORTS_PER_MINUTE: u32 = 50; +static APPLICATION_LOG: OnceLock = OnceLock::new(); + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LogStatus { + enabled: bool, + directory: Option, + last_error: Option, + dropped_records: u64, +} + +struct State { + status: LogStatus, + report_window: Instant, + reports: u32, + critical_reports: u32, +} + +#[derive(Clone)] +pub(crate) struct ApplicationLog { + state: Arc>, + session: Arc, + enabled: Arc, + artwork_queue: Arc>>, + queue_dropped: Arc, +} +struct QueuedArtwork { + event: &'static str, + message: String, +} + +struct Record<'a> { + timestamp_ms: u128, + category: &'static str, + session: &'a str, + version: &'static str, + platform: &'static str, + level: &'a str, + event: &'a str, + message: Option, + stack: Option, +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Level { + Info, + Warn, + Error, +} + +impl Level { + fn as_str(self) -> &'static str { + match self { + Self::Info => "info", + Self::Warn => "warn", + Self::Error => "error", + } + } +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum RendererEvent { + RendererStart, + RendererReady, + RendererError, + UnhandledRejection, + Console, + Navigation, + CommandError, + BootstrapError, + Artwork, +} + +impl RendererEvent { + fn is_critical(self) -> bool { + matches!( + self, + Self::RendererError | Self::UnhandledRejection | Self::BootstrapError + ) + } + + fn as_str(self) -> &'static str { + match self { + Self::RendererStart => "renderer_start", + Self::RendererReady => "renderer_ready", + Self::RendererError => "renderer_error", + Self::UnhandledRejection => "unhandled_rejection", + Self::Console => "console", + Self::Navigation => "navigation", + Self::CommandError => "command_error", + Self::BootstrapError => "bootstrap_error", + Self::Artwork => "artwork", + } + } +} + +impl ApplicationLog { + pub(crate) fn new(directory: PathBuf, enabled: bool) -> Self { + Self::with_directory(Ok(directory), enabled) + } + + fn with_directory(directory: Result, enabled: bool) -> Self { + let log = Self { + state: Arc::new(Mutex::new(State { + status: LogStatus { + enabled: false, + directory: directory.as_ref().ok().cloned(), + last_error: directory + .err() + .map(|_| "Could not locate the application log directory.".to_owned()), + dropped_records: 0, + }, + report_window: Instant::now(), + reports: 0, + critical_reports: 0, + })), + enabled: Arc::new(AtomicBool::new(false)), + artwork_queue: Arc::new(OnceLock::new()), + queue_dropped: Arc::new(AtomicU64::new(0)), + session: uuid::Uuid::new_v4().to_string().into(), + }; + log.set_enabled(enabled); + log + } + + pub(crate) fn status(&self) -> LogStatus { + let mut status = self + .state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .status + .clone(); + status.dropped_records = status + .dropped_records + .saturating_add(self.queue_dropped.load(Ordering::Relaxed)); + status + } + + fn queue_artwork(&self, event: &'static str, message: String) { + if !self.enabled.load(Ordering::Acquire) { + return; + } + let sender = self.artwork_queue.get_or_init(|| { + let (sender, receiver) = std::sync::mpsc::sync_channel::(64); + let state = Arc::downgrade(&self.state); + let session = self.session.clone(); + std::thread::spawn(move || { + while let Ok(report) = receiver.recv() { + let Some(state) = state.upgrade() else { + break; + }; + let mut state = state.lock().unwrap_or_else(|error| error.into_inner()); + Self::write( + &session, + &mut state, + "info", + report.event, + Some(&report.message), + None, + false, + ); + } + }); + sender + }); + if sender.try_send(QueuedArtwork { event, message }).is_err() { + self.queue_dropped.fetch_add(1, Ordering::Relaxed); + } + } + + pub(crate) fn set_enabled(&self, enabled: bool) { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + let changed = state.status.enabled != enabled; + state.status.enabled = enabled; + self.enabled.store(enabled, Ordering::Release); + if changed && enabled { + state.report_window = Instant::now(); + state.reports = 0; + state.critical_reports = 0; + if let Some(directory) = &state.status.directory { + let result = prepare(directory); + update_status(&mut state.status, result); + } + } + } + + pub(crate) fn report( + &self, + level: Level, + event: RendererEvent, + message: Option<&str>, + stack: Option<&str>, + ) -> Result<(), String> { + if message.is_some_and(|message| message.len() > MAX_MESSAGE_BYTES) + || stack.is_some_and(|stack| stack.len() > MAX_STACK_BYTES) + { + return Err("Application log report exceeds the size limit".to_owned()); + } + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if !state.status.enabled { + return Ok(()); + } + if state.report_window.elapsed() >= Duration::from_secs(60) { + state.report_window = Instant::now(); + state.reports = 0; + state.critical_reports = 0; + } + let reports = if event.is_critical() { + &mut state.critical_reports + } else { + &mut state.reports + }; + if *reports >= REPORTS_PER_MINUTE { + state.status.dropped_records = state.status.dropped_records.saturating_add(1); + return Err("Application log report rate limit reached".to_owned()); + } + *reports += 1; + Self::write( + &self.session, + &mut state, + level.as_str(), + event.as_str(), + message, + stack, + matches!(event, RendererEvent::Navigation), + ); + state.status.last_error.clone().map_or(Ok(()), Err) + } + + pub(crate) fn record( + &self, + level: &str, + event: &str, + message: Option<&str>, + stack: Option<&str>, + durable: bool, + ) { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + Self::write( + &self.session, + &mut state, + level, + event, + message, + stack, + durable, + ); + } + + fn record_panic(&self, info: &std::panic::PanicHookInfo<'_>) { + let mut state = match self.state.try_lock() { + Ok(state) => state, + Err(TryLockError::Poisoned(error)) => error.into_inner(), + Err(TryLockError::WouldBlock) => return, + }; + if !state.status.enabled { + return; + } + let payload = info + .payload() + .downcast_ref::<&str>() + .copied() + .or_else(|| info.payload().downcast_ref::().map(String::as_str)) + .unwrap_or("Rust panic"); + let location = info + .location() + .map(|location| { + let filename = Path::new(location.file()) + .file_name() + .unwrap_or_default() + .to_string_lossy(); + format!("{filename}:{}:{}", location.line(), location.column()) + }) + .unwrap_or_default(); + Self::write( + &self.session, + &mut state, + "error", + "panic", + Some(payload), + Some(&format!( + "{location}\n{}", + std::backtrace::Backtrace::force_capture() + )), + true, + ); + } + + fn write( + session: &str, + state: &mut State, + level: &str, + event: &str, + message: Option<&str>, + stack: Option<&str>, + durable: bool, + ) { + if !state.status.enabled { + return; + } + let Some(directory) = &state.status.directory else { + state.status.dropped_records = state.status.dropped_records.saturating_add(1); + return; + }; + let record = Record { + timestamp_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + category: "application", + session, + version: env!("CARGO_PKG_VERSION"), + platform: std::env::consts::OS, + level, + event, + message: message.map(|message| sanitize(message, MAX_MESSAGE_BYTES)), + stack: stack.map(|stack| sanitize(stack, MAX_STACK_BYTES)), + }; + let result = append(directory, &record, durable); + update_status(&mut state.status, result); + } +} + +fn update_status(status: &mut LogStatus, result: io::Result<()>) { + match result { + Ok(()) => status.last_error = None, + Err(error) => { + status.dropped_records = status.dropped_records.saturating_add(1); + status.last_error = Some(format!( + "Could not write application logs ({:?}). Check directory permissions and free disk space.", + error.kind() + )); + } + } +} + +fn prepare(directory: &Path) -> io::Result<()> { + fs::create_dir_all(directory)?; + for name in ["app.txt", "app.previous.txt"] { + let path = directory.join(name); + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(io::Error::other("Application log is not a regular file")); + } + Ok(metadata) if metadata.len() > MAX_LOG_BYTES => fs::remove_file(path)?, + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + Ok(()) +} + +fn open_log(path: &Path) -> io::Result { + let mut options = OpenOptions::new(); + options.create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + options.open(path) +} + +fn append(directory: &Path, record: &Record<'_>, durable: bool) -> io::Result<()> { + prepare(directory)?; + let path = directory.join("app.txt"); + let entry = format_record(record)?; + let mut file = open_log(&path)?; + if file.metadata()?.len().saturating_add(entry.len() as u64) > MAX_LOG_BYTES { + drop(file); + let previous = directory.join("app.previous.txt"); + match fs::remove_file(&previous) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + fs::rename(&path, previous)?; + file = open_log(&path)?; + } + file.write_all(&entry)?; + if durable { + file.sync_data()?; + } + Ok(()) +} + +fn format_record(record: &Record<'_>) -> io::Result> { + let timestamp = i64::try_from(record.timestamp_ms) + .ok() + .and_then(chrono::DateTime::from_timestamp_millis) + .ok_or_else(|| io::Error::other("Application log timestamp is out of range"))?; + let mut entry = format!( + "[{}] {} {}\n Context: {} | Legio {} | {} | session {}\n", + timestamp.format("%Y-%m-%d %H:%M:%S%.3f UTC"), + record.level.to_ascii_uppercase(), + record.event, + record.category, + record.version, + record.platform, + record.session, + ); + for (label, content) in [ + ("Message", record.message.as_deref()), + ("Stack", record.stack.as_deref()), + ] { + if let Some(content) = content.filter(|content| !content.is_empty()) { + entry.push_str(&format!(" {label}:\n")); + for line in content.lines() { + entry.push_str(" "); + entry.push_str(line); + entry.push('\n'); + } + } + } + entry.push('\n'); + Ok(entry.into_bytes()) +} + +fn redacted_location(text: &str) -> String { + let text = text.trim_end_matches([')', ']', '}', ',', ';']); + if let Some((prefix, column)) = text.rsplit_once(':') + && let Some((_, line)) = prefix.rsplit_once(':') + && !line.is_empty() + && !column.is_empty() + && line.bytes().all(|byte| byte.is_ascii_digit()) + && column.bytes().all(|byte| byte.is_ascii_digit()) + && line.parse::().is_ok() + && column.parse::().is_ok() + { + return format!("[redacted]:{line}:{column}"); + } + "[redacted]".to_owned() +} + +fn sanitize(text: &str, limit: usize) -> String { + static QUOTED_CONTENT: OnceLock = OnceLock::new(); + static PATH_FILTERS: OnceLock> = OnceLock::new(); + static FILTERS: OnceLock> = OnceLock::new(); + let quoted_content = QUOTED_CONTENT.get_or_init(|| { + Regex::new(r#"\"[^\"\n]*\"|'[^'\n]*'|`[^`\n]*`"#) + .expect("constant application log quoted-content pattern") + }); + let path_filters = PATH_FILTERS.get_or_init(|| { + [ + r#"(?i)\b[a-z][a-z0-9+.-]*://[^\s)\]\}<>\"']+"#, + r#"(?i)\b[a-z]:[\\/][^\r\n]*|\\\\[^\r\n]*"#, + r#"(?:~?/|\./|\.\./)[^\r\n]*"#, + ] + .into_iter() + .map(|pattern| Regex::new(pattern).expect("constant application log path pattern")) + .collect() + }); + let filters = FILTERS.get_or_init(|| [ + r"(?i)\b(?:set-)?cookie\s*:[^\r\n]*", + r"(?i)\b(?:bearer|basic)\s+[^\s,;]+", + r#"(?i)\b(?:password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|authorization|cookie|secret|api[_-]?key)\s*[=:]\s*(?:\"[^\"]*\"|'[^']*'|[^\s,;]+)"#, + r"(?i)\b(?:steam[ _-]*(?:app[ _-]*)?id|app[ _-]*id|game[ _-]*id|download[ _-]*id|account[ _-]*id)\s*(?:[:=]\s*)?[a-z0-9_-]+", + r"[\w.+-]+@[\w.-]+\.[a-zA-Z]{2,}", + r"\b(?:[a-fA-F0-9]{32,}|[0-9]{15,}|[a-fA-F0-9]{8}(?:-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12})\b", + r"\beyJ[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+){1,2}\b", + ].into_iter().map(|pattern| Regex::new(pattern).expect("constant application log redaction pattern")).collect()); + let mut result = text + .chars() + .filter(|character| !character.is_control() || matches!(character, '\n' | '\t')) + .collect::(); + result = quoted_content + .replace_all(&result, "[redacted]") + .into_owned(); + for filter in path_filters { + result = filter + .replace_all(&result, |capture: ®ex::Captures<'_>| { + redacted_location(&capture[0]) + }) + .into_owned(); + } + for filter in filters { + result = filter.replace_all(&result, "[redacted]").into_owned(); + } + if result.len() > limit { + let mut boundary = limit; + while !result.is_char_boundary(boundary) { + boundary -= 1; + } + result.truncate(boundary); + } + result +} + +fn saved_enabled(data_directory: &Path) -> bool { + let path = data_directory.join("legio.sqlite3"); + if !path.is_file() { + return false; + } + let read = || -> rusqlite::Result> { + let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + connection + .query_row( + "SELECT value FROM settings WHERE key = 'app_preferences'", + [], + |row| row.get(0), + ) + .optional() + }; + read() + .ok() + .flatten() + .and_then(|value| serde_json::from_str::(&value).ok()) + .and_then(|value| { + value + .get("applicationLoggingEnabled") + .and_then(serde_json::Value::as_bool) + }) + .unwrap_or(false) +} + +pub(crate) fn initialize(config: &tauri::Config) -> ApplicationLog { + let data_directory = dirs::data_dir().map(|directory| directory.join(&config.identifier)); + let log_directory = + dirs::data_local_dir().map(|directory| directory.join(&config.identifier).join("logs")); + initialize_at(data_directory.as_deref(), log_directory) +} + +fn initialize_at(data_directory: Option<&Path>, log_directory: Option) -> ApplicationLog { + APPLICATION_LOG + .get_or_init(|| { + let enabled = data_directory.is_some_and(saved_enabled); + let log = match log_directory { + Some(directory) => ApplicationLog::new(directory, enabled), + None => ApplicationLog::with_directory( + Err("Application log directory unavailable".to_owned()), + enabled, + ), + }; + let panic_log = log.clone(); + let previous_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + panic_log.record_panic(info); + previous_hook(info); + })); + log + }) + .clone() +} + +pub(crate) fn failure(context: &'static str, error: impl std::fmt::Display) { + if let Some(log) = APPLICATION_LOG.get() { + log.record( + "error", + "rust_failure", + Some(&format!("{context}: {error}")), + None, + false, + ); + } +} + +pub(crate) fn artwork_summary(message: String) { + artwork_checkpoint("artwork", message); +} + +pub(crate) fn artwork_checkpoint(event: &'static str, message: impl std::fmt::Display) { + if let Some(log) = APPLICATION_LOG + .get() + .filter(|log| log.enabled.load(Ordering::Acquire)) + { + log.queue_artwork(event, message.to_string()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + fn checkpoint(event: &'static str, message: impl std::fmt::Display) { + if let Some(log) = APPLICATION_LOG.get() { + log.record("info", event, Some(&message.to_string()), None, true); + } + } + + fn directory() -> PathBuf { + std::env::temp_dir().join(format!("legio-app-log-{}", uuid::Uuid::new_v4())) + } + + #[test] + fn artwork_reports_are_opt_in_bounded_and_readable() { + let directory = directory(); + let log = ApplicationLog::new(directory.clone(), false); + let event: RendererEvent = serde_json::from_str("\"artwork\"").unwrap(); + let message = "resource=image queue_wait_ms=10 metadata_ms=20 transfer_ms=30 conversion_ms=40 outcome=completed"; + log.report(Level::Info, event, Some(message), None).unwrap(); + assert!(!directory.join("app.txt").exists()); + log.set_enabled(true); + log.report(Level::Info, event, Some(message), None).unwrap(); + let text = fs::read_to_string(directory.join("app.txt")).unwrap(); + assert!(text.contains("INFO artwork") && text.contains("queue_wait_ms=10")); + for _ in 1..REPORTS_PER_MINUTE { + log.report(Level::Info, event, Some(message), None).unwrap(); + } + assert!(log.report(Level::Info, event, Some(message), None).is_err()); + assert_eq!(log.status().dropped_records, 1); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn persisted_records_are_readable_text_with_utc_context_and_multiline_stack() { + let directory = directory(); + let record = Record { + timestamp_ms: 0, + category: "application", + session: "test-session", + version: "0.2.0", + platform: "test-platform", + level: "error", + event: "renderer_error", + message: Some("TypeError: Cover unavailable\nRetry failed".to_owned()), + stack: Some("Error: Cover unavailable\n at load ([redacted]:123:456)".to_owned()), + }; + append(&directory, &record, false).unwrap(); + let text = fs::read_to_string(directory.join("app.txt")).unwrap(); + assert!( + text.starts_with("[1970-01-01 00:00:00.000 UTC] ERROR renderer_error\n"), + "{text}" + ); + assert!(text.contains( + " Context: application | Legio 0.2.0 | test-platform | session test-session\n" + )); + assert!(text.contains(" Message:\n TypeError: Cover unavailable\n Retry failed\n")); + assert!(text.contains( + " Stack:\n Error: Cover unavailable\n at load ([redacted]:123:456)\n" + )); + assert!(!text.contains("\\n")); + assert!(directory.join("app.txt").exists()); + assert!(!directory.join("app.jsonl").exists()); + fs::remove_dir_all(directory).unwrap(); + } + + fn last_record(text: &str) -> &str { + text.trim_end().rsplit("\n\n").next().unwrap() + } + + fn record_message(record: &str) -> &str { + record + .split_once(" Message:\n") + .unwrap() + .1 + .split("\n Stack:\n") + .next() + .unwrap() + } + + #[test] + fn new_text_logs_preserve_existing_jsonl_evidence() { + let directory = directory(); + fs::create_dir_all(&directory).unwrap(); + let old_evidence = b"{\"event\":\"earlier_crash\"}\n"; + for name in ["app.jsonl", "app.previous.jsonl"] { + fs::write(directory.join(name), old_evidence).unwrap(); + } + let log = ApplicationLog::new(directory.clone(), true); + log.record("info", "startup", None, None, true); + for name in ["app.jsonl", "app.previous.jsonl"] { + assert_eq!(fs::read(directory.join(name)).unwrap(), old_evidence); + } + let text = fs::read_to_string(directory.join("app.txt")).unwrap(); + assert!(text.lines().next().unwrap().ends_with("INFO startup")); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn opt_out_creates_nothing_and_disabling_stops_writes() { + let directory = directory(); + let log = ApplicationLog::new(directory.clone(), false); + log.record("error", "panic", Some("failure"), None, true); + assert!(!directory.exists()); + log.set_enabled(true); + log.record("info", "startup", None, None, false); + let before = fs::read(directory.join("app.txt")).unwrap(); + log.set_enabled(false); + log.record("error", "panic", Some("failure"), None, true); + assert_eq!(fs::read(directory.join("app.txt")).unwrap(), before); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn quoted_private_content_is_removed_before_greedy_path_redaction() { + let directory = directory(); + let log = ApplicationLog::new(directory.clone(), true); + for message in [ + "Could not open \"PRIVATE_USER /home/alice/My Private Game/config.json\"", + "Could not open 'PRIVATE_USER C:\\Users\\alice\\My Private Game\\config.json'", + "Could not open `PRIVATE_USER https://private.example/config.json`", + ] { + log.report( + Level::Error, + RendererEvent::CommandError, + Some(message), + None, + ) + .unwrap(); + } + let text = fs::read_to_string(directory.join("app.txt")).unwrap(); + assert!( + !text.contains("PRIVATE_USER"), + "private quoted prefix: {text}" + ); + assert!(!text.contains("alice")); + assert!(!text.contains("private.example")); + assert!(!text.contains("My Private Game")); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn records_redact_credentials_urls_paths_and_quoted_private_content() { + let directory = directory(); + let log = ApplicationLog::new(directory.clone(), true); + for (message, private) in [ + ("password=secret", "secret"), + ("password=\"super secret\"", "super secret"), + ("token:abc", "abc"), + ("Bearer jwtsecret", "jwtsecret"), + ( + "Authorization: Bearer privatecredential", + "privatecredential", + ), + ( + "Authorization: Basic cHJpdmF0ZXVzZXI6cGFzc3dvcmQ=", + "cHJpdmF0ZXVzZXI6cGFzc3dvcmQ=", + ), + ( + "https://user:pass@example.com/private?token=secret", + "example.com", + ), + ("/home/alice/Games/Private Game", "Private Game"), + ("C:\\Users\\Alice\\Games\\Private Game", "Private Game"), + ("'Private game'", "Private game"), + ("alice@example.com", "alice@example.com"), + ("Steam account 76561198000000001", "76561198000000001"), + ] { + log.record("error", "renderer_error", Some(message), None, false); + let text = fs::read_to_string(directory.join("app.txt")).unwrap(); + let record = last_record(&text); + assert!( + record + .lines() + .next() + .unwrap() + .ends_with("ERROR renderer_error") + ); + let sanitized = record_message(record); + assert!( + !sanitized.contains(private), + "leaked {private}: {sanitized}" + ); + assert!(sanitized.contains("redacted")); + } + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn concurrent_writes_retain_complete_bounded_records_and_previous_file() { + let directory = directory(); + let log = ApplicationLog::new(directory.clone(), true); + let workers: Vec<_> = (0..4) + .map(|_| { + let log = log.clone(); + std::thread::spawn(move || { + for _ in 0..300 { + log.record( + "error", + "rust_failure", + Some(&"failure ".repeat(128)), + None, + false, + ); + } + }) + }) + .collect(); + for worker in workers { + worker.join().unwrap(); + } + assert!(directory.join("app.previous.txt").exists()); + assert_eq!(fs::read_dir(&directory).unwrap().count(), 2); + for name in ["app.txt", "app.previous.txt"] { + let path = directory.join(name); + assert!(fs::metadata(&path).unwrap().len() <= 256 * 1024); + let text = fs::read_to_string(path).unwrap(); + assert!(text.ends_with("\n\n")); + for record in text.trim_end().split("\n\n") { + let lines: Vec<_> = record.lines().collect(); + assert_eq!(lines.len(), 4); + assert!(lines[0].ends_with("UTC] ERROR rust_failure")); + assert!(lines[1].starts_with(" Context: application | Legio ")); + assert_eq!(lines[2], " Message:"); + assert_eq!( + lines[3].trim_end(), + format!(" {}", "failure ".repeat(128)).trim_end() + ); + } + } + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn renderer_reports_redact_contextual_game_and_download_identifiers() { + let directory = directory(); + let log = ApplicationLog::new(directory.clone(), true); + log.report( + Level::Error, + RendererEvent::Console, + Some("Steam App ID 620; game ID private-game; download ID private-download"), + None, + ) + .unwrap(); + let text = fs::read_to_string(directory.join("app.txt")).unwrap(); + let message = record_message(last_record(&text)); + for private in ["620", "private-game", "private-download"] { + assert!(!message.contains(private), "leaked {private}"); + } + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn cookie_headers_redact_every_value_on_the_line() { + let directory = directory(); + let log = ApplicationLog::new(directory.clone(), true); + for header in ["Cookie", "Set-Cookie"] { + log.record( + "error", + "renderer_error", + Some(&format!( + "{header}: session_id=privatefirst; auth_session=privatesecond" + )), + None, + false, + ); + let text = fs::read_to_string(directory.join("app.txt")).unwrap(); + let message = record_message(last_record(&text)); + assert!(!message.contains("privatefirst")); + assert!(!message.contains("privatesecond")); + } + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn private_stack_urls_preserve_numeric_locations() { + let directory = directory(); + let log = ApplicationLog::new(directory.clone(), true); + log.record("error", "unhandled_rejection", Some("failure"), Some("at load (https://private.example/app-secret.js?token=private:123:456)\nat read (file:///home/alice/private.js:78:9)"), false); + let text = fs::read_to_string(directory.join("app.txt")).unwrap(); + let stack = text.split_once(" Stack:\n").unwrap().1; + assert!(stack.contains("[redacted]:123:456"), "{stack}"); + assert!(stack.contains("[redacted]:78:9"), "{stack}"); + for private in ["private", "alice", "example"] { + assert!(!stack.contains(private)); + } + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn durable_panic_record_is_readable_without_a_background_flush() { + let directory = directory(); + let log = ApplicationLog::new(directory.clone(), true); + log.record("error", "panic", Some("panic failure"), None, true); + let text = fs::read_to_string(directory.join("app.txt")).unwrap(); + assert!(text.lines().next().unwrap().ends_with("ERROR panic")); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn renderer_reports_reject_oversized_input_and_limit_each_budget() { + let directory = directory(); + let log = ApplicationLog::new(directory.clone(), true); + assert!( + log.report( + Level::Error, + RendererEvent::RendererError, + Some(&"x".repeat(1025)), + None + ) + .is_err() + ); + assert!( + log.report( + Level::Error, + RendererEvent::RendererError, + None, + Some(&"x".repeat(4097)) + ) + .is_err() + ); + assert!(!directory.join("app.txt").exists()); + for _ in 0..50 { + log.report(Level::Warn, RendererEvent::Console, Some("warning"), None) + .unwrap(); + } + assert!( + log.report(Level::Warn, RendererEvent::Console, None, None) + .is_err() + ); + for _ in 0..50 { + log.report( + Level::Error, + RendererEvent::RendererError, + Some("critical failure"), + None, + ) + .unwrap(); + } + assert!( + log.report(Level::Error, RendererEvent::UnhandledRejection, None, None) + .is_err() + ); + assert_eq!(log.status().dropped_records, 2); + let text = fs::read_to_string(directory.join("app.txt")).unwrap(); + assert_eq!( + text.lines().filter(|line| line.starts_with('[')).count(), + 100 + ); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn navigation_reports_persist_before_returning_and_surface_write_failures() { + let directory = directory(); + let log = ApplicationLog::new(directory.clone(), true); + log.report( + Level::Info, + RendererEvent::Navigation, + Some("Requested library transition"), + None, + ) + .unwrap(); + let text = fs::read_to_string(directory.join("app.txt")).unwrap(); + assert!(text.lines().next().unwrap().ends_with("INFO navigation")); + assert!(text.contains("Requested library transition")); + fs::remove_dir_all(&directory).unwrap(); + fs::write(&directory, "not a directory").unwrap(); + let error = log + .report( + Level::Info, + RendererEvent::Navigation, + Some("transition"), + None, + ) + .expect_err("navigation acknowledgment must surface failed persistence"); + assert!(error.contains("Could not write application logs")); + assert!(!error.contains(directory.to_str().unwrap())); + assert_eq!(log.status().last_error.as_deref(), Some(error.as_str())); + fs::remove_file(directory).unwrap(); + } + + #[test] + fn log_write_failures_remain_visible_without_private_path_details() { + let directory = directory(); + fs::write(&directory, "not a directory").unwrap(); + let log = ApplicationLog::new(directory.clone(), true); + log.record("error", "rust_failure", Some("failure"), None, false); + let status = log.status(); + assert!(status.dropped_records > 0); + let error = status.last_error.unwrap(); + assert!(error.contains("Could not write application logs")); + assert!(!error.contains(directory.to_str().unwrap())); + fs::remove_file(directory).unwrap(); + } + + #[test] + fn startup_opt_in_reads_existing_preferences_without_migration_or_creation() { + let directory = directory(); + assert!(!saved_enabled(&directory)); + assert!(!directory.exists()); + let database = crate::database::Database::open(&directory).unwrap(); + assert!(!saved_enabled(&directory)); + database + .save_settings(crate::database::Settings { + application_logging_enabled: true, + ..crate::database::Settings::default() + }) + .unwrap(); + drop(database); + let path = directory.join("legio.sqlite3"); + let connection = Connection::open(&path).unwrap(); + connection + .pragma_update(None, "user_version", 1_000_000) + .unwrap(); + drop(connection); + let before = fs::read(&path).unwrap(); + assert!(saved_enabled(&directory)); + assert_eq!(fs::read(&path).unwrap(), before); + assert!(crate::database::Database::open(&directory).is_err()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn preexisting_oversized_logs_are_removed_before_writing() { + let directory = directory(); + fs::create_dir_all(&directory).unwrap(); + for name in ["app.txt", "app.previous.txt"] { + fs::write(directory.join(name), vec![b'x'; 256 * 1024 + 1]).unwrap(); + } + let log = ApplicationLog::new(directory.clone(), true); + log.record("info", "startup", None, None, false); + let text = fs::read_to_string(directory.join("app.txt")).unwrap(); + assert!(text.lines().next().unwrap().ends_with("INFO startup")); + assert!(!directory.join("app.previous.txt").exists()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn native_checkpoint_child() { + let Some(directory) = std::env::var_os("LEGIO_CHECKPOINT_TEST_DIRECTORY") else { + return; + }; + let directory = PathBuf::from(directory); + let log = initialize_at(Some(&directory), Some(directory.join("logs"))); + if log.status().enabled { + for _ in 0..50 { + log.report(Level::Warn, RendererEvent::Console, Some("warning"), None) + .unwrap(); + log.report( + Level::Error, + RendererEvent::RendererError, + Some("renderer failure"), + None, + ) + .unwrap(); + } + assert!( + log.report(Level::Warn, RendererEvent::Console, None, None) + .is_err() + ); + assert!( + log.report(Level::Error, RendererEvent::RendererError, None, None) + .is_err() + ); + } + checkpoint("native_decode_begin", "width 640; height 480; bytes 2048"); + let source = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 32, + 16, + image::Rgba([64, 128, 192, 255]), + )); + let mut input = std::io::Cursor::new(Vec::new()); + source + .write_to(&mut input, image::ImageFormat::Png) + .unwrap(); + let transformed = crate::image_trim::hero_webp(input.get_ref(), 16, true).unwrap(); + crate::image_response::encode(Vec::new(), transformed).unwrap(); + if log.enabled.load(Ordering::Acquire) { + for _ in 0..200 { + if fs::read_to_string(directory.join("logs/app.txt")) + .is_ok_and(|text| text.contains("stage=payload_ready")) + { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + } + log.set_enabled(false); + checkpoint("disabled_checkpoint", "must not persist"); + } + + #[test] + fn blocked_log_sink_does_not_block_native_conversion_or_ipc() { + let Some(directory) = std::env::var_os("LEGIO_BLOCKED_LOG_TEST_DIRECTORY") else { + return; + }; + let log = initialize_at(None, Some(PathBuf::from(directory))); + log.set_enabled(true); + let guard = log.state.lock().unwrap(); + let (send, receive) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 16, + 16, + image::Rgba([64, 128, 192, 255]), + )); + let mut png = std::io::Cursor::new(Vec::new()); + image.write_to(&mut png, image::ImageFormat::Png).unwrap(); + let bytes = crate::image_trim::hero_webp(png.get_ref(), 8, true).unwrap(); + crate::image_response::encode(Vec::new(), bytes).unwrap(); + send.send(()).unwrap(); + }); + let completed = receive.recv_timeout(Duration::from_secs(2)).is_ok(); + drop(guard); + worker.join().unwrap(); + assert!( + completed, + "native conversion and publication waited for the log sink" + ); + } + + #[test] + fn native_conversion_logging_remains_nonblocking_in_subprocess() { + let directory = directory(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "application_log::tests::blocked_log_sink_does_not_block_native_conversion_or_ipc", + "--nocapture", + ]) + .env("LEGIO_BLOCKED_LOG_TEST_DIRECTORY", &directory) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn native_checkpoints_obey_saved_opt_in_and_bypass_exhausted_renderer_budgets() { + for enabled in [true, false] { + let directory = directory(); + let database = crate::database::Database::open(&directory).unwrap(); + database + .save_settings(crate::database::Settings { + application_logging_enabled: enabled, + ..crate::database::Settings::default() + }) + .unwrap(); + drop(database); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "application_log::tests::native_checkpoint_child", + "--nocapture", + ]) + .env("LEGIO_CHECKPOINT_TEST_DIRECTORY", &directory) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + if enabled { + let text = fs::read_to_string(directory.join("logs/app.txt")).unwrap(); + assert_eq!( + text.lines() + .filter(|line| line.ends_with("WARN console")) + .count(), + 50 + ); + assert_eq!( + text.lines() + .filter(|line| line.ends_with("ERROR renderer_error")) + .count(), + 50 + ); + assert!(text.contains("INFO native_decode_begin")); + assert!(text.contains("width 640; height 480; bytes 2048")); + assert!(text.contains("INFO artwork_processing")); + let mut previous = 0; + for stage in [ + "decode_start", + "decode_complete", + "resize_start", + "resize_complete", + "blur_start", + "blur_complete", + "encode_start", + "encode_complete", + "complete", + ] { + let position = text + .find(&format!("stage={stage}")) + .unwrap_or_else(|| panic!("missing artwork phase {stage}: {text}")); + assert!(position > previous, "artwork phase order: {stage}"); + previous = position; + } + assert!(text.contains("INFO artwork_ipc")); + assert!(last_record(&text).contains("stage=payload_ready")); + assert!(!text.contains("disabled_checkpoint")); + } else { + assert!(!directory.join("logs").exists()); + } + fs::remove_dir_all(directory).unwrap(); + } + } + + fn panic_subprocess(enabled: bool, locked: bool) -> PathBuf { + let directory = directory(); + let database = crate::database::Database::open(&directory).unwrap(); + database + .save_settings(crate::database::Settings { + application_logging_enabled: enabled, + ..crate::database::Settings::default() + }) + .unwrap(); + drop(database); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "application_log::tests::panic_hook_child", + "--nocapture", + ]) + .env("LEGIO_PANIC_TEST_DIRECTORY", &directory) + .env("LEGIO_PANIC_TEST_LOCKED", if locked { "1" } else { "0" }) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap(); + // Symbolizing debug backtraces can be slow while the full suite loads the disk. + let timeout = Duration::from_secs(if locked { 10 } else { 60 }); + let deadline = Instant::now() + timeout; + loop { + if let Some(exit) = child.try_wait().unwrap() { + assert!(!exit.success()); + break; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!( + "panic test subprocess timed out after {timeout:?} (logging_enabled={enabled}, writer_locked={locked})" + ); + } + std::thread::sleep(Duration::from_millis(10)); + } + directory + } + + #[test] + fn panic_hook_child() { + let Some(directory) = std::env::var_os("LEGIO_PANIC_TEST_DIRECTORY") else { + return; + }; + let directory = PathBuf::from(directory); + let log = initialize_at(Some(&directory), Some(directory.join("logs"))); + if std::env::var("LEGIO_PANIC_TEST_LOCKED").as_deref() == Ok("1") { + let _locked = log.state.lock().unwrap(); + panic!("simulated locked-writer panic"); + } + panic!("simulated panic token=privatecredential"); + } + + #[test] + fn actual_panic_hook_obeys_saved_opt_in_and_persists_redacted_details() { + let directory = panic_subprocess(true, false); + let text = fs::read_to_string(directory.join("logs/app.txt")).unwrap(); + assert!(text.lines().next().unwrap().ends_with("ERROR panic")); + assert!(!text.contains("privatecredential")); + assert!(text.contains(" Stack:\n application_log.rs:")); + fs::remove_dir_all(directory).unwrap(); + let directory = panic_subprocess(false, false); + assert!(!directory.join("logs").exists()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn panic_hook_does_not_deadlock_when_writer_is_locked() { + let directory = panic_subprocess(true, true); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/src-tauri/src/artwork_display.rs b/src-tauri/src/artwork_display.rs index 46dc0901..c517206b 100644 --- a/src-tauri/src/artwork_display.rs +++ b/src-tauri/src/artwork_display.rs @@ -41,7 +41,7 @@ pub(crate) fn refresh(app: &tauri::AppHandle) -> u32 { width } Err(error) => { - eprintln!("Could not inspect artwork display resolution: {error}"); + crate::application_log::failure("inspect_artwork_resolution", error); width(app) } } diff --git a/src-tauri/src/artwork_publication.rs b/src-tauri/src/artwork_publication.rs new file mode 100644 index 00000000..aea6dbed --- /dev/null +++ b/src-tauri/src/artwork_publication.rs @@ -0,0 +1,205 @@ +use serde::{Deserialize, Serialize}; +use std::{ + collections::{HashMap, HashSet}, + fs, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; +#[derive(Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PublicationMode { + Display, + NativeOnly, +} +#[derive(Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PublicationOutcome { + Displayed, + Abandoned, + Rejected, +} +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PublicationResult { + pub cache_warning: Option, +} +#[derive(Clone, Default)] +pub struct ArtworkPublication { + pending: Arc>>, +} +struct Pending { + stage: PathBuf, + destination: PathBuf, + created: Instant, + bytes: u64, +} +impl ArtworkPublication { + pub fn register(&self, stage: PathBuf, destination: PathBuf) -> Result { + let mut pending = self + .pending + .lock() + .map_err(|_| "Artwork publication unavailable")?; + let expired = pending + .iter() + .filter(|(_, item)| item.created.elapsed() >= Duration::from_secs(300)) + .map(|(token, _)| token.clone()) + .collect::>(); + for token in expired { + if let Some(item) = pending.remove(&token) { + remove_stage(&item.stage); + } + } + let bytes = fs::metadata(&stage) + .map_err(|error| format!("Could not inspect staged artwork: {error}"))? + .len(); + if pending.len() >= 128 + || bytes + pending.values().map(|item| item.bytes).sum::() > 32 * 1024 * 1024 + { + return Err("Artwork publication capacity reached".into()); + } + let token = uuid::Uuid::new_v4().to_string(); + pending.insert( + token.clone(), + Pending { + stage, + destination, + created: Instant::now(), + bytes, + }, + ); + Ok(token) + } + pub fn finish( + &self, + token: &str, + outcome: PublicationOutcome, + ) -> Result { + uuid::Uuid::parse_str(token).map_err(|_| "Invalid artwork publication token")?; + let item = self + .pending + .lock() + .map_err(|_| "Artwork publication unavailable")? + .remove(token) + .ok_or("Artwork publication token is no longer registered")?; + let cache_warning = if outcome == PublicationOutcome::Rejected { + None + } else { + fs::rename(&item.stage, &item.destination) + .err() + .map(|error| format!("Artwork is ready, but could not be saved locally: {error}")) + }; + if outcome == PublicationOutcome::Rejected || cache_warning.is_some() { + remove_stage(&item.stage); + } + Ok(PublicationResult { cache_warning }) + } + pub fn protected_destinations(&self) -> Result, String> { + Ok(self + .pending + .lock() + .map_err(|_| "Artwork publication unavailable")? + .values() + .map(|item| item.destination.clone()) + .collect()) + } + pub fn cleanup_orphans(&self, directory: &Path) -> Result<(), String> { + let pending = self + .pending + .lock() + .map_err(|_| "Artwork publication unavailable")?; + let entries = match fs::read_dir(directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(format!("Could not inspect artwork stages: {error}")), + }; + for entry in entries { + let entry = entry.map_err(|error| error.to_string())?; + let path = entry.path(); + if path + .extension() + .is_some_and(|extension| extension == "stage") + && !pending.values().any(|item| item.stage == path) + { + remove_stage(&path); + } + } + Ok(()) + } +} +fn remove_stage(path: &Path) { + if let Err(error) = fs::remove_file(path) + && error.kind() != std::io::ErrorKind::NotFound + { + crate::application_log::failure("remove_staged_artwork", error); + } +} +#[cfg(test)] +mod tests { + use super::*; + fn paths() -> (PathBuf, PathBuf, PathBuf) { + let root = std::env::temp_dir().join(format!("legio-publication-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let old = root.join("400-hero.asset"); + let stage = root.join("new.stage"); + std::fs::write(&old, b"old").unwrap(); + std::fs::write(&stage, b"new").unwrap(); + (root, old, stage) + } + #[test] + fn rejected_replacement_preserves_disk_generation() { + let (root, old, stage) = paths(); + let p = ArtworkPublication::default(); + let token = p.register(stage.clone(), old.clone()).unwrap(); + assert_eq!(std::fs::read(&old).unwrap(), b"old"); + p.finish(&token, PublicationOutcome::Rejected).unwrap(); + assert_eq!(std::fs::read(&old).unwrap(), b"old"); + assert!(!stage.exists()); + assert!(p.finish(&token, PublicationOutcome::Displayed).is_err()); + assert!( + p.finish("../400-hero.asset", PublicationOutcome::Displayed) + .is_err() + ); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] + fn abandoned_valid_replacement_finishes_as_prefetch() { + let (root, old, stage) = paths(); + let p = ArtworkPublication::default(); + let token = p.register(stage.clone(), old.clone()).unwrap(); + p.finish(&token, PublicationOutcome::Abandoned).unwrap(); + assert_eq!(std::fs::read(&old).unwrap(), b"new"); + assert!(!stage.exists()); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] + fn staged_replacement_survives_write_failure() { + let (root, old, stage) = paths(); + let p = ArtworkPublication::default(); + let token = p.register(stage.clone(), old.clone()).unwrap(); + std::fs::remove_file(&stage).unwrap(); + assert!( + p.finish(&token, PublicationOutcome::Displayed) + .unwrap() + .cache_warning + .is_some() + ); + assert_eq!(std::fs::read(&old).unwrap(), b"old"); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] + fn restart_removes_orphans_but_preserves_registered_stages() { + let (root, old, stage) = paths(); + let p = ArtworkPublication::default(); + let token = p.register(stage.clone(), old.clone()).unwrap(); + p.cleanup_orphans(&root).unwrap(); + assert!(stage.exists()); + assert!(p.protected_destinations().unwrap().contains(&old)); + p.finish(&token, PublicationOutcome::Rejected).unwrap(); + std::fs::write(&stage, b"orphan").unwrap(); + p.cleanup_orphans(&root).unwrap(); + assert!(!stage.exists()); + assert!(old.exists()); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src-tauri/src/artwork_scheduler.rs b/src-tauri/src/artwork_scheduler.rs new file mode 100644 index 00000000..fdab1cbc --- /dev/null +++ b/src-tauri/src/artwork_scheduler.rs @@ -0,0 +1,781 @@ +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + future::Future, + sync::{ + Arc, Mutex, Weak, + atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering}, + }, + time::Instant, +}; +use tokio::sync::Notify; + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkOrigin { + Visible, + Decorative, + Prefetch, +} +#[derive(Clone, Copy, Debug)] +pub enum WorkStage { + Network, + Conversion, +} +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", content = "message", rename_all = "snake_case")] +pub enum WorkError { + Cancelled, + Failed(String), +} +impl std::fmt::Display for WorkError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Cancelled => write!(f, "Artwork request cancelled"), + Self::Failed(message) => f.write_str(message), + } + } +} +#[derive(Clone, Default)] +pub struct ArtworkScheduler { + inner: Arc, +} +#[derive(Default)] +struct Inner { + state: Mutex, + changed: Notify, +} +#[derive(Default)] +struct State { + selected: Option, + next: u64, + requests: HashMap>, + cancelled: HashMap, + network: StageState, + conversion: StageState, +} +#[derive(Default)] +struct StageState { + queue: Vec, + active: HashMap, +} +struct Ticket { + id: u64, + lease: Arc, +} +struct Active { + app_id: u32, + reserved: bool, + preempted: bool, +} +struct Lease { + scheduler: Weak, + request_id: String, + app_id: u32, + origin: WorkOrigin, + priority_origin: AtomicU8, + cancelled: AtomicBool, + metadata: AtomicBool, + refresh: AtomicBool, + failed: AtomicBool, + used: AtomicBool, + timings: Timings, +} +#[derive(Default)] +struct Timings { + queue: AtomicU64, + metadata: AtomicU64, + transfer: AtomicU64, + conversion: AtomicU64, +} +impl Lease { + fn summary(&self) -> String { + let request_id = uuid::Uuid::parse_str(&self.request_id) + .map_or_else(|_| "internal".to_owned(), |id| id.to_string()); + let outcome = if self.cancelled.load(Ordering::Relaxed) { + "cancelled" + } else if self.failed.load(Ordering::Relaxed) { + "failed" + } else if self.used.load(Ordering::Relaxed) { + "completed" + } else { + "cached" + }; + format!( + "request_id={request_id} resource={} queue_wait_ms={} metadata_ms={} transfer_ms={} conversion_ms={} outcome={outcome}", + if self.metadata.load(Ordering::Relaxed) { + "metadata" + } else { + "image" + }, + self.timings.queue.load(Ordering::Relaxed), + self.timings.metadata.load(Ordering::Relaxed), + self.timings.transfer.load(Ordering::Relaxed), + self.timings.conversion.load(Ordering::Relaxed) + ) + } +} +#[derive(Clone)] +pub struct WorkHandle { + lease: Arc, +} +impl WorkHandle { + pub fn same_request(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.lease, &other.lease) + } + pub fn promote_from(&self, other: &Self) { + self.lease.priority_origin.fetch_min( + other.lease.priority_origin.load(Ordering::Relaxed), + Ordering::Relaxed, + ); + if !other.lease.refresh.load(Ordering::Relaxed) { + self.lease.refresh.store(false, Ordering::Relaxed); + } + if let Some(inner) = self.lease.scheduler.upgrade() { + inner.changed.notify_waiters(); + } + } + pub fn mark_refresh(&self) { + self.lease.refresh.store(true, Ordering::Relaxed); + } + pub fn mark_metadata(&self) { + self.lease.metadata.store(true, Ordering::Relaxed); + } + pub fn mark_failed(&self) { + self.lease.failed.store(true, Ordering::Relaxed); + } + pub fn is_cancelled(&self) -> bool { + self.lease.cancelled.load(Ordering::Acquire) + } +} +pub struct ConversionPermit { + inner: Arc, + stage: WorkStage, + id: u64, + admitted: bool, + lease: Arc, + started: Option, + metadata: bool, +} +impl Drop for Lease { + fn drop(&mut self) { + crate::application_log::artwork_summary(self.summary()); + if let Some(inner) = self.scheduler.upgrade() { + if let Ok(mut state) = inner.state.lock() { + state.requests.remove(&self.request_id); + } + inner.changed.notify_waiters(); + } + } +} +impl Drop for ConversionPermit { + fn drop(&mut self) { + if let Some(started) = self.started { + let timing = match self.stage { + WorkStage::Conversion => &self.lease.timings.conversion, + WorkStage::Network if self.metadata => &self.lease.timings.metadata, + WorkStage::Network => &self.lease.timings.transfer, + }; + timing.fetch_add( + started.elapsed().as_millis().min(u64::MAX as u128) as u64, + Ordering::Relaxed, + ); + } + if let Ok(mut state) = self.inner.state.lock() { + let stage = stage_mut(&mut state, self.stage); + if self.admitted { + stage.active.remove(&self.id); + } else { + stage.queue.retain(|ticket| ticket.id != self.id); + } + } + self.inner.changed.notify_waiters(); + } +} +fn stage_mut(state: &mut State, stage: WorkStage) -> &mut StageState { + match stage { + WorkStage::Network => &mut state.network, + WorkStage::Conversion => &mut state.conversion, + } +} +fn priority(lease: &Lease, selected: Option) -> u8 { + let origin = lease.priority_origin.load(Ordering::Relaxed); + u8::from(lease.refresh.load(Ordering::Relaxed)) * 6 + + origin * 2 + + u8::from(selected != Some(lease.app_id)) +} +impl ArtworkScheduler { + pub fn begin( + &self, + request_id: String, + app_id: u32, + origin: WorkOrigin, + ) -> Result { + if request_id.is_empty() || request_id.len() > 128 || app_id == 0 { + return Err(WorkError::Failed("Invalid artwork request".into())); + } + let mut state = self + .inner + .state + .lock() + .map_err(|_| WorkError::Failed("Artwork scheduler unavailable".into()))?; + state.cancelled.retain(|_, at| at.elapsed().as_secs() < 300); + if state.cancelled.contains_key(&request_id) { + return Err(WorkError::Cancelled); + } + if let Some(lease) = state.requests.get(&request_id).and_then(Weak::upgrade) { + drop(state); + if lease.app_id != app_id || lease.origin != origin { + return Err(WorkError::Failed( + "Artwork request identity mismatch".into(), + )); + } + if lease.cancelled.load(Ordering::Acquire) { + return Err(WorkError::Cancelled); + } + return Ok(WorkHandle { lease }); + } + let lease = Arc::new(Lease { + scheduler: Arc::downgrade(&self.inner), + request_id: request_id.clone(), + app_id, + origin, + priority_origin: AtomicU8::new(match origin { + WorkOrigin::Visible => 0, + WorkOrigin::Decorative => 1, + WorkOrigin::Prefetch => 2, + }), + cancelled: AtomicBool::new(false), + metadata: AtomicBool::new(false), + refresh: AtomicBool::new(false), + failed: AtomicBool::new(false), + used: AtomicBool::new(false), + timings: Timings::default(), + }); + state.requests.insert(request_id, Arc::downgrade(&lease)); + Ok(WorkHandle { lease }) + } + pub fn select_game(&self, app_id: Option) -> Result<(), WorkError> { + let mut state = self + .inner + .state + .lock() + .map_err(|_| WorkError::Failed("Artwork scheduler unavailable".into()))?; + state.selected = app_id; + for active in state.network.active.values_mut() { + if active.reserved && Some(active.app_id) != app_id { + active.preempted = true; + } + } + drop(state); + self.inner.changed.notify_waiters(); + Ok(()) + } + pub fn cancel(&self, request_id: &str) -> Result<(), WorkError> { + if request_id.is_empty() || request_id.len() > 128 { + return Err(WorkError::Failed("Invalid artwork request".into())); + } + let mut state = self + .inner + .state + .lock() + .map_err(|_| WorkError::Failed("Artwork scheduler unavailable".into()))?; + state.cancelled.retain(|_, at| at.elapsed().as_secs() < 300); + if state.cancelled.len() >= 1024 + && !state.cancelled.contains_key(request_id) + && let Some(oldest) = state + .cancelled + .iter() + .min_by_key(|(_, at)| **at) + .map(|(id, _)| id.clone()) + { + state.cancelled.remove(&oldest); + } + state + .cancelled + .insert(request_id.to_owned(), Instant::now()); + let lease = state.requests.get(request_id).and_then(Weak::upgrade); + if let Some(lease) = lease { + lease.cancelled.store(true, Ordering::Release); + } + drop(state); + self.inner.changed.notify_waiters(); + Ok(()) + } + async fn acquire( + &self, + work: &WorkHandle, + stage: WorkStage, + ) -> Result { + let queued_at = Instant::now(); + let id = { + let mut state = self + .inner + .state + .lock() + .map_err(|_| WorkError::Failed("Artwork scheduler unavailable".into()))?; + state.next += 1; + let id = state.next; + stage_mut(&mut state, stage).queue.push(Ticket { + id, + lease: work.lease.clone(), + }); + id + }; + let mut permit = ConversionPermit { + inner: self.inner.clone(), + stage, + id, + admitted: false, + lease: work.lease.clone(), + started: None, + metadata: false, + }; + loop { + let notified = self.inner.changed.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if work.lease.cancelled.load(Ordering::Acquire) { + return Err(WorkError::Cancelled); + } + let admitted = { + let mut state = self + .inner + .state + .lock() + .map_err(|_| WorkError::Failed("Artwork scheduler unavailable".into()))?; + let selected = state.selected; + let target = stage_mut(&mut state, stage); + let total = match stage { + WorkStage::Network => 5, + WorkStage::Conversion => 2, + }; + let reserve = + usize::from(selected.is_some() || matches!(stage, WorkStage::Network)); + let ordinary = target + .active + .values() + .filter(|active| !active.reserved) + .count(); + let chosen = target + .queue + .iter() + .filter(|t| !t.lease.cancelled.load(Ordering::Acquire)) + .min_by_key(|t| (priority(&t.lease, selected), t.id)) + .filter(|t| { + target.active.len() < total + && (selected == Some(t.lease.app_id) || ordinary < total - reserve) + }) + .map(|t| t.id); + if chosen == Some(id) { + target.queue.retain(|t| t.id != id); + target.active.insert( + id, + Active { + app_id: work.lease.app_id, + reserved: selected == Some(work.lease.app_id), + preempted: false, + }, + ); + true + } else { + false + } + }; + if admitted { + permit.admitted = true; + permit.started = Some(Instant::now()); + work.lease.used.store(true, Ordering::Relaxed); + work.lease.timings.queue.fetch_add( + queued_at.elapsed().as_millis().min(u64::MAX as u128) as u64, + Ordering::Relaxed, + ); + self.inner.changed.notify_waiters(); + return Ok(permit); + } + notified.await; + } + } + #[cfg(test)] + pub(crate) async fn acquire_for_test( + &self, + work: &WorkHandle, + stage: WorkStage, + ) -> Result { + self.acquire(work, stage).await + } + pub async fn acquire_conversion( + &self, + work: &WorkHandle, + ) -> Result { + self.acquire(work, WorkStage::Conversion).await + } + #[cfg(test)] + pub async fn run_network>>( + &self, + work: &WorkHandle, + operation: impl FnMut() -> F, + ) -> Result { + self.run_network_phase(work, operation, false) + .await + .map(|(result, _)| result) + } + pub async fn run_image>>( + &self, + work: &WorkHandle, + operation: impl FnMut() -> F, + ) -> Result<(T, ConversionPermit), WorkError> { + self.run_network_phase(work, operation, false).await + } + pub async fn run_metadata>>( + &self, + work: &WorkHandle, + operation: impl FnMut() -> F, + ) -> Result { + self.run_network_phase(work, operation, true) + .await + .map(|(result, _)| result) + } + async fn run_network_phase>>( + &self, + work: &WorkHandle, + mut operation: impl FnMut() -> F, + metadata: bool, + ) -> Result<(T, ConversionPermit), WorkError> { + loop { + let mut permit = self.acquire(work, WorkStage::Network).await?; + permit.metadata = metadata; + let future = operation(); + tokio::pin!(future); + loop { + let notified = self.inner.changed.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if work.lease.cancelled.load(Ordering::Acquire) { + return Err(WorkError::Cancelled); + } + let preempted = self + .inner + .state + .lock() + .map_err(|_| WorkError::Failed("Artwork scheduler unavailable".into()))? + .network + .active + .get(&permit.id) + .is_some_and(|active| active.preempted); + if preempted { + break; + } + tokio::select! { result = &mut future => { if result.is_err() { work.mark_failed(); } return result.map(|result| (result, permit)).map_err(WorkError::Failed); }, _ = &mut notified => {} } + } + drop(permit); + } + } +} +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::sync::Notify; + + async fn flush() { + for _ in 0..32 { + tokio::task::yield_now().await; + } + } + fn work(s: &ArtworkScheduler, id: &str, app: u32) -> WorkHandle { + s.begin(id.into(), app, WorkOrigin::Visible).unwrap() + } + #[tokio::test] + async fn mixed_metadata_pics_and_cdn_share_limits_and_phase_timings() { + let scheduler = ArtworkScheduler::default(); + scheduler.select_game(Some(400)).unwrap(); + let gate = Arc::new(tokio::sync::Semaphore::new(0)); + let started = Arc::new(AtomicUsize::new(0)); + let mut tasks = Vec::new(); + for i in 0..12 { + let scheduler = scheduler.clone(); + let gate = gate.clone(); + let started = started.clone(); + let work = work(&scheduler, &format!("mixed-{i}"), 400); + tasks.push(tokio::spawn(async move { + let operation = || async { + started.fetch_add(1, Ordering::SeqCst); + gate.acquire().await.unwrap().forget(); + Ok(()) + }; + if i % 3 == 2 { + scheduler.run_network(&work, operation).await.unwrap(); + } else { + scheduler.run_metadata(&work, operation).await.unwrap(); + } + work.lease.summary() + })); + } + flush().await; + assert_eq!(started.load(Ordering::SeqCst), 5); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + assert_eq!( + scheduler.inner.state.lock().unwrap().network.active.len(), + 5 + ); + gate.add_permits(12); + let mut summaries = Vec::new(); + for task in tasks { + summaries.push(task.await.unwrap()); + } + assert!( + summaries + .iter() + .any(|value| !value.contains("metadata_ms=0 ")) + ); + assert!( + summaries + .iter() + .any(|value| !value.contains("transfer_ms=0 ")) + ); + assert!( + summaries + .iter() + .any(|value| !value.contains("queue_wait_ms=0 ")) + ); + assert!( + summaries + .iter() + .all(|value| value.contains("request_id=internal") && !value.contains("mixed-")) + ); + } + #[tokio::test] + async fn cancellation_before_registration_never_admits_work() { + let scheduler = ArtworkScheduler::default(); + scheduler.cancel("late-request").unwrap(); + assert!(matches!( + scheduler.begin("late-request".into(), 400, WorkOrigin::Visible), + Err(WorkError::Cancelled) + )); + let state = scheduler.inner.state.lock().unwrap(); + assert!(state.network.active.is_empty() && state.conversion.active.is_empty()); + } + + #[tokio::test] + async fn ordinary_network_leaves_urgent_capacity() { + let scheduler = ArtworkScheduler::default(); + let started = Arc::new(AtomicUsize::new(0)); + let stop = Arc::new(Notify::new()); + let mut tasks = Vec::new(); + for id in 0..5 { + let s = scheduler.clone(); + let count = started.clone(); + let stop = stop.clone(); + let w = work(&s, &format!("normal-{id}"), id + 1); + tasks.push(tokio::spawn(async move { + s.run_network(&w, || async { + count.fetch_add(1, Ordering::SeqCst); + stop.notified().await; + Ok(()) + }) + .await + })); + } + flush().await; + assert_eq!(started.load(Ordering::SeqCst), 4); + scheduler.select_game(Some(99)).unwrap(); + let w = work(&scheduler, "urgent", 99); + scheduler + .run_network(&w, || async { + assert_eq!(started.load(Ordering::SeqCst), 4); + Ok(()) + }) + .await + .unwrap(); + for id in 0..5 { + scheduler.cancel(&format!("normal-{id}")).unwrap(); + } + for task in tasks { + assert!(matches!(task.await.unwrap(), Err(WorkError::Cancelled))); + } + assert!( + scheduler + .inner + .state + .lock() + .unwrap() + .network + .active + .is_empty() + ); + } + #[tokio::test] + async fn selection_promotes_pending_work() { + let s = ArtworkScheduler::default(); + let mut permits = Vec::new(); + let mut handles = Vec::new(); + for id in 1..=4 { + let w = work(&s, &format!("held-{id}"), id); + permits.push(s.acquire(&w, WorkStage::Network).await.unwrap()); + handles.push(w); + } + let a = work(&s, "a", 10); + let b = work(&s, "b", 11); + let sa = s.clone(); + let sb = s.clone(); + let ta = tokio::spawn(async move { sa.acquire(&a, WorkStage::Network).await }); + let tb = tokio::spawn(async move { sb.acquire(&b, WorkStage::Network).await }); + flush().await; + assert!(!ta.is_finished() && !tb.is_finished()); + s.select_game(Some(11)).unwrap(); + flush().await; + assert!(tb.is_finished()); + assert!(!ta.is_finished()); + drop(tb.await.unwrap().unwrap()); + s.cancel("a").unwrap(); + assert!(matches!(ta.await.unwrap(), Err(WorkError::Cancelled))); + } + #[tokio::test] + async fn selection_preempts_obsolete_urgent_transfer() { + let s = ArtworkScheduler::default(); + let mut held = Vec::new(); + let mut handles = Vec::new(); + for id in 1..=4 { + let w = work(&s, &format!("held-{id}"), id); + held.push(s.acquire(&w, WorkStage::Network).await.unwrap()); + handles.push(w); + } + s.select_game(Some(10)).unwrap(); + let a = work(&s, "a", 10); + let sa = s.clone(); + let starts = Arc::new(AtomicUsize::new(0)); + let count = starts.clone(); + let ta = tokio::spawn(async move { + sa.run_network(&a, || async { + count.fetch_add(1, Ordering::SeqCst); + std::future::pending::>().await + }) + .await + }); + flush().await; + assert_eq!(starts.load(Ordering::SeqCst), 1); + s.select_game(Some(11)).unwrap(); + let b = work(&s, "b", 11); + s.run_network(&b, || async { Ok(()) }).await.unwrap(); + s.select_game(Some(10)).unwrap(); + flush().await; + assert_eq!(starts.load(Ordering::SeqCst), 2); + s.cancel("a").unwrap(); + assert!(matches!(ta.await.unwrap(), Err(WorkError::Cancelled))); + } + #[tokio::test] + async fn conversion_reserves_capacity_without_preempting_codec() { + let s = ArtworkScheduler::default(); + let a = work(&s, "a", 1); + let b = work(&s, "b", 2); + s.select_game(Some(3)).unwrap(); + let first = s.acquire_conversion(&a).await.unwrap(); + let sb = s.clone(); + let normal = tokio::spawn(async move { sb.acquire_conversion(&b).await }); + flush().await; + assert!(!normal.is_finished()); + s.select_game(Some(3)).unwrap(); + let c = work(&s, "c", 3); + let urgent = s.acquire_conversion(&c).await.unwrap(); + s.select_game(Some(4)).unwrap(); + let d = work(&s, "d", 4); + let sd = s.clone(); + let next = tokio::spawn(async move { sd.acquire_conversion(&d).await }); + flush().await; + assert!(!next.is_finished()); + drop(urgent); + flush().await; + assert!(next.is_finished()); + assert!(!normal.is_finished()); + drop(next.await.unwrap().unwrap()); + drop(first); + assert!(normal.await.unwrap().is_ok()); + } + #[tokio::test] + async fn cancelled_waiter_releases_queue_and_capacity() { + let s = ArtworkScheduler::default(); + s.select_game(Some(3)).unwrap(); + let a = work(&s, "a", 1); + let b = work(&s, "b", 2); + let p = s.acquire_conversion(&a).await.unwrap(); + let sb = s.clone(); + let t = tokio::spawn(async move { sb.acquire_conversion(&b).await }); + flush().await; + s.cancel("b").unwrap(); + assert!(matches!(t.await.unwrap(), Err(WorkError::Cancelled))); + drop(p); + assert!(s.inner.state.lock().unwrap().conversion.queue.is_empty()); + let shared = s.begin("shared".into(), 3, WorkOrigin::Visible).unwrap(); + let shared2 = s.begin("shared".into(), 3, WorkOrigin::Visible).unwrap(); + assert!(Arc::ptr_eq(&shared.lease, &shared2.lease)); + assert!(s.begin("shared".into(), 4, WorkOrigin::Visible).is_err()); + } + + #[tokio::test] + async fn library_converts_two_images_when_no_game_is_selected() { + let s = ArtworkScheduler::default(); + let a = work(&s, "a", 1); + let b = work(&s, "b", 2); + let first = s.acquire_conversion(&a).await.unwrap(); + let second = tokio::time::timeout( + std::time::Duration::from_millis(100), + s.acquire_conversion(&b), + ) + .await; + assert!( + second.is_ok(), + "An unselected Library must use both conversion slots" + ); + drop(first); + } + + #[test] + fn visible_missing_assets_precede_selected_prefetch_and_refresh() { + let s = ArtworkScheduler::default(); + let visible = work(&s, "visible", 1); + let prefetch = s.begin("prefetch".into(), 2, WorkOrigin::Prefetch).unwrap(); + let refresh = work(&s, "refresh", 2); + refresh.mark_refresh(); + assert!(priority(&visible.lease, Some(2)) < priority(&prefetch.lease, Some(2))); + assert!(priority(&visible.lease, Some(2)) < priority(&refresh.lease, Some(2))); + } + + #[tokio::test] + async fn reserved_slot_does_not_let_refresh_pass_visible_missing_assets() { + let s = ArtworkScheduler::default(); + let mut held = Vec::new(); + let mut handles = Vec::new(); + for id in 1..=4 { + let w = work(&s, &format!("hold-{id}"), id); + held.push(s.acquire(&w, WorkStage::Network).await.unwrap()); + handles.push(w); + } + let missing = work(&s, "missing", 100); + let missing_scheduler = s.clone(); + let missing_task = tokio::spawn(async move { + missing_scheduler + .acquire(&missing, WorkStage::Network) + .await + }); + flush().await; + s.select_game(Some(200)).unwrap(); + let refresh = work(&s, "refresh", 200); + refresh.mark_refresh(); + let refresh_scheduler = s.clone(); + let refresh_task = tokio::spawn(async move { + refresh_scheduler + .acquire(&refresh, WorkStage::Network) + .await + }); + flush().await; + assert!( + !refresh_task.is_finished(), + "Visible missing artwork must precede refresh even with reserved capacity" + ); + held.pop(); + let missing_permit = missing_task.await.unwrap().unwrap(); + refresh_task.await.unwrap().unwrap(); + drop(missing_permit); + } +} diff --git a/src-tauri/src/catalog.rs b/src-tauri/src/catalog.rs index e83ebb27..9582f0cf 100644 --- a/src-tauri/src/catalog.rs +++ b/src-tauri/src/catalog.rs @@ -36,6 +36,31 @@ pub enum SourceAvailability { Unverified, } +#[derive(Debug, Clone, Copy, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CatalogSort { + #[default] + Relevance, + NameAsc, + NameDesc, +} + +#[derive(Debug, Clone, Copy, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CatalogAvailability { + #[default] + All, + Available, + Verified, +} + +#[derive(Debug, Clone, Copy, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct CatalogOptions { + pub sort: CatalogSort, + pub availability: CatalogAvailability, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct CatalogSearch { @@ -328,25 +353,14 @@ fn store( }).map_err(CatalogError::database) } -fn search_with_limit( +fn search_with_options( database: &Database, query: &str, current_time: i64, limit: u32, + options: CatalogOptions, ) -> Result { - let patterns: Vec<_> = query - .split_whitespace() - .map(|word| { - format!( - "%{}%", - word.to_lowercase() - .replace('\\', "\\\\") - .replace('%', "\\%") - .replace('_', "\\_") - ) - }) - .collect(); - let (last_refresh, rows) = database + let (last_refresh, mut rows) = database .with_connection(|connection| { let last_refresh: Option = connection .query_row( @@ -356,21 +370,11 @@ fn search_with_limit( ) .optional() .map_err(sql_error)?; - let filter = if patterns.is_empty() { - String::new() - } else { - let clauses = (1..=patterns.len()) - .map(|index| format!("search_name LIKE ?{index} ESCAPE '\\'")) - .collect::>() - .join(" OR "); - format!(" WHERE {clauses}") - }; - let sql = format!( - "SELECT steam_app_id, name, search_name, fetched_at FROM catalog_games{filter}" - ); - let mut statement = connection.prepare(&sql).map_err(sql_error)?; + let mut statement = connection + .prepare("SELECT steam_app_id, name, search_name, fetched_at FROM catalog_games") + .map_err(sql_error)?; let rows = statement - .query_map(rusqlite::params_from_iter(patterns.iter()), |row| { + .query_map([], |row| { Ok(( row.get::<_, u32>(0)?, row.get::<_, String>(1)?, @@ -384,11 +388,73 @@ fn search_with_limit( Ok((last_refresh, rows)) }) .map_err(CatalogError::database)?; + let source = legio_source_cache::cached(database).map_err(CatalogError::database)?; + if let Some(source) = &source { + let mut known: HashSet<_> = rows.iter().map(|row| row.0).collect(); + for entry in source + .manifest + .verified + .iter() + .chain(&source.manifest.unverified) + { + if known.insert(entry.steam_app_id) { + rows.push(( + entry.steam_app_id, + entry.name.clone(), + entry.name.to_lowercase(), + source.fetched_at, + )); + } + } + } let normalized_query = query.to_lowercase(); let query_words: Vec<_> = normalized_query.split_whitespace().collect(); + let verified: HashSet<_> = source + .iter() + .flat_map(|source| { + source + .manifest + .verified + .iter() + .map(|entry| entry.steam_app_id) + }) + .collect(); + let unverified: HashSet<_> = source + .iter() + .flat_map(|source| { + source + .manifest + .unverified + .iter() + .map(|entry| entry.steam_app_id) + }) + .collect(); let mut ranked = Vec::new(); let mut oldest = None; for (steam_app_id, name, search_name, fetched_at) in rows { + if !query_words.is_empty() && !query_words.iter().any(|word| search_name.contains(word)) { + continue; + } + let availability = if source.is_none() { + SourceAvailability::Unknown + } else if verified.contains(&steam_app_id) { + SourceAvailability::Verified + } else if unverified.contains(&steam_app_id) { + SourceAvailability::Unverified + } else { + SourceAvailability::Unavailable + }; + let included = match options.availability { + CatalogAvailability::All => true, + CatalogAvailability::Available => matches!( + availability, + SourceAvailability::Verified | SourceAvailability::Unverified + ), + CatalogAvailability::Verified => availability == SourceAvailability::Verified, + }; + if !included { + continue; + } if let Some(rank) = rank_normalized(&search_name, &normalized_query, &query_words) { oldest = Some(oldest.map_or(fetched_at, |value: i64| value.min(fetched_at))); ranked.push(( @@ -398,7 +464,7 @@ fn search_with_limit( CatalogGame { steam_app_id, name, - availability: SourceAvailability::Unknown, + availability, }, )); } @@ -406,27 +472,49 @@ fn search_with_limit( let total = ranked.len() as u32; let cached_at = oldest.or(last_refresh); ranked.sort_unstable_by(|left, right| { - left.0 - .cmp(&right.0) - .then_with(|| left.1.cmp(&right.1)) - .then_with(|| left.2.cmp(&right.2)) + let order = match options.sort { + CatalogSort::Relevance => left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)), + CatalogSort::NameAsc => left.1.cmp(&right.1), + CatalogSort::NameDesc => right.1.cmp(&left.1), + }; + order.then_with(|| left.2.cmp(&right.2)) }); let games = ranked .into_iter() .take(limit as usize) .map(|entry| entry.3) .collect(); - Ok(CatalogSearch { - games, - total, - next_offset: None, - cached_at, - stale: cached_at.is_none_or(|time| { - time > current_time || current_time.saturating_sub(time) >= STALE_SECONDS - }), - source_cached_at: None, - source_stale: true, - }) + Ok(merge_source( + CatalogSearch { + games, + total, + next_offset: None, + cached_at, + stale: cached_at.is_none_or(|time| { + time > current_time || current_time.saturating_sub(time) >= STALE_SECONDS + }), + source_cached_at: None, + source_stale: true, + }, + source, + current_time, + )) +} + +#[cfg(test)] +fn search_with_limit( + database: &Database, + query: &str, + current_time: i64, + limit: u32, +) -> Result { + search_with_options( + database, + query, + current_time, + limit, + CatalogOptions::default(), + ) } #[cfg(test)] @@ -476,16 +564,21 @@ pub async fn search_catalog( app: tauri::AppHandle, value: String, limit: Option, + options: Option, ) -> Result { let query = query(&value)?.to_owned(); - let limit = limit.unwrap_or(LOCAL_LIMIT).clamp(1, CACHE_LIMIT); + let limit = limit.unwrap_or(LOCAL_LIMIT).max(1); tauri::async_runtime::spawn_blocking(move || { let state = app.state::(); let database = state.database().map_err(CatalogError::database)?; let current_time = now()?; - let result = search_with_limit(database, &query, current_time, limit)?; - let source = legio_source_cache::cached(database).map_err(CatalogError::database)?; - Ok(merge_source(result, source, current_time)) + search_with_options( + database, + &query, + current_time, + limit, + options.unwrap_or_default(), + ) }) .await .map_err(|error| CatalogError::new(CatalogErrorKind::Internal, error.to_string()))? @@ -497,6 +590,7 @@ pub async fn refresh_catalog( value: String, skip: Option, limit: Option, + options: Option, ) -> Result { let query = query(&value)?.to_owned(); let skip = skip.unwrap_or(0); @@ -506,7 +600,7 @@ pub async fn refresh_catalog( "Catalog page is outside the cache limit.", )); } - let limit = limit.unwrap_or(LOCAL_LIMIT).clamp(1, CACHE_LIMIT); + let limit = limit.unwrap_or(LOCAL_LIMIT).max(1); let body = serde_json::to_vec(&HydraRequest { title: &query, take: REMOTE_LIMIT, @@ -520,7 +614,8 @@ pub async fn refresh_catalog( let database = state.database().map_err(CatalogError::database)?; let now = now()?; store(database, &query, &page, now)?; - let mut result = search_with_limit(database, &query, now, limit)?; + let mut result = + search_with_options(database, &query, now, limit, options.unwrap_or_default())?; let next = skip.saturating_add(page.fetched_count); if page.fetched_count > 0 && next < page.remote_count as usize @@ -528,8 +623,7 @@ pub async fn refresh_catalog( { result.next_offset = Some(next); } - let source = legio_source_cache::cached(database).map_err(CatalogError::database)?; - Ok(merge_source(result, source, now)) + Ok(result) }) .await .map_err(|error| CatalogError::new(CatalogErrorKind::Internal, error.to_string()))? @@ -549,6 +643,228 @@ mod tests { .unwrap() } + #[tokio::test] + async fn searches_source_only_titles_without_a_catalog_refresh() { + let directory = + std::env::temp_dir().join(format!("legio-catalog-test-{}", uuid::Uuid::new_v4())); + let app = test_app(&directory); + let state = app.state::(); + let database = state.database().unwrap(); + let manifest = serde_json::json!({ + "schemaVersion": 1, "generatedAt": "2026-10-10T00:00:00Z", + "verified": [{ + "steamAppId": 700, "name": "Source Exclusive", + "release": { "version": "1", "publishedAt": "2026-10-10T00:00:00Z" }, + "download": { "url": "https://example.invalid/a.zip", "sizeBytes": 1, + "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" } + }], "unverified": [] + }); + database.with_connection(|connection| { + connection.execute( + "INSERT INTO download_sources (id, url, manifest, fetched_at) VALUES ('source', 'https://example.invalid/store.json', ?1, 100)", + [serde_json::to_vec(&manifest).unwrap()], + ).map_err(sql_error)?; + Ok(()) + }).unwrap(); + let result = search_catalog(app.handle().clone(), "source".into(), Some(1), None) + .await + .unwrap(); + assert_eq!(result.total, 1); + assert_eq!( + result.games, + vec![CatalogGame { + steam_app_id: 700, + name: "Source Exclusive".into(), + availability: SourceAvailability::Verified, + }] + ); + drop(app); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[tokio::test] + async fn filters_and_sorts_before_limiting_results() { + let directory = + std::env::temp_dir().join(format!("legio-catalog-test-{}", uuid::Uuid::new_v4())); + let app = test_app(&directory); + let state = app.state::(); + let database = state.database().unwrap(); + let names = ["Game Alpha", "Game beta", "Game Zulu", "Game", "Other Game"]; + store( + database, + "fixture", + &CatalogPage { + games: names + .iter() + .enumerate() + .map(|(index, name)| CatalogGame { + steam_app_id: 400 + index as u32, + name: (*name).into(), + availability: SourceAvailability::Unknown, + }) + .collect(), + remote_count: 5, + fetched_count: 5, + }, + 100, + ) + .unwrap(); + let entry = |steam_app_id, name| { + serde_json::json!({ + "steamAppId": steam_app_id, "name": name, + "release": { "version": "1", "publishedAt": "2026-10-10T00:00:00Z" }, + "download": { "url": format!("https://example.invalid/{steam_app_id}.zip"), "sizeBytes": 1, + "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" } + }) + }; + let mut alternate = entry(402, "Game Zulu"); + alternate["download"]["url"] = + serde_json::json!("https://example.invalid/402-alternate.zip"); + let manifest = serde_json::json!({ + "schemaVersion": 1, "generatedAt": "2026-10-10T00:00:00Z", + "verified": [entry(402, "Different source name"), entry(405, "Game Omega")], + "unverified": [entry(401, "Game beta"), alternate] + }); + database.with_connection(|connection| { + connection.execute("INSERT INTO download_sources (id, url, manifest, fetched_at) VALUES ('source', 'https://example.invalid/store.json', ?1, 100)", [serde_json::to_vec(&manifest).unwrap()]).map_err(sql_error)?; + Ok(()) + }).unwrap(); + for (sort, availability, expected, total) in [ + ("relevance", "all", vec![403, 400], 6), + ("name_asc", "all", vec![403, 400], 6), + ("name_desc", "all", vec![404, 402], 6), + ("name_asc", "available", vec![401, 405], 3), + ("name_desc", "verified", vec![402, 405], 2), + ("relevance", "verified", vec![405, 402], 2), + ] { + let options = serde_json::from_value( + serde_json::json!({ "sort": sort, "availability": availability }), + ) + .unwrap(); + let result = + search_catalog(app.handle().clone(), "game".into(), Some(2), Some(options)) + .await + .unwrap(); + assert_eq!( + result + .games + .iter() + .map(|game| game.steam_app_id) + .collect::>(), + expected, + "{sort}, {availability}" + ); + assert_eq!(result.total, total, "{sort}, {availability}"); + if let Some(game) = result.games.iter().find(|game| game.steam_app_id == 402) { + assert_eq!(game.name, "Game Zulu"); + assert_eq!(game.availability, SourceAvailability::Verified); + } + } + let result = search_catalog( + app.handle().clone(), + "".into(), + Some(1), + Some(CatalogOptions { + sort: CatalogSort::NameDesc, + availability: CatalogAvailability::All, + }), + ) + .await + .unwrap(); + assert_eq!(result.games[0].steam_app_id, 404); + let alias = search_catalog(app.handle().clone(), "different".into(), None, None) + .await + .unwrap(); + assert!( + alias.games.is_empty(), + "source aliases must not replace a known catalog title" + ); + drop(app); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[tokio::test] + async fn available_filter_without_sources_excludes_unknown_games() { + let directory = + std::env::temp_dir().join(format!("legio-catalog-test-{}", uuid::Uuid::new_v4())); + let app = test_app(&directory); + let state = app.state::(); + store( + state.database().unwrap(), + "fixture", + &CatalogPage { + games: vec![CatalogGame { + steam_app_id: 400, + name: "Game".into(), + availability: SourceAvailability::Unknown, + }], + remote_count: 1, + fetched_count: 1, + }, + 100, + ) + .unwrap(); + let result = search_catalog( + app.handle().clone(), + "game".into(), + None, + Some(CatalogOptions { + sort: CatalogSort::Relevance, + availability: CatalogAvailability::Available, + }), + ) + .await + .unwrap(); + assert_eq!(result.total, 0); + assert!(result.games.is_empty()); + drop(app); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[tokio::test] + async fn source_titles_remain_reachable_beyond_the_remote_cache_limit() { + let directory = + std::env::temp_dir().join(format!("legio-catalog-test-{}", uuid::Uuid::new_v4())); + let app = test_app(&directory); + let state = app.state::(); + let database = state.database().unwrap(); + store( + database, + "fixture", + &CatalogPage { + games: (1..=CACHE_LIMIT) + .map(|steam_app_id| CatalogGame { + steam_app_id, + name: format!("Game {steam_app_id:05}"), + availability: SourceAvailability::Unknown, + }) + .collect(), + remote_count: CACHE_LIMIT, + fetched_count: CACHE_LIMIT as usize, + }, + 100, + ) + .unwrap(); + let manifest = serde_json::json!({ + "schemaVersion": 1, "generatedAt": "2026-10-10T00:00:00Z", "verified": [], + "unverified": [{ "steamAppId": 30000, "name": "ZZZ Source", + "release": { "version": "1", "publishedAt": "2026-10-10T00:00:00Z" }, + "download": { "url": "https://example.invalid/source.zip", "sizeBytes": 1 } }] + }); + database.with_connection(|connection| { + connection.execute("INSERT INTO download_sources (id, url, manifest, fetched_at) VALUES ('source', 'https://example.invalid/store.json', ?1, 100)", [serde_json::to_vec(&manifest).unwrap()]).map_err(sql_error)?; + Ok(()) + }).unwrap(); + let result = search_catalog(app.handle().clone(), "".into(), Some(CACHE_LIMIT + 1), None) + .await + .unwrap(); + assert_eq!(result.total, CACHE_LIMIT + 1); + assert_eq!(result.games.len(), (CACHE_LIMIT + 1) as usize); + assert_eq!(result.games.last().unwrap().steam_app_id, 30000); + drop(app); + std::fs::remove_dir_all(directory).unwrap(); + } + #[test] fn merges_source_by_app_id_without_replacing_catalog_identity() { let manifest = parse_manifest(br#"{"schemaVersion":1,"generatedAt":"2026-09-22T00:00:00Z","verified":[{"steamAppId":400,"name":"Source title","release":{"version":"1","publishedAt":"2026-09-22T00:00:00Z"},"download":{"url":"https://example.invalid/a.zip","sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","sizeBytes":1}}],"unverified":[{"steamAppId":401,"name":"Other source title","release":{"version":"1","publishedAt":"2026-09-22T00:00:00Z"},"download":{"url":"https://example.invalid/b.zip","sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","sizeBytes":1}}]}"#).unwrap(); @@ -758,7 +1074,7 @@ mod tests { ) .unwrap(); - let result = search_catalog(app.handle().clone(), "risk rain".into(), None) + let result = search_catalog(app.handle().clone(), "risk rain".into(), None, None) .await .unwrap(); assert_eq!(result.games, page.games); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index d2212b31..c2c511e5 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -200,7 +200,34 @@ pub fn save_game_compatibility_overrides( #[tauri::command] pub fn list_games(state: State<'_, DatabaseState>) -> Result, String> { - state.database()?.games() + let result = state.database().and_then(database::Database::games); + if let Err(error) = &result { + crate::application_log::failure("library_load", error); + } + result +} + +#[tauri::command] +pub fn get_application_log_status( + state: State<'_, crate::application_log::ApplicationLog>, +) -> crate::application_log::LogStatus { + state.status() +} + +#[tauri::command] +pub async fn report_application_event( + state: State<'_, crate::application_log::ApplicationLog>, + level: crate::application_log::Level, + event: crate::application_log::RendererEvent, + message: Option, + stack: Option, +) -> Result<(), String> { + let log = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || { + log.report(level, event, message.as_deref(), stack.as_deref()) + }) + .await + .map_err(|_| "Application log report task failed".to_owned())? } #[tauri::command] @@ -724,8 +751,9 @@ pub async fn search_catalog( app: AppHandle, query: String, limit: Option, + options: Option, ) -> Result { - crate::catalog::search_catalog(app, query, limit).await + crate::catalog::search_catalog(app, query, limit, options).await } #[tauri::command] @@ -735,25 +763,39 @@ pub async fn refresh_catalog( query: String, skip: Option, limit: Option, + options: Option, ) -> Result { - crate::catalog::refresh_catalog(app, state.inner(), query, skip, limit).await + crate::catalog::refresh_catalog(app, state.inner(), query, skip, limit, options).await } #[tauri::command] +#[allow(clippy::too_many_arguments)] pub async fn get_steam_details( app: AppHandle, state: State<'_, NetworkState>, + scheduler: State<'_, crate::artwork_scheduler::ArtworkScheduler>, steam_app_id: u32, - refresh: bool, + policy: crate::steam_details::DetailsPolicy, language: crate::locale::LanguagePreference, + request_id: String, + origin: crate::artwork_scheduler::WorkOrigin, ) -> Result { - crate::steam_details::get_details_localized( + let work = scheduler + .begin(request_id, steam_app_id, origin) + .map_err(|error| crate::steam_details::DetailsError { + kind: crate::steam_details::DetailsErrorKind::Cancelled, + message: error.to_string(), + status: None, + })?; + work.mark_metadata(); + Box::pin(crate::steam_details::get_details_localized_with_policy( app, state.inner(), steam_app_id, - refresh, + policy, language.resolve(), - ) + &work, + )) .await } @@ -768,6 +810,7 @@ pub fn get_artwork_display_width(app: AppHandle) -> u32 { } #[tauri::command] +#[allow(clippy::too_many_arguments)] pub async fn get_steam_asset( app: AppHandle, state: State<'_, NetworkState>, @@ -775,19 +818,43 @@ pub async fn get_steam_asset( asset: crate::steam_assets::AssetKind, index: Option, full: Option, - refresh: Option, -) -> Result { - crate::steam_assets::get_asset( + policy: crate::steam_assets::AssetPolicy, + screenshot_identity: Option, + request_id: String, + origin: crate::artwork_scheduler::WorkOrigin, + publication: crate::artwork_publication::PublicationMode, +) -> Result { + Box::pin(crate::steam_assets::get_asset_for_request( app, state.inner(), - steam_app_id, - asset, - index, - full.unwrap_or(false), - refresh.unwrap_or(false), - ) + crate::steam_assets::AssetRequest { + app_id: steam_app_id, + asset, + index, + full: full.unwrap_or(false), + policy, + screenshot_identity, + }, + crate::steam_assets::AssetDemand { + request_id, + origin, + publication, + }, + )) .await? .into_response() + .map_err(crate::artwork_scheduler::WorkError::Failed) +} +#[tauri::command] +pub async fn finish_steam_asset_update( + state: State<'_, crate::steam_assets::AssetCacheState>, + token: String, + outcome: crate::artwork_publication::PublicationOutcome, +) -> Result { + let cache = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || cache.finish(&token, outcome)) + .await + .map_err(|error| format!("Artwork publication task failed: {error}"))? } #[tauri::command] pub fn is_aur_package() -> bool { @@ -813,7 +880,7 @@ pub async fn prefetch_steam_hero( state: State<'_, NetworkState>, steam_app_id: u32, ) -> Result<(), String> { - crate::steam_assets::get_asset( + Box::pin(crate::steam_assets::get_asset( app, state.inner(), steam_app_id, @@ -821,7 +888,7 @@ pub async fn prefetch_steam_hero( None, false, false, - ) + )) .await .map(|_| ()) } @@ -849,3 +916,83 @@ pub fn take_source_links( ) -> Result { state.take() } + +#[cfg(test)] +mod tests { + use super::*; + + fn future_size(_: impl FnOnce(A) -> F) -> usize { + std::mem::size_of::() + } + + #[test] + fn artwork_command_futures_fit_the_windows_ui_stack_budget() { + // Tauri moves command futures through several frames on the 1 MiB UI stack. + let asset = future_size(|(app, state)| { + get_steam_asset( + app, + state, + 730, + crate::steam_assets::AssetKind::HeroBlur, + None, + None, + crate::steam_assets::AssetPolicy::CacheOrFetch, + None, + "future-size".into(), + crate::artwork_scheduler::WorkOrigin::Visible, + crate::artwork_publication::PublicationMode::Display, + ) + }); + let publication = future_size(|state| { + finish_steam_asset_update( + state, + "future-size".into(), + crate::artwork_publication::PublicationOutcome::Displayed, + ) + }); + let metadata = future_size(|(app, network, scheduler)| { + get_steam_details( + app, + network, + scheduler, + 730, + crate::steam_details::DetailsPolicy::CacheOrFetch, + crate::locale::LanguagePreference::English, + "future-size".into(), + crate::artwork_scheduler::WorkOrigin::Visible, + ) + }); + assert!( + publication <= 4096, + "Artwork publication future occupies {publication} bytes" + ); + assert!( + metadata <= 4096, + "Artwork metadata future occupies {metadata} bytes" + ); + let prefetch = future_size(|(app, state)| prefetch_steam_hero(app, state, 730)); + assert!( + asset <= 4096, + "Artwork command future occupies {asset} bytes" + ); + assert!( + prefetch <= 4096, + "Artwork prefetch future occupies {prefetch} bytes" + ); + } +} + +#[tauri::command] +pub fn set_artwork_game( + state: State<'_, crate::artwork_scheduler::ArtworkScheduler>, + steam_app_id: Option, +) -> Result<(), crate::artwork_scheduler::WorkError> { + state.select_game(steam_app_id) +} +#[tauri::command] +pub fn cancel_artwork_request( + state: State<'_, crate::artwork_scheduler::ArtworkScheduler>, + request_id: String, +) -> Result<(), crate::artwork_scheduler::WorkError> { + state.cancel(&request_id) +} diff --git a/src-tauri/src/compatibility_options.rs b/src-tauri/src/compatibility_options.rs index b31ab6ea..70a76f1b 100644 --- a/src-tauri/src/compatibility_options.rs +++ b/src-tauri/src/compatibility_options.rs @@ -37,7 +37,7 @@ impl PreparedOptions { match load_overlay_libraries(steam_root) { Ok(libraries) => Some(libraries), Err(error) => { - eprintln!("Steam overlay unavailable, launching without it: {error}"); + crate::application_log::failure("load_steam_overlay", error); None } } diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index b2c55942..5d01383a 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -220,9 +220,11 @@ pub struct Settings { pub download_notifications: bool, #[serde(default = "default_true")] pub verify_verified_downloads: bool, - #[serde(default = "default_true")] + #[serde(default)] pub diagnostics_enabled: bool, #[serde(default)] + pub application_logging_enabled: bool, + #[serde(default)] pub defer_extraction_while_playing: bool, } @@ -262,7 +264,8 @@ impl Default for Settings { sidebar_collapsed: true, download_notifications: true, verify_verified_downloads: true, - diagnostics_enabled: true, + diagnostics_enabled: false, + application_logging_enabled: false, defer_extraction_while_playing: false, } } @@ -348,6 +351,14 @@ impl DatabaseState { } } +#[cfg(test)] +pub(crate) fn close_test_database(state: &DatabaseState) { + let database = state.shared_database().unwrap(); + let mut connection = database.connection.lock().unwrap(); + let opened = std::mem::replace(&mut *connection, Connection::open_in_memory().unwrap()); + opened.close().unwrap(); +} + pub struct Database { connection: Mutex, pub(crate) source_cache: Mutex>, @@ -1466,7 +1477,7 @@ fn migrate(connection: &Connection) -> Result<(), String> { ).map_err(database_error)?; } } - Err(error) => eprintln!("Could not classify existing download sources: {error}"), + Err(error) => crate::application_log::failure("classify_download_sources", error), } } transaction @@ -1936,6 +1947,38 @@ pub(crate) fn database_error(error: rusqlite::Error) -> String { mod tests { use super::*; + #[test] + fn logging_requires_explicit_opt_in_for_fresh_and_legacy_settings() { + for settings in [ + Settings::default(), + serde_json::from_str(r#"{"theme":"system"}"#).unwrap(), + ] { + let settings = serde_json::to_value(settings).unwrap(); + assert_eq!(settings["diagnosticsEnabled"], false); + assert_eq!(settings["applicationLoggingEnabled"], false); + } + } + + #[test] + fn application_and_network_logging_preferences_persist_independently() { + let directory = temporary_directory(); + for (application, network) in [(true, false), (false, true), (false, false)] { + let database = Database::open(&directory).unwrap(); + let mut settings = serde_json::to_value(database.settings().unwrap()).unwrap(); + settings["applicationLoggingEnabled"] = application.into(); + settings["diagnosticsEnabled"] = network.into(); + database + .save_settings(serde_json::from_value(settings).unwrap()) + .unwrap(); + drop(database); + let reopened = Database::open(&directory).unwrap(); + let settings = serde_json::to_value(reopened.settings().unwrap()).unwrap(); + assert_eq!(settings["applicationLoggingEnabled"], application); + assert_eq!(settings["diagnosticsEnabled"], network); + } + fs::remove_dir_all(directory).unwrap(); + } + #[test] fn new_install_requires_onboarding_and_existing_preferences_skip_it() { assert!(!Settings::default().onboarding_completed); diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs index 0b7f155f..7b4cb477 100644 --- a/src-tauri/src/diagnostics.rs +++ b/src-tauri/src/diagnostics.rs @@ -63,6 +63,7 @@ pub struct Diagnostics { } impl Diagnostics { + #[cfg(test)] pub fn new(directory: Result) -> Self { Self::with_enabled(directory, true) } @@ -286,6 +287,17 @@ fn append(directory: &Path, record: &Record) -> io::Result<()> { mod tests { use super::*; + #[test] + fn disabled_network_logging_does_not_create_files_or_queue_records() { + let directory = std::env::temp_dir().join(format!("legio-logs-{}", uuid::Uuid::new_v4())); + let diagnostics = Diagnostics::with_enabled(Ok(directory.clone()), false); + drop(diagnostics.request(Operation::SteamConnectivity)); + let status = diagnostics.status(); + assert!(!status.enabled); + assert_eq!(status.pending_records, 0); + assert!(!directory.exists()); + } + #[test] fn blocked_writer_does_not_block_producers_or_status() { let (sender, _receiver) = mpsc::sync_channel(1); diff --git a/src-tauri/src/discord_presence.rs b/src-tauri/src/discord_presence.rs index b255a90a..7cc7ab7f 100644 --- a/src-tauri/src/discord_presence.rs +++ b/src-tauri/src/discord_presence.rs @@ -215,7 +215,7 @@ async fn run( fn report_error(previous: &mut Option, error: String) { if previous.as_ref() != Some(&error) { - eprintln!("Discord Rich Presence unavailable: {error}"); + crate::application_log::failure("discord_presence", &error); *previous = Some(error); } } diff --git a/src-tauri/src/download_queue.rs b/src-tauri/src/download_queue.rs index 08c43067..4350d3bc 100644 --- a/src-tauri/src/download_queue.rs +++ b/src-tauri/src/download_queue.rs @@ -557,7 +557,7 @@ fn spawn_staging(app: &AppHandle, id: &str) { if let Some(manager) = app.try_state::() && let Err(error) = queue.wait_for_idle(&manager).await { - eprintln!("Could not schedule download extraction {id}: {error}"); + crate::application_log::failure("schedule_extraction", error); return; } let worker_app = app.clone(); @@ -572,7 +572,7 @@ fn spawn_staging(app: &AppHandle, id: &str) { .await; match outcome { Ok(Ok(_)) => {} - Ok(Err(error)) => eprintln!("Could not verify and extract download {id}: {error}"), + Ok(Err(error)) => crate::application_log::failure("verify_and_extract", error), Err(error) => { let message = format!("Download staging task failed: {error}"); let worker_app = app.clone(); @@ -583,8 +583,9 @@ fn spawn_staging(app: &AppHandle, id: &str) { Ok(()) }) }).await; - eprintln!( - "Download staging task failed for {id}: {error}; failure persistence: {persisted:?}" + crate::application_log::failure( + "stage_download", + format!("{error}; failure persistence: {persisted:?}"), ); } } @@ -726,7 +727,7 @@ fn kick(app: AppHandle) { } tauri::async_runtime::spawn(async move { if let Err(error) = run_queue(&app).await { - eprintln!("Download queue stopped: {error}"); + crate::application_log::failure("download_queue_stopped", error); } app.state::() .running @@ -739,7 +740,7 @@ fn kick(app: AppHandle) { { Ok(true) => kick(app), Ok(false) => {} - Err(error) => eprintln!("Could not restart download queue: {error}"), + Err(error) => crate::application_log::failure("restart_download_queue", error), } }); } @@ -788,13 +789,17 @@ async fn run_queue(app: &AppHandle) -> Result<(), String> { .body(&job.name) .show() { - eprintln!("Could not show download notification: {error}"); + crate::application_log::failure( + "show_download_notification", + error, + ); } } Ok(_) => {} - Err(error) => { - eprintln!("Could not read download notification setting: {error}") - } + Err(error) => crate::application_log::failure( + "read_download_notification_settings", + error, + ), } } Err(error) => { @@ -1420,7 +1425,7 @@ pub fn start(app: AppHandle) -> Result<(), String> { .starting .store(false, Ordering::Release); if let Err(error) = result { - eprintln!("Could not recover downloads: {error}"); + crate::application_log::failure("recover_downloads", &error); if let Err(notification_error) = app .notification() .builder() @@ -1428,8 +1433,9 @@ pub fn start(app: AppHandle) -> Result<(), String> { .body(&error) .show() { - eprintln!( - "Could not show download recovery notification: {notification_error}" + crate::application_log::failure( + "show_recovery_notification", + notification_error, ); } } diff --git a/src-tauri/src/finalize_install.rs b/src-tauri/src/finalize_install.rs index 2c394e0d..76043483 100644 --- a/src-tauri/src/finalize_install.rs +++ b/src-tauri/src/finalize_install.rs @@ -521,9 +521,10 @@ pub(crate) fn recover(database: &Database, data_dir: &Path) -> Result<(), String load_intent(database, data_dir, &id).and_then(|intent| finish(database, &intent)); if let Err(error) = result { match record_error(database, &id, &error) { - Ok(()) => eprintln!("Could not recover installation {id}: {error}"), - Err(persist) => eprintln!( - "Could not recover installation {id}: {error}; could not save failure: {persist}" + Ok(()) => crate::application_log::failure("recover_installation", error), + Err(persist) => crate::application_log::failure( + "recover_installation", + format!("{error}; could not save failure: {persist}"), ), } } @@ -586,7 +587,7 @@ fn recover_cleanup(database: &Database, data_dir: &Path) -> Result<(), String> { let stage = data_dir.join("downloads").join(format!("{id}.stage")); if let Err(error) = cleanup_stage(database, &id, &stage) { save_cleanup_error(database, &id, &error)?; - eprintln!("Could not clean staged files for installed game {id}: {error}"); + crate::application_log::failure("clean_staged_files", error); } } Ok(()) diff --git a/src-tauri/src/image_response.rs b/src-tauri/src/image_response.rs index cf9b2041..8cf07047 100644 --- a/src-tauri/src/image_response.rs +++ b/src-tauri/src/image_response.rs @@ -2,9 +2,21 @@ pub(crate) fn encode(metadata: Vec, bytes: Vec) -> Result 4096 { return Err("Artwork metadata exceeds the size limit".to_owned()); } + crate::application_log::artwork_checkpoint( + "artwork_ipc", + format!( + "stage=payload_start; image_bytes={}; metadata_bytes={}", + bytes.len(), + metadata.len() + ), + ); let mut payload = Vec::with_capacity(4 + metadata.len() + bytes.len()); payload.extend_from_slice(&(metadata.len() as u32).to_le_bytes()); payload.extend_from_slice(&metadata); payload.extend_from_slice(&bytes); + crate::application_log::artwork_checkpoint( + "artwork_ipc", + format!("stage=payload_ready; bytes={}", payload.len()), + ); Ok(tauri::ipc::Response::new(payload)) } diff --git a/src-tauri/src/image_trim.rs b/src-tauri/src/image_trim.rs index 9eeecd7c..ec9e28af 100644 --- a/src-tauri/src/image_trim.rs +++ b/src-tauri/src/image_trim.rs @@ -1,10 +1,19 @@ use std::io::Cursor; +use std::sync::atomic::{AtomicU64, Ordering}; use image::{ImageFormat, ImageReader, Limits, RgbaImage}; const MAX_PIXELS: u64 = 8_000_000; const MAX_DIMENSION: u32 = 8192; const MAX_OUTPUT: usize = 2 * 1024 * 1024; +static NEXT_OPERATION: AtomicU64 = AtomicU64::new(1); + +fn checkpoint(operation: u64, stage: &'static str, details: impl std::fmt::Display) { + crate::application_log::artwork_checkpoint( + "artwork_processing", + format!("operation={operation}; stage={stage}; {details}"), + ); +} fn bounded_reader(bytes: &[u8], format: ImageFormat) -> Result>, String> { let (width, height) = ImageReader::with_format(Cursor::new(bytes), format) @@ -52,10 +61,17 @@ fn format(bytes: &[u8]) -> Result { } pub(crate) fn validate_image(bytes: &[u8]) -> Result<(), String> { + let operation = NEXT_OPERATION.fetch_add(1, Ordering::Relaxed); + checkpoint( + operation, + "decode_start", + format!("validation; bytes={}", bytes.len()), + ); bounded_reader(bytes, format(bytes)?)? .decode() - .map(|_| ()) - .map_err(|error| error.to_string()) + .map_err(|error| error.to_string())?; + checkpoint(operation, "decode_complete", "validation"); + Ok(()) } pub(crate) fn webp_asset(bytes: &[u8]) -> Result, String> { @@ -70,15 +86,32 @@ pub(crate) fn hero_webp(bytes: &[u8], max_width: u32, blurred: bool) -> Result) -> Result, String> { + let operation = NEXT_OPERATION.fetch_add(1, Ordering::Relaxed); let format = format(bytes)?; + checkpoint( + operation, + "decode_start", + format!("bytes={}; format={format:?}; hero={hero:?}", bytes.len()), + ); let image = bounded_reader(bytes, format)? .decode() .map_err(|error| error.to_string())?; + checkpoint( + operation, + "decode_complete", + format!("dimensions={}x{}", image.width(), image.height()), + ); if format == ImageFormat::WebP && (hero.is_none() || hero.is_some_and(|(width, blurred)| !blurred && image.width() <= width)) { - return bounded_output(bytes.to_vec()); + let output = bounded_output(bytes.to_vec())?; + checkpoint( + operation, + "complete", + format!("passthrough; bytes={}", output.len()), + ); + return Ok(output); } let rgba = image.into_rgba8(); let mut rgba = if format == ImageFormat::Png && hero.is_none() { @@ -96,18 +129,39 @@ fn transformed_webp(bytes: &[u8], hero: Option<(u32, bool)>) -> Result, let height = (u64::from(rgba.height()) * u64::from(width) / u64::from(rgba.width())).max(1) as u32; if width != rgba.width() { + checkpoint( + operation, + "resize_start", + format!("dimensions={width}x{height}"), + ); rgba = image::imageops::resize( &rgba, width, height, image::imageops::FilterType::Lanczos3, ); + checkpoint( + operation, + "resize_complete", + format!("dimensions={}x{}", rgba.width(), rgba.height()), + ); } if blurred { let sigma = rgba.width() as f32 * (18.0 / 1248.0); + checkpoint(operation, "blur_start", format!("sigma={sigma}")); rgba = image::imageops::fast_blur(&rgba, sigma); + checkpoint( + operation, + "blur_complete", + format!("dimensions={}x{}", rgba.width(), rgba.height()), + ); } } + checkpoint( + operation, + "encode_start", + format!("dimensions={}x{}", rgba.width(), rgba.height()), + ); let encoder = webp::Encoder::from_rgba(&rgba, rgba.width(), rgba.height()); let mut config = webp::WebPConfig::new().map_err(|_| "Could not initialize WebP encoder.")?; config.quality = 85.0; @@ -117,7 +171,14 @@ fn transformed_webp(bytes: &[u8], hero: Option<(u32, bool)>) -> Result, .encode_advanced(&config) .map_err(|error| format!("Could not encode artwork as WebP: {error:?}"))? .to_vec(); - bounded_output(output) + checkpoint( + operation, + "encode_complete", + format!("bytes={}", output.len()), + ); + let output = bounded_output(output)?; + checkpoint(operation, "complete", format!("bytes={}", output.len())); + Ok(output) } fn bounded_output(bytes: Vec) -> Result, String> { diff --git a/src-tauri/src/launch_journal.rs b/src-tauri/src/launch_journal.rs index fca727c6..bba6131f 100644 --- a/src-tauri/src/launch_journal.rs +++ b/src-tauri/src/launch_journal.rs @@ -53,7 +53,7 @@ impl LaunchJournal { .map_err(|error| format!("Could not write game launch journal: {error}")) })(); if let Err(error) = result { - eprintln!("{error}"); + crate::application_log::failure("write_launch_journal", error); } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8fc27f8b..e84ebea3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,8 +9,11 @@ use tauri::{ mod app_profile; mod appearance; +mod application_log; pub mod archive_install; mod artwork_display; +mod artwork_publication; +mod artwork_scheduler; mod catalog; mod commands; #[cfg(target_os = "linux")] @@ -54,6 +57,9 @@ mod settings; mod source_links; mod startup; mod startup_recovery; +#[cfg(target_os = "linux")] +mod webkit_features; +mod window_scale; struct TrayAvailable(bool); struct LaunchEvents(tauri::async_runtime::JoinHandle<()>); @@ -70,13 +76,33 @@ mod steam_pics; mod steam_process; mod steam_switch; mod steam_vdf; +#[cfg(any(windows, test))] +mod webview_diagnostics; pub fn run() -> tauri::Result<()> { - let shortcut_game_id = desktop_shortcuts::requested_game_id(std::env::args_os().skip(1)) - .map_err(std::io::Error::other)?; - let mut context = tauri::generate_context!(); app_profile::configure(context.config_mut(), tauri::is_dev()); + let log = application_log::initialize(context.config()); + log.record("info", "startup", None, None, true); + let result = run_application(context, log.clone()); + if let Err(error) = &result { + log.record( + "error", + "startup_failure", + Some(&error.to_string()), + None, + true, + ); + } + result +} + +fn run_application( + mut context: tauri::Context, + log: application_log::ApplicationLog, +) -> tauri::Result<()> { + let shortcut_game_id = desktop_shortcuts::requested_game_id(std::env::args_os().skip(1)) + .map_err(std::io::Error::other)?; if let Some(window) = context .config_mut() .app @@ -88,12 +114,13 @@ pub fn run() -> tauri::Result<()> { } tauri::Builder::default() + .manage(log) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { let handle = app.clone(); if let Err(error) = app.run_on_main_thread(move || { show_main_window(&handle); if let Err(error) = source_links::forward(&handle, &args) { - eprintln!("Could not forward source link: {error}"); + application_log::failure("forward_source_link", error); } match desktop_shortcuts::requested_game_id( args.into_iter().skip(1).map(std::ffi::OsString::from), @@ -102,10 +129,10 @@ pub fn run() -> tauri::Result<()> { launch_shortcut(&handle, id); } Ok(None) => {} - Err(error) => eprintln!("Invalid forwarded shortcut: {error}"), + Err(error) => application_log::failure("forward_shortcut", error), } }) { - eprintln!("Could not forward second-instance request: {error}"); + application_log::failure("forward_instance", error); } })) .plugin(tauri_plugin_deep_link::init()) @@ -114,12 +141,6 @@ pub fn run() -> tauri::Result<()> { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .setup(move |app| { - #[cfg(target_os = "linux")] - if appearance::supports_transparency() - && let Some(window) = app.get_webview_window("main") - { - native_rendering::configure(&window)?; - } let source_links = source_links::SourceLinks::default(); source_links .enqueue(std::env::args_os().skip(1)) @@ -128,7 +149,7 @@ pub fn run() -> tauri::Result<()> { && !cfg!(debug_assertions) && let Err(error) = source_links::register(app.handle()) { - eprintln!("Could not register Legio source links: {error}"); + application_log::failure("register_source_links", &error); source_links .registration_failed(error) .map_err(std::io::Error::other)?; @@ -139,6 +160,30 @@ pub fn run() -> tauri::Result<()> { .database()? .settings() .map_err(std::io::Error::other)?; + app.state::() + .set_enabled(initial_settings.application_logging_enabled); + if let Some(window) = app.get_webview_window("main") { + window_scale::install(&window)?; + } + #[cfg(target_os = "linux")] + if let Some(window) = app.get_webview_window("main") { + native_rendering::configure( + &window, + app.state::() + .inner() + .clone(), + appearance::supports_transparency(), + )?; + } + #[cfg(windows)] + if let Some(window) = app.get_webview_window("main") { + webview_diagnostics::install( + &window, + app.state::() + .inner() + .clone(), + ); + } let bandwidth_limit = database .database() .and_then(database::Database::download_bandwidth_limit) @@ -181,10 +226,10 @@ pub fn run() -> tauri::Result<()> { Ok(states) => { if let Err(error) = events_app.emit("legio:game-launch-states", states) { - eprintln!("Could not publish game activity: {error}"); + application_log::failure("publish_game_activity", error); } } - Err(error) => eprintln!("Could not read game activity: {error}"), + Err(error) => application_log::failure("read_game_activity", error), } } }))); @@ -200,10 +245,12 @@ pub fn run() -> tauri::Result<()> { let maintenance = assets.clone(); tauri::async_runtime::spawn_blocking(move || { if let Err(error) = maintenance.maintain() { - eprintln!("Image cache maintenance failed: {error}"); + application_log::failure("image_cache_maintenance", error); } }); + app.manage(assets.scheduler.clone()); app.manage(assets); + app.manage(steam_details::DetailsFetchState::default()); app.manage(game_artwork::GameArtworkStore::new( app.path().app_data_dir().map_err(|error| error.to_string()), )); @@ -260,7 +307,7 @@ pub fn run() -> tauri::Result<()> { }) .build(app); if let Err(error) = &tray_result { - eprintln!("Could not create system tray icon: {error}"); + application_log::failure("create_tray", error); } app.manage(TrayAvailable(tray_result.is_ok())); if std::env::args_os().any(|argument| argument == "--minimized") { @@ -283,6 +330,13 @@ pub fn run() -> tauri::Result<()> { if let Some(game_id) = shortcut_game_id { launch_shortcut(app.handle(), game_id); } + app.state::().record( + "info", + "startup_ready", + None, + None, + true, + ); Ok(()) }) .on_window_event(|window, event| { @@ -299,7 +353,7 @@ pub fn run() -> tauri::Result<()> { .hide() .and_then(|()| window.emit("legio:window-hidden", ())) { - eprintln!("Could not hide main window: {error}"); + application_log::failure("hide_window", error); } } else { api.prevent_close(); @@ -362,6 +416,8 @@ pub fn run() -> tauri::Result<()> { commands::set_game_executable, commands::get_network_status, commands::get_network_log_status, + commands::get_application_log_status, + commands::report_application_event, commands::get_legio_source, commands::add_download_source, commands::remove_download_source, @@ -387,6 +443,9 @@ pub fn run() -> tauri::Result<()> { commands::refresh_catalog, commands::get_steam_details, commands::get_steam_asset, + commands::finish_steam_asset_update, + commands::set_artwork_game, + commands::cancel_artwork_request, commands::get_artwork_display_width, commands::reset_steam_artwork_cache, commands::prefetch_steam_hero, @@ -395,6 +454,8 @@ pub fn run() -> tauri::Result<()> { .build(context)? .run(|app, event| { if matches!(event, tauri::RunEvent::Exit) { + app.state::() + .record("info", "shutdown", None, None, true); app.state::().shutdown(); app.state::().0.abort(); } @@ -406,7 +467,7 @@ fn show_main_window(app: &tauri::AppHandle) { if let Some(window) = app.get_webview_window("main") && let Err(error) = window.show().and_then(|()| window.set_focus()) { - eprintln!("Could not show main window: {error}"); + application_log::failure("show_window", error); } } @@ -424,13 +485,13 @@ fn launch_shortcut(app: &tauri::AppHandle, game_id: String) { Err(error) => Err(error), }; if let Err(error) = result { - eprintln!("Could not launch game from shortcut: {error}"); + application_log::failure("launch_shortcut", &error); match app.state::().error.lock() { Ok(mut saved) => *saved = Some(error.clone()), - Err(lock_error) => eprintln!("Could not save shortcut failure: {lock_error}"), + Err(lock_error) => application_log::failure("save_shortcut_failure", lock_error), } if let Err(event_error) = app.emit("legio:shortcut-launch-failed", error) { - eprintln!("Could not publish shortcut failure: {event_error}"); + application_log::failure("publish_shortcut_failure", event_error); } } }); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 379377fe..7f95a922 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,21 +1,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() -> tauri::Result<()> { - #[cfg(target_os = "linux")] - { - let hyprland = std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some() - || std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|desktop| { - desktop - .split(':') - .next() - .is_some_and(|name| name.trim().eq_ignore_ascii_case("hyprland")) - }); - if hyprland && std::env::var_os("WEBKIT_USE_SKIA_FOR_COMPOSITION").is_none() { - // WebKitGTK 2.54's Skia compositor flashes stale animation frames on Hyprland. - // TextureMapper keeps accelerated compositing, WebGL and transparency available. - // SAFETY: main is still single-threaded; Tauri and GTK have not been initialized. - unsafe { std::env::set_var("WEBKIT_USE_SKIA_FOR_COMPOSITION", "0") }; - } - } legio_lib::run() } diff --git a/src-tauri/src/manual_import.rs b/src-tauri/src/manual_import.rs index 32e6aec0..619fc8cd 100644 --- a/src-tauri/src/manual_import.rs +++ b/src-tauri/src/manual_import.rs @@ -373,7 +373,7 @@ async fn detect_steam_identity( let mut remote_attempted = false; let mut remote_error = None; for name in names { - let cached = match catalog::search_catalog(app.clone(), name.clone(), None).await { + let cached = match catalog::search_catalog(app.clone(), name.clone(), None, None).await { Ok(result) => result, Err(error) => { return SteamIdentificationPreview { @@ -386,7 +386,9 @@ async fn detect_steam_identity( let mut candidates = exact_matches(&cached.games, &name); if candidates.is_empty() && !remote_attempted { remote_attempted = true; - match catalog::refresh_catalog(app.clone(), network, name.clone(), None, None).await { + match catalog::refresh_catalog(app.clone(), network, name.clone(), None, None, None) + .await + { Ok(result) => candidates = exact_matches(&result.games, &name), Err(error) => remote_error = Some(error.message), } diff --git a/src-tauri/src/native_rendering.rs b/src-tauri/src/native_rendering.rs index e8519a00..597114d4 100644 --- a/src-tauri/src/native_rendering.rs +++ b/src-tauri/src/native_rendering.rs @@ -1,22 +1,138 @@ use gtk::glib::translate::{ToGlibPtr, from_glib_borrow}; use gtk::prelude::*; -pub(crate) fn configure(window: &tauri::WebviewWindow) -> tauri::Result<()> { - window.with_webview(|platform| { +pub(crate) fn configure( + window: &tauri::WebviewWindow, + log: crate::application_log::ApplicationLog, + transparent: bool, +) -> tauri::Result<()> { + window.with_webview(move |platform| { + use webkit2gtk::{SettingsExt, WebViewExt}; + let view = platform.inner(); - install_damage_handler(&view); - view.connect_realize(install_damage_handler); - view.connect_focus_in_event(|view, _| { - view.queue_draw(); - gtk::glib::Propagation::Proceed - }); - view.connect_focus_out_event(|view, _| { - view.queue_draw(); - gtk::glib::Propagation::Proceed + let version = crate::webkit_features::version(); + let skia_override = environment_switch("WEBKIT_USE_SKIA_FOR_COMPOSITION", &log); + reject_live_damage_override( + std::env::var_os("LEGIO_WEBKIT_DAMAGE_COMPOSITING").is_some(), + &log, + ); + let mut skia = None; + let mut damage = None; + let mut policy = String::from("unavailable"); + if let Some(settings) = WebViewExt::settings(&view) { + policy = format!("{:?}", settings.hardware_acceleration_policy()); + if version >= (2, 54, 0) { + match crate::webkit_features::Features::load() { + Ok(features) => { + skia = features.value(&settings, c"UseSkiaForComposition"); + damage = features.value(&settings, c"UseDamagingInformationForCompositing"); + } + Err(error) => { + log.record("warn", "linux_rendering_configuration", Some(error), None, false); + } + } + } else { + log.record( + "warn", + "linux_rendering_configuration", + Some("Legacy WebKit runtime: Skia composition requires WebKitGTK 2.54 or newer"), + None, + false, + ); + + } + } else { + log.record("error", "linux_rendering_configuration", Some("WebKit view settings unavailable"), None, false); + } + let legacy_damage = legacy_damage_required(version, skia_override.or(skia), transparent); + if legacy_damage { + install_damage_handler(&view); + view.connect_realize(install_damage_handler); + view.connect_focus_in_event(|view, _| { + view.queue_draw(); + gtk::glib::Propagation::Proceed + }); + view.connect_focus_out_event(|view, _| { + view.queue_draw(); + gtk::glib::Propagation::Proceed + }); + } + let backend = view.display().type_().name().to_owned(); + let disable_dmabuf = environment_switch("WEBKIT_DISABLE_DMABUF_RENDERER", &log); + let force_shm = environment_switch("WEBKIT_DMABUF_RENDERER_FORCE_SHM", &log); + log.record( + "info", + "linux_renderer", + Some(&format!( + "WebKitGTK={}.{}.{}; backend={backend}; skia_preference={skia:?}; damage_preference={damage:?}; skia_override={skia_override:?}; native_alpha={transparent}; legacy_gtk_damage={legacy_damage}; acceleration_policy={policy}; disable_dmabuf_override={disable_dmabuf:?}; force_shm_override={force_shm:?}", + version.0, version.1, version.2, + )), + None, + true, + ); + view.connect_web_process_terminated(move |_, reason| { + log.record("error", "linux_web_process_terminated", Some(&format!("reason={reason:?}")), None, true); }); }) } +fn reject_live_damage_override(present: bool, log: &crate::application_log::ApplicationLog) { + if present { + log.record( + "warn", + "linux_rendering_configuration", + Some("Live damage override unsupported: use the native rendering probe to configure composition before page creation"), + None, + false, + ); + } +} + +fn parse_switch(value: Option<&str>) -> Result, &'static str> { + match value { + None => Ok(None), + Some("0") => Ok(Some(false)), + Some("1") => Ok(Some(true)), + Some(_) => Err("Expected 0 or 1"), + } +} + +fn environment_switch( + name: &'static str, + log: &crate::application_log::ApplicationLog, +) -> Option { + match std::env::var(name) { + Ok(value) => match parse_switch(Some(&value)) { + Ok(value) => value, + Err(error) => { + log.record( + "warn", + "linux_rendering_configuration", + Some(&format!("{name}: {error}")), + None, + false, + ); + None + } + }, + Err(std::env::VarError::NotPresent) => None, + Err(std::env::VarError::NotUnicode(_)) => { + log.record( + "warn", + "linux_rendering_configuration", + Some(&format!("{name}: expected 0 or 1")), + None, + false, + ); + None + } + } +} + +fn legacy_damage_required(version: (u32, u32, u32), skia: Option, transparent: bool) -> bool { + transparent && (version < (2, 54, 0) || skia == Some(false)) +} + fn install_damage_handler(view: &impl IsA) { if let Some(window) = view.window() { // SAFETY: GTK owns the live window; the static callback retains no borrowed state. @@ -41,7 +157,7 @@ unsafe extern "C" fn expand_damage( ) }; if let Err(error) = full_damage(&damage, window.width(), window.height()) { - eprintln!("Could not expand transparent window damage: {error}"); + crate::application_log::failure("expand_window_damage", error); } } @@ -59,6 +175,46 @@ mod tests { use super::*; use gtk::cairo::{Context, Format, ImageSurface, Operator, RectangleInt, Region}; + #[test] + fn skia_runtime_does_not_install_legacy_damage_expansion() { + assert!(!legacy_damage_required((2, 54, 0), None, true)); + assert!(!legacy_damage_required((2, 54, 1), Some(true), true)); + assert!(!legacy_damage_required((2, 56, 0), None, true)); + assert!(!legacy_damage_required((3, 0, 0), None, true)); + } + + #[test] + fn legacy_damage_expansion_is_limited_to_transparent_legacy_views() { + assert!(legacy_damage_required((2, 52, 6), None, true)); + assert!(legacy_damage_required((2, 54, 1), Some(false), true)); + assert!(!legacy_damage_required((2, 52, 6), None, false)); + assert!(!legacy_damage_required((2, 54, 1), Some(false), false)); + } + + #[test] + fn diagnostic_switch_rejects_invalid_values_without_changing_defaults() { + assert_eq!(parse_switch(None), Ok(None)); + assert_eq!(parse_switch(Some("0")), Ok(Some(false))); + assert_eq!(parse_switch(Some("1")), Ok(Some(true))); + assert!(parse_switch(Some("false")).is_err()); + assert!(parse_switch(Some("")).is_err()); + assert!(parse_switch(Some("2")).is_err()); + } + + #[test] + fn unsupported_live_damage_override_is_reported_instead_of_claimed_applied() { + let directory = + std::env::temp_dir().join(format!("legio-rendering-log-{}", uuid::Uuid::new_v4())); + let log = crate::application_log::ApplicationLog::new(directory.clone(), true); + reject_live_damage_override(false, &log); + assert!(!directory.join("app.txt").exists()); + reject_live_damage_override(true, &log); + let text = std::fs::read_to_string(directory.join("app.txt")).unwrap(); + assert!(text.contains("linux_rendering_configuration")); + assert!(text.contains("before page creation")); + std::fs::remove_dir_all(directory).unwrap(); + } + fn draw_frame(surface: &mut ImageSurface, partial_damage: bool) -> Vec { let damage = Region::create_rectangle(&RectangleInt::new( 0, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 729b670b..29270475 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -19,6 +19,8 @@ pub(crate) fn save(app: &AppHandle, settings: Settings) -> Result() .set_enabled(saved.diagnostics_enabled); + app.state::() + .set_enabled(saved.application_logging_enabled); let data_dir = app .path() .app_data_dir() diff --git a/src-tauri/src/startup_recovery.rs b/src-tauri/src/startup_recovery.rs index 675cfb41..2b3e1c3c 100644 --- a/src-tauri/src/startup_recovery.rs +++ b/src-tauri/src/startup_recovery.rs @@ -86,7 +86,7 @@ pub(crate) fn start(app: AppHandle) { .and_then(|result| result) .and_then(|()| crate::download_queue::start(app.clone())); if let Err(error) = &result { - eprintln!("Startup recovery failed: {error}"); + crate::application_log::failure("startup_recovery", error); } app.state::().finish(result); }); diff --git a/src-tauri/src/steam_assets.rs b/src-tauri/src/steam_assets.rs index 11da1537..801041bf 100644 --- a/src-tauri/src/steam_assets.rs +++ b/src-tauri/src/steam_assets.rs @@ -7,7 +7,14 @@ use std::{ time::{Duration, Instant, SystemTime}, }; +use crate::{ + artwork_publication::{ + ArtworkPublication, PublicationMode, PublicationOutcome, PublicationResult, + }, + artwork_scheduler::{ArtworkScheduler, ConversionPermit, WorkError, WorkHandle, WorkOrigin}, +}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use tauri::Manager; use crate::{ @@ -21,7 +28,7 @@ use crate::{ const MAX_ASSET_BYTES: usize = 2 * 1024 * 1024; const MAX_HEADER_BYTES: usize = 4096; const CACHE_FILES: usize = 1024; -const FRESH_FOR: Duration = Duration::from_secs(72 * 60 * 60); +const FRESH_FOR: Duration = Duration::from_secs(5 * 24 * 60 * 60); const ORPHAN_AGE: Duration = Duration::from_secs(60 * 60); #[derive(Clone, Copy, Deserialize)] @@ -66,6 +73,7 @@ pub struct AssetResult { stale: bool, refresh_after: u64, cache_warning: Option, + replacement_token: Option, } impl AssetResult { @@ -90,15 +98,73 @@ struct CacheEntry { refresh_after: u64, } -type AssetRequest = tokio::sync::Mutex)>>; -type MetadataLookup = Arc>; +#[derive(Default)] +struct AssetFetch { + result: tokio::sync::Mutex)>>, + owner: Mutex>, +} +impl AssetFetch { + fn register(&self, work: &WorkHandle) -> Result<(), String> { + let mut owner = self + .owner + .lock() + .map_err(|_| "Artwork owner lock was poisoned")?; + if let Some(owner) = owner.as_ref() { + owner.promote_from(work); + } else { + *owner = Some(work.clone()); + } + Ok(()) + } + fn set_owner(&self, work: &WorkHandle) -> Result<(), String> { + let mut owner = self + .owner + .lock() + .map_err(|_| "Artwork owner lock was poisoned")?; + if let Some(previous) = owner.as_ref() { + work.promote_from(previous); + } + *owner = Some(work.clone()); + Ok(()) + } +} +#[derive(Default)] +struct MetadataFetch { + lookup: tokio::sync::Mutex, + owner: Mutex>, +} +impl std::ops::Deref for MetadataFetch { + type Target = tokio::sync::Mutex; + fn deref(&self) -> &Self::Target { + &self.lookup + } +} +type MetadataLookup = Arc; +struct MetadataOwnerGuard { + lock: MetadataLookup, + work: WorkHandle, +} +impl Drop for MetadataOwnerGuard { + fn drop(&mut self) { + if let Ok(mut owner) = self.lock.owner.lock() + && owner + .as_ref() + .is_some_and(|owner| owner.same_request(&self.work)) + { + *owner = None; + } + } +} #[derive(Clone)] pub struct AssetCacheState { directory: Result, + pub(crate) scheduler: ArtworkScheduler, + publication: ArtworkPublication, + failures: Arc>>, lock: Arc>, pics_locks: Arc>>, - asset_locks: Arc>>>, + asset_locks: Arc>>>, } #[derive(Default)] @@ -115,6 +181,9 @@ impl AssetCacheState { .map_err(|error| { format!("Could not resolve the application cache directory: {error}") }), + scheduler: ArtworkScheduler::default(), + publication: ArtworkPublication::default(), + failures: Arc::default(), lock: Arc::new(Mutex::new(Maintenance::default())), pics_locks: Arc::new(Mutex::new(BTreeMap::new())), asset_locks: Arc::new(Mutex::new(BTreeMap::new())), @@ -132,7 +201,53 @@ impl AssetCacheState { Ok(locks.entry(app_id).or_default().clone()) } - fn asset_lock(&self, key: &str) -> Result, String> { + async fn resolve_metadata( + &self, + app_id: u32, + work: &AssetWork, + fetched: impl std::future::Future>, + ) -> Result<(Option, Option), String> { + let lock = self.metadata_lock(app_id)?; + { + let mut owner = lock + .owner + .lock() + .map_err(|_| "Metadata owner lock was poisoned")?; + if let Some(owner) = owner.as_ref() { + owner.promote_from(&work.handle); + } else { + *owner = Some(work.handle.clone()); + } + } + let _owner_guard = MetadataOwnerGuard { + lock: lock.clone(), + work: work.handle.clone(), + }; + let path = self + .directory() + .map(|directory| directory.join("pics").join(format!("{app_id}.json"))); + Ok(crate::steam_pics::resolve( + path, + &lock, + work.policy != AssetPolicy::CacheOrFetch, + async { + { + let mut owner = lock + .owner + .lock() + .map_err(|_| "Metadata owner lock was poisoned")?; + if let Some(previous) = owner.as_ref() { + work.handle.promote_from(previous); + } + *owner = Some(work.handle.clone()); + } + fetched.await + }, + ) + .await) + } + + fn asset_lock(&self, key: &str) -> Result, String> { let mut locks = self .asset_locks .lock() @@ -141,7 +256,7 @@ impl AssetCacheState { if let Some(lock) = locks.get(key).and_then(Weak::upgrade) { return Ok(lock); } - let lock = Arc::new(AssetRequest::new(None)); + let lock = Arc::new(AssetFetch::default()); locks.insert(key.to_owned(), Arc::downgrade(&lock)); Ok(lock) } @@ -191,7 +306,9 @@ impl AssetCacheState { .lock() .map_err(|_| "Image cache lock was poisoned")?; let directory = self.directory()?; - let entries = match prune(directory) { + self.publication.cleanup_orphans(directory)?; + let entries = match prune_protected(directory, &self.publication.protected_destinations()?) + { Ok(entries) => entries, Err(error) if error.kind() == io::ErrorKind::NotFound => 0, Err(error) => return Err(format!("Could not prune the image cache: {error}")), @@ -250,17 +367,19 @@ impl AssetCacheState { if format.content_type() != header.content_type { return Err("A cached image has mismatched content type.".to_owned()); } - let stale = metadata - .modified() - .ok() - .and_then(|modified| SystemTime::now().duration_since(modified).ok()) - .is_none_or(|age| age >= FRESH_FOR) - || header.transform_version != 2; + let permanent = permanent_asset(key); + let stale = !permanent + && (artwork_expired( + metadata + .modified() + .ok() + .and_then(|modified| SystemTime::now().duration_since(modified).ok()), + ) || header.transform_version != 2); Ok(Some(CacheEntry { bytes, content_type: format.content_type(), stale, - refresh_after: if stale { + refresh_after: if stale || permanent { 0 } else { metadata @@ -272,6 +391,81 @@ impl AssetCacheState { })) } + fn publish( + &self, + key: &str, + url: &str, + bytes: &[u8], + content_type: &'static str, + mode: PublicationMode, + ) -> Result, String> { + if mode == PublicationMode::NativeOnly { + self.write(key, url, bytes, content_type)?; + return Ok(None); + } + let _guard = self + .lock + .lock() + .map_err(|_| "Image cache lock was poisoned")?; + let directory = self.directory()?; + fs::create_dir_all(directory) + .map_err(|error| format!("Could not create artwork staging: {error}"))?; + let header = serde_json::to_vec(&CacheHeader { + url: url.to_owned(), + content_type: content_type.to_owned(), + transform_version: 2, + }) + .map_err(|error| error.to_string())?; + if header.len() > MAX_HEADER_BYTES || bytes.len() > MAX_ASSET_BYTES { + return Err("Image cache entry exceeds the size limit".into()); + } + let stage = directory.join(format!("{}.stage", uuid::Uuid::new_v4())); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&stage) + .map_err(|error| format!("Could not stage artwork: {error}"))?; + let result = file + .write_all(&header) + .and_then(|()| file.write_all(b"\n")) + .and_then(|()| file.write_all(bytes)) + .and_then(|()| file.sync_data()); + drop(file); + if let Err(error) = result { + let _ = fs::remove_file(&stage); + return Err(format!("Could not stage artwork: {error}")); + } + match self + .publication + .register(stage.clone(), directory.join(format!("{key}.asset"))) + { + Ok(token) => Ok(Some(token)), + Err(error) => { + if let Err(cleanup) = fs::remove_file(stage) { + crate::application_log::failure("remove_staged_artwork", cleanup); + } + Err(error) + } + } + } + pub(crate) fn finish( + &self, + token: &str, + outcome: PublicationOutcome, + ) -> Result { + let mut guard = self + .lock + .lock() + .map_err(|_| "Image cache lock was poisoned")?; + let result = self.publication.finish(token, outcome)?; + guard.entries = None; + drop(guard); + if outcome != PublicationOutcome::Rejected { + self.maintain()?; + } + Ok(result) + } + fn write( &self, key: &str, @@ -318,7 +512,7 @@ impl AssetCacheState { }; if let Err(error) = fs::rename(&temporary, destination) { if let Err(cleanup) = fs::remove_file(&temporary) { - eprintln!("Could not remove a temporary image cache file: {cleanup}"); + crate::application_log::failure("remove_temporary_image_cache", cleanup); } return Err(format!("Could not save a cached image: {error}")); } @@ -331,7 +525,7 @@ impl AssetCacheState { .is_none_or(|last| last.elapsed() >= Duration::from_secs(60)) { guard.entries = Some( - prune(directory) + prune_protected(directory, &self.publication.protected_destinations()?) .map_err(|error| format!("Could not prune the image cache: {error}"))?, ); guard.last_prune = Some(Instant::now()); @@ -340,7 +534,10 @@ impl AssetCacheState { } } -fn prune(directory: &Path) -> io::Result { +fn prune_protected( + directory: &Path, + protected: &std::collections::HashSet, +) -> io::Result { let mut files = Vec::new(); for entry in fs::read_dir(directory)? { let entry = entry?; @@ -364,18 +561,40 @@ fn prune(directory: &Path) -> io::Result { .extension() .is_some_and(|extension| extension == "asset") { + if entry + .path() + .file_stem() + .and_then(|name| name.to_str()) + .is_some_and(permanent_asset) + { + continue; + } files.push((entry.metadata()?.modified()?, entry.path())); } } files.sort_by_key(|(modified, _)| *modified); let retained = files.len().min(CACHE_FILES); let remove = files.len().saturating_sub(CACHE_FILES); - for (_, path) in files.into_iter().take(remove) { + for (_, path) in files + .into_iter() + .filter(|(_, path)| !protected.contains(path)) + .take(remove) + { fs::remove_file(path)?; } Ok(retained) } +fn permanent_asset(key: &str) -> bool { + key.split_once('-').is_some_and(|(_, kind)| { + kind.starts_with("hero-blur-") + || kind == "clienticon-alpha" + || matches!(kind, "logo" | "logo-full") + || kind.starts_with("library_capsule") + || kind.starts_with("library_header") + }) +} + fn selected_url( details: &SteamDetails, asset: AssetKind, @@ -405,24 +624,25 @@ fn selected_url( Ok(url.to_owned()) } -static IMAGE_WORKERS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(2); - async fn optimized_asset_bytes( bytes: Vec, hero: Option<(u32, bool)>, + work: &AssetWork, ) -> Result, String> { - let permit = IMAGE_WORKERS - .acquire() + let permit = work + .scheduler + .acquire_conversion(&work.handle) .await .map_err(|error| error.to_string())?; - let result = tauri::async_runtime::spawn_blocking(move || match hero { - Some((width, blurred)) => crate::image_trim::hero_webp(&bytes, width, blurred), - None => webp_asset(&bytes), + tauri::async_runtime::spawn_blocking(move || { + let _permit = permit; + match hero { + Some((width, blurred)) => crate::image_trim::hero_webp(&bytes, width, blurred), + None => webp_asset(&bytes), + } }) .await - .map_err(|error| format!("Artwork processing task failed: {error}"))?; - drop(permit); - result + .map_err(|error| format!("Artwork processing task failed: {error}"))? } struct HeroSource { @@ -430,6 +650,7 @@ struct HeroSource { fallback_url: Option, } +#[cfg(test)] async fn load_asset( cache: AssetCacheState, network: &NetworkState, @@ -437,17 +658,46 @@ async fn load_asset( url: String, refresh: bool, hero: Option, +) -> Result { + let work = AssetWork::new( + &cache.scheduler, + 1, + AssetDemand { + request_id: uuid::Uuid::new_v4().to_string(), + origin: WorkOrigin::Visible, + publication: PublicationMode::NativeOnly, + }, + ) + .map_err(|error| error.to_string())?; + load_asset_scheduled(cache, network, key, url, refresh, hero, &work).await +} + +async fn load_asset_scheduled( + cache: AssetCacheState, + network: &NetworkState, + key: String, + url: String, + refresh: bool, + hero: Option, + work: &AssetWork, ) -> Result { let request_lock = cache.asset_lock(&key)?; - let mut request = request_lock.lock().await; + request_lock.register(&work.handle)?; + let mut request = request_lock.result.lock().await; if let Some((requested_url, result)) = request.as_ref() && requested_url == &url { return result.clone(); } + request_lock.set_owner(&work.handle)?; let requested_url = url.clone(); - let result = load_asset_inner(cache, network, key, url, refresh, hero).await; - *request = Some((requested_url, result.clone())); + let result = load_asset_inner(cache, network, key, url, refresh, hero, work).await; + if result + .as_ref() + .map_or_else(|error| error != "Artwork request cancelled", |_| true) + { + *request = Some((requested_url, result.clone())); + } result } @@ -458,6 +708,7 @@ async fn load_asset_inner( mut url: String, refresh: bool, hero: Option, + work: &AssetWork, ) -> Result { let reader = cache.clone(); let read_key = key.clone(); @@ -475,13 +726,14 @@ async fn load_asset_inner( let (previous, read_error) = match read { Ok(entry) => (entry, None), Err(error) => { - eprintln!("Steam image cache read failed: {error}"); + crate::application_log::failure("read_image_cache", &error); (None, Some(error)) } }; if !refresh && previous.as_ref().is_some_and(|entry| !entry.stale) { let entry = previous.ok_or_else(|| "Image cache entry disappeared.".to_owned())?; return Ok(AssetResult { + replacement_token: None, bytes: entry.bytes, content_type: entry.content_type, stale: false, @@ -489,54 +741,83 @@ async fn load_asset_inner( cache_warning: None, }); } - let fetched = match network.steam_asset(&url).await { - Err(crate::network::NetworkError::Http { status: 404 }) => { - if let Some(fallback) = hero.as_ref().and_then(|hero| hero.fallback_url.as_ref()) { - url = fallback.clone(); - network.steam_asset(&url).await - } else { - Err(crate::network::NetworkError::Http { status: 404 }) + let primary_url = url.clone(); + let fetched = work + .scheduler + .run_image(&work.handle, || async { + match network.steam_asset(&primary_url).await { + Err(crate::network::NetworkError::Http { status: 404 }) => { + match hero.as_ref().and_then(|hero| hero.fallback_url.as_ref()) { + Some(fallback) => network + .steam_asset(fallback) + .await + .map(|bytes| (bytes, Some(fallback.clone()))), + None => Err(crate::network::NetworkError::Http { status: 404 }), + } + } + result => result.map(|bytes| (bytes, None)), } + .map_err(|error| format!("Steam image request failed: {error:?}")) + }) + .await; + let fetched = match fetched { + Ok(((bytes, fallback), permit)) => { + if let Some(fallback) = fallback { + url = fallback; + } + *work + .image_permit + .lock() + .map_err(|_| "Artwork pipeline lock was poisoned")? = Some(permit); + Ok(bytes) } - result => result, + Err(error) => Err(error.to_string()), }; - let fetched = fetched - .map_err(|error| format!("Steam image request failed: {error:?}")) - .and_then(|bytes| { - ImageFormat::from_steam_bytes(&bytes) - .map(|_| bytes) - .ok_or_else(|| "Steam returned unsupported image content.".to_owned()) - }); + let fetched = fetched.and_then(|bytes| { + ImageFormat::from_steam_bytes(&bytes) + .map(|_| bytes) + .ok_or_else(|| "Steam returned unsupported image content.".to_owned()) + }); let fetched = match fetched { - Ok(bytes) => optimized_asset_bytes(bytes, hero.map(|hero| (hero.width, false))).await, + Ok(bytes) => optimized_asset_bytes(bytes, hero.map(|hero| (hero.width, false)), work).await, Err(error) => Err(error), }; + if fetched.is_err() { + work.image_permit + .lock() + .map_err(|_| "Artwork pipeline lock was poisoned")? + .take(); + } match fetched { Ok(bytes) => { let content_type = "image/webp"; let writer = cache.clone(); let write_key = key; let write_url = url; + let mode = work.publication; let (bytes, write_result) = tauri::async_runtime::spawn_blocking(move || { - let result = writer.write(&write_key, &write_url, &bytes, content_type); + let result = writer.publish(&write_key, &write_url, &bytes, content_type, mode); (bytes, result) }) .await .map_err(|error| format!("Image cache write task failed: {error}"))?; Ok(AssetResult { + replacement_token: write_result.as_ref().ok().cloned().flatten(), bytes, content_type, stale: false, refresh_after: SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .map_or(0, |time| (time + FRESH_FOR).as_millis() as u64), - cache_warning: write_result.err().map(|error| { + cache_warning: write_result.as_ref().err().map(|error| { format!("Image is visible, but could not be saved locally: {error}") }), }) } + Err(error) if error == "Artwork request cancelled" => Err(error), Err(error) => previous .map(|entry| AssetResult { + replacement_token: None, bytes: entry.bytes, content_type: entry.content_type, stale: true, @@ -552,6 +833,7 @@ async fn load_asset_inner( } } +#[allow(clippy::too_many_arguments)] async fn load_legacy_preview( cache: AssetCacheState, network: &NetworkState, @@ -560,6 +842,7 @@ async fn load_legacy_preview( refresh: bool, hero: Option, metadata_path: Result, + work: &AssetWork, ) -> Result, String> { let Ok(path) = metadata_path else { return Ok(None); @@ -568,7 +851,7 @@ async fn load_legacy_preview( Ok(None) => {} Ok(Some(_)) | Err(_) => return Ok(None), } - match load_asset(cache, network, key, url, refresh, hero).await { + match load_asset_scheduled(cache, network, key, url, refresh, hero, work).await { Ok(asset) => Ok(Some(asset)), Err(_) => Ok(None), } @@ -604,6 +887,312 @@ fn library_url( } } +#[derive(Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AssetPolicy { + CacheOrFetch, + RefreshIfExpired, + ForceRefresh, +} +pub struct AssetRequest { + pub app_id: u32, + pub asset: AssetKind, + pub index: Option, + pub full: bool, + pub policy: AssetPolicy, + pub screenshot_identity: Option, +} +pub struct AssetDemand { + pub request_id: String, + pub origin: WorkOrigin, + pub publication: PublicationMode, +} +struct AssetWork { + policy: AssetPolicy, + scheduler: ArtworkScheduler, + handle: WorkHandle, + publication: PublicationMode, + image_permit: Arc>>, +} +impl AssetWork { + fn new( + scheduler: &ArtworkScheduler, + app_id: u32, + demand: AssetDemand, + ) -> Result { + Ok(Self { + policy: AssetPolicy::CacheOrFetch, + scheduler: scheduler.clone(), + handle: scheduler.begin(demand.request_id, app_id, demand.origin)?, + publication: demand.publication, + image_permit: Arc::default(), + }) + } + fn native_only(&self) -> Self { + Self { + policy: self.policy, + scheduler: self.scheduler.clone(), + handle: self.handle.clone(), + publication: PublicationMode::NativeOnly, + image_permit: self.image_permit.clone(), + } + } +} +fn artwork_expired(age: Option) -> bool { + age.is_none_or(|age| age >= FRESH_FOR) +} +fn refresh_artwork(policy: AssetPolicy, stale: bool) -> bool { + policy == AssetPolicy::ForceRefresh || (policy == AssetPolicy::RefreshIfExpired && stale) +} +fn asset_key( + app_id: u32, + asset: AssetKind, + index: Option, + full: bool, + width: u32, + identity: Option<&str>, +) -> Result { + let adaptive = matches!(asset, AssetKind::Hero | AssetKind::HeroBlur); + let suffix = if adaptive { + format!( + "-display-v{}-{width}", + if matches!(asset, AssetKind::HeroBlur) { + 2 + } else { + 1 + } + ) + } else if full { + "-full".into() + } else { + String::new() + }; + let kind = match asset { + AssetKind::Header => "header", + AssetKind::Capsule => "capsule", + AssetKind::Hero => "hero", + AssetKind::HeroBlur => "hero-blur", + AssetKind::Logo => "logo", + AssetKind::Screenshot => "screenshot", + AssetKind::ClientIcon => "clienticon-alpha", + _ => asset + .library_filename(full) + .ok_or("Invalid image selection")?, + }; + Ok(if let Some(identity) = identity { + format!( + "{app_id}-screenshot-{:x}{suffix}", + Sha256::digest(identity.as_bytes()) + ) + } else { + match index { + Some(index) => format!("{app_id}-{kind}-{index}{suffix}"), + None => format!("{app_id}-{kind}{suffix}"), + } + }) +} +fn cached_result(entry: CacheEntry) -> AssetResult { + AssetResult { + bytes: entry.bytes, + content_type: entry.content_type, + stale: entry.stale, + refresh_after: entry.refresh_after, + cache_warning: None, + replacement_token: None, + } +} +fn selected_url_with_identity( + details: &SteamDetails, + asset: AssetKind, + index: Option, + full: bool, + identity: Option<&str>, +) -> Result { + if matches!(asset, AssetKind::Screenshot) + && let Some(identity) = identity + { + let parsed = reqwest::Url::parse(identity) + .map_err(|_| "Screenshot identity is invalid".to_owned())?; + if identity.len() > 2048 + || identity.chars().any(char::is_control) + || !is_steam_asset_url(&parsed) + { + return Err("Screenshot identity is invalid".to_owned()); + } + return Ok(identity.to_owned()); + } + selected_url(details, asset, index, full) +} +pub async fn get_asset_for_request( + app: tauri::AppHandle, + network: &NetworkState, + request: AssetRequest, + demand: AssetDemand, +) -> Result { + if request.app_id == 0 + || (request.index.is_some() != matches!(request.asset, AssetKind::Screenshot)) + || (request.screenshot_identity.is_some() + && !matches!(request.asset, AssetKind::Screenshot)) + { + return Err(WorkError::Failed("Invalid image selection".into())); + } + let cache = app.state::().inner().clone(); + let mut work = AssetWork::new(&cache.scheduler, request.app_id, demand)?; + work.policy = request.policy; + let width = crate::artwork_display::width(&app); + let full = if matches!(request.asset, AssetKind::Hero | AssetKind::HeroBlur) { + width > 1920 + } else { + request.full + }; + let identity = if matches!(request.asset, AssetKind::Screenshot) { + let app = app.clone(); + let app_id = request.app_id; + let index = request.index; + let requested_identity = request.screenshot_identity.clone(); + let identity = tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + let database = state.database()?; + let details = cached_details(database, app_id) + .map_err(|error| error.message)? + .ok_or("No cached Steam details are available for this App ID")?; + if let Some(identity) = requested_identity { + if !(0..details.assets.screenshots.len()).any(|candidate| { + selected_url(&details, AssetKind::Screenshot, Some(candidate), full) + .is_ok_and(|url| url == identity) + }) { + return Err("Screenshot identity no longer matches cached metadata".to_owned()); + } + Ok(identity) + } else { + selected_url(&details, AssetKind::Screenshot, index, full) + } + }) + .await + .map_err(|error| WorkError::Failed(error.to_string()))? + .map_err(WorkError::Failed)?; + if request + .screenshot_identity + .as_ref() + .is_some_and(|value| value != &identity) + { + return Err(WorkError::Failed( + "Screenshot identity no longer matches cached metadata".into(), + )); + } + Some(identity) + } else { + None + }; + let key = asset_key( + request.app_id, + request.asset, + request.index, + full, + width, + identity.as_deref(), + ) + .map_err(WorkError::Failed)?; + let previous = { + let cache = cache.clone(); + let key = key.clone(); + let identity = identity.clone(); + let legacy = asset_key( + request.app_id, + request.asset, + request.index, + full, + width, + None, + ) + .map_err(WorkError::Failed)?; + tauri::async_runtime::spawn_blocking(move || { + match cache.read_cached(&key, identity.as_deref()) { + Ok(None) if identity.is_some() => cache.read_cached(&legacy, identity.as_deref()), + result => result, + } + }) + .await + .map_err(|error| WorkError::Failed(error.to_string()))? + }; + let previous = match previous { + Ok(entry) => entry, + Err(error) => { + crate::application_log::failure("read_image_cache", error); + None + } + }; + if let Some(entry) = previous.as_ref() + && !(request.policy == AssetPolicy::ForceRefresh + || (!permanent_asset(&key) && refresh_artwork(request.policy, entry.stale))) + { + return Ok(cached_result(previous.ok_or_else(|| { + WorkError::Failed("Image cache entry disappeared".into()) + })?)); + } + if previous.is_some() && request.policy == AssetPolicy::RefreshIfExpired { + work.handle.mark_refresh(); + } + if request.policy != AssetPolicy::ForceRefresh && previous.is_some() { + let failures = cache + .failures + .lock() + .map_err(|_| WorkError::Failed("Artwork retry state unavailable".into()))?; + if failures + .get(&key) + .is_some_and(|retry| *retry > Instant::now()) + { + return Ok(cached_result(previous.ok_or_else(|| { + WorkError::Failed("Image cache entry disappeared".into()) + })?)); + } + } + let result = Box::pin(get_asset_core( + app, + network, + request.app_id, + request.asset, + request.index, + full, + previous.is_some() || request.policy == AssetPolicy::ForceRefresh, + identity.as_deref(), + &work, + )) + .await; + if work.handle.is_cancelled() { + return Err(WorkError::Cancelled); + } + let failed = !result.as_ref().is_ok_and(|result| !result.stale); + if failed { + work.handle.mark_failed(); + } + let mut failures = cache + .failures + .lock() + .map_err(|_| WorkError::Failed("Artwork retry state unavailable".into()))?; + failures.retain(|_, retry| *retry > Instant::now()); + if failed { + if failures.len() >= CACHE_FILES { + failures.clear(); + } + failures.insert(key, Instant::now() + Duration::from_secs(300)); + } else { + failures.remove(&key); + } + match result { + Ok(result) => Ok(result), + Err(error) if error == "Artwork request cancelled" => Err(WorkError::Cancelled), + Err(error) => match previous { + Some(entry) => { + let mut result = cached_result(entry); + result.stale = true; + result.cache_warning = Some(error); + Ok(result) + } + None => Err(WorkError::Failed(error)), + }, + } +} pub async fn get_asset( app: tauri::AppHandle, network: &NetworkState, @@ -612,6 +1201,43 @@ pub async fn get_asset( index: Option, full: bool, refresh: bool, +) -> Result { + get_asset_for_request( + app, + network, + AssetRequest { + app_id, + asset, + index, + full, + policy: if refresh { + AssetPolicy::ForceRefresh + } else { + AssetPolicy::CacheOrFetch + }, + screenshot_identity: None, + }, + AssetDemand { + request_id: uuid::Uuid::new_v4().to_string(), + origin: WorkOrigin::Prefetch, + publication: PublicationMode::NativeOnly, + }, + ) + .await + .map_err(|error| error.to_string()) +} + +#[allow(clippy::too_many_arguments)] +async fn get_asset_core( + app: tauri::AppHandle, + network: &NetworkState, + app_id: u32, + asset: AssetKind, + index: Option, + full: bool, + refresh: bool, + screenshot_identity: Option<&str>, + work: &AssetWork, ) -> Result { if app_id == 0 || (index.is_some() != matches!(asset, AssetKind::Screenshot)) { return Err("Invalid image selection.".to_owned()); @@ -625,40 +1251,24 @@ pub async fn get_asset( full }; let filename = asset.library_filename(full); - let suffix = if adaptive_hero { - let version = if matches!(asset, AssetKind::HeroBlur) { - 2 - } else { - 1 - }; - format!("-display-v{version}-{display_width}") - } else if full { - "-full".to_owned() - } else { - String::new() - }; - let kind = match asset { - AssetKind::Header => "header", - AssetKind::Capsule => "capsule", - AssetKind::Hero => "hero", - AssetKind::HeroBlur => "hero-blur", - AssetKind::Logo => "logo", - AssetKind::Screenshot => "screenshot", - // Avoid reusing opaque JPEG icons cached before ICO became the preferred source. - AssetKind::ClientIcon => "clienticon-alpha", - _ => filename.ok_or("Invalid image selection.")?, - }; - let key = match index { - Some(index) => format!("{app_id}-{kind}-{index}{suffix}"), - None => format!("{app_id}-{kind}{suffix}"), - }; + let key = asset_key( + app_id, + asset, + index, + full, + display_width, + screenshot_identity, + )?; let blur_lock = matches!(asset, AssetKind::HeroBlur) .then(|| state.asset_lock(&key)) .transpose()?; + if let Some(lock) = blur_lock.as_ref() { + lock.register(&work.handle)?; + } let (mut blur_request, refresh) = match blur_lock.as_ref() { - Some(lock) => match lock.try_lock() { + Some(lock) => match lock.result.try_lock() { Ok(guard) => (Some(guard), refresh), - Err(_) => (Some(lock.lock().await), false), + Err(_) => (Some(lock.result.lock().await), false), }, None => (None, refresh), }; @@ -667,6 +1277,9 @@ pub async fn get_asset( { return result.clone(); } + if let Some(lock) = blur_lock.as_ref() { + lock.set_owner(&work.handle)?; + } if !refresh { let reader = state.clone(); let read_key = key.clone(); @@ -675,8 +1288,9 @@ pub async fn get_asset( .await .map_err(|error| format!("Image cache read task failed: {error}"))?; match cached { - Ok(Some(entry)) => { + Ok(Some(entry)) if !refresh_artwork(work.policy, entry.stale) => { return Ok(AssetResult { + replacement_token: None, bytes: entry.bytes, content_type: entry.content_type, stale: entry.stale, @@ -684,52 +1298,54 @@ pub async fn get_asset( cache_warning: None, }); } - Ok(None) => {} - Err(error) => eprintln!("Steam image cache read failed: {error}"), + Ok(_) => {} + Err(error) => crate::application_log::failure("read_image_cache", error), } } if matches!(asset, AssetKind::HeroBlur) { + let dependency_work = work.native_only(); // The sharp cache is shared with banners; cards persist their own resized blur. - let hero = Box::pin(get_asset( + let hero = Box::pin(get_asset_core( app.clone(), network, app_id, AssetKind::Hero, None, full, - refresh, + work.policy == AssetPolicy::ForceRefresh, + None, + &dependency_work, )) .await; let (source, source_name) = match hero { Ok(hero) => (hero, "derived:library_hero"), Err(hero_error) => { - let header = Box::pin(get_asset( + let header = Box::pin(get_asset_core( app, network, app_id, AssetKind::Header, None, false, - refresh, + work.policy == AssetPolicy::ForceRefresh, + None, + &dependency_work, )) .await .map_err(|error| format!("{hero_error} Header fallback: {error}"))?; (header, "derived:header") } }; - let bytes = optimized_asset_bytes(source.bytes, Some((display_width, true))).await?; - let stale = source.stale; + let bytes = optimized_asset_bytes(source.bytes, Some((display_width, true)), work).await?; + let mode = work.publication; let (bytes, written) = tauri::async_runtime::spawn_blocking(move || { - let written = if stale { - Ok(()) - } else { - state.write(&key, source_name, &bytes, "image/webp") - }; + let written = state.publish(&key, source_name, &bytes, "image/webp", mode); (bytes, written) }) .await .map_err(|error| format!("Card image cache task failed: {error}"))?; let mut warning = source.cache_warning; + let replacement_token = written.as_ref().ok().cloned().flatten(); if let Err(error) = written { warning = Some(match warning { Some(existing) => format!("{existing} Card image cache: {error}"), @@ -737,10 +1353,11 @@ pub async fn get_asset( }); } let result = AssetResult { + replacement_token, bytes, content_type: "image/webp", - stale, - refresh_after: source.refresh_after, + stale: false, + refresh_after: 0, cache_warning: warning, }; if let Some(request) = blur_request.as_mut() { @@ -770,6 +1387,7 @@ pub async fn get_asset( fallback_url, }), path, + work, ) .await? { @@ -777,15 +1395,16 @@ pub async fn get_asset( } } let (selected, metadata_warning, hero_fallback) = if let Some(filename) = filename { - let path = state - .directory() - .map(|directory| directory.join("pics").join(format!("{app_id}.json"))); - let metadata_lock = state.metadata_lock(app_id)?; - let (assets, warning) = - crate::steam_pics::resolve(path, &metadata_lock, crate::steam_pics::fetch(app_id)) - .await; + let (assets, warning) = state + .resolve_metadata(app_id, work, async { + work.scheduler + .run_metadata(&work.handle, || crate::steam_pics::fetch(app_id)) + .await + .map_err(|error| error.to_string()) + }) + .await?; if let Some(warning) = warning.as_deref() { - eprintln!("Steam asset metadata lookup for {app_id}: {warning}"); + crate::application_log::failure("asset_metadata_lookup", warning); } let fallback = if matches!(asset, AssetKind::Hero) && full { Some(library_url(app_id, "library_hero.jpg", assets.as_ref())?) @@ -799,23 +1418,38 @@ pub async fn get_asset( ) } else { let app_for_lookup = app.clone(); - let url = tauri::async_runtime::spawn_blocking(move || { + let (details, language) = tauri::async_runtime::spawn_blocking(move || { let state = app_for_lookup.state::(); let database = state.database()?; - let details = cached_details(database, app_id) - .map_err(|error| error.message)? - .ok_or_else(|| { - "No cached Steam details are available for this App ID.".to_owned() - })?; - selected_url(&details, asset, index, full) + let language = database.settings()?.language.resolve(); + Ok::<_, String>(( + cached_details(database, app_id).map_err(|error| error.message)?, + language, + )) }) .await - .map_err(|error| format!("Image lookup task failed: {error}"))?; + .map_err(|error| format!("Image lookup task failed: {error}"))??; + let details = match details { + Some(details) => details, + None => Box::pin(crate::steam_details::get_details_localized_with_policy( + app.clone(), + network, + app_id, + crate::steam_details::DetailsPolicy::CacheOrFetch, + language, + &work.handle, + )) + .await + .map_err(|error| error.message)? + .details + .ok_or("Steam artwork metadata is unavailable")?, + }; + let url = selected_url_with_identity(&details, asset, index, full, screenshot_identity); (url, None, None) }; let loaded = match selected { Ok(url) => { - load_asset( + load_asset_scheduled( state, network, key, @@ -825,6 +1459,7 @@ pub async fn get_asset( width: display_width, fallback_url: hero_fallback, }), + work, ) .await } @@ -854,13 +1489,112 @@ pub(crate) async fn reset(app: tauri::AppHandle, steam_app_id: u32) -> Result<() let cache = state.clone(); tauri::async_runtime::spawn_blocking(move || cache.reset_app(steam_app_id)) .await - .map_err(|error| format!("Image cache reset task failed: {error}"))? -} - -#[cfg(test)] -mod tests { - use super::*; + .map_err(|error| format!("Image cache reset task failed: {error}"))? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn nested_blur_does_not_hold_network_or_conversion_capacity() { + let cache = cache(); + let root = cache.directory().unwrap().parent().unwrap().to_path_buf(); + let sharp = crate::image_trim::webp_asset(PNG).unwrap(); + cache + .write("400-hero-display-v1-1920", "test", &sharp, "image/webp") + .unwrap(); + let scheduler = cache.scheduler.clone(); + let ordinary = AssetWork::new( + &scheduler, + 1, + AssetDemand { + request_id: "ordinary-codec".into(), + origin: WorkOrigin::Visible, + publication: PublicationMode::NativeOnly, + }, + ) + .unwrap(); + let held_codec = scheduler + .acquire_conversion(&ordinary.handle) + .await + .unwrap(); + let started = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut transfers = Vec::new(); + for id in 1..=4 { + let work = AssetWork::new( + &scheduler, + id, + AssetDemand { + request_id: format!("ordinary-network-{id}"), + origin: WorkOrigin::Visible, + publication: PublicationMode::NativeOnly, + }, + ) + .unwrap(); + let count = started.clone(); + transfers.push(tokio::spawn(async move { + work.scheduler + .run_network(&work.handle, || async { + count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + std::future::pending::>().await + }) + .await + })); + } + while started.load(std::sync::atomic::Ordering::SeqCst) < 4 { + tokio::task::yield_now().await; + } + scheduler.select_game(Some(400)).unwrap(); + let mut context = tauri::test::mock_context(tauri::test::noop_assets()); + context.config_mut().identifier = format!("org.legio.test.{}", uuid::Uuid::new_v4()); + let app = tauri::test::mock_builder() + .manage(cache.clone()) + .build(context) + .unwrap(); + let network = NetworkState::new( + "0.2.0", + crate::diagnostics::Diagnostics::new(Err("test".into())), + ) + .unwrap(); + let image = tokio::time::timeout( + Duration::from_secs(5), + get_asset( + app.handle().clone(), + &network, + 400, + AssetKind::HeroBlur, + None, + false, + false, + ), + ) + .await + .unwrap() + .unwrap(); + assert!(ImageFormat::from_steam_bytes(&image.bytes).is_some()); + assert_eq!(started.load(std::sync::atomic::Ordering::SeqCst), 4); + for id in 1..=4 { + scheduler.cancel(&format!("ordinary-network-{id}")).unwrap(); + } + for transfer in transfers { + assert!(matches!(transfer.await.unwrap(), Err(WorkError::Cancelled))); + } + drop(held_codec); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn cached_artwork_ignores_expiry_outside_game_open() { + assert!(!refresh_artwork(AssetPolicy::CacheOrFetch, true)); + assert!(refresh_artwork(AssetPolicy::RefreshIfExpired, true)); + } + #[test] + fn page_open_refreshes_at_five_day_boundary() { + assert!(!artwork_expired(Some(Duration::from_secs(431_999)))); + assert!(artwork_expired(Some(Duration::from_secs(432_000)))); + assert!(artwork_expired(None)); + } #[test] fn resetting_game_images_removes_only_the_selected_apps_assets_and_metadata() { let cache = cache(); @@ -948,6 +1682,285 @@ mod tests { response } + #[tokio::test] + async fn acquired_image_keeps_network_slot_until_processing_finishes() { + let cache = cache(); + let scheduler = cache.scheduler.clone(); + let mut held_network = Vec::new(); + let mut handles = Vec::new(); + for id in 1..=4 { + let handle = scheduler + .begin(format!("held-{id}"), id, WorkOrigin::Visible) + .unwrap(); + held_network.push( + scheduler + .acquire_for_test(&handle, crate::artwork_scheduler::WorkStage::Network) + .await + .unwrap(), + ); + handles.push(handle); + } + scheduler.select_game(Some(900)).unwrap(); + let codec = scheduler + .begin("codec".into(), 900, WorkOrigin::Visible) + .unwrap(); + let codec_a = scheduler.acquire_conversion(&codec).await.unwrap(); + let codec_b = scheduler.acquire_conversion(&codec).await.unwrap(); + scheduler.select_game(Some(400)).unwrap(); + let work = AssetWork::new( + &scheduler, + 400, + AssetDemand { + request_id: "image".into(), + origin: WorkOrigin::Visible, + publication: PublicationMode::NativeOnly, + }, + ) + .unwrap(); + let (url, server) = server(response(PNG)); + let image_cache = cache.clone(); + let image = tokio::spawn(async move { + let network = NetworkState::new( + "0.2.0", + crate::diagnostics::Diagnostics::new(Err("test".into())), + ) + .unwrap(); + load_asset_scheduled( + image_cache, + &network, + "400-header".into(), + url, + false, + None, + &work, + ) + .await + }); + tauri::async_runtime::spawn_blocking(move || server.join().unwrap()) + .await + .unwrap(); + for _ in 0..100 { + tokio::task::yield_now().await; + } + assert!(!image.is_finished()); + let next_work = scheduler + .begin("next-image".into(), 400, WorkOrigin::Visible) + .unwrap(); + let next_scheduler = scheduler.clone(); + let next = tokio::spawn(async move { + next_scheduler + .run_network(&next_work, || async { Ok(()) }) + .await + }); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + !next.is_finished(), + "The next transfer must wait for the acquired image's processing" + ); + drop(codec_a); + drop(codec_b); + image.await.unwrap().unwrap(); + next.await.unwrap().unwrap(); + fs::remove_dir_all(cache.directory().unwrap().parent().unwrap()).unwrap(); + } + + #[tokio::test] + async fn visible_join_promotes_a_queued_prefetch_download() { + let cache = cache(); + let scheduler = cache.scheduler.clone(); + let mut held = Vec::new(); + let mut handles = Vec::new(); + for id in 1..=4 { + let handle = scheduler + .begin(format!("held-{id}"), id, WorkOrigin::Visible) + .unwrap(); + held.push( + scheduler + .acquire_for_test(&handle, crate::artwork_scheduler::WorkStage::Network) + .await + .unwrap(), + ); + handles.push(handle); + } + let prefetch = AssetWork::new( + &scheduler, + 400, + AssetDemand { + request_id: "prefetch-owner".into(), + origin: WorkOrigin::Prefetch, + publication: PublicationMode::NativeOnly, + }, + ) + .unwrap(); + let (url, server) = server(response(PNG)); + let first_cache = cache.clone(); + let first_url = url.clone(); + let first = tokio::spawn(async move { + let network = NetworkState::new( + "0.2.0", + crate::diagnostics::Diagnostics::new(Err("test".into())), + ) + .unwrap(); + load_asset_scheduled( + first_cache, + &network, + "400-header".into(), + first_url, + false, + None, + &prefetch, + ) + .await + }); + tokio::time::sleep(Duration::from_millis(50)).await; + let ordinary = scheduler + .begin("ordinary-missing".into(), 100, WorkOrigin::Visible) + .unwrap(); + let ordinary_scheduler = scheduler.clone(); + let ordinary_task = tokio::spawn(async move { + ordinary_scheduler + .acquire_for_test(&ordinary, crate::artwork_scheduler::WorkStage::Network) + .await + }); + scheduler.select_game(Some(400)).unwrap(); + let visible = AssetWork::new( + &scheduler, + 400, + AssetDemand { + request_id: "visible-join".into(), + origin: WorkOrigin::Visible, + publication: PublicationMode::NativeOnly, + }, + ) + .unwrap(); + let second_cache = cache.clone(); + let second = tokio::spawn(async move { + let network = NetworkState::new( + "0.2.0", + crate::diagnostics::Diagnostics::new(Err("test".into())), + ) + .unwrap(); + load_asset_scheduled( + second_cache, + &network, + "400-header".into(), + url, + false, + None, + &visible, + ) + .await + }); + let results = tokio::time::timeout(Duration::from_secs(1), async { + ( + first.await.unwrap().unwrap(), + second.await.unwrap().unwrap(), + ) + }) + .await; + assert!( + results.is_ok(), + "A visible selected request must promote the shared queued download" + ); + let (a, b) = results.unwrap(); + assert_eq!(a.bytes, b.bytes); + server.join().unwrap(); + scheduler.cancel("ordinary-missing").unwrap(); + assert!(matches!( + ordinary_task.await.unwrap(), + Err(WorkError::Cancelled) + )); + fs::remove_dir_all(cache.directory().unwrap().parent().unwrap()).unwrap(); + } + + #[tokio::test] + async fn visible_asset_promotes_a_queued_shared_metadata_lookup() { + let cache = cache(); + let scheduler = cache.scheduler.clone(); + let mut held = Vec::new(); + let mut handles = Vec::new(); + for id in 1..=4 { + let handle = scheduler + .begin(format!("held-{id}"), id, WorkOrigin::Visible) + .unwrap(); + held.push( + scheduler + .acquire_for_test(&handle, crate::artwork_scheduler::WorkStage::Network) + .await + .unwrap(), + ); + handles.push(handle); + } + let prefetch = AssetWork::new( + &scheduler, + 400, + AssetDemand { + request_id: "metadata-prefetch".into(), + origin: WorkOrigin::Prefetch, + publication: PublicationMode::NativeOnly, + }, + ) + .unwrap(); + let first_cache = cache.clone(); + let first = tokio::spawn(async move { + first_cache + .resolve_metadata(400, &prefetch, async { + prefetch + .scheduler + .run_metadata(&prefetch.handle, || async { + Ok(serde_json::from_str("{}").unwrap()) + }) + .await + .map_err(|error| error.to_string()) + }) + .await + }); + tokio::time::sleep(Duration::from_millis(50)).await; + let ordinary = scheduler + .begin("ordinary-missing".into(), 100, WorkOrigin::Visible) + .unwrap(); + let ordinary_scheduler = scheduler.clone(); + let ordinary_task = tokio::spawn(async move { + ordinary_scheduler + .acquire_for_test(&ordinary, crate::artwork_scheduler::WorkStage::Network) + .await + }); + scheduler.select_game(Some(400)).unwrap(); + let visible = AssetWork::new( + &scheduler, + 400, + AssetDemand { + request_id: "metadata-visible".into(), + origin: WorkOrigin::Visible, + publication: PublicationMode::NativeOnly, + }, + ) + .unwrap(); + let second_cache = cache.clone(); + let second = tokio::spawn(async move { + second_cache + .resolve_metadata(400, &visible, async { + panic!("The visible join must reuse shared metadata") + }) + .await + }); + let result = tokio::time::timeout(Duration::from_secs(1), async { + first.await.unwrap().unwrap(); + second.await.unwrap().unwrap(); + }) + .await; + assert!( + result.is_ok(), + "The shared metadata owner must inherit visible selected demand" + ); + scheduler.cancel("ordinary-missing").unwrap(); + assert!(matches!( + ordinary_task.await.unwrap(), + Err(WorkError::Cancelled) + )); + fs::remove_dir_all(cache.directory().unwrap().parent().unwrap()).unwrap(); + } + #[tokio::test] async fn unrelated_games_do_not_wait_for_another_games_metadata() { let cache = cache(); @@ -956,7 +1969,7 @@ mod tests { let _busy_game = busy_lock.lock().await; let other_lock = cache.metadata_lock(401).unwrap(); let lookup = - crate::steam_pics::resolve(Ok(directory.join("401.json")), &other_lock, async { + crate::steam_pics::resolve(Ok(directory.join("401.json")), &other_lock, true, async { Ok(serde_json::from_str::("{}").unwrap()) }); let result = tokio::time::timeout(Duration::from_millis(100), lookup).await; @@ -982,6 +1995,16 @@ mod tests { crate::diagnostics::Diagnostics::new(Err("test".into())), ) .unwrap(); + let work = AssetWork::new( + &cache.scheduler, + 400, + AssetDemand { + request_id: uuid::Uuid::new_v4().to_string(), + origin: WorkOrigin::Prefetch, + publication: PublicationMode::NativeOnly, + }, + ) + .unwrap(); let result = load_legacy_preview( cache.clone(), &network, @@ -990,6 +2013,7 @@ mod tests { false, None, Ok(path), + &work, ) .await .unwrap(); @@ -1013,6 +2037,16 @@ mod tests { crate::diagnostics::Diagnostics::new(Err("test".into())), ) .unwrap(); + let work = AssetWork::new( + &cache.scheduler, + 400, + AssetDemand { + request_id: uuid::Uuid::new_v4().to_string(), + origin: WorkOrigin::Prefetch, + publication: PublicationMode::NativeOnly, + }, + ) + .unwrap(); let result = load_legacy_preview( cache.clone(), &network, @@ -1024,6 +2058,7 @@ mod tests { fallback_url: None, }), Ok(path.clone()), + &work, ) .await .unwrap(); @@ -1273,6 +2308,113 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[tokio::test] + async fn card_blur_from_an_expired_sharp_source_survives_restart() { + let cache = cache(); + let root = cache.directory().unwrap().parent().unwrap().to_path_buf(); + let sharp = crate::image_trim::webp_asset(PNG).unwrap(); + cache + .write("400-hero-display-v1-1920", "test", &sharp, "image/webp") + .unwrap(); + let path = cache + .directory() + .unwrap() + .join("400-hero-display-v1-1920.asset"); + fs::OpenOptions::new() + .write(true) + .open(path) + .unwrap() + .set_times( + fs::FileTimes::new() + .set_modified(SystemTime::now() - Duration::from_secs(6 * 86400)), + ) + .unwrap(); + let mut context = tauri::test::mock_context(tauri::test::noop_assets()); + context.config_mut().identifier = format!("org.legio.test.{}", uuid::Uuid::new_v4()); + let app = tauri::test::mock_builder() + .manage(cache.clone()) + .build(context) + .unwrap(); + let network = NetworkState::new( + "0.2.0", + crate::diagnostics::Diagnostics::new(Err("test".into())), + ) + .unwrap(); + let first = get_asset( + app.handle().clone(), + &network, + 400, + AssetKind::HeroBlur, + None, + false, + false, + ) + .await + .unwrap(); + let reopened = AssetCacheState::new(Ok(root.clone())); + let saved = reopened + .read_cached("400-hero-blur-display-v2-1920", None) + .unwrap() + .expect("A Library card must persist even when its sharp source is expired"); + assert_eq!(saved.bytes, first.bytes); + assert!(!saved.stale); + assert!( + cache + .read_cached("400-hero-display-v1-1920", None) + .unwrap() + .unwrap() + .stale + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn library_cards_and_client_icons_ignore_age_and_survive_cache_pressure() { + let cache = cache(); + let sharp = crate::image_trim::webp_asset(PNG).unwrap(); + for key in [ + "400-hero-blur-display-v2-1920", + "400-clienticon-alpha", + "400-logo", + "400-library_capsule.jpg", + "400-library_header.jpg", + ] { + cache.write(key, "test", &sharp, "image/webp").unwrap(); + let path = cache.directory().unwrap().join(format!("{key}.asset")); + fs::OpenOptions::new() + .write(true) + .open(path) + .unwrap() + .set_times( + fs::FileTimes::new() + .set_modified(SystemTime::now() - Duration::from_secs(365 * 86400)), + ) + .unwrap(); + assert!(!cache.read_cached(key, None).unwrap().unwrap().stale); + } + for index in 0..=CACHE_FILES { + cache + .write( + &format!("400-screenshot-{index}"), + "test", + &sharp, + "image/webp", + ) + .unwrap(); + } + cache.maintain().unwrap(); + for key in [ + "400-hero-blur-display-v2-1920", + "400-clienticon-alpha", + "400-logo", + "400-library_capsule.jpg", + "400-library_header.jpg", + ] { + assert!(cache.read_cached(key, None).unwrap().is_some()); + } + fs::remove_dir_all(cache.directory().unwrap().parent().unwrap()).unwrap(); + } + #[tokio::test] async fn missing_hero_uses_a_blurred_header_without_modifying_the_sharp_header() { let cache = cache(); @@ -1342,6 +2484,77 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[tokio::test] + async fn missing_header_waits_for_corrupt_metadata_recovery() { + let cache = cache(); + let root = cache.directory().unwrap().parent().unwrap().to_path_buf(); + let mut context = tauri::test::mock_context(tauri::test::noop_assets()); + context.config_mut().identifier = format!("org.legio.test.{}", uuid::Uuid::new_v4()); + let app = tauri::test::mock_builder() + .manage(DatabaseState::new(Ok(root.join("data")))) + .manage(cache.scheduler.clone()) + .manage(crate::steam_details::DetailsFetchState::default()) + .manage(cache.clone()) + .build(context) + .unwrap(); + let database = app.state::(); + database.database().unwrap().with_connection(|connection| connection.execute("INSERT INTO steam_details_cache (steam_app_id,details,fetched_at) VALUES (400,'broken',0)", []).map_err(|error| error.to_string())).unwrap(); + let details_state = app.state::(); + let lock = crate::steam_details::hold_metadata_for_test(&details_state, 400); + let guard = lock.lock().await; + let network = NetworkState::new( + "0.2.0", + crate::diagnostics::Diagnostics::new(Err("test".into())), + ) + .unwrap(); + let loading = get_asset( + app.handle().clone(), + &network, + 400, + AssetKind::Header, + None, + false, + false, + ); + tokio::pin!(loading); + assert!( + tokio::time::timeout(Duration::from_millis(150), &mut loading) + .await + .is_err(), + "corrupt metadata must reach the existing recovery lock instead of failing the artwork" + ); + let url = "https://cdn.steamstatic.com/400/header.jpg"; + let repaired = serde_json::json!({"steamAppId":400,"name":"Portal","appType":"game","developers":[],"publishers":[],"genres":[],"detailedDescription":"Ready","systemRequirements":{},"assets":{"header":url,"capsule":null,"background":null,"screenshots":[]}}).to_string(); + database + .database() + .unwrap() + .with_connection(|connection| { + connection + .execute( + "UPDATE steam_details_cache SET details=?1,fetched_at=?2", + rusqlite::params![ + repaired, + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + ], + ) + .map_err(|error| error.to_string()) + }) + .unwrap(); + cache.write("400-header", url, PNG, "image/png").unwrap(); + drop(guard); + let image = tokio::time::timeout(Duration::from_secs(3), loading) + .await + .unwrap() + .unwrap(); + assert_eq!(image.bytes, PNG); + crate::database::close_test_database(&app.state::()); + drop(app); + fs::remove_dir_all(root).unwrap(); + } + #[tokio::test] async fn cached_artwork_bypasses_locked_metadata_lookup() { let cache = cache(); @@ -1379,6 +2592,31 @@ mod tests { crate::diagnostics::Diagnostics::new(Err("test".into())), ) .unwrap(); + cache.scheduler.select_game(Some(400)).unwrap(); + let capacity_work = cache + .scheduler + .begin("saturated-cache-test".into(), 400, WorkOrigin::Visible) + .unwrap(); + let mut network_capacity = Vec::new(); + for _ in 0..5 { + network_capacity.push( + cache + .scheduler + .acquire_for_test(&capacity_work, crate::artwork_scheduler::WorkStage::Network) + .await + .unwrap(), + ); + } + let mut conversion_capacity = Vec::new(); + for _ in 0..2 { + conversion_capacity.push( + cache + .scheduler + .acquire_conversion(&capacity_work) + .await + .unwrap(), + ); + } let metadata_lock = cache.metadata_lock(400).unwrap(); let _guard = metadata_lock.lock().await; for (asset, index, key) in [ @@ -1407,6 +2645,7 @@ mod tests { assert!(image.cache_warning.is_none()); } assert!(!cache.directory().unwrap().join("pics").exists()); + crate::database::close_test_database(&app.state::()); drop(app); fs::remove_dir_all(root).unwrap(); } @@ -1566,7 +2805,9 @@ mod tests { cache.write("400-header", &url, PNG, "image/png").unwrap(); let path = cache.directory().unwrap().join("400-header.asset"); let old = SystemTime::now() - FRESH_FOR - Duration::from_secs(1); - fs::File::open(&path) + fs::OpenOptions::new() + .write(true) + .open(&path) .unwrap() .set_times(fs::FileTimes::new().set_modified(old)) .unwrap(); @@ -1599,7 +2840,9 @@ mod tests { cache.write("400-header", &url, PNG, "image/png").unwrap(); let path = cache.directory().unwrap().join("400-header.asset"); let old = SystemTime::now() - FRESH_FOR - Duration::from_secs(1); - fs::File::open(&path) + fs::OpenOptions::new() + .write(true) + .open(&path) .unwrap() .set_times(fs::FileTimes::new().set_modified(old)) .unwrap(); @@ -1637,6 +2880,27 @@ mod tests { assert!(selected_url(&details, AssetKind::Capsule, Some(0), false).is_err()); } + #[test] + fn screenshot_download_keeps_validated_identity_after_metadata_reordering() { + let mut details: SteamDetails = serde_json::from_value(serde_json::json!({ + "steamAppId":400,"name":"Portal","appType":"game","developers":[],"publishers":[],"genres":[], + "assets":{"header":null,"capsule":null,"background":null,"screenshots":[ + {"thumbnail":"https://cdn.steamstatic.com/400/a.jpg"}, + {"thumbnail":"https://cdn.steamstatic.com/400/b.jpg"}]} + })).unwrap(); + let validated = selected_url(&details, AssetKind::Screenshot, Some(0), false).unwrap(); + details.assets.screenshots.swap(0, 1); + let selected = selected_url_with_identity( + &details, + AssetKind::Screenshot, + Some(0), + false, + Some(&validated), + ) + .unwrap(); + assert_eq!(selected, validated); + } + #[test] fn full_screenshot_selection_prefers_the_large_image() { let details: SteamDetails = serde_json::from_value(serde_json::json!({ @@ -1697,7 +2961,7 @@ mod tests { } #[test] - fn webp_cache_survives_reopen_and_expires_after_72_hours() { + fn webp_cache_survives_reopen_and_expires_after_five_days() { let cache = cache(); let bytes = webp_asset(PNG).unwrap(); let url = "https://steamstatic.com/image.jpg"; @@ -1711,8 +2975,10 @@ mod tests { .unwrap() .to_path_buf())); let path = cache.directory().unwrap().join("400-header.asset"); - for (hours, stale) in [(71, false), (73, true)] { - fs::File::open(&path) + for (hours, stale) in [(119, false), (121, true)] { + fs::OpenOptions::new() + .write(true) + .open(&path) .unwrap() .set_times( fs::FileTimes::new() @@ -1762,7 +3028,9 @@ mod tests { assert!(cache.read_cached("400-header", None).unwrap().is_some()); assert!(orphan.exists()); let old = SystemTime::now() - ORPHAN_AGE - Duration::from_secs(1); - fs::File::open(&orphan) + fs::OpenOptions::new() + .write(true) + .open(&orphan) .unwrap() .set_times(fs::FileTimes::new().set_modified(old)) .unwrap(); diff --git a/src-tauri/src/steam_details.rs b/src-tauri/src/steam_details.rs index 22bdee95..98488344 100644 --- a/src-tauri/src/steam_details.rs +++ b/src-tauri/src/steam_details.rs @@ -15,7 +15,7 @@ use crate::{ const CACHE_LIMIT: u32 = 128; const MAX_DETAILS_BYTES: usize = 512 * 1024; -const STALE_SECONDS: i64 = 24 * 60 * 60; +const STALE_SECONDS: i64 = 3 * 24 * 60 * 60; const MAX_SCREENSHOTS: usize = 8; #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -84,6 +84,7 @@ pub struct DetailsResult { #[derive(Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum DetailsErrorKind { + Cancelled, InvalidAppId, Unavailable, InvalidResponse, @@ -439,6 +440,25 @@ fn cached( ) } +fn recover_cached( + result: Result, +) -> Result { + match result { + Err(error) + if error.message.starts_with("Invalid cached Steam details:") + || error.message.starts_with("Cached Steam App ID") => + { + crate::application_log::failure("read_steam_metadata_cache", &error.message); + Ok(DetailsResult { + details: None, + cached_at: None, + stale: false, + }) + } + result => result, + } +} + pub(crate) fn cached_details( database: &Database, app_id: u32, @@ -449,7 +469,7 @@ pub(crate) fn cached_details( .map_err(DetailsError::database)? .language; let time = now()?; - let details = cached_localized(database, app_id, time, language)?.details; + let details = recover_cached(cached_localized(database, app_id, time, language))?.details; if details.is_some() { return Ok(details); } @@ -458,11 +478,7 @@ pub(crate) fn cached_details( } else { crate::locale::LanguagePreference::Italian }; - Ok(cached_localized(database, app_id, time, alternate)?.details) -} - -fn needs_fetch(previous: &DetailsResult, refresh: bool) -> bool { - refresh || previous.details.is_none() || previous.stale + Ok(recover_cached(cached_localized(database, app_id, time, alternate))?.details) } async fn blocking(task: F) -> Result @@ -475,55 +491,408 @@ where .map_err(|error| DetailsError::new(DetailsErrorKind::Internal, error.to_string()))? } -pub async fn get_details_localized( +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DetailsPolicy { + CacheOrFetch, + RefreshIfExpired, + ForceRefresh, +} +type MetadataLocks = HashMap<(u32, String), std::sync::Weak>>; + +#[derive(Default)] +pub struct DetailsFetchState { + locks: std::sync::Mutex, + failures: std::sync::Mutex>, +} +impl DetailsFetchState { + fn lock(&self, key: &(u32, String)) -> Result>, DetailsError> { + let mut locks = self.locks.lock().map_err(|_| { + DetailsError::new(DetailsErrorKind::Internal, "Metadata scheduler unavailable") + })?; + locks.retain(|_, lock| lock.strong_count() > 0); + if let Some(lock) = locks.get(key).and_then(std::sync::Weak::upgrade) { + return Ok(lock); + } + let lock = Arc::new(tokio::sync::Mutex::new(())); + locks.insert(key.clone(), Arc::downgrade(&lock)); + Ok(lock) + } +} +fn should_fetch(previous: &DetailsResult, policy: DetailsPolicy) -> bool { + previous.details.is_none() + || policy == DetailsPolicy::ForceRefresh + || (policy == DetailsPolicy::RefreshIfExpired && previous.stale) +} +#[allow(clippy::too_many_arguments)] +async fn details_with_fetch( + database: Arc, + state: &DetailsFetchState, + scheduler: &crate::artwork_scheduler::ArtworkScheduler, + work: &crate::artwork_scheduler::WorkHandle, + app_id: u32, + policy: DetailsPolicy, + language: crate::locale::LanguagePreference, + mut fetch: Fetch, + clock: Clock, +) -> Result +where + F: std::future::Future, String>>, + Fetch: FnMut() -> F, + Clock: Fn() -> Result, +{ + let time = clock()?; + let read = |database: Arc| async move { + blocking(move || recover_cached(cached_localized(&database, app_id, time, language))).await + }; + let previous = read(database.clone()).await?; + if !should_fetch(&previous, policy) { + return Ok(previous); + } + let key = (app_id, language.code().to_owned()); + let lock = state.lock(&key)?; + let waiting = lock.try_lock().is_err(); + let _guard = lock.lock().await; + let previous = read(database.clone()).await?; + if !should_fetch(&previous, policy) + || (waiting && previous.details.is_some() && !previous.stale) + { + return Ok(previous); + } + if policy != DetailsPolicy::ForceRefresh && previous.details.is_some() { + let failures = state.failures.lock().map_err(|_| { + DetailsError::new( + DetailsErrorKind::Internal, + "Metadata retry state unavailable", + ) + })?; + if failures + .get(&key) + .is_some_and(|retry| *retry > tokio::time::Instant::now()) + { + return Ok(previous); + } + } + if previous.details.is_some() && policy == DetailsPolicy::RefreshIfExpired { + work.mark_refresh(); + } + let fetched = async { + let bytes = + scheduler + .run_metadata(work, &mut fetch) + .await + .map_err(|error| match error { + crate::artwork_scheduler::WorkError::Cancelled => { + DetailsError::new(DetailsErrorKind::Cancelled, "Artwork request cancelled") + } + crate::artwork_scheduler::WorkError::Failed(message) => { + DetailsError::new(DetailsErrorKind::Network, message) + } + })?; + let fetched_at = clock()?; + blocking(move || { + let details = decode(&bytes, app_id)?; + store_localized(&database, &details, fetched_at, language)?; + Ok(DetailsResult { + details: Some(details), + cached_at: Some(fetched_at), + stale: false, + }) + }) + .await + } + .await; + let mut failures = state.failures.lock().map_err(|_| { + DetailsError::new( + DetailsErrorKind::Internal, + "Metadata retry state unavailable", + ) + })?; + failures.retain(|_, retry| *retry > tokio::time::Instant::now()); + match fetched { + Ok(result) => { + failures.remove(&key); + Ok(result) + } + Err(error) if error.kind == DetailsErrorKind::Cancelled => Err(error), + Err(error) => { + if failures.len() >= CACHE_LIMIT as usize { + failures.clear(); + } + failures.insert( + key, + tokio::time::Instant::now() + std::time::Duration::from_secs(300), + ); + crate::application_log::failure("artwork_metadata_refresh", &error.message); + if policy != DetailsPolicy::ForceRefresh && previous.details.is_some() { + Ok(previous) + } else { + Err(error) + } + } + } +} + +pub async fn get_details_localized_with_policy( app: tauri::AppHandle, network: &NetworkState, app_id: u32, - refresh: bool, + policy: DetailsPolicy, language: crate::locale::LanguagePreference, + work: &crate::artwork_scheduler::WorkHandle, ) -> Result { validate_app_id(app_id)?; - let (database, time) = blocking({ + let database = blocking({ let app = app.clone(); - move || -> Result<(Arc, i64), DetailsError> { - let state = app.state::(); - let database = state.shared_database().map_err(DetailsError::database)?; - Ok((database, now()?)) + move || { + app.state::() + .shared_database() + .map_err(DetailsError::database) } }) .await?; - let previous = blocking({ - let database = database.clone(); - move || cached_localized(&database, app_id, time, language) - }) - .await?; - if !needs_fetch(&previous, refresh) { - return Ok(previous); - } - let bytes = match network.steam_details(app_id, language).await { - Ok(bytes) => bytes, - // Stale metadata is preferable to an empty panel during an implicit - // refresh. An explicit refresh still reports its failure. - Err(_error) if !refresh && previous.details.is_some() => return Ok(previous), - Err(error) => return Err(error.into()), - }; - let details = blocking(move || { - let details = decode(&bytes, app_id)?; - store_localized(&database, &details, time, language)?; - Ok::<_, DetailsError>(details) - }) - .await?; - Ok(DetailsResult { - details: Some(details), - cached_at: Some(time), - stale: false, - }) + details_with_fetch( + database, + app.state::().inner(), + app.state::() + .inner(), + work, + app_id, + policy, + language, + || async { + network + .steam_details(app_id, language) + .await + .map_err(|error| { + let error = DetailsError::from(error); + error.message + }) + }, + now, + ) + .await +} + +#[cfg(test)] +pub async fn get_details_localized( + app: tauri::AppHandle, + network: &NetworkState, + app_id: u32, + refresh: bool, + language: crate::locale::LanguagePreference, +) -> Result { + let scheduler = app.state::(); + let work = scheduler + .begin( + uuid::Uuid::new_v4().to_string(), + app_id, + crate::artwork_scheduler::WorkOrigin::Visible, + ) + .map_err(|error| DetailsError::new(DetailsErrorKind::Internal, error.to_string()))?; + get_details_localized_with_policy( + app.clone(), + network, + app_id, + if refresh { + DetailsPolicy::ForceRefresh + } else { + DetailsPolicy::CacheOrFetch + }, + language, + &work, + ) + .await +} + +#[cfg(test)] +pub(crate) fn hold_metadata_for_test( + state: &DetailsFetchState, + app_id: u32, +) -> Arc> { + state + .lock(&( + app_id, + crate::locale::LanguagePreference::English.code().to_owned(), + )) + .unwrap() } #[cfg(test)] mod tests { use super::*; + async fn policy_fixture( + policy: DetailsPolicy, + time: i64, + fail: bool, + state: &DetailsFetchState, + database: Arc, + calls: &std::sync::atomic::AtomicUsize, + ) -> Result { + let scheduler = crate::artwork_scheduler::ArtworkScheduler::default(); + let work = scheduler + .begin( + uuid::Uuid::new_v4().to_string(), + 400, + crate::artwork_scheduler::WorkOrigin::Visible, + ) + .unwrap(); + details_with_fetch( + database, + state, + &scheduler, + &work, + 400, + policy, + crate::locale::LanguagePreference::English, + || async { + calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if fail { + Err("offline".to_owned()) + } else { + Ok(fixture(400)) + } + }, + || Ok(time), + ) + .await + } + fn policy_database() -> (std::path::PathBuf, Arc) { + let path = directory(); + let database = Arc::new(Database::open(&path).unwrap()); + let mut details = decode(&fixture(400), 400).unwrap(); + details.detailed_description = Some("Cached text".into()); + details.system_requirements = Some(SystemRequirements { + minimum: Some("Cached requirements".into()), + recommended: None, + }); + store(&database, &details, 100).unwrap(); + (path, database) + } + #[tokio::test] + async fn stale_list_metadata_returns_without_fetch() { + let (path, database) = policy_database(); + let state = DetailsFetchState::default(); + let calls = std::sync::atomic::AtomicUsize::new(0); + for _ in 0..3 { + let result = policy_fixture( + DetailsPolicy::CacheOrFetch, + 400_000, + false, + &state, + database.clone(), + &calls, + ) + .await + .unwrap(); + assert_eq!(result.cached_at, Some(100)); + } + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0); + drop(database); + std::fs::remove_dir_all(path).unwrap(); + } + #[tokio::test] + async fn page_open_refreshes_at_three_day_boundary() { + let (path, database) = policy_database(); + let state = DetailsFetchState::default(); + let calls = std::sync::atomic::AtomicUsize::new(0); + policy_fixture( + DetailsPolicy::RefreshIfExpired, + 259_299, + false, + &state, + database.clone(), + &calls, + ) + .await + .unwrap(); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0); + let result = policy_fixture( + DetailsPolicy::RefreshIfExpired, + 259_300, + false, + &state, + database.clone(), + &calls, + ) + .await + .unwrap(); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(result.cached_at, Some(259_300)); + drop(database); + std::fs::remove_dir_all(path).unwrap(); + } + #[tokio::test] + async fn failed_refresh_preserves_previous_and_does_not_advance_age() { + let (path, database) = policy_database(); + let state = DetailsFetchState::default(); + let calls = std::sync::atomic::AtomicUsize::new(0); + for _ in 0..2 { + let result = policy_fixture( + DetailsPolicy::RefreshIfExpired, + 400_000, + true, + &state, + database.clone(), + &calls, + ) + .await + .unwrap(); + assert_eq!(result.cached_at, Some(100)); + assert_eq!( + result.details.unwrap().detailed_description.as_deref(), + Some("Cached text") + ); + } + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + assert!( + policy_fixture( + DetailsPolicy::ForceRefresh, + 400_000, + true, + &state, + database.clone(), + &calls + ) + .await + .is_err() + ); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2); + drop(database); + std::fs::remove_dir_all(path).unwrap(); + } + #[tokio::test] + async fn future_timestamp_revalidates_only_on_open() { + let (path, database) = policy_database(); + let state = DetailsFetchState::default(); + let calls = std::sync::atomic::AtomicUsize::new(0); + policy_fixture( + DetailsPolicy::CacheOrFetch, + 99, + false, + &state, + database.clone(), + &calls, + ) + .await + .unwrap(); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0); + policy_fixture( + DetailsPolicy::RefreshIfExpired, + 99, + false, + &state, + database.clone(), + &calls, + ) + .await + .unwrap(); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + drop(database); + std::fs::remove_dir_all(path).unwrap(); + } + fn fixture(app_id: u32) -> Vec { serde_json::to_vec(&serde_json::json!({app_id.to_string(): {"success":true,"data":{ "steam_appid":app_id,"name":"Portal","type":"game", @@ -548,6 +917,8 @@ mod tests { context.config_mut().identifier = format!("org.legio.test.{}", uuid::Uuid::new_v4()); tauri::test::mock_builder() .manage(DatabaseState::new(Ok(directory.to_path_buf()))) + .manage(crate::artwork_scheduler::ArtworkScheduler::default()) + .manage(DetailsFetchState::default()) .build(context) .unwrap() } @@ -620,10 +991,10 @@ mod tests { cached_at: None, stale: false, }; - assert!(!needs_fetch(&fresh, false)); - assert!(needs_fetch(&fresh, true)); - assert!(needs_fetch(&stale, false)); - assert!(needs_fetch(&missing, false)); + assert!(!should_fetch(&fresh, DetailsPolicy::CacheOrFetch)); + assert!(should_fetch(&fresh, DetailsPolicy::ForceRefresh)); + assert!(should_fetch(&stale, DetailsPolicy::RefreshIfExpired)); + assert!(should_fetch(&missing, DetailsPolicy::CacheOrFetch)); } #[test] @@ -744,6 +1115,42 @@ mod tests { std::fs::remove_dir_all(path).unwrap(); } + #[tokio::test] + async fn corrupt_metadata_is_a_miss_for_missing_artwork_and_is_repaired() { + let (path, database) = policy_database(); + database + .with_connection(|connection| { + connection + .execute("UPDATE steam_details_cache SET details = 'broken'", []) + .map_err(|error| error.to_string()) + }) + .unwrap(); + assert!(cached_details(&database, 400).unwrap().is_none()); + let calls = std::sync::atomic::AtomicUsize::new(0); + let result = policy_fixture( + DetailsPolicy::CacheOrFetch, + 1000, + false, + &DetailsFetchState::default(), + database.clone(), + &calls, + ) + .await + .unwrap(); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + assert!(result.details.unwrap().assets.header.is_some()); + assert!( + cached_details(&database, 400) + .unwrap() + .unwrap() + .assets + .header + .is_some() + ); + drop(database); + std::fs::remove_dir_all(path).unwrap(); + } + #[tokio::test] async fn app_reads_cached_details_without_a_network_request() { let path = directory(); @@ -774,6 +1181,7 @@ mod tests { assert_eq!(result.details, Some(details)); assert_eq!(result.cached_at, Some(fetched_at)); assert!(!result.stale); + crate::database::close_test_database(&app.state::()); drop(app); std::fs::remove_dir_all(path).unwrap(); } diff --git a/src-tauri/src/steam_pics.rs b/src-tauri/src/steam_pics.rs index 444f6d52..aaa4c862 100644 --- a/src-tauri/src/steam_pics.rs +++ b/src-tauri/src/steam_pics.rs @@ -137,7 +137,7 @@ pub(crate) async fn read_cache(path: &Path) -> Result Ok(assets) => assets, Err(error) => { if let Err(cleanup) = tokio::fs::remove_file(path).await { - eprintln!("Could not remove an unreadable Steam PICS cache: {cleanup}"); + crate::application_log::failure("remove_unreadable_metadata_cache", cleanup); } return Err(format!("Invalid Steam PICS cache: {error}")); } @@ -163,7 +163,7 @@ async fn write_cache(path: &Path, assets: &PicsAssets) -> Result<(), String> { .map_err(|error| format!("Could not write Steam PICS cache: {error}"))?; if let Err(error) = tokio::fs::rename(&temporary, path).await { if let Err(cleanup) = tokio::fs::remove_file(&temporary).await { - eprintln!("Could not remove temporary Steam PICS cache: {cleanup}"); + crate::application_log::failure("remove_temporary_metadata_cache", cleanup); } return Err(format!("Could not save Steam PICS cache: {error}")); } @@ -178,6 +178,7 @@ pub(crate) struct Lookup { pub(crate) async fn resolve( path: Result, lock: &tokio::sync::Mutex, + refresh_expired: bool, fetched: impl Future>, ) -> (Option, Option) { let (path, mut warning) = match path { @@ -192,7 +193,10 @@ pub(crate) async fn resolve( } else { None }; - if cached.as_ref().is_some_and(|(_, stale)| !stale) { + if cached + .as_ref() + .is_some_and(|(_, stale)| !refresh_expired || !stale) + { return (cached.map(|(assets, _)| assets), warning); } @@ -204,7 +208,10 @@ pub(crate) async fn resolve( Err(error) => warning = Some(error), } } - if cached.as_ref().is_some_and(|(_, stale)| !stale) { + if cached + .as_ref() + .is_some_and(|(_, stale)| !refresh_expired || !stale) + { return (cached.map(|(assets, _)| assets), warning); } let result = match lookup.recent.as_ref() { @@ -213,7 +220,14 @@ pub(crate) async fn resolve( } _ => { let result = fetched.await; - lookup.recent = Some((Instant::now(), result.clone())); + lookup.recent = if result + .as_ref() + .is_err_and(|error| error == "Artwork request cancelled") + { + None + } else { + Some((Instant::now(), result.clone())) + }; result } }; @@ -280,8 +294,8 @@ pub(crate) async fn fetch(app_id: u32) -> Result { if client.is_logged_in() { match tokio::time::timeout(Duration::from_secs(3), client.log_off()).await { Ok(Ok(())) => {} - Ok(Err(error)) => eprintln!("Steam anonymous logout failed: {error}"), - Err(_) => eprintln!("Steam anonymous logout timed out."), + Ok(Err(error)) => crate::application_log::failure("steam_anonymous_logout", error), + Err(_) => crate::application_log::failure("steam_anonymous_logout", "Timed out"), } } result @@ -381,8 +395,10 @@ mod tests { async fn cache_miss_fetches_and_persists_metadata() { let path = cache_path(); let lock = tokio::sync::Mutex::new(Lookup::default()); - let (assets, warning) = - resolve(Ok(path.clone()), &lock, async { Ok(logo_assets(HASH)) }).await; + let (assets, warning) = resolve(Ok(path.clone()), &lock, true, async { + Ok(logo_assets(HASH)) + }) + .await; let (saved, stale) = read_cache(&path).await.unwrap().unwrap(); assert_eq!( saved.url(4656000, "logo.png"), @@ -403,7 +419,7 @@ mod tests { let _guard = lock.lock().await; let (assets, warning) = tokio::time::timeout( Duration::from_secs(3), - resolve(Ok(path.clone()), &lock, async { + resolve(Ok(path.clone()), &lock, true, async { panic!("Fresh metadata must not connect to Steam") }), ) @@ -419,14 +435,42 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn expired_metadata_for_library_bypasses_network_and_lookup_lock() { + let path = cache_path(); + expire_cache(&path).await; + let lock = tokio::sync::Mutex::new(Lookup::default()); + let guard = lock.lock().await; + let result = tokio::time::timeout( + Duration::from_millis(100), + resolve(Ok(path.clone()), &lock, false, async { + panic!("Library artwork must reuse expired metadata") + }), + ) + .await; + drop(guard); + let (assets, warning) = + result.expect("Library cache lookup must not wait for a refresh lock"); + assert_eq!( + assets.unwrap().url(4656000, "logo.png"), + Some(format!("{CDN}/4656000/{HASH}/logo.png")) + ); + assert!(warning.is_none()); + tokio::fs::remove_dir_all(path.parent().unwrap()) + .await + .unwrap(); + } + #[tokio::test] async fn expired_metadata_refreshes_and_saves_the_new_hash() { let path = cache_path(); expire_cache(&path).await; let lock = tokio::sync::Mutex::new(Lookup::default()); let hash = "b".repeat(40); - let (assets, warning) = - resolve(Ok(path.clone()), &lock, async { Ok(logo_assets(&hash)) }).await; + let (assets, warning) = resolve(Ok(path.clone()), &lock, true, async { + Ok(logo_assets(&hash)) + }) + .await; let expected = logo_assets(&hash).url(4656000, "logo.png"); assert_eq!(assets.unwrap().url(4656000, "logo.png"), expected); let (saved, stale) = read_cache(&path).await.unwrap().unwrap(); @@ -442,12 +486,12 @@ mod tests { async fn same_game_queued_lookups_reuse_a_recent_offline_result() { let path = cache_path(); let lock = tokio::sync::Mutex::new(Lookup::default()); - let (_, warning) = resolve(Ok(path.clone()), &lock, async { + let (_, warning) = resolve(Ok(path.clone()), &lock, true, async { Err("Steam is offline".into()) }) .await; assert_eq!(warning.as_deref(), Some("Steam is offline")); - let (_, repeated_warning) = resolve(Ok(path), &lock, async { + let (_, repeated_warning) = resolve(Ok(path), &lock, true, async { panic!("A queued request must not start another slow Steam connection") }) .await; @@ -459,7 +503,7 @@ mod tests { let path = cache_path(); expire_cache(&path).await; let lock = tokio::sync::Mutex::new(Lookup::default()); - let (assets, warning) = resolve(Ok(path.clone()), &lock, async { + let (assets, warning) = resolve(Ok(path.clone()), &lock, true, async { Err("Steam is offline".into()) }) .await; @@ -474,6 +518,29 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn cancelled_lookup_does_not_poison_the_next_visible_request() { + let path = cache_path(); + let lock = tokio::sync::Mutex::new(Lookup::default()); + let (_, warning) = resolve(Ok(path.clone()), &lock, true, async { + Err("Artwork request cancelled".into()) + }) + .await; + assert_eq!(warning.as_deref(), Some("Artwork request cancelled")); + let (assets, warning) = resolve(Ok(path.clone()), &lock, true, async { + Ok(logo_assets(HASH)) + }) + .await; + assert!( + assets.is_some(), + "Cancellation must not be reused as a cached metadata failure" + ); + assert!(warning.is_none()); + tokio::fs::remove_dir_all(path.parent().unwrap()) + .await + .unwrap(); + } + #[tokio::test] #[ignore = "Requires a live anonymous Steam CM connection"] async fn live_pics_resolves_modern_assets() { diff --git a/src-tauri/src/webkit_features.rs b/src-tauri/src/webkit_features.rs new file mode 100644 index 00000000..a06b83a7 --- /dev/null +++ b/src-tauri/src/webkit_features.rs @@ -0,0 +1,112 @@ +use std::{ffi::CStr, ptr::NonNull}; + +use gtk::glib::translate::ToGlibPtr; +use webkit2gtk::Settings; + +type FeatureList = libc::c_void; +type Feature = libc::c_void; +type SettingsPtr = *mut webkit2gtk::ffi::WebKitSettings; + +pub(crate) struct Features { + list: NonNull, + find: unsafe extern "C" fn(*mut FeatureList, *const libc::c_char) -> *mut Feature, + get: unsafe extern "C" fn(SettingsPtr, *mut Feature) -> libc::c_int, + unref: unsafe extern "C" fn(*mut FeatureList), +} + +impl Features { + pub(crate) fn load() -> Result { + // Tauri's bindings predate the feature API; resolve it without raising the runtime minimum. + macro_rules! symbol { + ($name:literal, $ty:ty) => {{ + // SAFETY: WebKit is linked for the process lifetime. Each ABI matches its C header. + let pointer = unsafe { libc::dlsym(libc::RTLD_DEFAULT, $name.as_ptr()) }; + if pointer.is_null() { + return Err("Loaded WebKit does not expose the required feature API"); + } + // SAFETY: dlsym resolved the named function with the explicitly specified C ABI. + unsafe { std::mem::transmute::<*mut libc::c_void, $ty>(pointer) } + }}; + } + let all = symbol!( + c"webkit_settings_get_all_features", + unsafe extern "C" fn() -> *mut FeatureList + ); + let find = symbol!( + c"webkit_feature_list_find", + unsafe extern "C" fn(*mut FeatureList, *const libc::c_char) -> *mut Feature + ); + let get = symbol!( + c"webkit_settings_get_feature_enabled", + unsafe extern "C" fn(SettingsPtr, *mut Feature) -> libc::c_int + ); + let unref = symbol!( + c"webkit_feature_list_unref", + unsafe extern "C" fn(*mut FeatureList) + ); + // SAFETY: the API returns an owned feature list; Drop releases exactly this reference. + let list = NonNull::new(unsafe { all() }).ok_or("WebKit returned no feature list")?; + Ok(Self { + list, + find, + get, + unref, + }) + } + + pub(crate) fn value(&self, settings: &Settings, name: &CStr) -> Option { + // SAFETY: the list and settings are live, and name is a terminated C string. + let feature = unsafe { (self.find)(self.list.as_ptr(), name.as_ptr()) }; + if feature.is_null() { + return None; + } + // SAFETY: the feature is borrowed from our owned list and settings remains alive. + Some(unsafe { (self.get)(settings.to_glib_none().0, feature) } != 0) + } +} + +impl Drop for Features { + fn drop(&mut self) { + // SAFETY: list is the sole owned reference returned by get_all_features. + unsafe { (self.unref)(self.list.as_ptr()) }; + } +} + +pub(crate) fn version() -> (u32, u32, u32) { + // SAFETY: these linked, argument-free APIs only return the loaded library version. + unsafe { + ( + webkit2gtk::ffi::webkit_get_major_version(), + webkit2gtk::ffi::webkit_get_minor_version(), + webkit2gtk::ffi::webkit_get_micro_version(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore = "Requires a display and a loaded WebKitGTK 2.54+ runtime"] + fn native_feature_snapshot_reports_engine_defaults_and_missing_features() { + assert!(version() >= (2, 54, 0)); + gtk::init().expect("initialize GTK on the native test thread"); + let settings = Settings::new(); + let features = Features::load().expect("feature API available"); + assert_eq!( + features.value(&settings, c"UseSkiaForComposition"), + Some(true) + ); + assert!( + features + .value(&settings, c"UseDamagingInformationForCompositing") + .is_some() + ); + assert_eq!(features.value(&settings, c"LegioNonexistentFeature"), None); + assert_eq!( + features.value(&settings, c"UseSkiaForComposition"), + Some(true) + ); + } +} diff --git a/src-tauri/src/webview_diagnostics.rs b/src-tauri/src/webview_diagnostics.rs new file mode 100644 index 00000000..7c636c62 --- /dev/null +++ b/src-tauri/src/webview_diagnostics.rs @@ -0,0 +1,293 @@ +//! Observe WebView2 subprocess failures while the host is alive. +//! Native host termination cannot deliver these WebView2 callbacks. + +use crate::application_log::ApplicationLog; + +fn record_process_failure( + log: &ApplicationLog, + kind: Option, + reason: Option, + exit: Option, +) { + let kind = kind.map_or_else(|| "unavailable".to_owned(), |value| value.to_string()); + let reason = reason.map_or_else(|| "unavailable".to_owned(), |value| value.to_string()); + let exit = exit.map_or_else( + || "unavailable".to_owned(), + |value| format!("0x{:08X}", value as u32), + ); + log.record( + "error", + "webview_process_failed", + Some(&format!( + "kind: {kind}; reason: {reason}; exit_code: {exit}" + )), + None, + true, + ); +} + +fn record_browser_exit(log: &ApplicationLog, kind: Option) { + let level = if kind == Some(0) { "info" } else { "error" }; + let kind = kind.map_or_else(|| "unavailable".to_owned(), |value| value.to_string()); + log.record( + level, + "webview_browser_exited", + Some(&format!("kind: {kind}")), + None, + true, + ); +} + +fn record_runtime(log: &ApplicationLog, version: &str) { + let version = version + .split_whitespace() + .next() + .filter(|version| { + !version.is_empty() + && version.len() <= 64 + && version + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'.') + }) + .unwrap_or("unavailable"); + log.record( + "info", + "webview_runtime", + Some(&format!("WebView2 version: {version}")), + None, + true, + ); +} + +#[cfg(windows)] +pub(crate) fn install(window: &tauri::WebviewWindow, log: ApplicationLog) { + let callback_log = log.clone(); + if let Err(error) = window.with_webview(move |platform| { + windows_observers::install(platform.controller(), platform.environment(), &callback_log); + }) { + log.record( + "error", + "webview_diagnostics_error", + Some(&format!("schedule_observers: {error}")), + None, + true, + ); + } +} + +#[cfg(windows)] +mod windows_observers { + use super::*; + use webview2_com::Microsoft::Web::WebView2::Win32::{ + COREWEBVIEW2_BROWSER_PROCESS_EXIT_KIND, COREWEBVIEW2_PROCESS_FAILED_KIND, + COREWEBVIEW2_PROCESS_FAILED_REASON, ICoreWebView2Controller, ICoreWebView2Environment, + ICoreWebView2Environment5, ICoreWebView2ProcessFailedEventArgs2, + }; + use webview2_com::{BrowserProcessExitedEventHandler, ProcessFailedEventHandler, take_pwstr}; + use windows::core::{Interface, PWSTR}; + + fn record_error(log: &ApplicationLog, stage: &'static str, error: &windows::core::Error) { + log.record( + "error", + "webview_diagnostics_error", + Some(&format!( + "{stage}; hresult: 0x{:08X}", + error.code().0 as u32 + )), + None, + true, + ); + } + + pub(super) fn install( + controller: ICoreWebView2Controller, + environment: ICoreWebView2Environment, + log: &ApplicationLog, + ) { + let mut version = PWSTR::null(); + // SAFETY: The live environment writes an allocated string to a valid output pointer. + match unsafe { environment.BrowserVersionString(&mut version) } { + Ok(()) => record_runtime(log, &take_pwstr(version)), + Err(error) => record_error(log, "read_runtime_version", &error), + } + + // SAFETY: The controller is supplied on its owning UI thread by with_webview. + let webview = match unsafe { controller.CoreWebView2() } { + Ok(webview) => webview, + Err(error) => { + record_error(log, "get_core_webview", &error); + return; + } + }; + let failure_log = log.clone(); + let failure_handler = ProcessFailedEventHandler::create(Box::new(move |_, args| { + let mut kind = None; + let mut reason = None; + let mut exit = None; + if let Some(args) = args { + let mut value = COREWEBVIEW2_PROCESS_FAILED_KIND::default(); + // SAFETY: WebView2 supplies live event arguments and this output is initialized. + match unsafe { args.ProcessFailedKind(&mut value) } { + Ok(()) => kind = Some(value.0), + Err(error) => record_error(&failure_log, "read_failure_kind", &error), + } + if let Ok(extended) = args.cast::() { + let mut value = COREWEBVIEW2_PROCESS_FAILED_REASON::default(); + // SAFETY: The queried interface is live and the output pointer is valid. + match unsafe { extended.Reason(&mut value) } { + Ok(()) => reason = Some(value.0), + Err(error) => record_error(&failure_log, "read_failure_reason", &error), + } + let mut value = 0; + // SAFETY: The queried interface is live and the output pointer is valid. + match unsafe { extended.ExitCode(&mut value) } { + Ok(()) => exit = Some(value), + Err(error) => record_error(&failure_log, "read_failure_exit_code", &error), + } + } + } + record_process_failure(&failure_log, kind, reason, exit); + Ok(()) + })); + // Registrations last for the event source lifetime. Each handler captures only the log, + // avoiding a reference cycle with the WebView or environment that owns the handler. + let mut failure_token = 0; + // SAFETY: The live WebView retains the COM handler until its event registration ends. + let failure_registered = + match unsafe { webview.add_ProcessFailed(&failure_handler, &mut failure_token) } { + Ok(()) => true, + Err(error) => { + record_error(log, "register_process_failed", &error); + false + } + }; + + let browser_registered = match environment.cast::() { + Ok(environment) => { + let browser_log = log.clone(); + let browser_handler = + BrowserProcessExitedEventHandler::create(Box::new(move |_, args| { + let kind = if let Some(args) = args { + let mut value = COREWEBVIEW2_BROWSER_PROCESS_EXIT_KIND::default(); + // SAFETY: WebView2 supplies live event arguments and a valid output pointer. + match unsafe { args.BrowserProcessExitKind(&mut value) } { + Ok(()) => Some(value.0), + Err(error) => { + record_error(&browser_log, "read_browser_exit_kind", &error); + None + } + } + } else { + None + }; + record_browser_exit(&browser_log, kind); + Ok(()) + })); + let mut browser_token = 0; + // SAFETY: The environment retains the COM handler for its own lifetime. + match unsafe { + environment.add_BrowserProcessExited(&browser_handler, &mut browser_token) + } { + Ok(()) => true, + Err(error) => { + record_error(log, "register_browser_exited", &error); + false + } + } + } + Err(error) => { + record_error(log, "get_browser_exit_interface", &error); + false + } + }; + log.record( + "info", + "webview_observers_ready", + Some(&format!( + "process_failed: {failure_registered}; browser_exited: {browser_registered}" + )), + None, + true, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn directory() -> std::path::PathBuf { + std::env::temp_dir().join(format!("legio-webview-log-{}", uuid::Uuid::new_v4())) + } + + fn records(directory: &std::path::Path) -> String { + let path = directory.join("app.txt"); + assert!( + path.is_file(), + "WebView failures must reach the native application log" + ); + std::fs::read_to_string(path).unwrap() + } + + #[test] + fn process_failure_persists_kind_reason_and_signed_exit_code() { + let directory = directory(); + let log = ApplicationLog::new(directory.clone(), true); + record_process_failure(&log, Some(1), Some(0), Some(-1073741819)); + let records = records(&directory); + assert!(records.contains("ERROR webview_process_failed")); + let message = records.as_str(); + for field in ["kind: 1", "reason: 0", "exit_code: 0xC0000005"] { + assert!(message.contains(field), "{message}"); + } + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn unavailable_extended_fields_do_not_discard_a_process_failure() { + let directory = directory(); + let log = ApplicationLog::new(directory.clone(), true); + record_process_failure(&log, Some(1), None, None); + let records = records(&directory); + let message = records.as_str(); + assert!(message.contains("kind: 1")); + assert!(message.contains("reason: unavailable")); + assert!(message.contains("exit_code: unavailable")); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn browser_exits_distinguish_normal_shutdown_from_failure() { + let directory = directory(); + let log = ApplicationLog::new(directory.clone(), true); + record_browser_exit(&log, Some(0)); + record_browser_exit(&log, Some(1)); + let records = records(&directory); + assert!(records.contains("INFO webview_browser_exited")); + assert!(records.contains("ERROR webview_browser_exited")); + assert!(records.contains("kind: 1")); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn runtime_logging_keeps_version_and_excludes_unexpected_private_text() { + let directory = directory(); + let log = ApplicationLog::new(directory.clone(), true); + record_runtime(&log, "135.0.1.2"); + record_runtime(&log, "https://PRIVATE_USER/private-profile"); + let records = records(&directory); + assert!(records.contains("135.0.1.2")); + assert!(!records.contains("PRIVATE_USER")); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn opted_out_webview_events_create_no_files() { + let directory = directory(); + let log = ApplicationLog::new(directory.clone(), false); + record_process_failure(&log, Some(1), Some(0), Some(-1073741819)); + record_browser_exit(&log, Some(1)); + record_runtime(&log, "135.0.1.2"); + assert!(!directory.exists()); + } +} diff --git a/src-tauri/src/window_scale.rs b/src-tauri/src/window_scale.rs new file mode 100644 index 00000000..9928169c --- /dev/null +++ b/src-tauri/src/window_scale.rs @@ -0,0 +1,89 @@ +use std::sync::Mutex; + +use tauri::WindowEvent; + +pub(crate) fn install(window: &tauri::WebviewWindow) -> tauri::Result<()> { + let last_zoom = Mutex::new(None); + update(window, &last_zoom)?; + let webview = window.clone(); + window.on_window_event(move |event| { + if matches!( + event, + WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } + ) && let Err(error) = update(&webview, &last_zoom) + { + crate::application_log::failure("resize_interface", error); + } + }); + Ok(()) +} + +fn update(window: &tauri::WebviewWindow, last_zoom: &Mutex>) -> tauri::Result<()> { + let size = window.inner_size()?; + let Some(zoom) = zoom_for_size(size.width, size.height, window.scale_factor()?) else { + return Ok(()); + }; + let mut last_zoom = last_zoom.lock().map_err(|_| { + tauri::Error::Io(std::io::Error::other("Interface scaling state unavailable")) + })?; + if *last_zoom != Some(zoom) { + window.set_zoom(zoom)?; + *last_zoom = Some(zoom); + } + Ok(()) +} + +pub(crate) fn zoom_for_size(width: u32, height: u32, display_scale: f64) -> Option { + if width == 0 || height == 0 || !display_scale.is_finite() || display_scale <= 0.0 { + return None; + } + Some( + (f64::from(width) / display_scale / 1280.0) + .min(f64::from(height) / display_scale / 720.0) + .min(1.0), + ) +} + +#[cfg(test)] +mod tests { + use super::zoom_for_size; + + #[test] + fn compact_windows_preserve_the_full_layout() { + for (width, height, expected) in [ + (960, 540, 0.75), + (1120, 630, 0.875), + (1280, 720, 1.0), + (1920, 1080, 1.0), + ] { + let zoom = zoom_for_size(width, height, 1.0).unwrap(); + assert_eq!(zoom, expected); + assert!(f64::from(width) / zoom >= 1280.0); + assert!(f64::from(height) / zoom >= 720.0); + } + } + + #[test] + fn either_dimension_can_limit_the_scale() { + assert_eq!(zoom_for_size(1280, 540, 1.0), Some(0.75)); + assert_eq!(zoom_for_size(960, 900, 1.0), Some(0.75)); + } + + #[test] + fn display_scaling_is_applied_once() { + assert_eq!(zoom_for_size(1200, 675, 1.25), Some(0.75)); + assert_eq!(zoom_for_size(1920, 1080, 1.5), Some(1.0)); + } + + #[test] + fn minimized_or_invalid_sizes_do_not_change_zoom() { + for (width, height, scale) in [ + (0, 0, 1.0), + (960, 0, 1.0), + (960, 540, 0.0), + (960, 540, f64::NAN), + ] { + assert_eq!(zoom_for_size(width, height, scale), None); + } + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index da889f4c..f6e5207c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,10 +13,10 @@ { "label": "main", "title": "Legio Launcher", - "width": 960, - "height": 640, - "minWidth": 720, - "minHeight": 480, + "width": 1280, + "height": 720, + "minWidth": 960, + "minHeight": 540, "resizable": true, "fullscreen": false, "decorations": false, diff --git a/src/App.svelte b/src/App.svelte index 9f20109d..f24385e8 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -4,6 +4,7 @@ import { appearancePreview } from "./lib/stores/appearance"; import { appInfo } from "./lib/stores/app-info"; import AppBackground from "./lib/components/layout/AppBackground.svelte"; + import { startArtworkSelectionTracking } from "./lib/services/artwork-work"; import { onMount } from "svelte"; import { derived } from "svelte/store"; import Sidebar from "./lib/components/layout/Sidebar.svelte"; @@ -21,6 +22,7 @@ import { hasRunningGame } from "./lib/stores/launch"; import { observeMediaPlayback } from "./lib/services/media-playback"; import { startArtworkDisplayTracking } from "./lib/services/artwork-display"; + import { applicationErrorStack, describeApplicationError, logApplicationEvent } from "./lib/services/application-log"; const mediaActive = derived([windowVisible, hasRunningGame], ([$visible, $running]) => $visible && !$running); const appearance = $derived($appearancePreview?.appearance ?? $settings.data.appearance); @@ -38,9 +40,15 @@ }); onMount(() => { + const stopArtworkSelection = startArtworkSelectionTracking(); const stopArtworkDisplays = startArtworkDisplayTracking(); const stopMedia = observeMediaPlayback(mediaActive.subscribe); - void hydrateApp().catch((reason) => console.error(reason)); + void hydrateApp().then(() => { + logApplicationEvent("info", "renderer_ready", "Application resources loaded"); + }).catch(reason => { + logApplicationEvent("error", "bootstrap_error", describeApplicationError(reason), applicationErrorStack(reason)); + console.error("Application resource initialization failed", reason); + }); if (import.meta.env.PROD) void checkForAppUpdate(); const media = window.matchMedia("(prefers-color-scheme: dark)"); @@ -51,6 +59,7 @@ media.addEventListener("change", updateSystemTheme); return () => { + stopArtworkSelection(); stopArtworkDisplays(); stopMedia(); media.removeEventListener("change", updateSystemTheme); @@ -79,9 +88,9 @@ /> -
+
{#if $settings.status === "ready" && $settings.data.onboardingCompleted}{/if} -
+
{#if $settings.status === "error" || $appInfo.status === "error"} void hydrateApp()} /> diff --git a/src/app.css b/src/app.css index 7a68b054..4f371f41 100644 --- a/src/app.css +++ b/src/app.css @@ -53,7 +53,7 @@ body { font-family: "Inter", system-ui, sans-serif; } -html, body, #app { min-height: 100%; } +html, body, #app { width: 100%; height: 100%; min-width: 0; min-height: 0; } :root { --legio-background: #000000; --legio-surface: #18181b; @@ -88,23 +88,11 @@ html, body, #app { min-height: 100%; } transition: transform 300ms ease-out; } .group:hover > .legio-artwork-zoom { transform: translateZ(0) scale(1.05); } -::view-transition-group(root) { animation: none; } -::view-transition-image-pair(root) { isolation: isolate; } -::view-transition-old(root) { - animation: legio-page-out 210ms linear both; - mix-blend-mode: plus-lighter; -} -::view-transition-new(root) { - animation: legio-page-in 210ms linear both; - mix-blend-mode: plus-lighter; -} -@keyframes legio-page-in { from { opacity: 0; } to { opacity: 1; } } -@keyframes legio-page-out { from { opacity: 1; } to { opacity: 0; } } dialog.legio-dialog { opacity: 1; border-color: transparent; box-shadow: none; - transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease, -webkit-backdrop-filter 180ms ease, backdrop-filter 180ms ease; + transition: border-color 180ms ease, box-shadow 180ms ease; } dialog.legio-dialog[data-visible="true"] { border-color: rgb(255 255 255 / 0.15); @@ -116,7 +104,6 @@ dialog.legio-dialog[data-visible="true"] { dialog.legio-dialog > .legio-dialog-content { opacity: 0; transition: opacity 180ms ease; - will-change: opacity; } dialog.legio-dialog[data-visible="true"] > .legio-dialog-content { opacity: 1; @@ -124,7 +111,6 @@ dialog.legio-dialog[data-visible="true"] > .legio-dialog-content { dialog.legio-dialog::backdrop { opacity: 0; transition: opacity 180ms ease; - will-change: opacity; } dialog.legio-dialog[data-visible="true"]::backdrop { opacity: 1; } .legio-toast { @@ -142,7 +128,11 @@ dialog.legio-dialog[data-visible="true"]::backdrop { opacity: 1; } -webkit-backdrop-filter: none; backdrop-filter: none; } -dialog.legio-dialog[data-visible="true"] { +dialog.legio-dialog > .legio-dialog-surface { + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; background-color: color-mix(in srgb, var(--legio-surface) calc(var(--legio-dialog-opacity) * 100%), transparent); -webkit-backdrop-filter: var(--legio-dialog-blur); backdrop-filter: var(--legio-dialog-blur); diff --git a/src/lib/components/layout/DitherBackground.svelte b/src/lib/components/layout/DitherBackground.svelte index 52645c36..d9dd8adc 100644 --- a/src/lib/components/layout/DitherBackground.svelte +++ b/src/lib/components/layout/DitherBackground.svelte @@ -1,7 +1,8 @@ -