From 609e014315449cce21418aee25528aad2a0450af Mon Sep 17 00:00:00 2001 From: fraa2a <123754424+fraa2a@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:09:48 +0200 Subject: [PATCH 01/25] feat: add store filters and prepare 0.2.0 release --- .github/workflows/ci.yml | 39 ++ docs/releases/0.2.0-windows-test.md | 44 ++ .../screenshots/store-availability.jpg | Bin 0 -> 19593 bytes docs/releases/screenshots/store-sort.jpg | Bin 0 -> 20499 bytes package.json | 1 + scripts/test_catalog.mjs | 142 ++++++ scripts/test_store_filters.mjs | 78 ++++ src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/catalog.rs | 422 +++++++++++++++--- src-tauri/src/commands.rs | 6 +- src-tauri/src/manual_import.rs | 6 +- src/lib/features/store/StoreView.svelte | 57 ++- src/lib/i18n/en.json | 9 + src/lib/services/catalog.ts | 49 +- src/lib/stores/catalog.ts | 32 +- 16 files changed, 785 insertions(+), 104 deletions(-) create mode 100644 docs/releases/0.2.0-windows-test.md create mode 100644 docs/releases/screenshots/store-availability.jpg create mode 100644 docs/releases/screenshots/store-sort.jpg create mode 100644 scripts/test_catalog.mjs create mode 100644 scripts/test_store_filters.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f0a5d20..6493883a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,9 @@ jobs: - 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 @@ -247,5 +250,41 @@ 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 + 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 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..42c3d6d7 --- /dev/null +++ b/docs/releases/0.2.0-windows-test.md @@ -0,0 +1,44 @@ +# Legio 0.2.0 Windows acceptance test + +The release PR adds store sorting and availability filters. 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. +- 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. + +## 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. diff --git a/docs/releases/screenshots/store-availability.jpg b/docs/releases/screenshots/store-availability.jpg new file mode 100644 index 0000000000000000000000000000000000000000..be5e117328bfd2412f5af512c246656b8fc0aa0b GIT binary patch literal 19593 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 literal 0 HcmV?d00001 diff --git a/docs/releases/screenshots/store-sort.jpg b/docs/releases/screenshots/store-sort.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6ae67f50e6c9aac66a91bf5ed13c924c7c9fed89 GIT binary patch literal 20499 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 fZwUN`z;6irhQMzK{D#192>gb?|7!@O?~nX ({ + 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_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/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8754999d..c9d1afe2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2451,7 +2451,7 @@ dependencies = [ [[package]] name = "legio" -version = "0.1.5" +version = "0.2.0" dependencies = [ "base64 0.23.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 42ed69fb..35307ce2 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" 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..4fea468c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -724,8 +724,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,8 +736,9 @@ 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] 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/lib/features/store/StoreView.svelte b/src/lib/features/store/StoreView.svelte index d793669b..d7142a6f 100644 --- a/src/lib/features/store/StoreView.svelte +++ b/src/lib/features/store/StoreView.svelte @@ -1,10 +1,11 @@ @@ -78,6 +74,31 @@ {#if $source.data.warning}

{$source.data.warning}

