The six built-in library scanners become plugins — M6/WP6.4 #199

Merged
enricobuehler merged 3 commits from worktree-remove-builtin-scanners into main 2026-08-13 15:19:44 +00:00
24 changed files with 411 additions and 2429 deletions
+47
View File
@@ -14,6 +14,53 @@ with the version table of the release you are moving to, then read **Breaking ch
## v0.27.1 — in development
### The six built-in library scanners are gone — every game source is a plugin (⚠ operator-visible)
The host no longer scans any launcher itself. `library/{steam,epic,gog,heroic,lutris,xbox}.rs` and
the `scanner_defs()` table are deleted; `GET /library/scanners` now lists exactly what the operator
has installed, and every row reports `origin: "plugin"`. This is M6/WP6.4, the end of the migration
whose bridge half shipped in v0.26.0 — the plugins have been published and index-pinned since
2026-08-08.
**A host with no library plugins installed has an empty grid.** That is the upgrade note: the
console's Library page offers one-click install per source (the D9 nudge, still there and still
never auto-installing), and nothing about a title changes when its plugin takes over.
Why that last part is true, and why this was safe to do as a deletion rather than a rewrite: a
plugin **claims** its store (D2), and a claimed entry surfaces under the deterministic
`<store>:<external_id>` id the scanner used to produce. Entry ids, GameStream FNV-1a app ids,
client-side art caches, Moonlight pins, the operator's per-source toggles and their per-entry hides
are all keyed on that id and none of them move. `library-scanners.json` keeps its name, its shape
and its contents — an operator who had `steam` switched off still has it switched off, with no
migration step.
What survives the scanners, deliberately:
- **`launch.rs` in full.** Launch is host-owned by design D1 — a plugin publishes a validated
*value* and the host builds the command — so every typed kind (`steam_appid`, `steam_ui`,
`launcher_ui`, `epic`, `gog`, `aumid`, `xbox`, `lutris_id`, `playnite`) stays exactly as it was.
`xbox_pfn()` moved here from the deleted `xbox.rs`: resolving a package Identity to its
PackageFamilyName needs `AppRepository` enumeration, which is readable by the host (LocalSystem)
and denied to the plugin runner (LocalService), and that measured asymmetry is the entire reason
the `xbox` launch kind exists.
- **`SourceOrigin::Builtin`.** No host build emits it, but the web console ships as its own package
and is expected to drive an N-1 host that still does, so the variant stays in the schema and the
console keeps its `builtin` handling.
- **The store-label table.** Six ids keep their display names (`steam` → "Steam", …) so a source row
does not rename itself to a bare id the day its plugin takes over.
Removed with them: the background cover-art warmer and its on-disk cache (they existed only for the
GOG and Xbox scanners, the two sources that had to ask a network catalog what a cover was — a
plugin resolves art while it scans), the legacy `steam:` branch of the art proxy, and the
`GameMeta::pc()` helper. **The host now makes no outbound HTTP request to build a library at all.**
**Dependency drop (packager-visible):** `rusqlite` (with its bundled, `cc`-compiled SQLite) and
`roxmltree` leave the host's dependency graph — they had no other users. `winreg` stays: `launch.rs`,
`procscan/windows.rs` and the two `audio/windows/` modules still need it. `base64`/`ureq` stay, as
the M6 plan predicted.
A stale `library-art-cache.json` from an older host is ignored, not migrated.
### GameStream is now opt-in on EVERY route (⚠ packager-visible default change)
The secure native-only host is the default everywhere; the Moonlight-compat planes (plain-HTTP
Generated
+1 -93
View File
@@ -1353,18 +1353,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastbloom"
version = "0.17.0"
@@ -1999,32 +1987,11 @@ dependencies = [
"zerocopy 0.8.56",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "hashlink"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "heck"
@@ -2195,7 +2162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"hashbrown",
"serde",
"serde_core",
]
@@ -2477,17 +2444,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "libsqlite3-sys"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libvpl-sys"
version = "0.27.0"
@@ -3688,9 +3644,7 @@ dependencies = [
"quinn",
"rand 0.9.5",
"rcgen",
"roxmltree",
"rsa",
"rusqlite",
"rustls",
"rusty_enet",
"semver",
@@ -4137,15 +4091,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "roxmltree"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]]
name = "rpkg-config"
version = "0.1.2"
@@ -4173,31 +4118,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rsqlite-vfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.20",
]
[[package]]
name = "rusqlite"
version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [
"bitflags 2.13.1",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -4769,18 +4689,6 @@ dependencies = [
"der",
]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "strsim"
version = "0.11.1"
+11 -11
View File
@@ -997,7 +997,7 @@
"library"
],
"summary": "List the game library",
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
"description": "Every title this host knows about, sorted by title: the entries each installed library plugin\nhas synced (Steam, Lutris, Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …) plus the user's\nown custom entries. Artwork fields are URLs the client fetches directly, except local files on\nthe host, which are rewritten to this API's own art proxy. `?provider=` narrows to the entries a\ngiven external provider owns; `?platform=` to one platform (case-insensitive — whatever the\nsource authored, conventionally `PC` for desktop stores).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
"operationId": "getLibrary",
"parameters": [
{
@@ -1052,7 +1052,7 @@
"library"
],
"summary": "Fetch one cover-art image for a library entry",
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URL convention second\n(newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case\nthis 404s and the client falls through to its next art candidate).",
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file; anything else 404s and\nthe client falls through to its next art candidate.\n\nThe host fetches nothing here. Art a plugin published as an `http(s)` URL is fetched by the\nclient directly — this proxy exists for the *local* files a plugin finds on the host's own disk\n(a launcher's cover cache), which a client has no way to read.",
"operationId": "getLibraryArt",
"parameters": [
{
@@ -1380,7 +1380,7 @@
"library"
],
"summary": "Replace a provider's library entries (declarative reconcile)",
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimately have zero installed titles).",
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what let a library plugin reproduce the entries the in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches, and is why\nremoving those scanners changed nothing downstream. One provider per store; a second claimant\ngets 409. The claim is released by `DELETE`, not by an empty reconcile (a store can legitimately\nhave zero installed titles).",
"operationId": "reconcileProviderEntries",
"parameters": [
{
@@ -1538,8 +1538,8 @@
"tags": [
"library"
],
"summary": "List the library scanners",
"description": "The installed-store scanners this host supports — the list is platform-dependent (Steam\neverywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console\nrenders a toggle only for scanners that can do anything here. Scanners default to enabled;\ndisabling one hides its titles from every library surface from the next read. The user-curated\ncustom store is not a scanner and is always on.",
"summary": "List the library sources",
"description": "Every game source on this host with its enable state — one row per installed library plugin\n(Steam, Lutris, Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …), so the list reflects what\nthe operator has actually installed rather than what this build happens to support. Sources\ndefault to enabled; disabling one hides its titles from every library surface from the next\nread. The user-curated custom store is not a source and is always on.\n\nOlder hosts (≤ v0.27.x) also listed the six scanners built into the host binary, with\n`origin: \"builtin\"`. Those are gone; every row now reports `origin: \"plugin\"`.",
"operationId": "listLibraryScanners",
"responses": {
"200": {
@@ -1573,8 +1573,8 @@
"tags": [
"library"
],
"summary": "Enable or disable a library scanner",
"description": "Persists the toggle and applies it from the next library read (no restart). Disabling a scanner\nhides its titles everywhere — the console grid, native clients, and the GameStream app list —\nand re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits\n`library.changed` with the scanner id as `source` when the state changed.",
"summary": "Enable or disable a library source",
"description": "Persists the toggle and applies it from the next library read (no restart). Disabling a source\nhides its titles everywhere — the console grid, native clients, and the GameStream app list —\nand re-enabling brings them straight back. Nothing is deleted: the plugin may keep reconciling\nwhile its source is off, and those entries simply aren't surfaced. Emits `library.changed` with\nthe source id as `source` when the state changed.",
"operationId": "setLibraryScanner",
"parameters": [
{
@@ -5397,7 +5397,7 @@
"string",
"null"
],
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
"description": "The external provider owning this entry (entries synced by a provider plugin, RFC §8) —\n`None` only for the manual entries the operator typed in. The console uses it for\nattribution; `GET /library?provider=` filters on it."
},
"role": {
"$ref": "#/components/schemas/GameRole",
@@ -7143,7 +7143,7 @@
},
"origin": {
"$ref": "#/components/schemas/SourceOrigin",
"description": "Where the source comes from: `builtin` (a scanner in this host build) or `plugin`."
"description": "Where the source comes from. Always `plugin` from this host build onward — see\n[`SourceOrigin`]."
},
"provider": {
"type": [
@@ -7163,7 +7163,7 @@
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether the scanner should run on this host."
"description": "Whether this source should contribute titles on this host."
}
}
},
@@ -7958,7 +7958,7 @@
},
{
"name": "library",
"description": "Game library: installed-store titles (Steam) plus user-curated custom entries"
"description": "Game library: the titles each installed library plugin syncs, plus user-curated custom entries"
},
{
"name": "stats",
+5 -9
View File
@@ -219,10 +219,6 @@ pipewire = "0.9"
# in the subsystem extraction, and each of those crates declares them itself — only the manifest
# entries were left behind, along with comments describing code this crate no longer contains.
# Verified unused before removal: zero `use`/path references across src/ + build.rs.
# Read the Lutris library DB (`pga.db`) for the Lutris store provider. `bundled` vendors + compiles
# SQLite (cc, already needed for ffmpeg/opus) so there's no system libsqlite3 runtime dependency —
# clean for the deb/rpm/flatpak packaging. Opened read-only/immutable (Lutris may hold it open).
rusqlite = { version = "0.40", features = ["bundled"] }
# `libcuda.so.1` is dlopen'd at runtime (NOT link-time) so one Linux binary runs on NVIDIA
# (zero-copy via CUDA) AND on AMD/Intel (VAAPI, no NVIDIA driver present) — see `zerocopy::cuda`.
libloading = "0.9"
@@ -319,12 +315,12 @@ windows = { version = "0.62", features = [
# kept a fourth windows-sys major compiling. 0.8.1 moves to `windows-sys 0.61`, which the tree
# already builds, and that duplicate is gone. (0.8.0 is NOT enough — it lands on 0.59.)
windows-service = "0.8"
# Read the GOG.com install registry (HKLM\SOFTWARE\WOW6432Node\GOG.com\Games) for the GOG store
# provider — ergonomic + correct-by-construction vs. hand-rolled Reg* FFI for subkey enumeration.
# Registry reads the host does on Windows — ergonomic + correct-by-construction vs. hand-rolled
# Reg* FFI for subkey enumeration. Survived the removal of the built-in store scanners (which is
# what first pulled it in): `library/launch.rs` resolves launcher install paths for the typed launch
# kinds a plugin publishes, `procscan/windows.rs` maps processes, and the two `audio/windows/`
# modules read and clean up device nodes.
winreg = "0.56"
# Parse each Xbox/Game-Pass game's MicrosoftGame.config (GDK manifest XML) for the Xbox store
# provider — a small read-only DOM is all we need (Identity/Executable/ShellVisuals/StoreId).
roxmltree = "0.21"
# WASAPI loopback audio capture (default render endpoint -> 48 kHz stereo f32 for the Opus path).
wasapi = "0.24"
# Shared host<->driver wire contract for the pf-vdisplay IddCx virtual-display backend: the
+4 -4
View File
@@ -44,8 +44,8 @@ fn parse_compositor(s: &str) -> Option<crate::vdisplay::Compositor> {
}
/// The GameStream catalog Moonlight sees in `/applist`: the operator base ([`base_catalog`] — Desktop +
/// apps.json) with the host's auto-detected game library ([`append_library`]) layered on top, so a
/// Moonlight client sees the same Steam/Epic/GOG/Xbox titles the native clients do instead of just Desktop.
/// apps.json) with the host's game library ([`append_library`]) layered on top, so a Moonlight client
/// sees the same titles the native clients do instead of just Desktop.
pub fn catalog() -> Vec<AppEntry> {
let mut apps = base_catalog();
append_library(&mut apps);
@@ -128,7 +128,7 @@ fn base_catalog() -> Vec<AppEntry> {
/// the small Desktop/apps.json ids so the two never collide.
const LIBRARY_ID_BASE: u32 = 0x4000_0000;
/// Append the host's installed game library ([`crate::library::all_games`] — Steam/Epic/GOG/Xbox/custom)
/// Append the host's game library ([`crate::library::all_games`] — every enabled source's titles)
/// to `apps`. Each title gets a STABLE GameStream `<ID>` derived from its store-qualified library id
/// (Moonlight caches appids, so a title keeps its id across host restarts), carries that library id so
/// the launch path resolves it against the host's own library, and is de-duplicated (by id) against the
@@ -251,7 +251,7 @@ mod tests {
/// pins that the claimed shape is that shape, and that an unclaimed one would NOT have been.
#[test]
fn a_claimed_plugin_entry_keeps_the_scanners_gamestream_id() {
// What the built-in scanner produced, and what the steam plugin produces once it claims.
// What the built-in scanner produced, and what the steam plugin produces now that it claims.
assert_eq!(stable_app_id("steam:440"), stable_app_id("steam:440"));
// The same title reconciled WITHOUT a claim gets an opaque `custom:` id — a different app
// id, i.e. exactly the breakage the claim prevents.
+42 -138
View File
@@ -1,15 +1,25 @@
//! Game library (plan: "surface the user's games"). A small adapter layer over the *stores*
//! installed on the host — today **Steam** (read from local files, no API key) and a
//! user-curated **custom** store (CRUD'd via the management API / web console). Every store
//! produces the same [`GameEntry`], so a client renders one uniform grid and never has to know
//! which launcher a title came from. Future stores (Heroic/Epic, GOG, Lutris, EmuDeck) are just
//! more [`LibraryProvider`]s.
//! Game library (plan: "surface the user's games"). One uniform [`GameEntry`] grid over every
//! source of titles on this host, so a client never has to know which launcher a title came from.
//!
//! Artwork is keyed only by Steam appid against the public Steam CDN (no auth) — the client
//! fetches the posters directly. Custom entries carry user-supplied art URLs.
//! **Every source is a plugin.** The host itself scans nothing: library plugins (Steam, Lutris,
//! Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …) reconcile their titles into the stored
//! catalog over the provider API, each claiming its store so its entries keep the stable
//! `<store>:<external_id>` ids everything downstream already pins (`custom.rs`, design D2). The
//! user-curated **custom** store — entries the operator typed in via the management API / web
//! console — lives in the same catalog and is the one source that is not a plugin.
//!
//! Until v0.28.0 the host also carried six built-in scanners that read the launchers' local files
//! directly. They were the bridge while the plugins were written; the plugins are the product now,
//! and the scanners are gone. What survives them is deliberate: the entry model here, the whole of
//! `launch.rs` (a plugin publishes a validated *value*, the host builds the command — design D1),
//! and the source toggles in `scanners.rs`, whose ids match the claims by construction so an
//! operator's disabled state carried across the extraction untouched.
//!
//! Artwork rides on the entries themselves — a plugin supplies URLs or local files, and the host's
//! art proxy serves the local ones ([`art`]) so a client never receives an unreachable `C:\…` path.
//!
//! This module is read-mostly metadata; *launching* a chosen title (mapping [`LaunchSpec`] onto a
//! gamescope session) is a later step — the launch hint is carried here so that wiring is trivial.
//! gamescope session) is `launch.rs`.
// Shared vocabulary re-exported to the submodules (each is `use super::*`).
pub(crate) use anyhow::{Context, Result};
@@ -23,40 +33,18 @@ pub(crate) use utoipa::ToSchema;
mod art;
mod custom;
mod detect;
#[cfg(windows)]
mod epic;
#[cfg(windows)]
mod gog;
#[cfg(target_os = "linux")]
mod heroic;
mod hidden;
mod launch;
#[cfg(target_os = "linux")]
mod lutris;
mod plugin_launch;
mod scanners;
mod steam;
#[cfg(windows)]
mod xbox;
pub use art::*;
pub use custom::*;
pub use detect::*;
#[cfg(windows)]
pub use epic::*;
#[cfg(windows)]
pub use gog::*;
#[cfg(target_os = "linux")]
pub use heroic::*;
pub use hidden::*;
pub use launch::*;
#[cfg(target_os = "linux")]
pub use lutris::*;
pub use plugin_launch::*;
pub use scanners::*;
pub use steam::*;
#[cfg(windows)]
pub use xbox::*;
/// Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for
/// custom). The client prefers `portrait` for a grid and falls back to `header` when a title has
@@ -129,17 +117,6 @@ pub struct GameMeta {
pub players: Option<u8>,
}
impl GameMeta {
/// The one field an installed-store scanner can assert about its own titles: they run on this
/// host, i.e. on a PC. Everything else stays absent (the launchers' local files don't carry it).
pub(crate) fn pc() -> Self {
GameMeta {
platform: Some("PC".into()),
..Default::default()
}
}
}
/// What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big
/// Picture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,
/// leases and lists exactly like a game (design D4), and clients that don't know the field render it
@@ -233,9 +210,9 @@ pub struct GameEntry {
/// How the host would launch it, when known.
#[serde(skip_serializing_if = "Option::is_none")]
pub launch: Option<LaunchSpec>,
/// The external provider owning this entry (custom-store entries synced by a provider
/// plugin, RFC §8) — `None` for installed-store titles and manual custom entries. The
/// console uses it for attribution; `GET /library?provider=` filters on it.
/// The external provider owning this entry (entries synced by a provider plugin, RFC §8) —
/// `None` only for the manual entries the operator typed in. The console uses it for
/// attribution; `GET /library?provider=` filters on it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// How to recognize this title's process(es) once it is running ([`DetectSpec`]) — filled in by
@@ -278,21 +255,8 @@ fn is_not_hidden(hidden: &bool) -> bool {
!*hidden
}
/// A store that contributes titles to the library. The trait is the extension point for future
/// launchers; today only [`SteamProvider`] implements it.
pub trait LibraryProvider {
/// Stable store id (`"steam"`, …).
fn store(&self) -> &'static str;
/// Enumerate installed/owned titles. Best-effort: returns empty (not an error) when the store
/// isn't present, so one missing launcher never fails the whole library.
fn list(&self) -> Vec<GameEntry>;
}
/// Steam art, keyed to one of the four [`Artwork`] fields. Newer/recently-updated titles serve
/// their CDN assets from a per-asset-hash path the client can't predict (e.g.
/// `.../apps/<id>/<hash>/header.jpg`), so the flat legacy URL [`steam_art`] guesses 404s for them —
/// [`steam_art_bytes`] is the robust resolver: local Steam cache (exact, no guessing) first, the
/// flat CDN URL as a fallback (still correct for the many titles that haven't been re-hashed).
/// Which of the four [`Artwork`] fields an art request names — the `<kind>` in
/// `GET /library/art/<id>/<kind>`, and the preference order the GameStream cover proxy walks.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArtKind {
Portrait,
@@ -311,51 +275,26 @@ impl ArtKind {
_ => None,
}
}
/// Filenames Steam itself caches this kind under in `appcache/librarycache/<appid>/<hash>/`,
/// tried in order (the 2x portrait, when present, is the sharper asset).
fn local_filenames(self) -> &'static [&'static str] {
match self {
Self::Portrait => &["library_600x900_2x.jpg", "library_600x900.jpg"],
Self::Hero => &["library_hero.jpg"],
Self::Logo => &["logo.png"],
// Steam's local cache names the header asset differently from the store CDN's
// `header.jpg` (see `cdn_filename`).
Self::Header => &["library_header.jpg"],
}
}
/// The legacy flat-URL filename on the public Steam CDN (works for any title the CDN hasn't
/// migrated to a per-asset hash path).
fn cdn_filename(self) -> &'static str {
match self {
Self::Portrait => "library_600x900.jpg",
Self::Hero => "library_hero.jpg",
Self::Logo => "logo.png",
Self::Header => "header.jpg",
}
}
}
/// The full library: every *enabled* source's titles merged + the custom entries, sorted by title.
/// The full library: every *enabled* source's titles, sorted by title.
///
/// Two independent gates run here, both at READ time so neither ever mutates stored state:
/// Two gates run here, both at READ time so neither ever mutates stored state:
///
/// * **The operator's source toggles** (`scanners.rs`, persisted as a disabled-set in
/// `library-scanners.json`) hide a source's titles from every surface — this grid, native clients,
/// `/applist`, and launch resolution. They apply to built-in scanners *and* to plugin sources,
/// which is what lets one toggle keep working verbatim across the whole migration: the ids match
/// (provider id = claimed store id = old scanner id).
/// * **Store claims** (D2): while a library plugin holds a store's claim, the matching built-in
/// scanner is skipped so the two never double-list the same titles during the bridge releases.
/// Removing the plugin releases the claim and the built-in comes straight back.
/// `/applist`, and launch resolution. The plugin may keep reconciling while its source is off; the
/// entries stay stored and simply aren't surfaced.
/// * **The operator's per-entry hides** (`hidden.rs`), applied here rather than at each call site so
/// a hidden title is gone from every surface by construction. [`all_games_for_operator`] is the
/// single deliberate exception.
///
/// The user-curated custom store is not a source and always contributes.
/// Manual custom entries — the ones the operator typed in — carry no source and always contribute.
///
/// A **third** gate rides on top of these two: the operator's per-entry hides (`hidden.rs`). It is
/// applied here rather than at each call site so a hidden title is gone from every surface by
/// construction — the grid, native clients, `/applist`, and launch resolution — exactly as a
/// disabled source's titles are. [`all_games_for_operator`] is the single deliberate exception.
/// There is no longer a third gate. Store claims used to suppress the built-in scanner a plugin had
/// taken over; with the built-ins gone there is nothing left to suppress, so a claim now only fixes
/// the ids a provider's entries surface under (`custom.rs::library_id_for`) and names its row in the
/// sources list.
pub fn all_games() -> Vec<GameEntry> {
let hidden = hidden_ids();
let mut games = collect_games();
@@ -385,47 +324,12 @@ pub fn all_games_for_operator() -> Vec<OperatorGameEntry> {
/// the hidden set, never in what they collect.
fn collect_games() -> Vec<GameEntry> {
let off = disabled_scanners();
let claimed = claimed_stores();
// A built-in scanner runs when the operator hasn't disabled it AND no plugin has claimed its
// store out from under it.
let on = |id: &str| !off.contains(id) && !claimed.contains_key(id);
let mut games = Vec::new();
if on("steam") {
games.extend(SteamProvider.list());
}
// The Lutris + Heroic providers are Linux-only (their launchers are); on other hosts the library
// is Steam + custom. Each provider is best-effort (empty when its store isn't present).
#[cfg(target_os = "linux")]
{
if on("lutris") {
games.extend(LutrisProvider.list());
}
if on("heroic") {
games.extend(HeroicProvider.list());
}
}
// Windows store providers (their launchers are Windows-only): Epic + GOG + Xbox/Game Pass.
#[cfg(windows)]
{
if on("epic") {
games.extend(EpicProvider.list());
}
if on("gog") {
games.extend(GogProvider.list());
}
if on("xbox") {
games.extend(XboxProvider.list());
}
}
// Stored entries: manual ones always contribute; a provider's are subject to the same source
// toggle a built-in scanner is (WP2.6). The plugin may keep reconciling while it is off — the
// entries stay stored and simply aren't surfaced, exactly like a disabled scanner's titles.
games.extend(
load_custom()
.into_iter()
.filter(|e| !source_id_for(e).is_some_and(|src| off.contains(src)))
.map(GameEntry::from),
);
// Manual entries always contribute; a provider's are subject to the operator's source toggle.
let mut games: Vec<GameEntry> = load_custom()
.into_iter()
.filter(|e| !source_id_for(e).is_some_and(|src| off.contains(src)))
.map(GameEntry::from)
.collect();
games.sort_by_key(|g| g.title.to_lowercase());
games
}
+28 -209
View File
@@ -1,109 +1,15 @@
//! Artwork cache + background warmer: the on-disk poster cache, the per-store fetchers, and the
//! Artwork serving: the local-file confinement rules, the art-proxy rewrite, and the
//! `fetch_box_art` dispatch the management art proxy serves from. Split out of the `library` facade (plan §W5).
//!
//! There is no art *cache* or background *warmer* here any more. Both existed for the built-in GOG
//! and Xbox scanners, the only two sources that had to reach a network catalog to learn what a
//! title's cover was; every other source carried its own art. Those scanners were removed in
//! v0.28.0, and the library plugins that replaced them resolve art while they scan and publish it on
//! the entry — so the host now only ever *serves* art it was handed, and never fetches any on its
//! own schedule. A stale `library-art-cache.json` left by an older host is simply ignored.
use super::*;
/// The persisted art cache: GameEntry id → resolved [`Artwork`]. An entry's PRESENCE means "already
/// resolved" (even an empty Artwork = fetched, none found) so the warmer never re-fetches it.
fn art_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Artwork>> {
static CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, Artwork>>,
> = std::sync::OnceLock::new();
CACHE.get_or_init(|| {
let loaded = std::fs::read_to_string(art_cache_path())
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
std::sync::Mutex::new(loaded)
})
}
/// The art cache lives in the canonical HOST config dir (`%ProgramData%\punktfunk` on Windows /
/// `~/.config/punktfunk` on Linux — `pf_paths::config_dir`, NOT the legacy XDG/HOME `config_dir`
/// below that the custom store still uses).
fn art_cache_path() -> PathBuf {
pf_paths::config_dir().join("library-art-cache.json")
}
/// The cached art for a library id, if it has been resolved (positive or negative). `None` = not yet
/// warmed → the provider shows title-only until the warmer fills it in.
pub(crate) fn cached_art(id: &str) -> Option<Artwork> {
art_cache().lock().unwrap().get(id).cloned()
}
/// Record resolved art for a library id + persist the cache (write-then-rename; best-effort).
fn store_art(id: &str, art: Artwork) {
let mut cache = art_cache().lock().unwrap();
cache.insert(id.to_string(), art);
if let Ok(json) = serde_json::to_string(&*cache) {
let path = art_cache_path();
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, json).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
}
/// Start the host-lifetime cover-art warmer: every few minutes, fetch + cache art for any library
/// entry whose store needs a network lookup (GOG / Xbox) and isn't cached yet. Idempotent — once
/// everything is cached a pass makes no network calls (and a host with only self-art stores never
/// fetches at all). Call once from `serve()`; the returned handle can be dropped to detach it.
pub fn start_art_warmer() -> std::thread::JoinHandle<()> {
std::thread::Builder::new()
.name("pf-art-warmer".into())
.spawn(|| loop {
warm_art_once();
std::thread::sleep(std::time::Duration::from_secs(300));
})
.expect("spawn art warmer thread")
}
/// One warming pass: resolve uncached GOG/Xbox art. Other stores carry their own art (Steam CDN
/// template, Heroic CDN URLs, Lutris data: URLs, custom user URLs) and are skipped.
fn warm_art_once() {
for g in all_games() {
if cached_art(&g.id).is_some() {
continue;
}
let Some((store, localid)) = g.id.split_once(':') else {
continue;
};
let art = match store {
"gog" => fetch_gog_art(localid),
// The xbox id is the StoreId when present, else the PFN (contains '_', no displaycatalog
// entry) → cache empty for those so they aren't retried every pass.
"xbox" if !localid.contains('_') => fetch_xbox_art(localid),
"xbox" => Artwork::default(),
_ => continue, // steam/heroic/lutris/custom resolve their own art
};
store_art(&g.id, art);
}
}
/// HTTP GET + parse JSON with a bounded timeout. `None` on any network/parse failure (best-effort —
/// art is non-essential, so a failure just leaves the title-only card).
fn fetch_json(url: &str) -> Option<serde_json::Value> {
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(10)))
// Don't follow redirects — a redirect target (`3xx` → `http://169.254.169.254/…` or an
// internal host) would be an SSRF pivot from the privileged host. Matches the webhook path
// (security-review 2026-07-17). A rare legitimately-redirecting CDN just yields no art.
.max_redirects(0)
.build()
.into();
let body = agent
.get(url)
.call()
.ok()?
.body_mut()
.read_to_string()
.ok()?;
serde_json::from_str(&body).ok()
}
/// Fetch one image URL for the GameStream `/appasset` cover proxy, as `(bytes, content-type)`. Handles
/// `data:` URLs (Lutris inlines art that way) by decoding inline, and `http(s)` URLs by a bounded GET
/// (8 MiB cap so a hostile/huge art URL can't balloon host memory). `None` on any non-image scheme,
@@ -169,11 +75,11 @@ pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
/// Playnite back-compat (it predates the `file://` contract).
/// * POSIX absolute (`/home/u/covers/x.jpg`) — Lutris covers and Steam's `librarycache`.
///
/// The POSIX widening is why the two `/`-leading shapes the **host itself emits** must be excluded
/// explicitly: its own art-proxy path (`/api/v1/library/art/…`, which [`proxy_local_art`] writes and
/// which must survive a second pass unchanged) and a protocol-relative URL (`//cdn/…`, what GOG's and
/// Microsoft's catalogs return — see [`abs_url`]). Mistaking either for a file would break the proxy
/// round-trip or silently drop CDN art.
/// The POSIX widening is why the two `/`-leading shapes must be excluded explicitly: the host's own
/// art-proxy path (`/api/v1/library/art/…`, which [`proxy_local_art`] writes and which must survive a
/// second pass unchanged) and a protocol-relative URL (`//cdn/…`, which GOG's and Microsoft's
/// catalogs return and a plugin may pass straight through). Mistaking either for a file would break
/// the proxy round-trip or silently drop CDN art.
pub fn is_local_art_path(v: &str) -> bool {
if v.starts_with("http://") || v.starts_with("https://") || v.starts_with("data:") {
return false;
@@ -475,107 +381,20 @@ pub fn proxy_local_art(id: &str, art: &mut Artwork) {
/// `(bytes, content-type)`. Resolves the id against the host's OWN library. Blocking — call off the
/// async runtime (e.g. `spawn_blocking`).
pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
// Same resolution order as the management art proxy (WP1.2): the stored catalog first, for ANY
// id, so a library plugin's entries resolve without the warmer knowing its store.
if let Some(entry) = entry_for_library_id(id) {
return [
ArtKind::Portrait,
ArtKind::Header,
ArtKind::Hero,
ArtKind::Logo,
]
.into_iter()
.filter_map(|kind| art_field(&entry.art, kind))
.find_map(|v| resolve_art_bytes(&v));
}
// Legacy in-host Steam scanner: its `Artwork` fields are relative proxy paths (see `steam_art`)
// the *client* resolves against the host — meaningless to `fetch_image`, which expects an
// absolute URL. Resolve those kinds directly instead of going through the URL fields.
if let Some(appid) = id
.strip_prefix("steam:")
.and_then(|s| s.parse::<u32>().ok())
{
return [
ArtKind::Portrait,
ArtKind::Header,
ArtKind::Hero,
ArtKind::Logo,
]
.into_iter()
.find_map(|kind| steam_art_bytes(appid, kind));
}
// The remaining in-host scanners (heroic/lutris/epic/gog/xbox) carry absolute CDN URLs.
let g = all_games().into_iter().find(|g| g.id == id)?;
[g.art.portrait, g.art.header, g.art.hero, g.art.logo]
.into_iter()
.flatten()
.find_map(|url| resolve_art_bytes(&url))
}
/// Make a protocol-relative URL (`//host/...`, common in GOG + MS catalog responses) absolute https.
fn abs_url(u: &str) -> String {
u.strip_prefix("//")
.map(|rest| format!("https://{rest}"))
.unwrap_or_else(|| u.to_string())
}
/// GOG cover art via the public (no-auth) product API. Field names / URL shapes are GOG-specific and
/// best-effort (worth on-box confirmation); a wrong URL just degrades to the title card client-side.
fn fetch_gog_art(product_id: &str) -> Artwork {
let Some(v) = fetch_json(&format!(
"https://api.gog.com/products/{product_id}?expand=images"
)) else {
return Artwork::default();
};
let img = |k: &str| {
v.get("images")
.and_then(|i| i.get(k))
.and_then(|u| u.as_str())
.map(abs_url)
};
Artwork {
portrait: img("verticalCover"),
hero: img("background"),
logo: img("logo2x"),
header: img("logo"),
}
}
/// Xbox cover art via the (unofficial, no-auth) Microsoft display catalog, keyed by StoreId. Best-
/// effort: the endpoint is internal/unstable, so on drift this just yields no art (title-only).
fn fetch_xbox_art(store_id: &str) -> Artwork {
let Some(v) = fetch_json(&format!(
"https://displaycatalog.mp.microsoft.com/v7.0/products/{store_id}?market=US&languages=en-us&fieldsTemplate=Details"
)) else {
return Artwork::default();
};
let images = v
.get("Products")
.and_then(|p| p.as_array())
.and_then(|a| a.first())
.and_then(|p| p.get("LocalizedProperties"))
.and_then(|l| l.as_array())
.and_then(|a| a.first())
.and_then(|lp| lp.get("Images"))
.and_then(|i| i.as_array());
let mut art = Artwork::default();
for img in images.into_iter().flatten() {
let (Some(purpose), Some(uri)) = (
img.get("ImagePurpose").and_then(|v| v.as_str()),
img.get("Uri").and_then(|v| v.as_str()),
) else {
continue;
};
let url = abs_url(uri);
match purpose {
"Poster" => art.portrait = Some(url),
"SuperHeroArt" | "Hero" => art.hero = Some(url),
"Logo" => art.logo = Some(url),
"BoxArt" => art.header = Some(url),
_ => {}
}
}
art
// Same resolution as the management art proxy (WP1.2): the stored catalog, for ANY id, so a
// library plugin's entries resolve without this ever knowing which store they came from. That
// used to be the first of three branches — the other two served the built-in scanners (a
// `steam:` id whose art was a relative proxy path, and the CDN-URL scanners) and went with them.
let entry = entry_for_library_id(id)?;
[
ArtKind::Portrait,
ArtKind::Header,
ArtKind::Hero,
ArtKind::Logo,
]
.into_iter()
.filter_map(|kind| art_field(&entry.art, kind))
.find_map(|v| resolve_art_bytes(&v))
}
#[cfg(test)]
@@ -627,7 +446,7 @@ mod tests {
"/api/v1/library/art/custom:abc/portrait"
));
assert!(!is_local_art_path("/api/v1/library/art/steam:570/hero"));
// …nor a protocol-relative CDN URL (what GOG / the MS catalog return — see `abs_url`).
// …nor a protocol-relative CDN URL (what GOG / the MS catalog return).
assert!(!is_local_art_path("//images.gog.com/abc_vertical.jpg"));
// A relative path is not absolute — nothing to serve.
assert!(!is_local_art_path("covers/x.jpg"));
+5 -4
View File
@@ -166,7 +166,7 @@ pub struct Catalog {
///
/// The map — not the entries — is the authority for a claim, which is exactly why it survives an
/// **empty reconcile**: a store the plugin legitimately owns can have zero installed titles, and
/// the built-in scanner it suppresses must stay suppressed anyway. Releasing is explicit
/// it must keep owning the store's id space regardless. Releasing is explicit
/// (`DELETE /library/provider/{p}`, or the plugin claiming a different store).
#[serde(default)]
pub claims: BTreeMap<String, String>,
@@ -573,7 +573,7 @@ fn reconcile_entries(
///
/// Claiming is idempotent for the holder and refused for anyone else. A provider holds at most one
/// store, so claiming a new one releases whatever it held before — otherwise an abandoned claim would
/// go on suppressing a built-in scanner with nothing to replace it.
/// go on holding a store id that no plugin is filling any more, locking out the next claimant.
pub fn reconcile_provider(
provider: &str,
store: Option<&str>,
@@ -712,8 +712,9 @@ mod tests {
assert_eq!(g.meta.platform.as_deref(), Some("PS2"));
}
/// D2's core promise: a **claimed** entry is indistinguishable from what the built-in scanner
/// produced. Same id, same store badge — plus the provider attribution the scanner never had.
/// D2's core promise, and the reason removing the built-in scanners was invisible downstream: a
/// **claimed** entry is indistinguishable from what the scanner produced. Same id, same store
/// badge — plus the provider attribution the scanner never had.
#[test]
fn a_claimed_entry_reproduces_the_scanner_identity() {
let mut e = manual("host-assigned", "Portal 2");
-224
View File
@@ -1,224 +0,0 @@
//! Epic Games Store provider: installed manifests + the catalog-cache art index + launch URIs. Split out of the `library` facade (plan §W5).
use super::*;
/// Reads the Epic Games Launcher's local install manifests. Windows-only. Best-effort: empty when
/// the launcher (or its manifest dir) isn't present.
#[cfg(windows)]
pub struct EpicProvider;
#[cfg(windows)]
impl LibraryProvider for EpicProvider {
fn store(&self) -> &'static str {
"epic"
}
fn list(&self) -> Vec<GameEntry> {
let data = epic_data_dir();
let Ok(rd) = std::fs::read_dir(data.join("Manifests")) else {
return Vec::new();
};
// Parse the (best-effort) artwork cache ONCE: catalogItemId -> Artwork.
let art = epic_art_index(&data.join("Catalog").join("catcache.bin"));
let mut games = Vec::new();
for entry in rd.flatten() {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) != Some("item") {
continue;
}
// `.item` manifests are small JSON; cap the read so a planted giant can't OOM the host.
let Some(bytes) = read_capped(&p, 1024 * 1024) else {
continue;
};
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
continue;
};
if let Some(g) = epic_entry(&v, &art) {
games.push(g);
}
}
games
}
}
/// `%ProgramData%\Epic\EpicGamesLauncher\Data` (machine-wide, SYSTEM-readable).
#[cfg(windows)]
fn epic_data_dir() -> PathBuf {
std::env::var_os("ProgramData")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
.join("Epic")
.join("EpicGamesLauncher")
.join("Data")
}
/// Map one `.item` manifest to a [`GameEntry`], or `None` if it isn't a launchable game. Uses
/// Playnite's proven EXCLUSION filter (skip `UE_*` Unreal components; skip a DLC/addon unless it is
/// `addons/launchable`) rather than a positive `games`-category match, which can drop legit titles.
#[cfg(windows)]
fn epic_entry(
v: &serde_json::Value,
art: &std::collections::HashMap<String, Artwork>,
) -> Option<GameEntry> {
let s = |k: &str| v.get(k).and_then(|x| x.as_str());
let app_name = s("AppName")?.to_string();
if app_name.starts_with("UE_") {
return None; // Unreal Engine component, not a game
}
let cats: Vec<&str> = v
.get("AppCategories")
.and_then(|c| c.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str()).collect())
.unwrap_or_default();
if cats.contains(&"addons") && !cats.contains(&"addons/launchable") {
return None; // non-launchable DLC/addon
}
// Drop stale records whose install dir is gone.
let install = s("InstallLocation")?;
if !Path::new(install).is_dir() {
return None;
}
let title = s("DisplayName").unwrap_or(&app_name).to_string();
let namespace = s("CatalogNamespace").unwrap_or("");
let catalog = s("CatalogItemId").unwrap_or("");
// The robust launch form is the namespace:catalogItemId:appName triple; fall back to the bare
// appName when those ids are absent (some manifests lack them) — never drop the launch entirely.
let value = if !namespace.is_empty() && !catalog.is_empty() {
format!("{namespace}:{catalog}:{app_name}")
} else {
app_name.clone()
};
// Detect signals: the manifest's own `LaunchExecutable` (relative to the install dir) is exact
// when present; the install dir covers the rest (Epic hands off to its launcher, so the host
// never owns the game's process).
let detect = match s("LaunchExecutable")
.map(|rel| Path::new(install).join(rel))
.filter(|p| p.is_file())
{
Some(exe) => DetectSpec::exe(exe).with_dir(install),
None => DetectSpec::dir(install),
};
Some(GameEntry {
provider: None,
role: GameRole::Game,
icon: None,
meta: GameMeta::pc(),
id: format!("epic:{app_name}"),
store: "epic".into(),
title,
art: art.get(catalog).cloned().unwrap_or_default(),
launch: Some(LaunchSpec {
kind: "epic".into(),
value,
}),
detect,
})
}
/// Read a launcher cache/manifest with a hard size cap, so a local unprivileged user can't plant a
/// multi-GB file under the launcher's (Users-writable) data dir that OOMs the privileged host when
/// it's loaded — then base64/JSON-decoded into further copies — during library enumeration
/// (security-review 2026-06-28 S4). Returns `None` if missing, empty, or over `max`. Mirrors the
/// Linux lutris-art reader's 1 MiB cap.
#[cfg(windows)]
fn read_capped(path: &Path, max: u64) -> Option<Vec<u8>> {
let meta = std::fs::metadata(path).ok()?;
if meta.len() == 0 || meta.len() > max {
if meta.len() > max {
tracing::warn!(path = %path.display(), len = meta.len(), max, "launcher cache exceeds size cap — skipping");
}
return None;
}
std::fs::read(path).ok()
}
/// Best-effort parse of `catcache.bin` (base64-encoded JSON array of catalog items) into
/// catalogItemId → [`Artwork`] from each item's `keyImages`. Empty map on any read/decode failure
/// (the format is community-reverse-engineered + can lag a fresh install → titles just show no art).
#[cfg(windows)]
fn epic_art_index(catcache: &Path) -> std::collections::HashMap<String, Artwork> {
use base64::Engine as _;
let mut map = std::collections::HashMap::new();
// 32 MiB cap: comfortably fits a real catalog cache, blocks a planted giant (S4).
let Some(raw) = read_capped(catcache, 32 * 1024 * 1024) else {
return map;
};
let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(raw) else {
return map;
};
let Ok(items) = serde_json::from_slice::<serde_json::Value>(&decoded) else {
return map;
};
let Some(arr) = items.as_array() else {
return map;
};
for item in arr {
let Some(cat) = item
.get("id")
.or_else(|| item.get("catalogItemId"))
.and_then(|v| v.as_str())
else {
continue;
};
let Some(images) = item.get("keyImages").and_then(|v| v.as_array()) else {
continue;
};
let mut art = Artwork::default();
for img in images {
let (Some(ty), Some(url)) = (
img.get("type").and_then(|v| v.as_str()),
img.get("url").and_then(|v| v.as_str()),
) else {
continue;
};
if !(url.starts_with("http://") || url.starts_with("https://")) {
continue;
}
match ty {
"DieselGameBoxTall" => art.portrait = Some(url.to_string()),
"DieselGameBox" => art.hero = Some(url.to_string()),
"DieselGameBoxLogo" => art.logo = Some(url.to_string()),
_ => {}
}
}
if art.portrait.is_some() || art.hero.is_some() || art.logo.is_some() {
map.insert(cat.to_string(), art);
}
}
map
}
// The `epic` launch mapping (`epic_launch_uri`) lives in `launch.rs` (WP1.1) — this module
// enumerates, it does not launch.
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn epic_filters_and_builds_launch() {
let dir = std::env::temp_dir().join(format!("pf-epic-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let inst = dir.to_string_lossy().into_owned();
let empty = std::collections::HashMap::new();
// Normal game with the full triple → kept, triple launch value.
let game = serde_json::json!({
"AppName": "Fortnite", "DisplayName": "Fortnite", "CatalogNamespace": "fn",
"CatalogItemId": "abc123", "InstallLocation": inst.clone(),
"AppCategories": ["public", "games", "applications"]
});
let e = epic_entry(&game, &empty).expect("game kept");
assert_eq!(e.id, "epic:Fortnite");
assert_eq!(e.launch.as_ref().unwrap().value, "fn:abc123:Fortnite");
// UE component, non-launchable addon, and a missing install dir are all skipped.
let ue = serde_json::json!({"AppName":"UE_5.3","InstallLocation":inst.clone(),"AppCategories":["engines"]});
assert!(epic_entry(&ue, &empty).is_none());
let dlc =
serde_json::json!({"AppName":"DLC","InstallLocation":inst,"AppCategories":["addons"]});
assert!(epic_entry(&dlc, &empty).is_none());
let gone = serde_json::json!({"AppName":"Gone","InstallLocation":"C:\\nope-xyz","AppCategories":["games"]});
assert!(epic_entry(&gone, &empty).is_none());
std::fs::remove_dir_all(&dir).ok();
}
}
-165
View File
@@ -1,165 +0,0 @@
//! GOG Galaxy store provider: installed games from the Galaxy DB + play-task launch resolution. Split out of the `library` facade (plan §W5).
use super::art::cached_art;
use super::*;
/// Reads the GOG.com install registry + per-game `.info` files. Windows-only. Best-effort: empty
/// when GOG isn't installed.
#[cfg(windows)]
pub struct GogProvider;
#[cfg(windows)]
impl LibraryProvider for GogProvider {
fn store(&self) -> &'static str {
"gog"
}
fn list(&self) -> Vec<GameEntry> {
gog_games()
}
}
#[cfg(windows)]
fn gog_games() -> Vec<GameEntry> {
use winreg::enums::HKEY_LOCAL_MACHINE;
use winreg::RegKey;
// 32-bit GOG writes under WOW6432Node; a 64-bit process reads the explicit path directly.
let Ok(games_key) =
RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey("SOFTWARE\\WOW6432Node\\GOG.com\\Games")
else {
return Vec::new();
};
let mut out = Vec::new();
for sub in games_key.enum_keys().flatten() {
// The subkey name IS the GOG product id.
let Ok(k) = games_key.open_subkey(&sub) else {
continue;
};
let Ok(path) = k.get_value::<String, _>("PATH") else {
continue;
};
if !Path::new(&path).is_dir() {
continue;
}
let title = k
.get_value::<String, _>("GAMENAME")
.unwrap_or_else(|_| sub.clone());
// Resolve the primary play task (exe + args + workdir) from goggame-<id>.info; skip if absent.
let Some((exe, args, workdir)) = gog_play_task(&path, &sub) else {
continue;
};
let id = format!("gog:{sub}");
// Art (public api.gog.com) is resolved off the hot path by the background warmer; read
// whatever it has cached (title-only until warmed).
let art = cached_art(&id).unwrap_or_default();
// GOG launches the game's exe directly (no Galaxy), so the host owns the process and the
// spec is only the fallback for a stub launcher that hands off; both signals are exact here.
let detect = DetectSpec::exe(&exe).with_dir(&path);
out.push(GameEntry {
provider: None,
role: GameRole::Game,
icon: None,
meta: GameMeta::pc(),
id,
store: "gog".into(),
title,
art,
launch: Some(LaunchSpec {
kind: "gog".into(),
value: format!("{exe}\t{args}\t{workdir}"),
}),
detect,
});
}
out
}
/// Join a manifest-supplied relative path onto `install`, rejecting anything that could escape (or
/// replace) it: a drive prefix (`C:`), a root (`\`), or a `..` component — any of which `Path::join`
/// lets REPLACE or climb out of `install` on Windows. Keeps a crafted `goggame-*.info` from pointing
/// the play task's exe or working dir at an arbitrary program (security-review 2026-07-17). `None`
/// ⇒ the path is out of bounds and the caller refuses it.
#[cfg(windows)]
fn confined_join(install: &str, rel: &str) -> Option<PathBuf> {
use std::path::Component;
let rp = Path::new(rel);
if rp.components().any(|c| {
matches!(
c,
Component::Prefix(_) | Component::RootDir | Component::ParentDir
)
}) {
return None;
}
Some(Path::new(install).join(rp))
}
/// The primary play task from `<install>\goggame-<id>.info`: `(absolute exe, args, working dir)`.
/// Prefers `isPrimary` + `FileTask`, else the first `FileTask`. Paths are resolved against `install`
/// and confined to it ([`confined_join`]).
#[cfg(windows)]
fn gog_play_task(install: &str, id: &str) -> Option<(String, String, String)> {
let text =
std::fs::read_to_string(Path::new(install).join(format!("goggame-{id}.info"))).ok()?;
let v: serde_json::Value = serde_json::from_str(&text).ok()?;
let tasks = v.get("playTasks")?.as_array()?;
let is_file =
|t: &serde_json::Value| t.get("type").and_then(|s| s.as_str()) == Some("FileTask");
let pick = tasks
.iter()
.find(|t| {
t.get("isPrimary")
.and_then(|b| b.as_bool())
.unwrap_or(false)
&& is_file(t)
})
.or_else(|| tasks.iter().find(|t| is_file(t)))?;
let rel = pick.get("path").and_then(|s| s.as_str())?;
// Refuse the launch outright if the manifest's exe path escapes the install dir.
let exe = confined_join(install, rel)?;
let args = pick
.get("arguments")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
let workdir = pick
.get("workingDir")
.and_then(|s| s.as_str())
// A working dir that escapes falls back to the install root (safe) rather than failing.
.and_then(|w| confined_join(install, w))
.unwrap_or_else(|| Path::new(install).to_path_buf());
Some((
exe.to_string_lossy().into_owned(),
args,
workdir.to_string_lossy().into_owned(),
))
}
// The `gog` launch mapping (`gog_spawn`) lives in `launch.rs` (WP1.1) — this module enumerates and
// resolves the spawn triple off disk, but turning that triple into a command line is launch-side.
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn gog_play_task_picks_primary_filetask() {
let dir = std::env::temp_dir().join(format!("pf-gog-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let id = "1207658924";
std::fs::write(
dir.join(format!("goggame-{id}.info")),
r#"{"playTasks":[
{"isPrimary":false,"type":"FileTask","path":"other.exe"},
{"isPrimary":true,"type":"FileTask","path":"bin\\game.exe","arguments":"-w","workingDir":"bin"}
]}"#,
)
.unwrap();
let (exe, args, wd) = gog_play_task(&dir.to_string_lossy(), id).unwrap();
std::fs::remove_dir_all(&dir).ok();
assert!(exe.ends_with("bin\\game.exe"), "exe={exe}");
assert_eq!(args, "-w");
assert!(wd.ends_with("bin"), "wd={wd}");
}
}
-179
View File
@@ -1,179 +0,0 @@
//! Heroic (Epic/GOG) store provider: installed games from Heroic's JSON stores + CDN art. Split out of the `library` facade (plan §W5).
use super::*;
/// Reads Heroic Games Launcher's local library cache. One provider surfaces all three of Heroic's
/// backends (legendary=Epic, gog=GOG, nile=Amazon). Linux-only for now (Heroic on Windows uses a
/// different config path and the launch path isn't wired there yet).
#[cfg(target_os = "linux")]
pub struct HeroicProvider;
#[cfg(target_os = "linux")]
impl LibraryProvider for HeroicProvider {
fn store(&self) -> &'static str {
"heroic"
}
fn list(&self) -> Vec<GameEntry> {
let Some(root) = heroic_root() else {
return Vec::new();
};
let mut games = Vec::new();
// (cache file, runner id, the electron-store data key holding the games array)
for (file, runner, key) in [
("legendary_library.json", "legendary", "library"),
("gog_library.json", "gog", "games"),
("nile_library.json", "nile", "library"),
] {
let path = root.join("store_cache").join(file);
match heroic_games(&path, runner, key) {
Ok(mut g) => games.append(&mut g),
Err(e) => {
tracing::debug!(error = %e, file, "heroic store_cache not read (store unused?)")
}
}
}
games
}
}
/// The first existing Heroic config root: `$XDG_CONFIG_HOME/heroic`, classic `~/.config/heroic`, or
/// the Flatpak path.
#[cfg(target_os = "linux")]
fn heroic_root() -> Option<PathBuf> {
let mut candidates = Vec::new();
if let Some(d) = std::env::var_os("XDG_CONFIG_HOME") {
candidates.push(PathBuf::from(d).join("heroic"));
}
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
candidates.push(home.join(".config/heroic"));
candidates.push(home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic"));
}
candidates.into_iter().find(|p| p.is_dir())
}
/// Parse one runner's `store_cache/*_library.json` (an electron-store object whose `key` holds the
/// games array). Keeps only installed titles whose install dir still exists (the latter works around
/// Heroic's gog `is_installed` bug, #2691). Art comes straight from the cached public CDN URLs.
#[cfg(target_os = "linux")]
fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<GameEntry>> {
let raw = std::fs::read_to_string(path)?;
let root: serde_json::Value = serde_json::from_str(&raw)?;
let arr = root
.get(key)
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("no '{key}' array in {}", path.display()))?;
let mut games = Vec::new();
for g in arr {
if !g
.get("is_installed")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
continue; // the cache also lists owned-but-not-installed titles
}
// The install dir doubles as this title's detect signal (Heroic hands off to
// legendary/gogdl/nile, so the host never sees the game's own process any other way).
let install_path = g
.get("install")
.and_then(|i| i.get("install_path"))
.and_then(|p| p.as_str())
.filter(|p| Path::new(p).is_dir());
let Some(install_path) = install_path else {
continue;
};
let Some(app_name) = g
.get("app_name")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
else {
continue;
};
let title = g
.get("title")
.and_then(|v| v.as_str())
.unwrap_or(app_name)
.to_string();
// Only emit http(s) art (sideloaded titles can carry local file:// paths the client can't fetch).
let http = |k: &str| {
g.get(k)
.and_then(|v| v.as_str())
.filter(|s| s.starts_with("http://") || s.starts_with("https://"))
.map(String::from)
};
let art = Artwork {
portrait: http("art_square"),
header: http("art_cover"),
hero: http("art_background").or_else(|| http("art_cover")),
logo: http("art_logo"),
};
games.push(GameEntry {
provider: None,
role: GameRole::Game,
icon: None,
meta: GameMeta::pc(),
id: format!("heroic:{runner}:{app_name}"),
store: "heroic".into(),
title,
art,
launch: Some(LaunchSpec {
kind: "heroic".into(),
value: format!("{runner}:{app_name}"),
}),
// The install dir is the reliable signal. `HEROIC_APP_NAME` is also stamped on the game's
// env by Heroic's launch path; it is carried as a second, cheap signal (a union — if a
// Heroic version doesn't set it, the install dir still matches).
detect: DetectSpec::dir(install_path)
.with_env("HEROIC_APP_NAME", Some(app_name.to_string())),
});
}
Ok(games)
}
// The `heroic` launch mapping (`heroic_command` + its launcher-prefix probe) lives in `launch.rs`
// (WP1.1) — this module enumerates, it does not launch.
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn heroic_games_parses_installed_with_cdn_art() {
let dir = std::env::temp_dir().join(format!("pf-heroic-test-{}", std::process::id()));
let install = dir.join("game-install");
std::fs::create_dir_all(&install).unwrap();
let path = dir.join("legendary_library.json");
let json = format!(
r#"{{"library":[
{{"app_name":"Quail","title":"Quail","is_installed":true,
"install":{{"install_path":"{inst}"}},
"art_square":"https://cdn/quail_tall.jpg","art_cover":"https://cdn/quail_wide.jpg",
"art_logo":"file:///local/logo.png"}},
{{"app_name":"Owned","title":"Owned Only","is_installed":false,
"install":{{"install_path":"{inst}"}}}}
]}}"#,
inst = install.display()
);
std::fs::write(&path, json).unwrap();
let games = heroic_games(&path, "legendary", "library").unwrap();
std::fs::remove_dir_all(&dir).ok();
assert_eq!(games.len(), 1); // the uninstalled title is filtered out
assert_eq!(games[0].id, "heroic:legendary:Quail");
assert_eq!(games[0].title, "Quail");
assert_eq!(
games[0].art.portrait.as_deref(),
Some("https://cdn/quail_tall.jpg")
);
assert_eq!(
games[0].art.header.as_deref(),
Some("https://cdn/quail_wide.jpg")
);
assert!(games[0].art.logo.is_none()); // file:// art is dropped (client can't fetch it)
let l = games[0].launch.as_ref().unwrap();
assert_eq!(
(l.kind.as_str(), l.value.as_str()),
("heroic", "legendary:Quail")
);
}
}
+60 -1
View File
@@ -325,7 +325,7 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::Pa
/// Windows: the default Steam install's `steam.exe`, if present. A non-default Steam install dir
/// (registry `Valve\Steam\InstallPath`) isn't covered — the explorer.exe protocol fallback handles
/// that case. Mirrors [`steam_roots`]' "default Program Files dirs" approach.
/// that case. Probes the default Program Files dirs, in `ProgramFiles(x86)`-first order.
#[cfg(windows)]
fn steam_exe() -> Option<std::path::PathBuf> {
for var in ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"] {
@@ -339,6 +339,47 @@ fn steam_exe() -> Option<std::path::PathBuf> {
None
}
/// Resolve a package's PackageFamilyName by finding its
/// `AppRepository\Packages\<PackageFullName>` dir (machine-wide, SYSTEM-readable) and reducing the
/// full name to `Name_PublisherHash`. This READS the authoritative PFN — never compute the hash.
///
/// **Readable by the host, NOT by the plugin runner.** Measured on 2026-08-06: that directory is
/// `UnauthorizedAccessException` for `NT AUTHORITY\LocalService` (which the runner is), while the
/// host service runs as LocalSystem and enumerates all 348 entries. That asymmetry is the entire
/// reason the `xbox` launch kind exists — a library plugin sends the package Identity it CAN read
/// out of `MicrosoftGame.config`, and this resolves the rest at launch time.
///
/// It lives here rather than beside a scanner because it is **launch** vocabulary: the in-host Xbox
/// scanner that used to share it was removed with the rest of the built-ins, and the plugin that
/// replaced it depends on exactly this resolution step.
#[cfg(windows)]
fn xbox_pfn(identity: &str) -> Option<String> {
let pkgs = std::path::PathBuf::from(std::env::var_os("ProgramData")?)
.join("Microsoft")
.join("Windows")
.join("AppRepository")
.join("Packages");
let prefix = format!("{identity}_");
for e in std::fs::read_dir(&pkgs).ok()?.flatten() {
let dn = e.file_name().to_string_lossy().into_owned();
if dn.starts_with(&prefix) {
if let Some(pfn) = pfn_from_full(&dn, identity) {
return Some(pfn);
}
}
}
None
}
/// PackageFamilyName from a PackageFullName dir name
/// (`Name_Version_Arch_ResourceId_PublisherHash`) → `Name_PublisherHash`. The hash is the last
/// `_`-segment; `Name` is the caller's identity.
#[cfg(windows)]
fn pfn_from_full(dir_name: &str, identity: &str) -> Option<String> {
let hash = dir_name.rsplit('_').next()?;
(!hash.is_empty() && hash != dir_name).then(|| format!("{identity}_{hash}"))
}
// ------------------------------------------------------- per-kind launch values (host-owned ABI)
//
// Each helper below turns a store's launch VALUE — the only part a scanner (or, after extraction, a
@@ -941,6 +982,24 @@ mod tests {
assert!(gog_spawn("").is_none());
}
/// Moved here with `xbox_pfn` when the built-in scanners were removed: reducing a
/// PackageFullName to its family name is what the `xbox` launch kind does with the Identity a
/// de-privileged plugin sends it, so the guard belongs to the launch path now.
#[cfg(windows)]
#[test]
fn pfn_reduces_a_package_full_name_to_its_family() {
assert_eq!(
pfn_from_full(
"Microsoft.624F8B84B80_1.0.0.0_x64__8wekyb3d8bbwe",
"Microsoft.624F8B84B80"
)
.as_deref(),
Some("Microsoft.624F8B84B80_8wekyb3d8bbwe")
);
// No `_` at all → nothing to reduce, and we must not invent a hash.
assert!(pfn_from_full("NoUnderscore", "NoUnderscore").is_none());
}
#[cfg(windows)]
#[test]
fn windows_launch_for_maps_and_guards() {
-194
View File
@@ -1,194 +0,0 @@
//! Lutris store provider: installed games from the Lutris SQLite DB + lutris.net CDN art. Split out of the `library` facade (plan §W5).
use super::*;
/// Reads the **local** Lutris library DB (`pga.db`) — no network. Installed titles only; cover art
/// from Lutris's on-disk cache, inlined as `data:` URLs. Linux-only (Lutris is Linux-only).
#[cfg(target_os = "linux")]
pub struct LutrisProvider;
#[cfg(target_os = "linux")]
impl LibraryProvider for LutrisProvider {
fn store(&self) -> &'static str {
"lutris"
}
fn list(&self) -> Vec<GameEntry> {
let Some(db) = lutris_db() else {
return Vec::new();
};
lutris_games(&db).unwrap_or_else(|e| {
tracing::warn!(error = %e, db = %db.display(), "lutris pga.db read failed — skipping");
Vec::new()
})
}
}
/// The first existing Lutris `pga.db`: XDG data dir, the classic `~/.local/share`, or Flatpak.
#[cfg(target_os = "linux")]
fn lutris_db() -> Option<PathBuf> {
let mut candidates = Vec::new();
if let Some(d) = std::env::var_os("XDG_DATA_HOME") {
candidates.push(PathBuf::from(d).join("lutris/pga.db"));
}
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
candidates.push(home.join(".local/share/lutris/pga.db"));
candidates.push(home.join(".var/app/net.lutris.Lutris/data/lutris/pga.db"));
}
candidates.into_iter().find(|p| p.is_file())
}
/// Installed games from a Lutris `pga.db`. Opened **read-only + immutable** (via a SQLite URI) so a
/// running Lutris holding the file can't make us block or fail, and we never write to it.
#[cfg(target_os = "linux")]
fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
use rusqlite::OpenFlags;
// `immutable=1` treats the DB as read-only-and-unchanging → no locking against a live Lutris. The
// path goes into the URI literally; a `?`/`#` in it (vanishingly rare on Linux) would mis-parse,
// so fall back to a plain read-only open in that case.
let path = db.to_string_lossy();
let conn = if path.contains('?') || path.contains('#') {
rusqlite::Connection::open_with_flags(db, OpenFlags::SQLITE_OPEN_READ_ONLY)?
} else {
rusqlite::Connection::open_with_flags(
format!("file:{path}?immutable=1"),
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
)?
};
// `directory` (the game's install dir — our detect signal) is not load-bearing for the library, so
// a pga.db schema without it must not cost the whole Lutris store: try the richer query first and
// fall back to the historical one on any prepare error.
const SELECT_WITH_DIR: &str = "SELECT id, slug, name, directory FROM games \
WHERE installed = 1 AND name IS NOT NULL AND name <> '' \
ORDER BY name COLLATE NOCASE";
const SELECT_PLAIN: &str = "SELECT id, slug, name, NULL FROM games \
WHERE installed = 1 AND name IS NOT NULL AND name <> '' \
ORDER BY name COLLATE NOCASE";
let mut stmt = match conn.prepare(SELECT_WITH_DIR) {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "lutris pga.db has no `directory` column — listing without \
install dirs (game-exit detection unavailable for Lutris titles)");
conn.prepare(SELECT_PLAIN)?
}
};
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
))
})?;
let mut games = Vec::new();
for (id, slug, name, directory) in rows.flatten() {
games.push(GameEntry {
provider: None,
role: GameRole::Game,
icon: None,
meta: GameMeta::pc(),
id: format!("lutris:{id}"),
store: "lutris".into(),
title: name,
art: slug.as_deref().map(lutris_art).unwrap_or_default(),
launch: Some(LaunchSpec {
kind: "lutris_id".into(),
value: id.to_string(),
}),
// Lutris stamps no per-game env marker we can rely on, so the install dir is the whole
// recipe; a game with none (an emulator entry pointing at a bare ROM) stays untracked.
detect: directory
.filter(|d| !d.trim().is_empty())
.map(DetectSpec::dir)
.unwrap_or_default(),
});
}
Ok(games)
}
/// Lutris cover art (local files keyed by slug) inlined as `data:` URLs — Lutris has no public CDN
/// keyed by a stable id (unlike Steam/Heroic), and `Artwork` fields are URLs the client fetches, so a
/// self-contained `data:` URL needs no host-served endpoint. `coverart` → portrait, `banners` → header.
#[cfg(target_os = "linux")]
fn lutris_art(slug: &str) -> Artwork {
Artwork {
portrait: lutris_image("coverart", slug),
header: lutris_image("banners", slug),
..Default::default()
}
}
/// Find `<kind>/<slug>.jpg` across the current (0.5.18+), legacy (`~/.cache`), and Flatpak Lutris
/// dirs and inline it as `data:image/jpeg;base64,…`. Skips a missing or implausibly large file (a
/// 1 MiB cap bounds the catalog JSON so a few big files can't bloat it).
#[cfg(target_os = "linux")]
fn lutris_image(kind: &str, slug: &str) -> Option<String> {
use base64::Engine as _;
// `slug` comes verbatim from Lutris's `pga.db` (untrusted at this layer). Reject any path
// separator, parent ref, or NUL so a crafted slug can't escape the art roots and read an
// arbitrary `<slug>.jpg` off disk — the bytes are base64-inlined into the `/api/v1/library`
// JSON a paired client can GET, so an escape is an arbitrary-file-read exfil primitive
// (security-review 2026-07-17). Real Lutris slugs are `[a-z0-9-]`.
if slug.is_empty()
|| slug.contains('/')
|| slug.contains('\\')
|| slug.contains("..")
|| slug.contains('\0')
{
return None;
}
let home = std::env::var_os("HOME").map(PathBuf::from)?;
let roots = [
home.join(".local/share/lutris"),
home.join(".cache/lutris"),
home.join(".var/app/net.lutris.Lutris/data/lutris"),
home.join(".var/app/net.lutris.Lutris/cache/lutris"),
];
for root in roots {
let p = root.join(kind).join(format!("{slug}.jpg"));
let Ok(meta) = std::fs::metadata(&p) else {
continue;
};
if meta.len() == 0 || meta.len() > 1024 * 1024 {
continue;
}
if let Ok(bytes) = std::fs::read(&p) {
let enc = base64::engine::general_purpose::STANDARD.encode(&bytes);
return Some(format!("data:image/jpeg;base64,{enc}"));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn lutris_games_reads_installed_only() {
use rusqlite::Connection;
let dir = std::env::temp_dir().join(format!("pf-lutris-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let db = dir.join("pga.db");
{
let c = Connection::open(&db).unwrap();
c.execute_batch(
"CREATE TABLE games (id INTEGER PRIMARY KEY, slug TEXT, name TEXT, installed INTEGER);
INSERT INTO games (id,slug,name,installed) VALUES (42,'elden-ring','ELDEN RING',1);
INSERT INTO games (id,slug,name,installed) VALUES (7,'owned','Owned Only',0);
INSERT INTO games (id,slug,name,installed) VALUES (9,'noname',NULL,1);",
)
.unwrap();
}
let games = lutris_games(&db).unwrap();
std::fs::remove_dir_all(&dir).ok();
// Only the installed, named row; the uninstalled + NULL-name rows are filtered out.
assert_eq!(games.len(), 1);
assert_eq!(games[0].id, "lutris:42");
assert_eq!(games[0].store, "lutris");
assert_eq!(games[0].title, "ELDEN RING");
let l = games[0].launch.as_ref().unwrap();
assert_eq!((l.kind.as_str(), l.value.as_str()), ("lutris_id", "42"));
}
}
+90 -86
View File
@@ -1,14 +1,18 @@
//! Library-scanner settings: which installed-store scanners run on this host. Every scanner is
//! **on by default** (the shipped behavior before this existed); the operator can turn one off in
//! the web console, which hides its titles from every library surface (console grid, native
//! clients, the GameStream app list, launch resolution) from the next read. Only the *disabled*
//! set is persisted, so a scanner added in a future build starts enabled without a migration.
//! Game-source settings: which of this host's library sources contribute titles. Every source is
//! **on by default**; the operator can turn one off in the web console, which hides its titles from
//! every library surface (console grid, native clients, the GameStream app list, launch resolution)
//! from the next read. Only the *disabled* set is persisted, so a source that appears later starts
//! enabled without a migration.
//!
//! The user-curated **custom** store is not a scanner (nothing is scanned — the operator typed the
//! entries in) and cannot be disabled here; provider plugins (RFC §8) likewise own their entries
//! through the reconcile API. Down the road the scanners themselves are slated to become plugins —
//! the stable per-scanner ids this module fixes (`steam`, `lutris`, …, matching each entry's
//! `store` field) are the forward seam for that migration.
//! **Every source here is a plugin now.** Through v0.27.x this module also enumerated the six
//! scanners compiled into the host; that list is gone with the scanners themselves. The forward seam
//! it was built for did its job exactly as designed — the ids never changed (provider id = claimed
//! store id = old scanner id), so an operator who had `steam` switched off before the migration
//! still has it switched off after, with nothing to carry over and no migration step. That property
//! is the reason `library-scanners.json` keeps its name and its shape.
//!
//! The user-curated **custom** store is not a source (nothing is scanned — the operator typed the
//! entries in) and cannot be disabled here.
use super::*;
@@ -27,8 +31,9 @@ pub struct ScannerInfo {
pub label: String,
/// Whether this host runs the source (default true).
pub enabled: bool,
/// Where the source comes from: `builtin` (a scanner in this host build) or `plugin`.
#[schema(example = "builtin")]
/// Where the source comes from. Always `plugin` from this host build onward — see
/// [`SourceOrigin`].
#[schema(example = "plugin")]
pub origin: SourceOrigin,
/// The provider id backing a `plugin` source — absent for a built-in scanner.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -43,30 +48,31 @@ pub struct ScannerInfo {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum SourceOrigin {
/// A scanner compiled into this host build.
/// A scanner compiled into the host build.
///
/// **No host build emits this any more** — the built-in scanners were removed in v0.28.0. The
/// variant is kept deliberately, because it is still part of the API's vocabulary: the web
/// console ships as its own package and is expected to drive an N-1 host, which does still
/// report `builtin` sources. Deleting it here would drop `builtin` from the OpenAPI enum and
/// narrow the console's generated union out from under that pairing.
#[allow(dead_code)]
Builtin,
/// A plugin reconciling entries over the provider API.
Plugin,
}
/// The scanners compiled into THIS host build: (id, label). Steam is cross-platform; the rest are
/// platform-gated exactly like their provider modules in `library.rs` — keep the two in sync when
/// adding a store.
fn scanner_defs() -> Vec<(&'static str, &'static str)> {
let mut defs = vec![("steam", "Steam")];
#[cfg(target_os = "linux")]
{
defs.push(("lutris", "Lutris"));
defs.push(("heroic", "Heroic (Epic / GOG / Amazon)"));
}
#[cfg(windows)]
{
defs.push(("epic", "Epic Games Launcher"));
defs.push(("gog", "GOG Galaxy"));
defs.push(("xbox", "Xbox / Game Pass"));
}
defs
}
/// Display names for the stores that used to have a built-in scanner, so a source keeps the label
/// the operator has been toggling for releases instead of renaming itself to a bare id the day its
/// plugin takes over. Anything not listed (rom-manager, playnite, a third-party provider) falls back
/// to its own id, which is what those sources have always shown.
const STORE_LABELS: &[(&str, &str)] = &[
("steam", "Steam"),
("lutris", "Lutris"),
("heroic", "Heroic (Epic / GOG / Amazon)"),
("epic", "Epic Games Launcher"),
("gog", "GOG Galaxy"),
("xbox", "Xbox / Game Pass"),
];
/// Persisted shape (`library-scanners.json`): only the ids the operator turned OFF. Absent file =
/// nothing disabled = the pre-existing all-scanners-on behavior.
@@ -104,49 +110,26 @@ fn save_settings(settings: &ScannerSettings) -> Result<()> {
Ok(())
}
/// The disabled-scanner ids, loaded once per library read ([`all_games`] consults it per store).
/// The disabled source ids, loaded once per library read ([`all_games`] filters each entry on it).
pub(crate) fn disabled_scanners() -> HashSet<String> {
load_settings().disabled.into_iter().collect()
}
/// Every game source on this host with its current enable state (WP2.6):
///
/// 1. the built-in scanners this build compiled in, **minus** any whose store a plugin has claimed
/// (the plugin replaces it, so showing both would offer two toggles for one thing);
/// 2. the claimed stores themselves, as plugin sources;
/// 3. any other provider that has entries — the *emergent* case (rom-manager, playnite), which has
/// never had a toggle before and gets one for free here.
/// 1. every **claimed store** — a library plugin that took a store's id, so its entries surface as
/// `steam:570` rather than `custom:<opaque>`;
/// 2. every other provider that has entries the *emergent* case (rom-manager, playnite), which
/// never claimed a store but still owns a set of titles the operator may want to switch off.
///
/// Built-ins keep their fixed definition order (stable for the console); plugin sources follow,
/// sorted by id.
/// Sorted by id, which is a stable order for the console. This used to lead with the built-in
/// scanners compiled into the host, minus any store a plugin had claimed out from under them; with
/// the scanners gone the subtraction has nothing left to subtract and the list is plugins only.
pub fn list_scanners() -> Vec<ScannerInfo> {
let off = disabled_scanners();
let claims = crate::library::claimed_stores();
let entries = crate::library::load_custom();
let mut out: Vec<ScannerInfo> = scanner_defs()
.into_iter()
.filter(|(id, _)| !claims.contains_key(*id))
.map(|(id, label)| ScannerInfo {
id: id.to_string(),
label: label.to_string(),
enabled: !off.contains(id),
origin: SourceOrigin::Builtin,
provider: None,
entries: None,
})
.collect();
// A claimed store shows under the SCANNER's label where we know one, so the row a user has been
// toggling for releases doesn't rename itself out from under them mid-migration.
let label_for = |id: &str| {
scanner_defs()
.into_iter()
.find(|(sid, _)| *sid == id)
.map(|(_, label)| label.to_string())
.unwrap_or_else(|| id.to_string())
};
let mut plugin_ids: Vec<(String, String)> = claims
.iter()
.map(|(store, provider)| (store.clone(), provider.clone()))
@@ -163,27 +146,43 @@ pub fn list_scanners() -> Vec<ScannerInfo> {
plugin_ids.sort();
plugin_ids.dedup();
out.extend(plugin_ids.into_iter().map(|(id, provider)| {
let count = entries
.iter()
.filter(|e| crate::library::source_id_for(e) == Some(id.as_str()))
.count();
ScannerInfo {
label: label_for(&id),
enabled: !off.contains(&id),
origin: SourceOrigin::Plugin,
provider: Some(provider),
entries: Some(count),
id,
}
}));
out
plugin_ids
.into_iter()
.map(|(id, provider)| {
let count = entries
.iter()
.filter(|e| crate::library::source_id_for(e) == Some(id.as_str()))
.count();
ScannerInfo {
label: store_label(&id),
enabled: !off.contains(&id),
origin: SourceOrigin::Plugin,
provider: Some(provider),
entries: Some(count),
id,
}
})
.collect()
}
/// Whether `id` names a source that exists on this host right now — a compiled-in scanner, a claimed
/// store, or a provider with entries. The toggle accepts exactly these (an unknown id still 404s).
/// A source's display name — see [`STORE_LABELS`]; its own id when we know no nicer name.
fn store_label(id: &str) -> String {
STORE_LABELS
.iter()
.find(|(sid, _)| *sid == id)
.map(|(_, label)| (*label).to_string())
.unwrap_or_else(|| id.to_string())
}
/// Whether `id` names a source that exists on this host right now — a claimed store or a provider
/// with entries. The toggle accepts exactly these (an unknown id still 404s).
///
/// Note this is now strictly "a source that is really here". While the built-ins existed it also
/// accepted any compiled-in scanner id, which was the same thing for them; a plugin that has never
/// reconciled has no entries and no claim, so there is nothing to toggle and 404 is the honest
/// answer.
fn is_known_source(id: &str) -> bool {
scanner_defs().iter().any(|(sid, _)| *sid == id) || list_scanners().iter().any(|s| s.id == id)
list_scanners().iter().any(|s| s.id == id)
}
/// Enable/disable one source. `None` when `id` names no source on this host (the mgmt layer maps
@@ -221,14 +220,19 @@ pub fn set_scanner_enabled(id: &str, enabled: bool) -> Result<Option<Vec<Scanner
mod tests {
use super::*;
/// The label table is what keeps a source row named "Steam" instead of "steam" now that the
/// scanner which used to supply that name is gone. Pin both halves: the ids are unique, and an
/// id we know nothing about degrades to itself rather than to an empty or panicking label.
#[test]
fn steam_is_always_a_scanner_and_ids_are_unique() {
let defs = scanner_defs();
assert!(defs.iter().any(|(id, _)| *id == "steam"));
let ids: HashSet<_> = defs.iter().map(|(id, _)| *id).collect();
assert_eq!(ids.len(), defs.len(), "scanner ids must be unique");
// `custom` is a store but never a scanner — the toggle surface must not offer it.
fn store_labels_are_unique_and_unknown_ids_degrade_to_themselves() {
let ids: HashSet<_> = STORE_LABELS.iter().map(|(id, _)| *id).collect();
assert_eq!(ids.len(), STORE_LABELS.len(), "source ids must be unique");
// `custom` is a store but never a source — the toggle surface must not offer it.
assert!(!ids.contains("custom"));
assert_eq!(store_label("steam"), "Steam");
assert_eq!(store_label("rom-manager"), "rom-manager");
assert_eq!(store_label(""), "");
}
#[test]
-812
View File
@@ -1,812 +0,0 @@
//! Steam store provider: installed-title scan (local `libraryfolders.vdf` + app manifests, no
//! API key) and Steam-CDN / local-`librarycache` artwork. Split out of the `library` facade (plan §W5).
use super::art::fetch_image;
use super::*;
/// Reads the **local** Steam install — no Steam Web API key, no network. Installed titles come
/// from `steamapps/appmanifest_<appid>.acf`; extra library folders from
/// `steamapps/libraryfolders.vdf`; the user's own non-Steam shortcuts ("Add a Non-Steam Game to My
/// Library") from each account's binary `userdata/<id>/config/shortcuts.vdf`; artwork from the
/// public Steam CDN by appid, or the user's per-account `grid/` overrides (all a shortcut ever has).
pub struct SteamProvider;
impl LibraryProvider for SteamProvider {
fn store(&self) -> &'static str {
"steam"
}
fn list(&self) -> Vec<GameEntry> {
let mut by_appid: std::collections::BTreeMap<u32, Installed> = Default::default();
for steamapps in steam_library_dirs() {
for app in scan_manifests(&steamapps) {
// First library wins; dedups appids present in several libraries.
by_appid.entry(app.appid).or_insert(app);
}
}
let mut games: Vec<GameEntry> = by_appid
.into_values()
.filter(|app| !is_steam_tool(app.appid, &app.name))
.map(|app| GameEntry {
provider: None,
role: GameRole::Game,
icon: None,
meta: GameMeta::pc(),
id: format!("steam:{}", app.appid),
store: "steam".into(),
art: steam_art(app.appid),
launch: Some(LaunchSpec {
kind: "steam_appid".into(),
value: app.appid.to_string(),
}),
// The appid alone is authoritative on Linux (Steam's launch reaper); the install dir
// is what the Windows matcher — which has no reaper to watch — keys off instead.
detect: match app.install_dir {
Some(dir) => DetectSpec::steam(app.appid).with_dir(dir),
None => DetectSpec::steam(app.appid),
},
title: app.name,
})
.collect();
// Non-Steam shortcuts have no `appmanifest` — [`scan_manifests`] can't see them, so the
// user's own custom entries are gathered separately from `shortcuts.vdf`.
games.extend(steam_shortcuts());
games
}
}
/// The Steam CDN poster/hero/logo/header for an appid — relative proxy paths the *client* resolves
/// against the host it just talked to (so they work the same whichever interface/port the client
/// reached the host on), backed by [`steam_art_bytes`] on the way out. Not every appid has a
/// 600×900 capsule, but `header.jpg` is effectively universal — the client falls back to it.
fn steam_art(appid: u32) -> Artwork {
let url = |kind: &str| Some(format!("/api/v1/library/art/steam:{appid}/{kind}"));
Artwork {
portrait: url("portrait"),
hero: url("hero"),
logo: url("logo"),
header: url("header"),
}
}
/// Resolve one Steam cover-art kind to bytes: the host's own local Steam cache first (exact — it's
/// literally what the user's Steam client already shows for this title), then the user's per-account
/// `grid/` overrides (the *only* art a non-Steam shortcut ever has), then the legacy flat CDN URL.
/// `None` when none has it (the client then falls through to its next art candidate). Blocking
/// (disk + network) — call off the async runtime.
pub fn steam_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
if let Some(local) =
steam_local_art_bytes(appid, kind).or_else(|| steam_grid_art_bytes(appid, kind))
{
return Some(local);
}
// A non-Steam shortcut's appid has the high bit set (see [`shortcut_appid`]) and is never a real
// store appid, so the CDN would only 404 — skip the wasted request and fall through cleanly.
if appid & 0x8000_0000 != 0 {
return None;
}
let url = format!(
"https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}/{}",
kind.cdn_filename()
);
fetch_image(&url)
}
/// Cap on a local librarycache file we'll read into memory — generous for a Steam-quality JPEG/PNG
/// (these run well under 2 MiB in practice) while bounding a pathological file.
const LOCAL_ART_MAX_BYTES: u64 = 8 * 1024 * 1024;
/// `appcache/librarycache/<appid>/<hash>/<filename>` across every Steam root, for whichever
/// `<hash>` subdirectory actually has this kind's file (Steam reuses one hash dir per asset
/// version, so there's normally exactly one candidate per kind).
fn steam_local_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
steam_roots()
.into_iter()
.find_map(|root| find_local_art_file(&root, appid, kind))
.and_then(|path| {
let bytes = std::fs::read(&path).ok()?;
let ctype = if path.extension().is_some_and(|e| e == "png") {
"image/png"
} else {
"image/jpeg"
};
Some((bytes, ctype.to_string()))
})
}
/// Find this kind's cached file under one Steam root's `appcache/librarycache/<appid>/<hash>/`,
/// trying each hash subdirectory (normally just one) and each candidate filename in priority
/// order. Pure path lookup — no env/HOME dependency — so it's unit-testable against a plain
/// directory fixture.
fn find_local_art_file(root: &Path, appid: u32, kind: ArtKind) -> Option<PathBuf> {
let cache_dir = root
.join("appcache")
.join("librarycache")
.join(appid.to_string());
let hash_dirs = std::fs::read_dir(&cache_dir).ok()?;
for hash_dir in hash_dirs.flatten() {
for name in kind.local_filenames() {
let path = hash_dir.path().join(name);
let Ok(meta) = std::fs::metadata(&path) else {
continue;
};
if meta.len() > 0 && meta.len() <= LOCAL_ART_MAX_BYTES {
return Some(path);
}
}
}
None
}
/// Artwork a user set in Steam itself for a **non-Steam shortcut** (or a `grid/` override for a real
/// title), stored per-account under `userdata/<id>/config/grid/`, keyed by the same 32-bit appid the
/// shortcut carries. Tried before the CDN — a shortcut has no CDN art at all, so this is where its
/// poster lives.
fn steam_grid_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
for root in steam_roots() {
let Ok(users) = std::fs::read_dir(root.join("userdata")) else {
continue;
};
for user in users.flatten() {
let grid = user.path().join("config").join("grid");
if let Some(path) = find_grid_art_file(&grid, appid, kind) {
if let Ok(bytes) = std::fs::read(&path) {
let ctype = if path.extension().is_some_and(|e| e == "png") {
"image/png"
} else {
"image/jpeg"
};
return Some((bytes, ctype.to_string()));
}
}
}
}
None
}
/// The `grid/` filenames Steam stores this art kind under for appid `<A>`, tried in order (PNG then
/// JPG — Steam accepts either). Pure path logic, so it's unit-testable against a directory fixture.
fn find_grid_art_file(grid: &Path, appid: u32, kind: ArtKind) -> Option<PathBuf> {
for name in grid_filenames(kind, appid) {
let path = grid.join(&name);
if let Ok(meta) = std::fs::metadata(&path) {
if meta.len() > 0 && meta.len() <= LOCAL_ART_MAX_BYTES {
return Some(path);
}
}
}
None
}
/// The `userdata/<id>/config/grid/` basenames Steam names each art kind under for appid `<A>`:
/// portrait `<A>p`, hero `<A>_hero`, logo `<A>_logo`, and the wide capsule `<A>` — each as `.png`
/// then `.jpg`.
fn grid_filenames(kind: ArtKind, appid: u32) -> Vec<String> {
let both = |base: String| vec![format!("{base}.png"), format!("{base}.jpg")];
match kind {
ArtKind::Portrait => both(format!("{appid}p")),
ArtKind::Hero => both(format!("{appid}_hero")),
ArtKind::Logo => both(format!("{appid}_logo")),
ArtKind::Header => both(format!("{appid}")),
}
}
/// Candidate Steam roots (classic, Flatpak, Deck) that actually exist, canonicalized + deduped.
#[cfg(not(target_os = "windows"))]
fn steam_roots() -> Vec<PathBuf> {
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
return Vec::new();
};
let candidates = [
home.join(".local/share/Steam"),
home.join(".steam/steam"),
home.join(".steam/root"),
home.join(".var/app/com.valvesoftware.Steam/.local/share/Steam"), // Flatpak Steam
];
steam_roots_existing(candidates)
}
/// Windows Steam roots: the default install dirs under Program Files. Games installed on other
/// drives are still found via each root's `libraryfolders.vdf` (see [`steam_library_dirs`]). A
/// non-default Steam install dir (registry `Valve\Steam\InstallPath`) isn't covered yet.
#[cfg(target_os = "windows")]
fn steam_roots() -> Vec<PathBuf> {
let mut candidates = Vec::new();
for var in ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"] {
if let Some(pf) = std::env::var_os(var) {
candidates.push(PathBuf::from(pf).join("Steam"));
}
}
steam_roots_existing(candidates)
}
/// Keep only the candidate roots that exist (have a `steamapps` dir), canonicalized + deduped.
fn steam_roots_existing(candidates: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
let mut seen = HashSet::new();
let mut roots = Vec::new();
for c in candidates {
if let Ok(canon) = c.canonicalize() {
if canon.join("steamapps").is_dir() && seen.insert(canon.clone()) {
roots.push(canon);
}
}
}
roots
}
/// Every `steamapps` dir holding installed titles: each root's own, plus the extra library
/// folders listed in `libraryfolders.vdf` (Steam lets you install games on other drives).
fn steam_library_dirs() -> Vec<PathBuf> {
let mut seen = HashSet::new();
let mut dirs = Vec::new();
let mut push = |steamapps: PathBuf, dirs: &mut Vec<PathBuf>| {
if let Ok(canon) = steamapps.canonicalize() {
if canon.is_dir() && seen.insert(canon.clone()) {
dirs.push(canon);
}
}
};
for root in steam_roots() {
let steamapps = root.join("steamapps");
if let Ok(text) = std::fs::read_to_string(steamapps.join("libraryfolders.vdf")) {
for path in vdf_paths(&text) {
push(PathBuf::from(path).join("steamapps"), &mut dirs);
}
}
push(steamapps, &mut dirs);
}
dirs
}
/// Pull every `"path" "<dir>"` value out of a `libraryfolders.vdf`. We don't need a full VDF
/// parser for the two flat fields we read. On Windows the values are backslash-escaped
/// (`D:\\SteamLibrary`), so unescape `\\` → `\`; Linux paths need no unescaping.
fn vdf_paths(text: &str) -> Vec<String> {
text.lines()
.filter_map(|l| vdf_value(l.trim(), "path"))
.map(|p| {
#[cfg(target_os = "windows")]
{
p.replace("\\\\", "\\")
}
#[cfg(not(target_os = "windows"))]
{
p.to_string()
}
})
.collect()
}
/// `"<key>" "<value>"` on a single line → `<value>`. Used for both VDF and ACF flat fields.
fn vdf_value<'a>(line: &'a str, key: &str) -> Option<&'a str> {
let rest = line.strip_prefix(&format!("\"{key}\""))?;
let after = &rest[rest.find('"')? + 1..];
Some(&after[..after.find('"')?])
}
/// One installed Steam title, as read from its `appmanifest_<appid>.acf`.
struct Installed {
appid: u32,
name: String,
/// `<steamapps>/common/<installdir>`, when the manifest names one and it exists on disk — the
/// game's own files, used to recognize its processes ([`DetectSpec::install_dir`]).
install_dir: Option<PathBuf>,
}
/// Scan a `steamapps` dir for `appmanifest_*.acf` files → the installed titles it describes.
fn scan_manifests(steamapps: &Path) -> Vec<Installed> {
let Ok(rd) = std::fs::read_dir(steamapps) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in rd.flatten() {
let fname = entry.file_name();
let fname = fname.to_string_lossy();
if !(fname.starts_with("appmanifest_") && fname.ends_with(".acf")) {
continue;
}
if let Ok(text) = std::fs::read_to_string(entry.path()) {
let appid = text.lines().find_map(|l| vdf_value(l.trim(), "appid"));
let name = text.lines().find_map(|l| vdf_value(l.trim(), "name"));
if let (Some(Ok(appid)), Some(name)) = (appid.map(str::parse::<u32>), name) {
// `installdir` is a bare folder name relative to this library's `common/`.
let install_dir = text
.lines()
.find_map(|l| vdf_value(l.trim(), "installdir"))
.map(|d| steamapps.join("common").join(d))
.filter(|p| p.is_dir());
out.push(Installed {
appid,
name: name.to_string(),
install_dir,
});
}
}
}
out
}
/// Steam installs runtimes/redistributables as "apps" too — keep them out of a *game* library.
fn is_steam_tool(appid: u32, name: &str) -> bool {
// Steamworks Common Redistributables; Steam Linux Runtime 1.0/2.0/3.0 (Sniper/Soldier).
const TOOL_IDS: &[u32] = &[228980, 1070560, 1391110, 1628350, 1493710];
if TOOL_IDS.contains(&appid) {
return true;
}
let n = name.to_ascii_lowercase();
n.contains("proton")
|| n.starts_with("steam linux runtime")
|| n.contains("steamworks common")
|| n.contains("steamvr")
}
/// One non-Steam shortcut ("Add a Non-Steam Game to My Library"), as read from `shortcuts.vdf`.
struct Shortcut {
/// The 32-bit shortcut appid Steam assigns it (high bit set — see [`shortcut_appid`]). Keys the
/// entry id and its `grid/` artwork; the 64-bit launch id derives from it ([`shortcut_gameid`]).
appid: u32,
/// Display name (`AppName`).
name: String,
/// The shortcut's target (`Exe`), as stored — Steam quotes it. This *is* the game (a shortcut
/// points straight at it, with no launcher in between), so it doubles as the detect signal.
exe: String,
/// Whether Steam has this shortcut hidden from the library (`IsHidden`) — we honor that.
hidden: bool,
}
/// Every non-Steam shortcut across all Steam accounts on this host, as launchable [`GameEntry`]s.
/// These carry no `appmanifest`, so [`scan_manifests`] never sees them — this is the only path that
/// surfaces a user's custom Steam entries. Best-effort: an unreadable/absent `shortcuts.vdf`
/// contributes nothing; hidden shortcuts and duplicate appids are dropped.
fn steam_shortcuts() -> Vec<GameEntry> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for path in shortcuts_files() {
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
for sc in parse_shortcuts(&bytes) {
if seen.insert(sc.appid) {
if let Some(entry) = shortcut_entry(sc) {
out.push(entry);
}
}
}
}
out
}
/// Map one parsed [`Shortcut`] to a library entry, or `None` if Steam has it hidden. Launch reuses
/// the `steam_appid` recipe (`steam steam://rungameid/<id>`) — the value is the 64-bit shortcut
/// game id, not the 32-bit appid, because the plain appid won't launch a non-Steam shortcut.
fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
if sc.hidden {
return None;
}
Some(GameEntry {
provider: None,
role: GameRole::Game,
icon: None,
meta: GameMeta::pc(),
id: format!("steam:{}", sc.appid),
store: "steam".into(),
title: sc.name,
art: steam_art(sc.appid),
launch: Some(LaunchSpec {
kind: "steam_appid".into(),
value: shortcut_gameid(sc.appid).to_string(),
}),
detect: shortcut_detect(&sc.exe),
})
}
/// Detect signals for a non-Steam shortcut: its `Exe` target is the game itself, so the executable
/// (and its folder, which catches a launcher script that execs a sibling binary) identifies it. Steam
/// stores the target quoted and may include trailing arguments; only an existing absolute path is
/// asserted — a guess would be worse than no tracking at all.
fn shortcut_detect(exe: &str) -> DetectSpec {
let mut spec = crate::library::spec_from_command(exe);
if let Some(dir) = spec.exe.as_deref().and_then(Path::parent) {
spec.install_dir = Some(dir.to_path_buf());
}
spec
}
/// Every `userdata/<id>/config/shortcuts.vdf` under each Steam root — one file per Steam account
/// that has signed in on this host.
fn shortcuts_files() -> Vec<PathBuf> {
let mut files = Vec::new();
for root in steam_roots() {
let Ok(users) = std::fs::read_dir(root.join("userdata")) else {
continue;
};
for user in users.flatten() {
let path = user.path().join("config").join("shortcuts.vdf");
if path.is_file() {
files.push(path);
}
}
}
files
}
// `shortcut_gameid` (the 64-bit `rungameid` composition) moved to `launch.rs` (WP1.1) — it is launch
// vocabulary; this module only reads the 32-bit appid out of `shortcuts.vdf`.
/// The 32-bit appid Steam derives for a shortcut from its target+name — `crc32(exe + name)` with the
/// high bit set. Only used when `shortcuts.vdf` omits the stored `appid` (very old Steam); modern
/// Steam writes it, and we prefer the stored value.
fn shortcut_appid(exe: &str, name: &str) -> u32 {
crc32(format!("{exe}{name}").as_bytes()) | 0x8000_0000
}
/// Standard reflected (IEEE) CRC-32 — a few short strings' worth per scan, so a table-free bitwise
/// loop is plenty. Matches what Steam uses to hash a shortcut's `exe + name`.
fn crc32(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in data {
crc ^= byte as u32;
for _ in 0..8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
}
}
!crc
}
/// Parse a **binary** `shortcuts.vdf` into its shortcuts. The format is Steam's binary KeyValues: a
/// 1-byte type tag (`0x00` nested map, `0x01` string, `0x02` int32), a NUL-terminated key, then a
/// type-specific payload; `0x08` closes the current map. The whole file is one `shortcuts` map whose
/// children (keyed `"0"`, `"1"`, …) are the individual shortcuts. Lenient and panic-free: a
/// truncated file or an unrecognized tag stops the walk and returns whatever parsed so far.
fn parse_shortcuts(buf: &[u8]) -> Vec<Shortcut> {
let mut out = Vec::new();
let mut pos = 0usize;
// Enter the top-level map (`<0x00> "shortcuts" <NUL>`); tolerate any key name.
if buf.first() != Some(&0x00) {
return out;
}
pos += 1;
if read_cstr(buf, &mut pos).is_none() {
return out;
}
// Each child is a map describing one shortcut, until the map-closing `0x08`.
while let Some(&tag) = buf.get(pos) {
pos += 1;
if tag != 0x00 {
break; // `0x08` (end of shortcuts) or anything unexpected
}
if read_cstr(buf, &mut pos).is_none() {
break; // the index key ("0", "1", …)
}
match parse_one_shortcut(buf, &mut pos) {
Some(sc) => out.push(sc),
None => break,
}
}
out
}
/// Parse one shortcut's fields (positioned just after its index key) up to the map-closing `0x08`,
/// pulling the ones we surface. `None` on a truncated/garbled entry.
fn parse_one_shortcut(buf: &[u8], pos: &mut usize) -> Option<Shortcut> {
let mut appid: Option<u32> = None;
let mut name = String::new();
let mut exe = String::new();
let mut hidden = false;
loop {
let tag = *buf.get(*pos)?;
*pos += 1;
if tag == 0x08 {
break; // end of this shortcut
}
let key = read_cstr(buf, pos)?.to_ascii_lowercase();
match tag {
0x00 => skip_map(buf, pos)?, // nested map (e.g. `tags`) — not needed
0x01 => {
let val = read_cstr(buf, pos)?;
match key.as_str() {
"appname" => name = val,
"exe" => exe = val,
_ => {}
}
}
0x02 => {
let val = read_i32(buf, pos)?;
match key.as_str() {
"appid" => appid = Some(val as u32),
"ishidden" => hidden = val != 0,
_ => {}
}
}
0x07 => *pos += 8, // uint64 — skip
_ => return None, // unknown tag: payload size unknown, can't continue safely
}
}
if name.trim().is_empty() {
return None; // nothing worth showing
}
// Prefer the stored appid; fall back to Steam's derivation when it's absent (0 / missing).
let appid = appid
.filter(|a| *a != 0)
.unwrap_or_else(|| shortcut_appid(&exe, &name));
Some(Shortcut {
appid,
name,
exe,
hidden,
})
}
/// Skip a nested map's contents (positioned just after its key) up to and including its `0x08`.
fn skip_map(buf: &[u8], pos: &mut usize) -> Option<()> {
loop {
let tag = *buf.get(*pos)?;
*pos += 1;
if tag == 0x08 {
return Some(());
}
read_cstr(buf, pos)?; // key
match tag {
0x00 => skip_map(buf, pos)?,
0x01 => {
read_cstr(buf, pos)?;
}
0x02 => *pos += 4,
0x07 => *pos += 8,
_ => return None,
}
}
}
/// Read a NUL-terminated UTF-8 string (lossy) starting at `pos`, advancing past the terminator.
/// `None` if the buffer ends before a NUL.
fn read_cstr(buf: &[u8], pos: &mut usize) -> Option<String> {
let start = *pos;
let end = buf.get(start..)?.iter().position(|&b| b == 0)? + start;
let s = String::from_utf8_lossy(&buf[start..end]).into_owned();
*pos = end + 1;
Some(s)
}
/// Read a little-endian int32 at `pos`, advancing 4 bytes. `None` if fewer than 4 bytes remain.
fn read_i32(buf: &[u8], pos: &mut usize) -> Option<i32> {
let bytes: [u8; 4] = buf.get(*pos..*pos + 4)?.try_into().ok()?;
*pos += 4;
Some(i32::from_le_bytes(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vdf_value_extracts_quoted_field() {
assert_eq!(
vdf_value("\"path\"\t\t\"/mnt/games/SteamLibrary\"", "path"),
Some("/mnt/games/SteamLibrary")
);
assert_eq!(vdf_value("\"appid\"\t\t\"570\"", "appid"), Some("570"));
assert_eq!(vdf_value("\"name\"\t\t\"Dota 2\"", "name"), Some("Dota 2"));
assert_eq!(vdf_value("\"installdir\"\t\t\"x\"", "appid"), None);
}
#[test]
fn vdf_paths_pulls_all_library_folders() {
let vdf = r#"
"libraryfolders"
{
"0"
{
"path" "/home/u/.local/share/Steam"
"apps" { "570" "123" }
}
"1"
{
"path" "/mnt/ssd/SteamLibrary"
}
}
"#;
assert_eq!(
vdf_paths(vdf),
vec![
"/home/u/.local/share/Steam".to_string(),
"/mnt/ssd/SteamLibrary".to_string()
]
);
}
#[test]
fn tools_are_filtered_but_games_kept() {
assert!(is_steam_tool(228980, "Steamworks Common Redistributables"));
assert!(is_steam_tool(1493710, "Proton Experimental"));
assert!(is_steam_tool(0, "Steam Linux Runtime 3.0 (sniper)"));
assert!(!is_steam_tool(570, "Dota 2"));
assert!(!is_steam_tool(1245620, "ELDEN RING"));
}
#[test]
fn steam_art_points_at_the_host_art_proxy() {
let art = steam_art(570);
assert_eq!(
art.portrait.as_deref(),
Some("/api/v1/library/art/steam:570/portrait")
);
assert_eq!(
art.header.as_deref(),
Some("/api/v1/library/art/steam:570/header")
);
}
#[test]
fn find_local_art_file_matches_the_hashed_librarycache_layout() {
let dir = tempfile::tempdir().unwrap();
let cache = dir
.path()
.join("appcache/librarycache/3527290/480bd879ac737921bfa2529a6fea15961267ad21");
std::fs::create_dir_all(&cache).unwrap();
std::fs::write(cache.join("library_600x900.jpg"), b"not really a jpeg").unwrap();
let found = find_local_art_file(dir.path(), 3527290, ArtKind::Portrait).unwrap();
assert_eq!(found, cache.join("library_600x900.jpg"));
// A kind with no cached file, and an appid with no cache dir at all, both miss cleanly.
assert_eq!(
find_local_art_file(dir.path(), 3527290, ArtKind::Hero),
None
);
assert_eq!(
find_local_art_file(dir.path(), 570, ArtKind::Portrait),
None
);
}
#[test]
fn find_local_art_file_prefers_the_2x_portrait() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path().join("appcache/librarycache/570/somehash");
std::fs::create_dir_all(&cache).unwrap();
std::fs::write(cache.join("library_600x900.jpg"), b"1x").unwrap();
std::fs::write(cache.join("library_600x900_2x.jpg"), b"2x").unwrap();
let found = find_local_art_file(dir.path(), 570, ArtKind::Portrait).unwrap();
assert_eq!(found, cache.join("library_600x900_2x.jpg"));
}
// --- Non-Steam shortcuts (custom Steam entries) ---
/// Build one binary-VDF field for a test `shortcuts.vdf`.
fn field_str(key: &str, val: &str) -> Vec<u8> {
let mut v = vec![0x01u8];
v.extend_from_slice(key.as_bytes());
v.push(0);
v.extend_from_slice(val.as_bytes());
v.push(0);
v
}
fn field_i32(key: &str, val: i32) -> Vec<u8> {
let mut v = vec![0x02u8];
v.extend_from_slice(key.as_bytes());
v.push(0);
v.extend_from_slice(&val.to_le_bytes());
v
}
fn map_open(key: &str) -> Vec<u8> {
let mut v = vec![0x00u8];
v.extend_from_slice(key.as_bytes());
v.push(0);
v
}
#[test]
fn parse_shortcuts_reads_entries_honors_hidden_and_key_case() {
let mut buf = Vec::new();
buf.extend(map_open("shortcuts"));
// Entry 0: a normal shortcut, with a nested `tags` map to exercise skip_map, and mixed-case
// keys (Steam has shipped both `AppName` and `appname`). appid stored as a negative i32.
buf.extend(map_open("0"));
buf.extend(field_i32("appid", -1838178284)); // == 2456789012 as u32
buf.extend(field_str("AppName", "My Emulator"));
buf.extend(field_str("Exe", "\"/usr/bin/foo\""));
buf.extend(field_i32("IsHidden", 0));
buf.extend(map_open("tags"));
buf.extend(field_str("0", "emulator"));
buf.push(0x08); // end tags
buf.push(0x08); // end entry 0
// Entry 1: hidden, lowercase key variant.
buf.extend(map_open("1"));
buf.extend(field_str("appname", "Hidden Game"));
buf.extend(field_i32("ishidden", 1));
buf.push(0x08); // end entry 1
buf.push(0x08); // end shortcuts
let scs = parse_shortcuts(&buf);
assert_eq!(scs.len(), 2);
assert_eq!(scs[0].appid, 2_456_789_012);
assert_eq!(scs[0].name, "My Emulator");
assert!(!scs[0].hidden);
assert_eq!(scs[1].name, "Hidden Game");
assert!(scs[1].hidden);
// A hidden shortcut is dropped from the surfaced library; a visible one launches via its
// 64-bit game id (not the bare appid).
assert!(shortcut_entry(scs.into_iter().nth(1).unwrap()).is_none());
}
#[test]
fn parse_shortcuts_is_lenient() {
assert!(parse_shortcuts(b"").is_empty()); // not even a top-level map
assert!(parse_shortcuts(b"{not binary vdf}").is_empty());
// A truncated entry (buffer ends mid-int) yields what parsed cleanly before it — here, none.
let mut buf = Vec::new();
buf.extend(map_open("shortcuts"));
buf.extend(map_open("0"));
buf.extend_from_slice(b"\x02appid\x00\x01\x02"); // 2 of 4 int bytes, then EOF
assert!(parse_shortcuts(&buf).is_empty());
}
#[test]
fn shortcut_entry_launches_via_rungameid() {
let sc = Shortcut {
appid: 2_456_789_012,
name: "My Emulator".into(),
exe: "\"/opt/emu/run.sh\"".into(),
hidden: false,
};
let entry = shortcut_entry(sc).unwrap();
assert_eq!(entry.id, "steam:2456789012");
assert_eq!(entry.store, "steam");
let launch = entry.launch.unwrap();
assert_eq!(launch.kind, "steam_appid");
// Value is the 64-bit game id — digits only, so it passes the shared appid guard.
assert_eq!(launch.value, shortcut_gameid(2_456_789_012).to_string());
assert!(launch.value.bytes().all(|b| b.is_ascii_digit()));
}
// `shortcut_gameid_composes_appid_and_marker` moved with the function to `launch.rs` (WP1.1).
#[test]
fn crc32_matches_the_known_check_value_and_derives_a_high_bit_appid() {
assert_eq!(crc32(b"123456789"), 0xCBF4_3926); // IEEE CRC-32 check value
// A derived shortcut appid always has the high bit set (so it never collides with a real
// store appid, and its CDN art fetch is skipped).
assert_ne!(
shortcut_appid("\"/usr/bin/foo\"", "My Emulator") & 0x8000_0000,
0
);
}
#[test]
fn grid_filenames_follow_steams_naming() {
assert_eq!(
grid_filenames(ArtKind::Portrait, 42),
vec!["42p.png", "42p.jpg"]
);
assert_eq!(
grid_filenames(ArtKind::Hero, 42),
vec!["42_hero.png", "42_hero.jpg"]
);
assert_eq!(
grid_filenames(ArtKind::Logo, 42),
vec!["42_logo.png", "42_logo.jpg"]
);
assert_eq!(
grid_filenames(ArtKind::Header, 42),
vec!["42.png", "42.jpg"]
);
}
#[test]
fn find_grid_art_file_matches_the_userdata_grid_layout() {
let grid = tempfile::tempdir().unwrap();
std::fs::write(grid.path().join("2456789012p.jpg"), b"poster").unwrap();
let found = find_grid_art_file(grid.path(), 2_456_789_012, ArtKind::Portrait).unwrap();
assert_eq!(found, grid.path().join("2456789012p.jpg"));
// Missing kinds and a zero-byte file both miss cleanly.
assert_eq!(
find_grid_art_file(grid.path(), 2_456_789_012, ArtKind::Hero),
None
);
std::fs::write(grid.path().join("42p.png"), b"").unwrap();
assert_eq!(find_grid_art_file(grid.path(), 42, ArtKind::Portrait), None);
}
}
-212
View File
@@ -1,212 +0,0 @@
//! Xbox / Microsoft Store (UWP) provider: installed packages, PFN resolution, and store art. Split out of the `library` facade (plan §W5).
use super::art::cached_art;
use super::*;
/// Reads installed Xbox / Game Pass / Store GDK games from the flat-file install dirs. Windows-only.
/// Best-effort: empty when no `XboxGames` dir exists.
#[cfg(windows)]
pub struct XboxProvider;
#[cfg(windows)]
impl LibraryProvider for XboxProvider {
fn store(&self) -> &'static str {
"xbox"
}
fn list(&self) -> Vec<GameEntry> {
xbox_games()
}
}
/// Scan each fixed drive's default `<drive>:\XboxGames` for GDK games — the presence of
/// `Content\MicrosoftGame.config` is the game marker (so we list games, not ordinary UWP apps). A
/// custom install folder (set via the undocumented `.GamingRoot`) isn't covered; the default folder
/// is the common case. Non-GDK pure-UWP Store games (under the ACL-locked WindowsApps) are missed too.
#[cfg(windows)]
fn xbox_games() -> Vec<GameEntry> {
let mut games = Vec::new();
for letter in b'C'..=b'Z' {
let root = PathBuf::from(format!("{}:\\XboxGames", letter as char));
let Ok(rd) = std::fs::read_dir(&root) else {
continue;
};
for entry in rd.flatten() {
let title_dir = entry.path();
let cfg = title_dir.join("Content").join("MicrosoftGame.config");
if !cfg.is_file() {
continue;
}
// Cap the read like the other untrusted on-disk manifests (Epic `read_capped`, Lutris
// art) — a planted multi-GB MicrosoftGame.config under `<drive>:\XboxGames\…\Content\`
// must not OOM the privileged host during enumeration (security-review 2026-07-17). A
// real GDK manifest is a few KB.
match cfg.metadata() {
Ok(m) if m.len() <= 1024 * 1024 => {}
_ => continue,
}
let Ok(text) = std::fs::read_to_string(&cfg) else {
continue;
};
let folder = title_dir
.file_name()
.map(|f| f.to_string_lossy().into_owned());
let Some((name, app_id, title, store_id)) = xbox_parse_config(&text, folder.as_deref())
else {
continue;
};
let Some(pfn) = xbox_pfn(&name) else {
tracing::debug!(package = %name, "xbox: no AppRepository entry → can't resolve PFN, skipping");
continue;
};
let id_key = if store_id.is_empty() {
pfn.clone()
} else {
store_id
};
let id = format!("xbox:{id_key}");
// Art (unofficial displaycatalog, keyed by StoreId) is resolved off the hot path by the
// background warmer; read whatever it has cached (title-only until warmed / if no StoreId).
let art = cached_art(&id).unwrap_or_default();
games.push(GameEntry {
provider: None,
role: GameRole::Game,
icon: None,
meta: GameMeta::pc(),
id,
store: "xbox".into(),
title,
art,
launch: Some(LaunchSpec {
kind: "aumid".into(),
value: format!("{pfn}!{app_id}"),
}),
// AUMID activation goes through the shell, so the host never owns the process: the
// title's `Content` dir (which holds the game's binaries) is the detect signal.
detect: DetectSpec::dir(title_dir.join("Content")),
});
}
}
games.sort_by(|a, b| a.id.cmp(&b.id));
games.dedup_by(|a, b| a.id == b.id); // same game on two drives → one entry
games
}
/// Parse the fields we need from a `MicrosoftGame.config`: `(Identity Name, AppId, title, StoreId)`.
/// AppId is the `<Executable>`'s `Id` (the AUMID app id, typically "Game"). The title prefers
/// `ShellVisuals@DefaultDisplayName`, but that can be an unresolved `ms-resource:` ref → fall back to
/// the install folder name, then the package name.
#[cfg(windows)]
fn xbox_parse_config(text: &str, folder: Option<&str>) -> Option<(String, String, String, String)> {
let doc = roxmltree::Document::parse(text).ok()?;
let root = doc.root_element();
let name = root
.children()
.find(|n| n.has_tag_name("Identity"))?
.attribute("Name")?
.to_string();
let app_id = root
.children()
.find(|n| n.has_tag_name("ExecutableList"))
.and_then(|el| {
el.children()
.filter(|n| n.has_tag_name("Executable"))
.find_map(|e| e.attribute("Id"))
})?
.to_string();
let ddn = root
.children()
.find(|n| n.has_tag_name("ShellVisuals"))
.and_then(|sv| sv.attribute("DefaultDisplayName"))
.filter(|s| !s.is_empty() && !s.starts_with("ms-resource"));
let title = ddn
.map(String::from)
.or_else(|| folder.map(String::from))
.unwrap_or_else(|| name.clone());
let store_id = root
.children()
.find(|n| n.has_tag_name("StoreId"))
.and_then(|n| n.text())
.unwrap_or("")
.to_string();
Some((name, app_id, title, store_id))
}
/// Resolve a package's PackageFamilyName by finding its
/// `AppRepository\Packages\<PackageFullName>` dir (machine-wide, SYSTEM-readable) and reducing the
/// full name to `Name_PublisherHash`. This READS the authoritative PFN — never compute the hash.
///
/// **Readable by the host, NOT by the plugin runner.** Measured on 2026-08-06: that directory is
/// `UnauthorizedAccessException` for `NT AUTHORITY\LocalService` (which the runner is), while the
/// host service runs as LocalSystem and enumerates all 348 entries. That asymmetry is why the
/// `xbox` launch kind exists — a library plugin sends the package Identity it CAN read out of
/// `MicrosoftGame.config`, and this resolves the rest at launch time (see `launch.rs`).
#[cfg(windows)]
pub(crate) fn xbox_pfn(identity: &str) -> Option<String> {
let pkgs = PathBuf::from(std::env::var_os("ProgramData")?)
.join("Microsoft")
.join("Windows")
.join("AppRepository")
.join("Packages");
let prefix = format!("{identity}_");
for e in std::fs::read_dir(&pkgs).ok()?.flatten() {
let dn = e.file_name().to_string_lossy().into_owned();
if dn.starts_with(&prefix) {
if let Some(pfn) = pfn_from_full(&dn, identity) {
return Some(pfn);
}
}
}
None
}
/// PackageFamilyName from a PackageFullName dir name
/// (`Name_Version_Arch_ResourceId_PublisherHash`) → `Name_PublisherHash`. The hash is the last
/// `_`-segment; `Name` is the caller's identity.
#[cfg(windows)]
fn pfn_from_full(dir_name: &str, identity: &str) -> Option<String> {
let hash = dir_name.rsplit('_').next()?;
(!hash.is_empty() && hash != dir_name).then(|| format!("{identity}_{hash}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn xbox_parse_config_and_pfn() {
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<Game configVersion="1">
<Identity Name="Microsoft.624F8B84B80" Publisher="CN=Microsoft" Version="1.0.0.0" />
<ExecutableList>
<Executable Name="gamelaunchhelper.exe" Id="Game" />
</ExecutableList>
<StoreId>9NBLGGH4R315</StoreId>
<ShellVisuals DefaultDisplayName="Halo Infinite" Square150x150Logo="x.png" />
</Game>"#;
let (name, app_id, title, store_id) = xbox_parse_config(xml, Some("HaloInfinite")).unwrap();
assert_eq!(name, "Microsoft.624F8B84B80");
assert_eq!(app_id, "Game");
assert_eq!(title, "Halo Infinite");
assert_eq!(store_id, "9NBLGGH4R315");
// An ms-resource DefaultDisplayName is unresolvable → fall back to the install folder name.
let xml2 = r#"<Game><Identity Name="Pkg.Name"/>
<ExecutableList><Executable Id="App"/></ExecutableList>
<ShellVisuals DefaultDisplayName="ms-resource:DisplayName"/></Game>"#;
let (_, app2, title2, sid2) = xbox_parse_config(xml2, Some("MyGameFolder")).unwrap();
assert_eq!(app2, "App");
assert_eq!(title2, "MyGameFolder");
assert_eq!(sid2, "");
// PackageFamilyName reduced from a PackageFullName dir name (the hash is the last segment).
assert_eq!(
pfn_from_full(
"Microsoft.624F8B84B80_1.0.0.0_x64__8wekyb3d8bbwe",
"Microsoft.624F8B84B80"
)
.as_deref(),
Some("Microsoft.624F8B84B80_8wekyb3d8bbwe")
);
assert!(pfn_from_full("NoUnderscore", "NoUnderscore").is_none());
}
}
+1 -1
View File
@@ -314,7 +314,7 @@ pub fn openapi_json() -> String {
(name = "pairing", description = "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)"),
(name = "native", description = "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"),
(name = "session", description = "Active streaming session control"),
(name = "library", description = "Game library: installed-store titles (Steam) plus user-curated custom entries"),
(name = "library", description = "Game library: the titles each installed library plugin syncs, plus user-curated custom entries"),
(name = "stats", description = "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"),
(name = "logs", description = "Host log stream: the newest in-memory log entries, cursor-paged for live following"),
(name = "events", description = "Host lifecycle events: an SSE stream (client/session/stream lifecycle, pairing, displays, library, host) with Last-Event-ID resume and server-side kind filters"),
+39 -47
View File
@@ -1,5 +1,5 @@
//! Library-tagged management endpoints: installed-store + custom game entries and box art.
//! Split out of the `mgmt` facade (plan §W5).
//! Library-tagged management endpoints: the game catalog (plugin-synced + custom entries), the
//! source toggles, the provider reconcile API and box art. Split out of the `mgmt` facade (plan §W5).
use super::auth::AuthLane;
use super::shared::*;
@@ -70,11 +70,12 @@ pub(crate) struct LibraryQuery {
/// List the game library
///
/// Every installed-store title (Steam, read from the host's local files — no Steam API key)
/// merged with the user's custom entries, sorted by title. Artwork fields are URLs the client
/// fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the
/// entries a given external provider owns; `?platform=` to one platform (case-insensitive —
/// installed-store titles are `PC`, custom/provider entries carry whatever was authored).
/// Every title this host knows about, sorted by title: the entries each installed library plugin
/// has synced (Steam, Lutris, Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …) plus the user's
/// own custom entries. Artwork fields are URLs the client fetches directly, except local files on
/// the host, which are rewritten to this API's own art proxy. `?provider=` narrows to the entries a
/// given external provider owns; `?platform=` to one platform (case-insensitive — whatever the
/// source authored, conventionally `PC` for desktop stores).
///
/// **The operator's own lane additionally sees the titles they have HIDDEN**, each carrying
/// `hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The
@@ -222,17 +223,20 @@ pub(crate) async fn set_library_entry_hidden(
/// Request body for `setLibraryScanner`.
#[derive(Deserialize, ToSchema)]
pub(crate) struct ScannerToggle {
/// Whether the scanner should run on this host.
/// Whether this source should contribute titles on this host.
enabled: bool,
}
/// List the library scanners
/// List the library sources
///
/// The installed-store scanners this host supports — the list is platform-dependent (Steam
/// everywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console
/// renders a toggle only for scanners that can do anything here. Scanners default to enabled;
/// disabling one hides its titles from every library surface from the next read. The user-curated
/// custom store is not a scanner and is always on.
/// Every game source on this host with its enable state — one row per installed library plugin
/// (Steam, Lutris, Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …), so the list reflects what
/// the operator has actually installed rather than what this build happens to support. Sources
/// default to enabled; disabling one hides its titles from every library surface from the next
/// read. The user-curated custom store is not a source and is always on.
///
/// Older hosts (≤ v0.27.x) also listed the six scanners built into the host binary, with
/// `origin: "builtin"`. Those are gone; every row now reports `origin: "plugin"`.
#[utoipa::path(
get,
path = "/library/scanners",
@@ -247,12 +251,13 @@ pub(crate) async fn list_library_scanners() -> Json<Vec<crate::library::ScannerI
Json(crate::library::list_scanners())
}
/// Enable or disable a library scanner
/// Enable or disable a library source
///
/// Persists the toggle and applies it from the next library read (no restart). Disabling a scanner
/// Persists the toggle and applies it from the next library read (no restart). Disabling a source
/// hides its titles everywhere — the console grid, native clients, and the GameStream app list —
/// and re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits
/// `library.changed` with the scanner id as `source` when the state changed.
/// and re-enabling brings them straight back. Nothing is deleted: the plugin may keep reconciling
/// while its source is off, and those entries simply aren't surfaced. Emits `library.changed` with
/// the source id as `source` when the state changed.
#[utoipa::path(
put,
path = "/library/scanners/{id}",
@@ -436,11 +441,11 @@ pub(crate) struct ReconcileQuery {
///
/// `?store=` additionally **claims** that store for the provider: its entries then surface with
/// deterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque
/// `custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner
/// used to produce, right down to the GameStream app ids and client-side art caches. One provider
/// per store; a second claimant gets 409. While a claim is held the matching built-in scanner is
/// suppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty
/// reconcile (a store can legitimately have zero installed titles).
/// `custom:<id>` ones — which is what let a library plugin reproduce the entries the in-host scanner
/// used to produce, right down to the GameStream app ids and client-side art caches, and is why
/// removing those scanners changed nothing downstream. One provider per store; a second claimant
/// gets 409. The claim is released by `DELETE`, not by an empty reconcile (a store can legitimately
/// have zero installed titles).
#[utoipa::path(
put,
path = "/library/provider/{provider}",
@@ -551,11 +556,12 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Res
///
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
/// the image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,
/// and a library plugin's claimed-store entries) serves its local art file. A Steam title falls back
/// to the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the
/// user's Steam client already shows for it), the public Steam CDN's flat URL convention second
/// (newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case
/// this 404s and the client falls through to its next art candidate).
/// and a library plugin's claimed-store entries) serves its local art file; anything else 404s and
/// the client falls through to its next art candidate.
///
/// The host fetches nothing here. Art a plugin published as an `http(s)` URL is fetched by the
/// client directly — this proxy exists for the *local* files a plugin finds on the host's own disk
/// (a launcher's cover cache), which a client has no way to read.
#[utoipa::path(
get,
path = "/library/art/{id}/{kind}",
@@ -575,10 +581,11 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) ->
let Some(kind) = crate::library::ArtKind::parse(&kind) else {
return api_error(StatusCode::NOT_FOUND, "unknown art kind");
};
// `library.json` FIRST, for ANY id (WP1.2). Stored entries — manual, provider-synced, and (once
// store claims land) a scanner plugin's `steam:570` all serve their local art file from here,
// so the proxy never has to know which store an id belongs to. Steam ids aren't stored today, so
// this misses and the legacy branch below still answers them.
// `library.json`, for ANY id (WP1.2): manual entries, provider-synced entries and a library
// plugin's claimed-store `steam:570` all serve their local art file from here, so the proxy never
// has to know which store an id belongs to. This was one of two branches — the second resolved a
// `steam:` id through the in-host Steam scanner's own cache/CDN ladder, and was retired with that
// scanner (M6). Steam ids now arrive here like every other claimed store's.
let stored = {
let id = id.clone();
tokio::task::spawn_blocking(move || crate::library::library_local_art_bytes(&id, kind))
@@ -587,20 +594,5 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) ->
if let Ok(Some((bytes, ctype))) = stored {
return ([(header::CONTENT_TYPE, ctype)], bytes).into_response();
}
// Legacy in-host Steam scanner: local Steam cache, then the flat CDN URL. Retired with the
// scanner itself once the steam plugin claims the store (M6).
if let Some(appid) = id
.strip_prefix("steam:")
.and_then(|s| s.parse::<u32>().ok())
{
return match tokio::task::spawn_blocking(move || {
crate::library::steam_art_bytes(appid, kind)
})
.await
{
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
};
}
api_error(StatusCode::NOT_FOUND, "no art of that kind for this title")
}
+16 -7
View File
@@ -2136,11 +2136,16 @@ async fn hooks_get_shape_and_put_validation() {
// ------------------------------------------------------------------ library scanners
/// The scanner list is platform-shaped and read-only-safe; the toggle rejects unknown ids
/// The source list is plugin-shaped and read-only-safe; the toggle rejects unknown ids
/// with 404. (A successful toggle PUT would write the developer's real
/// `library-scanners.json`, so the write path is exercised only through the unknown-id
/// rejection here — the settings round-trip itself is unit-tested in `library::scanners`
/// against pure shapes.)
///
/// This used to assert that `steam` is present on every platform, which was the defining property
/// while the scanners were compiled in. It is deliberately gone: the list is now derived entirely
/// from what the operator has installed, so on a host with no library plugins it is legitimately
/// empty. What replaces it is the invariant that outlives the built-ins — **every** row is a plugin.
#[tokio::test]
async fn library_scanner_list_and_unknown_toggle() {
let app = test_app(test_state(), None);
@@ -2149,12 +2154,16 @@ async fn library_scanner_list_and_unknown_toggle() {
assert_eq!(s, StatusCode::OK);
let scanners = json.as_array().expect("a scanner array");
assert!(
scanners
.iter()
.any(|sc| sc["id"] == "steam" && sc["label"].is_string() && sc["enabled"].is_boolean()),
"steam must be a scanner on every platform: {json}"
scanners.iter().all(|sc| sc["origin"] == "plugin"),
"no host build reports a builtin source any more: {json}"
);
// Only platform-available scanners appear (`custom` is a store, never a scanner).
assert!(
scanners.iter().all(|sc| sc["id"].is_string()
&& sc["label"].is_string()
&& sc["enabled"].is_boolean()),
"every source row must carry the shape the console renders: {json}"
);
// `custom` is a store, never a source — the toggle surface must not offer it.
assert!(scanners.iter().all(|sc| sc["id"] != "custom"));
let (s, json) = send(
@@ -2170,7 +2179,7 @@ async fn library_scanner_list_and_unknown_toggle() {
assert_eq!(
s,
StatusCode::NOT_FOUND,
"unknown scanner id must 404: {json}"
"unknown source id must 404: {json}"
);
}
+4 -4
View File
@@ -404,10 +404,10 @@ pub(crate) async fn serve(
crate::vdisplay::preflight_takeover_privilege();
// …and the other end of that: give the box its session back when WE are the ones going away.
install_shutdown_restore();
// Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com /
// displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never
// blocks on the network. A no-op on a host whose stores all carry their own art.
let _art_warmer = crate::library::start_art_warmer();
// (No cover-art warmer any more: it existed to fetch GOG/Xbox art off the hot path for the two
// built-in scanners that had to ask a network catalog what a cover was. Those scanners are gone,
// and a library plugin resolves art while it scans and publishes it on the entry — so `all_games()`
// never touches the network to begin with.)
// Pairing state (arming PIN + trust store) is shared with the management API. If it was armed
// at startup (the CLI flags), surface the PIN the headless operator reads from the log; the
// web console arms it on demand instead (a fresh, time-limited PIN).
+26 -21
View File
@@ -4,37 +4,42 @@ description: How Punktfunk finds your installed games, how to add one by hand, a
---
Every Punktfunk host keeps one **game library** — a single list of titles that every surface reads
from. It has three sources: the launchers the host scans on disk, entries you add by hand in the
[web console](/docs/web-console), and titles a [plugin](/docs/plugins) syncs in.
from. It has two sources: the [plugins](/docs/plugins) you install for the launchers you actually
use, and entries you add by hand in the [web console](/docs/web-console).
Whichever source a title came from, it looks the same everywhere: a poster, a name, and a stable id
like `steam:570` or `custom:9f2a1c…`. Pick one on a client and the host launches it into the stream.
## Where your games come from
The host reads your launchers' **own local files**. There are no accounts to connect and no API
keys — nothing leaves the machine to build the list. Each scanner is best-effort: a launcher that
isn't installed simply contributes nothing. Cover art is the one exception, and it needs no account
either — see [Cover art](#cover-art).
**Install a plugin for each launcher you want in the library.** A fresh host holds no games until
you do — go to the console's **Library** page, open **Game sources**, and install the ones you use.
It takes a click each.
Which scanners exist depends on the host's operating system:
Each plugin reads that launcher's **own local files** on the host. There are no accounts to connect
and no API keys — nothing leaves the machine to build the list. A launcher that isn't installed
contributes nothing, so installing a plugin you turn out not to need costs you an empty source and
nothing else.
| Source | Linux host | Windows host | What it reads |
| Plugin | Linux host | Windows host | What it reads |
|---|---|---|---|
| **Steam** | ✅ | ✅ | Installed titles from `appmanifest_<appid>.acf` in every Steam library folder, plus your own **non-Steam shortcuts** |
| **Lutris** | ✅ | — | The local Lutris database (`pga.db`) |
| **Heroic (Epic / GOG / Amazon)** | ✅ | — | Heroic Games Launcher's local library cache, all three of its backends |
| **Epic Games Launcher** | — | ✅ | The launcher's install manifests |
| **GOG Galaxy** | — | ✅ | The GOG install registry and each game's `.info` file |
| **Xbox / Game Pass** | — | ✅ | GDK games in each drive's default `XboxGames` folder |
| **GOG** | — | ✅ | The GOG install registry and each game's `.info` file |
| **Playnite** | — | ✅ | Your Playnite library, whichever stores it aggregates |
| **ROM Manager** | ✅ | ✅ | Your ROM folders, matched against a metadata source |
Every scanner is **on by default**.
> Through v0.27.x six of these scanners were built into the host itself and ran whether you wanted
> them or not. From **v0.28.0** they are plugins like any other. If you were already running the
> plugin for a launcher, nothing changes — the ids, art and app ids are identical by design. If you
> were relying on the built-in scanner, install that launcher's plugin once and your grid comes back
> exactly as it was, including anything you had switched off or hidden.
A few things are deliberately left out. Steam's tooling — Proton, the Steam Linux Runtimes, Steamworks
Common Redistributables, SteamVR — is filtered out, so your grid holds games rather than plumbing. A
non-Steam shortcut you have hidden inside Steam stays hidden here too. On Windows, an Xbox game
installed outside the default `XboxGames` folder isn't found, and a Microsoft Store game that isn't a
GDK title isn't listed at all.
non-Steam shortcut you have hidden inside Steam stays hidden here too.
To see exactly what the host resolved, run [`punktfunk-host library`](/docs/host-cli) on the host: it
prints the whole library as JSON. That answers "does the host see my games?" without involving a
@@ -42,19 +47,19 @@ client.
## Turning a source off
The console's **Library** page has a **Game sources** card with one chip per scanner this host
supports. A chip is highlighted when the host scans that launcher; click it to turn the scanner off.
The console's **Library** page has a **Game sources** card with one chip per source this host has.
A chip is highlighted when that source is contributing titles; click it to turn the source off.
Turning a source off hides its titles from **everywhere at once** — the console grid, every native
client, the Moonlight app list, and launching. Nothing is deleted, the change needs no restart, and
turning the source back on brings the titles straight back on the next read.
client, the Moonlight app list, and launching. Nothing is deleted and the change needs no restart:
the plugin keeps its titles, they simply stop being shown, and turning the source back on brings
them straight back on the next read. (To remove a source's titles for good, uninstall its plugin.)
The list of chips is built from what the host can actually do, so a Windows host never offers you a
Lutris toggle. Your hand-added entries are not a scanner and have no chip — they are always shown.
Your hand-added entries are not a source and have no chip — they are always shown.
The choice is stored per host in `library-scanners.json`, next to the rest of the host config
(`~/.config/punktfunk/` on Linux, `%ProgramData%\punktfunk\` on Windows). Only the sources you turned
*off* are written down, so a scanner added by a future release starts enabled.
*off* are written down, so a source added later starts enabled.
## Adding a game by hand
+13 -7
View File
@@ -1,19 +1,25 @@
---
title: Plugins
description: First-party plugins — sync your ROM collection or Playnite library into the game library, or hand a real USB device on the couch to the host — and how to install them.
description: First-party plugins — bring each of your game launchers into the library, sync a ROM collection or Playnite library, or hand a real USB device on the couch to the host — and how to install them.
---
Plugins extend the host through the **scripting runner** (see [Events & hooks](/docs/automation)). A
plugin runs alongside the host, reconciles titles into your [game library](/docs/game-library) as a
provider — so they appear in the grid on every client — and can add its own page to the [web console](/docs/web-console).
Three first-party plugins today:
**Your game library is built from plugins.** From v0.28.0 the host scans no launcher on its own, so
install the one for each launcher you use — that is what puts games in the grid.
| Plugin | What it does |
|---|---|
| **ROM Manager** | Scans your ROM directories, matches each platform to an installed emulator, and syncs them into the library with box art. |
| **Playnite** | Mirrors your [Playnite](https://playnite.link) library — every store and emulator it manages — into the library, launched back through Playnite. |
| **VirtualHere** | Hands a real USB device on the couch — wheel, HOTAS, pad — to the host while you play, and gives it back after. Needs [VirtualHere](https://www.virtualhere.com/), sold separately. |
| Plugin | Host | What it does |
|---|---|---|
| **Steam** | Linux, Windows | Your installed Steam titles and non-Steam shortcuts, with Steam's own art, plus a Big Picture tile. |
| **Lutris** | Linux | Your Lutris games, with art, and a tile that opens Lutris itself. |
| **Heroic** | Linux | Everything Heroic manages — Epic, GOG and Amazon — plus a tile that opens Heroic. |
| **Epic Games Store** | Windows | Your installed Epic titles, from the launcher's own manifests. |
| **GOG** | Windows | Your installed GOG games, launched directly — Galaxy is never involved. |
| **Playnite** | Windows | Mirrors your [Playnite](https://playnite.link) library — every store and emulator it manages — into the library, launched back through Playnite. |
| **ROM Manager** | Linux, Windows | Scans your ROM directories, matches each platform to an installed emulator, and syncs them into the library with box art. |
| **VirtualHere** | Linux, Windows | Hands a real USB device on the couch — wheel, HOTAS, pad — to the host while you play, and gives it back after. Needs [VirtualHere](https://www.virtualhere.com/), sold separately. |
## Installing from the console
+11 -1
View File
@@ -19,7 +19,17 @@ WORKDIR /repo/web
# --ignore-scripts: the root `prepare` script runs codegen, which needs sources that
# aren't copied yet — `bun run build` regenerates everything below.
COPY web/package.json web/bun.lock ./
RUN bun install --frozen-lockfile --ignore-scripts
# Retried, because a single failed tarball kills the whole install and takes the image build with
# it. Seen in CI as `error: Fail extracting tarball for "@rolldown/binding-linux-x64-musl"` — a
# 7.7 MB optional binding that bun fetches on any linux-x64 host (the lockfile records `os`/`cpu`
# but no libc, so the musl and glibc bindings are equally eligible) and that had arrived truncated.
# The lockfile is not at fault: `bun install --frozen-lockfile` accepts it, regenerating it with the
# same bun is byte-identical, and this exact layer builds clean for --platform linux/amd64.
# Two attempts with a pause, then fail for real — this recovers a truncated download and does NOT
# paper over a runner that is out of disk, which fails identically on every attempt.
RUN bun install --frozen-lockfile --ignore-scripts \
|| { echo "bun install failed — retrying once"; sleep 5; \
bun install --frozen-lockfile --ignore-scripts; }
COPY api/openapi.json /repo/api/openapi.json
COPY web/ ./
+8
View File
@@ -33,6 +33,10 @@ import { SourceSettingsDialog } from "./SourceSettings";
* one list of sources whose ids match whether they came from a built-in scanner or the plugin that
* replaced it so one surface is both simpler and the only honest presentation (design D6).
*
* A host from v0.28.0 on has no built-in scanners left and reports every source as `plugin`. The
* `builtin` handling below is kept deliberately: the console ships as its own package and is
* expected to drive an N-1 host, which still reports them.
*
* Deliberately kept under the existing "Game sources" label rather than a new "Plugins" heading:
* `store_title` and `nav_plugins` are both already "Plugins", and a third would be worse than the
* merge is good.
@@ -117,6 +121,10 @@ export const SourcesSection: FC<{
// The bridge-release nudge (design D9): a built-in scanner still doing the work, with its
// replacement plugin sitting uninstalled in the catalog. One click per scanner, and NEVER a
// silent auto-install — installing code stays an explicit operator act.
//
// Against a v0.28.0+ host this is always empty (no source reports `builtin` any more) and the
// banner never renders. It stays for the N-1 host this console may be driving, where it is still
// the migration path.
const migratable = scanners.data
.filter((s) => s.origin === "builtin" && s.enabled)
.map((s) => ({