{/if} + {#if $catalog.error && $catalog.status !== "error"} + void runCatalogSearch($storeQuery, options, true)} + retryLabel={t("Riprova", $language)} /> + {/if} + +
+ { sort = value as CatalogSort; }} /> + { availability = value as CatalogAvailability; }} /> +
+ {#if trimmed.length > 0 && $catalog.results.length > 0}
{#if $catalog.refreshing} @@ -95,7 +116,7 @@ loadingMessage={t("Ricerca in corso...", $language)} {emptyMessage} error={$catalog.error} - onRetry={() => void runCatalogSearch($storeQuery)} + onRetry={() => void runCatalogSearch($storeQuery, options, true)} /> {#if entries.length > 0} diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index b5a8c2c7..947593be 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -1,4 +1,13 @@ { + "Ordina per": "Sort by", + "Più affine": "Most relevant", + "Alfabetico crescente (A-Z)": "Alphabetical ascending (A-Z)", + "Alfabetico decrescente (Z-A)": "Alphabetical descending (Z-A)", + "Disponibilità": "Availability", + "Tutti": "All", + "Solo disponibili": "Available only", + "Solo verified": "Verified only", + "Nessun gioco corrisponde ai filtri selezionati.": "No games match the selected filters.", "Gioco aggiunto. Avviso sull'icona: {0}": "Game added. Icon warning: {0}", "Avanzate": "Advanced", "Log e diagnostica": "Logs and diagnostics", diff --git a/src/lib/services/catalog.ts b/src/lib/services/catalog.ts index 7dffdbd4..26382560 100644 --- a/src/lib/services/catalog.ts +++ b/src/lib/services/catalog.ts @@ -1,5 +1,14 @@ import { invoke } from "@tauri-apps/api/core"; +export type CatalogSort = "relevance" | "name_asc" | "name_desc"; +export type CatalogAvailability = "all" | "available" | "verified"; +export interface CatalogOptions { + sort: CatalogSort; + availability: CatalogAvailability; +} + +const defaults: CatalogOptions = { sort: "relevance", availability: "all" }; + export interface CatalogGame { steamAppId: number; name: string; @@ -16,46 +25,56 @@ export interface CatalogSearch { sourceStale: boolean; } -export function searchCatalog(query: string, limit?: number): Promise { - return invoke("search_catalog", { query, limit }); +export function searchCatalog(query: string, limit?: number, options: CatalogOptions = defaults): Promise { + return invoke("search_catalog", { query, limit, options }); } -export function refreshCatalog(query: string, skip = 0, limit?: number): Promise { - return invoke("refresh_catalog", { query, skip, limit }); +export function refreshCatalog(query: string, skip = 0, limit?: number, options: CatalogOptions = defaults): Promise { + return invoke("refresh_catalog", { query, skip, limit, options }); } const recent = new Map(); const pending = new Map>(); const maxAge = 24 * 60 * 60 * 1000; +let generation = 0; + +function cacheKey(query: string, options: CatalogOptions): string { + return JSON.stringify([query.trim().toLocaleLowerCase(), options.sort, options.availability]); +} export function invalidateCatalogAvailability(): void { + generation++; recent.clear(); + pending.clear(); } -export function cachedCatalogSearch(query: string): CatalogSearch | null { - const entry = recent.get(query.trim().toLocaleLowerCase()); +export function cachedCatalogSearch(query: string, options: CatalogOptions = defaults): CatalogSearch | null { + const entry = recent.get(cacheKey(query, options)); return entry !== undefined && Date.now() - entry.at < maxAge ? entry.result : null; } -export function refreshCatalogCached(query: string): Promise { - const key = query.trim().toLocaleLowerCase(); - const cached = cachedCatalogSearch(key); +export function refreshCatalogCached(query: string, options: CatalogOptions = defaults, forceRefresh = false): Promise { + const key = cacheKey(query, options); + const cached = forceRefresh ? null : cachedCatalogSearch(query, options); if (cached !== null) return Promise.resolve(cached); const active = pending.get(key); if (active !== undefined) return active; - const request = refreshCatalogPage(query, 0).finally(() => pending.delete(key)); + const request = refreshCatalogPage(query, 0, undefined, options).finally(() => { + if (pending.get(key) === request) pending.delete(key); + }); pending.set(key, request); return request; } -export async function refreshCatalogPage(query: string, skip: number, limit?: number): Promise { - const result = await refreshCatalog(query, skip, limit); - rememberCatalogSearch(query, result); +export async function refreshCatalogPage(query: string, skip: number, limit?: number, options: CatalogOptions = defaults): Promise { + const started = generation; + const result = await refreshCatalog(query, skip, limit, options); + if (started === generation) rememberCatalogSearch(query, result, options); return result; } -export function rememberCatalogSearch(query: string, result: CatalogSearch): void { - const key = query.trim().toLocaleLowerCase(); +export function rememberCatalogSearch(query: string, result: CatalogSearch, options: CatalogOptions = defaults): void { + const key = cacheKey(query, options); recent.delete(key); recent.set(key, { result, at: Date.now() }); if (recent.size > 100) { diff --git a/src/lib/stores/catalog.ts b/src/lib/stores/catalog.ts index 7521f2a0..dd6c5f88 100644 --- a/src/lib/stores/catalog.ts +++ b/src/lib/stores/catalog.ts @@ -1,5 +1,5 @@ import { get, writable } from "svelte/store"; -import { cachedCatalogSearch, refreshCatalogCached, refreshCatalogPage, rememberCatalogSearch, searchCatalog, type CatalogGame, type CatalogSearch } from "../services/catalog"; +import { cachedCatalogSearch, refreshCatalogCached, refreshCatalogPage, rememberCatalogSearch, searchCatalog, type CatalogGame, type CatalogSearch, type CatalogOptions } from "../services/catalog"; import { toMessage } from "../utils/errors"; import type { LoadStatus } from "./resource"; import { source } from "./source"; @@ -7,6 +7,7 @@ import { sourceStatusFor } from "../features/store/source-status"; interface CatalogState { query: string; + options: CatalogOptions; status: LoadStatus; results: CatalogGame[]; total: number; @@ -19,6 +20,7 @@ interface CatalogState { const initial: CatalogState = { query: "", + options: { sort: "relevance", availability: "all" }, status: "idle", results: [], total: 0, @@ -31,7 +33,14 @@ const initial: CatalogState = { export const catalog = writable(initial); +let requestId = 0; +let previousManifest = get(source).data.manifest; + source.subscribe(({ data }) => { + if (data.manifest !== previousManifest) { + previousManifest = data.manifest; + requestId++; + } catalog.update((state) => ({ ...state, results: state.results.map((game) => ({ ...game, availability: sourceStatusFor(data.manifest, game.steamAppId).availability })), @@ -39,8 +48,6 @@ source.subscribe(({ data }) => { })); }); -let requestId = 0; - function applySearch(state: CatalogState, search: CatalogSearch, refreshing: boolean): CatalogState { const snapshot = get(source).data; return { @@ -56,19 +63,20 @@ function applySearch(state: CatalogState, search: CatalogSearch, refreshing: boo }; } -export async function runCatalogSearch(query: string): Promise { +export async function runCatalogSearch(query: string, options: CatalogOptions = get(catalog).options, forceRefresh = false): Promise { const request = ++requestId; const trimmed = query.trim(); - const cached = cachedCatalogSearch(trimmed); + options = { ...options }; + const cached = forceRefresh ? null : cachedCatalogSearch(trimmed, options); if (cached !== null) { - catalog.update((state) => applySearch({ ...state, query: trimmed }, cached, false)); + catalog.update((state) => applySearch({ ...state, query: trimmed, options }, cached, false)); return; } - catalog.update(() => ({ ...initial, query: trimmed, status: "loading" })); + catalog.update(() => ({ ...initial, query: trimmed, options, status: "loading" })); let local: CatalogSearch; try { - local = await searchCatalog(trimmed); + local = await searchCatalog(trimmed, undefined, options); } catch (error) { if (request !== requestId) return; catalog.update((state) => ({ ...state, status: "error", refreshing: false, error: toMessage(error) })); @@ -78,7 +86,7 @@ export async function runCatalogSearch(query: string): Promise { catalog.update((state) => applySearch(state, local, true)); try { - const fresh = await refreshCatalogCached(trimmed); + const fresh = await refreshCatalogCached(trimmed, options, forceRefresh); if (request !== requestId) return; catalog.update((state) => applySearch(state, fresh, false)); } catch (error) { @@ -95,10 +103,10 @@ export async function loadMoreCatalog(query: string, limit: number): Promise current.results.length) { - result = { ...await searchCatalog(current.query, limit), nextOffset: current.nextOffset }; - if (request === requestId) rememberCatalogSearch(current.query, result); + result = { ...await searchCatalog(current.query, limit, current.options), nextOffset: current.nextOffset }; + if (request === requestId) rememberCatalogSearch(current.query, result, current.options); } else if (current.nextOffset !== null) { - result = await refreshCatalogPage(current.query, current.nextOffset, limit); + result = await refreshCatalogPage(current.query, current.nextOffset, limit, current.options); } if (request !== requestId) return; if (result !== null) catalog.update((state) => applySearch(state, result, false)); From 744200a4a729a3b063974abe8e868deeb600a15c Mon Sep 17 00:00:00 2001 From: fraa2a <123754424+fraa2a@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:26:17 +0200 Subject: [PATCH 02/25] feat: add opt-in application diagnostics and responsive layout fixes --- .github/workflows/ci.yml | 8 + docs/releases/0.2.0-windows-test.md | 19 +- .../screenshots/application-logging.png | Bin 0 -> 40562 bytes docs/releases/screenshots/home-fullscreen.png | Bin 0 -> 39643 bytes docs/releases/screenshots/home-minimum.png | Bin 0 -> 50897 bytes package.json | 1 + scripts/test_application_logging.mjs | 246 +++++ scripts/test_steam_image_cache.mjs | 2 + scripts/test_visual_rendering.mjs | 2 + src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 2 + src-tauri/build.rs | 2 + src-tauri/permissions/main.toml | 2 + src-tauri/src/application_log.rs | 870 ++++++++++++++++++ src-tauri/src/artwork_display.rs | 2 +- src-tauri/src/commands.rs | 29 +- src-tauri/src/compatibility_options.rs | 2 +- src-tauri/src/database.rs | 41 +- src-tauri/src/diagnostics.rs | 12 + src-tauri/src/discord_presence.rs | 2 +- src-tauri/src/download_queue.rs | 32 +- src-tauri/src/finalize_install.rs | 9 +- src-tauri/src/launch_journal.rs | 2 +- src-tauri/src/lib.rs | 65 +- src-tauri/src/native_rendering.rs | 2 +- src-tauri/src/settings.rs | 2 + src-tauri/src/startup_recovery.rs | 2 +- src-tauri/src/steam_assets.rs | 8 +- src-tauri/src/steam_pics.rs | 8 +- src-tauri/tauri.conf.json | 4 +- src/App.svelte | 8 +- .../components/layout/MainContainer.svelte | 10 + src/lib/components/layout/Sidebar.svelte | 6 +- src/lib/features/home/ActivityCalendar.svelte | 6 +- src/lib/features/home/HomeView.svelte | 10 +- src/lib/features/library/GameIcon.svelte | 4 +- .../features/settings/AdvancedSettings.svelte | 18 +- src/lib/i18n/en.json | 6 +- src/lib/services/app-updater.ts | 2 +- src/lib/services/app.ts | 2 +- src/lib/services/appearance.ts | 2 +- src/lib/services/application-log.ts | 90 ++ src/lib/services/artwork-display.ts | 2 +- src/lib/services/catalog.ts | 2 +- src/lib/services/downloads.ts | 2 +- src/lib/services/game-artwork.ts | 2 +- src/lib/services/game-settings.ts | 2 +- src/lib/services/invoke.ts | 11 + src/lib/services/launch.ts | 2 +- src/lib/services/legio-source.ts | 2 +- src/lib/services/local-state.ts | 3 +- src/lib/services/manual-import.ts | 2 +- src/lib/services/network.ts | 2 +- src/lib/services/news.ts | 2 +- src/lib/services/playtime.ts | 2 +- src/lib/services/steam-accounts.ts | 2 +- src/lib/services/steam-details.ts | 2 +- src/lib/services/steam.ts | 2 +- src/lib/stores/library-artwork.ts | 2 +- src/lib/stores/settings.ts | 6 +- src/main.ts | 18 +- 61 files changed, 1511 insertions(+), 99 deletions(-) create mode 100644 docs/releases/screenshots/application-logging.png create mode 100644 docs/releases/screenshots/home-fullscreen.png create mode 100644 docs/releases/screenshots/home-minimum.png create mode 100644 scripts/test_application_logging.mjs create mode 100644 src-tauri/src/application_log.rs create mode 100644 src/lib/services/application-log.ts create mode 100644 src/lib/services/invoke.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6493883a..1a70434e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,9 @@ 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 @@ -214,6 +217,11 @@ 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 + run: 'cargo test --manifest-path src-tauri/Cargo.toml --locked --lib application_log::tests::' + - name: Test Windows Steam discovery if: runner.os == 'Windows' shell: bash diff --git a/docs/releases/0.2.0-windows-test.md b/docs/releases/0.2.0-windows-test.md index 42c3d6d7..ab847aed 100644 --- a/docs/releases/0.2.0-windows-test.md +++ b/docs/releases/0.2.0-windows-test.md @@ -1,6 +1,6 @@ # Legio 0.2.0 Windows acceptance test -The release PR adds store sorting and availability filters. The Windows CI job produces an NSIS installer before merge. Keep the PR open until the Windows acceptance test succeeds. +The release PR adds store sorting and availability filters, optional application logging, 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 @@ -30,9 +30,26 @@ This installer uses the production application profile. Back up an existing Legi ## Windows regression checks - Start and restart the installed app; confirm settings and library persist. +- Open the library repeatedly, with an empty library and existing games, in both windowed and maximized modes. 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 960x640 logical pixels. Test at the minimum, maximized, and with Windows display scaling enabled. +- 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.jsonl` 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.jsonl` 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.jsonl` and `app.previous.jsonl`, 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.jsonl` plus `app.previous.jsonl` 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. OS faults, forced termination and native WebView crashes may leave only the last application checkpoint. ## Release sequence diff --git a/docs/releases/screenshots/application-logging.png b/docs/releases/screenshots/application-logging.png new file mode 100644 index 0000000000000000000000000000000000000000..010742c819f3dea11a8513ac1182389f0b596590 GIT binary patch literal 40562 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

eD*JR}sI%ux%{%?B}C-Z@P-A8C3+RV#?c&& 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 S5?sTAGz(bZ&4u>=Zvp@@h(0y| literal 0 HcmV?d00001 diff --git a/docs/releases/screenshots/home-fullscreen.png b/docs/releases/screenshots/home-fullscreen.png new file mode 100644 index 0000000000000000000000000000000000000000..a384cd6c6bc21803c828ecbabaff790c4002aca1 GIT binary patch literal 39643 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

~M+e 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Ð-u@wG2%kIGam$2u&__ zY;UoH1FowuIMg1pkg=RQK@g7b$hz#rnK|6Yjt`MO(5(|zj-~mV%Y=Q+^TJJf1>Ype zbeG&nLNzG(j!_%ztPLxyfSDm$TIKk~kAeFslMTip)&bs8j}MiU)M=qn_UL>xPg%e^ zcx$ch|8vLBU3u(mt=FJHloPWgNEVFh%8QjjHhT{U8x|_+7xeW6k<_$@!2OrRbh?_l z*Y$uRXAent^VICbfoiT_r|`0cKQ(tW()~5a-cl`l$WS+?w6v#Yo)P^J&M7FQ-;JvnfI7_41l_K#K!=$g zIF8UUK64CGR~APIW+ikN>S;s(SV~$Gv2}cvwD$5V1&Be}z}V9)Y!Q-|ev}J^*$ekn zz!d7620!t<@gf7pa4*pXi#|M*(bkp&A8-zjK_Su+)XuQm$faRgepSnnLFXE#^JO_& z%3pGQ8?>+*g!BZsZ)Wtep+P?_cQ}WxvyAVM&1t8_W4S&~GRRVnbg}LE_SsLcl46aM zDHze2efdL|AhKXjRxMqP#H#1o&g*MeaApNg9wv?AFHc&75nCOu3gDC{ib?o%I8o?EnR~^ z@r+{ zWnpJXa!7upc9*xmwTw9c_7fgK!H(nSwbw0%tuCYM^WzSV5f#{pW1X$7ERj--97g-y zIP{yl);ElQ#)o%%cL0lJUnLn|J%tb9!T{D-;Yo60B@X^&E~K83`YN8iqW$ z`73a8szUdqH zofe9Joc=xPf2=fA_A4&WqXx0V!5YM{d;6DW>ml}@|{1r36BT9tJNeaSgCpYjdJ z@83jExa>^TCB-vGTNVI3Ng?`xu%MF&bZMe8r~e!!|6PUid%pkP#`*uG{6V{4cl;k{ CjRaEw literal 0 HcmV?d00001 diff --git a/docs/releases/screenshots/home-minimum.png b/docs/releases/screenshots/home-minimum.png new file mode 100644 index 0000000000000000000000000000000000000000..d22bfe0cf63c5a0965f62a6fcec7747e919b30fd GIT binary patch literal 50897 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 DQ-3b$ literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 747d322c..cc4039e2 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", diff --git a/scripts/test_application_logging.mjs b/scripts/test_application_logging.mjs new file mode 100644 index 00000000..7f613565 --- /dev/null +++ b/scripts/test_application_logging.mjs @@ -0,0 +1,246 @@ +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; + +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 pageTransition = () => ({ duration: 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(); } +}); diff --git a/scripts/test_steam_image_cache.mjs b/scripts/test_steam_image_cache.mjs index b7c7183b..52e8a288 100644 --- a/scripts/test_steam_image_cache.mjs +++ b/scripts/test_steam_image_cache.mjs @@ -7,6 +7,8 @@ const { load } = createModuleLoader({ "src/lib/i18n": dataModule(`import { writable } from "${import.meta.resolve("svelte/store")}"; export const language = writable("en");`), }); 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 }); diff --git a/scripts/test_visual_rendering.mjs b/scripts/test_visual_rendering.mjs index 9865901c..7c2b90c5 100644 --- a/scripts/test_visual_rendering.mjs +++ b/scripts/test_visual_rendering.mjs @@ -74,6 +74,7 @@ test("navigation removes previous page content before the new page enters", asyn 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"); @@ -125,6 +126,7 @@ test("native page fades snapshot the outgoing page and update to the latest navi 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")); navigation.selectedGameId.set(null); navigation.activeSection.set("home"); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c9d1afe2..44e10a7a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2456,9 +2456,11 @@ dependencies = [ "base64 0.23.1", "chrono", "compress-tools", + "dirs", "gtk", "image", "libc", + "regex", "reqwest 0.13.5", "rusqlite", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 35307ce2..40df4687 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" 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..47b644f9 --- /dev/null +++ b/src-tauri/src/application_log.rs @@ -0,0 +1,870 @@ +use std::{ + fs::{self, OpenOptions}, + io::{self, Write}, + path::{Path, PathBuf}, + sync::{Arc, Mutex, OnceLock, TryLockError}, + 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, +} + +#[derive(Serialize)] +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, +} + +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", + } + } +} + +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, + })), + session: uuid::Uuid::new_v4().to_string().into(), + }; + log.set_enabled(enabled); + log + } + + pub(crate) fn status(&self) -> LogStatus { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .status + .clone() + } + + 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; + 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( + &mut state, + level.as_str(), + event.as_str(), + message, + stack, + false, + ); + Ok(()) + } + + 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(&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( + &mut state, + "error", + "panic", + Some(payload), + Some(&format!( + "{location}\n{}", + std::backtrace::Backtrace::force_capture() + )), + true, + ); + } + + fn write( + &self, + 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: &self.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.jsonl", "app.previous.jsonl"] { + 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.jsonl"); + let mut line = serde_json::to_vec(record)?; + line.push(b'\n'); + let mut file = open_log(&path)?; + if file.metadata()?.len().saturating_add(line.len() as u64) > MAX_LOG_BYTES { + drop(file); + let previous = directory.join("app.previous.jsonl"); + 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(&line)?; + if durable { + file.sync_data()?; + } + Ok(()) +} + +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, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn directory() -> PathBuf { + std::env::temp_dir().join(format!("legio-app-log-{}", uuid::Uuid::new_v4())) + } + + #[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.jsonl")).unwrap(); + log.set_enabled(false); + log.record("error", "panic", Some("failure"), None, true); + assert_eq!(fs::read(directory.join("app.jsonl")).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.jsonl")).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.jsonl")).unwrap(); + let record: serde_json::Value = + serde_json::from_str(text.lines().last().unwrap()).unwrap(); + assert_eq!(record["event"], "renderer_error"); + let sanitized = record["message"].as_str().unwrap(); + 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.jsonl").exists()); + assert_eq!(fs::read_dir(&directory).unwrap().count(), 2); + for name in ["app.jsonl", "app.previous.jsonl"] { + let path = directory.join(name); + assert!(fs::metadata(&path).unwrap().len() <= 256 * 1024); + for line in fs::read_to_string(path).unwrap().lines() { + let value: serde_json::Value = serde_json::from_str(line).unwrap(); + assert_eq!(value["event"], "rust_failure"); + } + } + 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.jsonl")).unwrap(); + let record: serde_json::Value = serde_json::from_str(text.trim()).unwrap(); + let message = record["message"].as_str().unwrap(); + 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.jsonl")).unwrap(); + let record: serde_json::Value = + serde_json::from_str(text.lines().last().unwrap()).unwrap(); + let message = record["message"].as_str().unwrap(); + 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.jsonl")).unwrap(); + let record: serde_json::Value = serde_json::from_str(text.trim()).unwrap(); + let stack = record["stack"].as_str().unwrap(); + 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.jsonl")).unwrap(); + let value: serde_json::Value = serde_json::from_str(text.trim()).unwrap(); + assert_eq!(value["event"], "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.jsonl").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.jsonl")).unwrap(); + assert_eq!(text.lines().count(), 100); + fs::remove_dir_all(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.jsonl", "app.previous.jsonl"] { + 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.jsonl")).unwrap(); + let record: serde_json::Value = serde_json::from_str(text.trim()).unwrap(); + assert_eq!(record["event"], "startup"); + assert!(!directory.join("app.previous.jsonl").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(); + let deadline = Instant::now() + Duration::from_secs(10); + 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 hook deadlocked while the log writer was 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.jsonl")).unwrap(); + let record: serde_json::Value = serde_json::from_str(text.trim()).unwrap(); + assert_eq!(record["event"], "panic"); + assert!(!text.contains("privatecredential")); + assert!( + record["stack"] + .as_str() + .unwrap() + .contains("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/commands.rs b/src-tauri/src/commands.rs index 4fea468c..1bdf26b3 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] 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..2fde1e40 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, } } @@ -1466,7 +1469,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 +1939,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/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..c01e0112 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ use tauri::{ mod app_profile; mod appearance; +mod application_log; pub mod archive_install; mod artwork_display; mod catalog; @@ -72,11 +73,29 @@ mod steam_switch; mod steam_vdf; 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 +107,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 +122,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()) @@ -128,7 +148,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 +159,8 @@ pub fn run() -> tauri::Result<()> { .database()? .settings() .map_err(std::io::Error::other)?; + app.state::() + .set_enabled(initial_settings.application_logging_enabled); let bandwidth_limit = database .database() .and_then(database::Database::download_bandwidth_limit) @@ -181,10 +203,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,7 +222,7 @@ 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); @@ -260,7 +282,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 +305,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 +328,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 +391,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, @@ -395,6 +426,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 +439,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 +457,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/native_rendering.rs b/src-tauri/src/native_rendering.rs index e8519a00..15818e26 100644 --- a/src-tauri/src/native_rendering.rs +++ b/src-tauri/src/native_rendering.rs @@ -41,7 +41,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); } } 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..406e9c60 100644 --- a/src-tauri/src/steam_assets.rs +++ b/src-tauri/src/steam_assets.rs @@ -318,7 +318,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}")); } @@ -475,7 +475,7 @@ 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)) } }; @@ -685,7 +685,7 @@ pub async fn get_asset( }); } Ok(None) => {} - Err(error) => eprintln!("Steam image cache read failed: {error}"), + Err(error) => crate::application_log::failure("read_image_cache", error), } } if matches!(asset, AssetKind::HeroBlur) { @@ -785,7 +785,7 @@ pub async fn get_asset( crate::steam_pics::resolve(path, &metadata_lock, crate::steam_pics::fetch(app_id)) .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())?) diff --git a/src-tauri/src/steam_pics.rs b/src-tauri/src/steam_pics.rs index 444f6d52..750f93f3 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}")); } @@ -280,8 +280,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 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index da889f4c..f49c2bee 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -15,8 +15,8 @@ "title": "Legio Launcher", "width": 960, "height": 640, - "minWidth": 720, - "minHeight": 480, + "minWidth": 960, + "minHeight": 640, "resizable": true, "fullscreen": false, "decorations": false, diff --git a/src/App.svelte b/src/App.svelte index 9f20109d..be790437 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -21,6 +21,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); @@ -40,7 +41,12 @@ onMount(() => { 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)"); diff --git a/src/lib/components/layout/MainContainer.svelte b/src/lib/components/layout/MainContainer.svelte index 402c1c1a..5e2548ac 100644 --- a/src/lib/components/layout/MainContainer.svelte +++ b/src/lib/components/layout/MainContainer.svelte @@ -7,6 +7,7 @@ import StoreView from "../../features/store/StoreView.svelte"; import { activeSection, selectedGameId } from "../../stores/navigation"; import { pageTransition, reducedMotion } from "../../utils/motion"; + import { logApplicationEvent } from "../../services/application-log"; const gamePrefix = "library:"; @@ -24,12 +25,21 @@ transition?.skipTransition(); }); + function pageName(value: string): string { + return value.startsWith(gamePrefix) ? "game_details" : value; + } + + $effect(() => { + logApplicationEvent("info", "navigation", `Displayed: ${pageName(displayedTarget)}`); + }); + $effect(() => { const next = target; if (next === untrack(() => displayedTarget)) { transition?.skipTransition(); return; } + logApplicationEvent("info", "navigation", `Requested: ${pageName(next)}; transition: ${nativeTransitions && !reducedMotion ? "native" : "fade"}`); transition?.skipTransition(); if (!nativeTransitions || reducedMotion || !mounted) { displayedTarget = next; diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index e1e5741c..1735b6fe 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -58,7 +58,7 @@ ? "w-[13.5rem]" : "w-16"} transition-[width] duration-300 ease-out" > -