Files
punktfunk/design/game-library-stores.md
T
enricobuehler d01a8fd17a
windows-host / package (push) Failing after 4m16s
ci / rust (push) Failing after 4m56s
ci / web (push) Failing after 22s
ci / docs-site (push) Successful in 1m7s
android / android (push) Successful in 9m19s
ci / bench (push) Successful in 4m47s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Failing after 3s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / deploy-docs (push) Has been skipped
deb / build-publish (push) Failing after 6m29s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 7m4s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m17s
apple / swift (push) Successful in 1m13s
apple / screenshots (push) Successful in 5m27s
feat(host): HDR Vulkan layer so Vulkan games get HDR on the virtual display
NVIDIA/AMD Vulkan ICDs refuse to *advertise* an HDR color space for a surface on an
IddCx indirect/virtual display, so Vulkan games (Doom: The Dark Ages, id Tech, Indiana
Jones, …) report "device does not support HDR" — even though Windows HDR, DWM compose,
and the client PQ stream all work, and the ICD happily *accepts + presents* a forced HDR
swapchain there. The whole gap is enumeration; the community (Apollo/Sunshine/VDD) wrote
this off as kernel-side / unfixable.

Add VK_LAYER_PUNKTFUNK_hdr_inject (packaging/windows/pf-vkhdr-layer/): a standalone
cdylib Vulkan implicit layer that appends {A2B10G10R10, HDR10_ST2084} + {RGBA16F, scRGB}
to vkGetPhysicalDeviceSurfaceFormats[2]KHR (no need to hook vkCreateSwapchainKHR — the
ICD doesn't validate the color space there). Self-gated on the surface monitor's actual
advanced-color state (DisplayConfig GET_ADVANCED_COLOR_INFO), so it is a complete no-op
on SDR sessions and real monitors (dedup). Always-on (registry-discovered) so it works
regardless of how a game is launched — env-scoping silently fails for already-running
Steam. Escape hatches: DISABLE_PF_VKHDR, PF_VKHDR_EXCLUDE, and a built-in kernel-anti-
cheat denylist.

The installer builds/signs/stages it and registers it under
HKLM64\SOFTWARE\Khronos\Vulkan\ImplicitLayers (opt-out "Install the HDR Vulkan layer"
task); windows-host CI fmt+clippy-gates it (msvc-only FFI).

Live-validated on the RTX box: Doom: The Dark Ages enables HDR over the pf-vdisplay
virtual display.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:33:20 +00:00

27 KiB

Game library: more game stores

Status: design / not started · Author research: web-backed, adversarially verified (2026-06-26).

Goal: extend the unified game library so it enumerates and launches titles from more stores — on Windows Xbox / Game Pass, Epic, EA app (and GOG / Ubisoft / Battle.net / Amazon); on Linux Heroic (Epic+GOG+Amazon), Lutris, and a .desktop/Flatpak catch-all.


1. Where the extension point already is

The library lives in crates/punktfunk-host/src/library.rs and is already a plug-in system — its own doc comment names these exact targets. Adding a store is a new LibraryProvider, not a rewrite.

pub trait LibraryProvider {
    fn store(&self) -> &'static str;   // "steam", ...
    fn list(&self) -> Vec<GameEntry>;  // best-effort: empty (not Err) if the store is absent
}
pub struct GameEntry { id: String /* "<store>:<localid>" */, store, title, art: Artwork, launch: Option<LaunchSpec> }
pub struct Artwork   { portrait, hero, logo, header: Option<String> } // URLs the CLIENT fetches
pub struct LaunchSpec{ kind: String, value: String }                  // today: "steam_appid" | "command"

Today: SteamProvider (reads local .acf / .vdf files — no API key, no network) plus a user-curated custom store. all_games() merges them; launch_command(id) resolves a store-qualified id against the host's own library and maps the LaunchSpec to a shell command, with injection guards (steam_appid is validated digits-only; the client never sends a raw command).

The "read the launcher's own on-disk files, no auth" approach is the gold standard we replicate per store.

Surfaces touched by adding stores:

  • library.rs — new providers (the bulk of the work is small per store).
  • mgmt.rs :1138 — serves /library; OpenAPI-generated TS client picks up new stores as data.
  • web/src/sections/Library/view.tsx — the grid; store badge is hard-coded steam-vs-custom, needs generalizing per game.store.
  • Launch wiring: punktfunk1.rs :573 (native) and gamestream/stream.rs :122 (Moonlight).

The legacy GameStream apps.json (gamestream/apps.rs) is a separate Moonlight surface (session recipes: compositor + nested command) and stays as-is.


2. The two cross-cutting pieces (this is the real work)

Per-store enumeration is mostly easy. Two shared problems gate everything — especially Windows.

2a. Launch abstraction + the Windows launch gap

  • Linux runs the chosen title as a shell command nested in the per-session gamescope (set_launch_command / PUNKTFUNK_GAMESCOPE_APP). Works today.
  • Windows captures the whole desktop (DXGI/WGC); there is no nesting, and VirtualDisplay::set_launch_command is a no-op (vdisplay.rs:57). So on Windows nothing is auto-started — the user just sees the desktop.

Plan. Stop returning a single Linux shell string from command_for; introduce an internal enum and an OS-aware resolver:

enum LaunchAction { Shell(String), Spawn { exe: PathBuf, args: Vec<String>, workdir: Option<PathBuf> } }
fn resolve_launch(&LaunchSpec) -> Option<LaunchAction>          // cfg-aware
fn launch_command(id) -> Option<String>                         // Linux: thin Shell wrapper (back-compat)
#[cfg(windows)] fn launch_title(id) -> Result<()>               // resolve Spawn + run in interactive session

The Windows launcher already exists in the codebase — reuse it. capture/windows/wgc_relay.rs:196-204 does exactly the needed sequence: WTSGetActiveConsoleSessionId → WTSQueryUserToken → DuplicateTokenEx(TokenPrimary) → CreateEnvironmentBlock → CreateProcessAsUserW(lpDesktop="winsta0\\default").

  • Factor that into windows/interactive.rs::spawn_in_active_session(exe, args, workdir) -> u32.
  • Critical: use the logged-in user token (WTSQueryUserToken, as wgc_relay does) — not windows/service.rs:449-510's variant, which duplicates the SYSTEM token and only retargets its session id. UWP/appx activation, the user-hive protocol handlers (HKCU\Software\Classes), and each launcher's auth/entitlement context all require the real user's token. The host process stays SYSTEM.
  • For URI-handoff kinds (Epic/Steam/EA/Amazon/GOG-Galaxy) build a concrete EXE + the URI as a separate argv element. CreateProcessAsUserW does no shell/protocol resolution — never cmd /c, never a bare URI. For schemes with no exe-argv form (amazon-games://, origin2://), add an impersonate-token ShellExecuteEx fallback (ImpersonateLoggedOnUser on a worker thread + CoInitialize).
  • Order: launch the title after the interactive capture pipeline is live, so the game renders onto the already-captured desktop and grabs foreground.
  • Caveats: WTSQueryUserToken fails when no interactive user is logged on (a pre-login box can stream the login/secure desktop but can't auto-launch a title); on the lock/secure desktop a launch may queue until unlock. Needs on-glass validation (RTX box) that each launcher EXE accepts its URI on argv and that post-capture launch grabs foreground.

2b. Artwork: a layered, no-auth-first ArtResolver

Steam gets free CDN art keyed by appid. Most stores don't. Layered ladder, degrade to a title-only card:

  1. Steam → public Steam CDN by appid (unchanged, client fetches directly).
  2. Stores that already hold public CDN URLs → emit verbatim, no host endpoint: Heroic store_cache art_* (Epic/GOG/Amazon CDN), itch cover_url, GOG via public api.gog.com/products/<id>?expand=images (one cached lookup), Epic via local catcache.bin keyImages.
  3. Xbox → one unofficial no-auth displaycatalog.mp.microsoft.com lookup by StoreId, cached, degrade to no-art offline. (Not a stable contract — tolerate drift.)
  4. Genuinely-local art (Lutris coverart/banners JPEGs, Flatpak/.desktop icons, Bottles) → a new host-served endpoint is required, because Artwork carries URLs the client fetches and a file on the host has no public URL.
  5. Opt-in SteamGridDB enrichment (v2 API https://www.steamgriddb.com/api/v2, Authorization: Bearer <operator key>, off by default) to fill gaps. Not no-auth; never blocks listing.
  6. None → existing title-only card.

New endpoint: GET /library/art/<entryId>/<slot> (slot ∈ portrait|hero|logo|header) on mgmt.rs. It resolves entryId in the host library to a known on-disk absolute path (never interpolates raw client input into a filesystem path), sanitizes the slot, rejects .., streams the bytes with the right content-type. Reserve data: URLs for tiny logos only (don't bloat the catalog JSON that crosses the control plane). See open question on whether this GET bypasses the mgmt bearer (images are non-sensitive and the streaming client connects over punktfunk/1, not the bearer-gated REST).


3. Security model (preserved and extended)

The invariant is unchanged: the client sends only a store-qualified GameEntry.id (e.g. lutris:42, xbox:9NBLGGH4R315, epic:fn:4fe…:Fortnite) in Hello.launch. The host looks it up in its own enumerated library, reads the host-derived LaunchSpec, and resolves it. The client never sends a LaunchSpec, command, URI, or path.

Per-kind charset validators are belt-and-suspenders before any interpolation (values are already host-derived from local files the host owns):

kind guard
steam_appid, lutris_id, uplay digits only
battlenet ^[A-Za-z0-9]+$ (case-sensitive)
amazon ^[A-Za-z0-9-]+$
aumid ^[A-Za-z0-9._-]+![A-Za-z0-9._-]+$ (the ! separator)
epic ≤3 :-split parts, each ^[A-Za-z0-9._-]+$, then URL-encode colons
heroic runner ∈ {legendary,gog,nile} + appName ^[A-Za-z0-9._-]+$
ea_offer_ids ^[A-Za-z0-9._,-]+$ (allow comma)

On Windows never route a client-influenced string through cmd /c start. resolve_launch yields Spawn{exe,args,workdir}; CreateProcessAsUserW launches a concrete EXE with the URI/flags as separate argv elements. The operator-only command kind (custom store + provider-generated Linux shell lines for desktop/itch) is host-derived/operator-typed, never client-set.

The one net-new surface is GET /library/art — covered in §2b (id-resolved path, no traversal).


4. New LaunchSpec kinds

kind value holds maps to
lutris_id pga.db games.id (digits) Linux Shell lutris lutris:rungameid/<id> (nests in gamescope)
heroic <runner>:<appName> Linux argv heroic --no-gui "heroic://launch?appName=<app>&runner=<runner>"
aumid <PFN>!<AppId> Windows Spawn explorer.exe "shell:AppsFolder\<aumid>" (interactive session)
epic <namespace>:<catalogItemId>:<appName> Windows Spawn EpicGamesLauncher.exe + com.epicgames.launcher://apps/<ns>%3A<cat>%3A<app>?action=launch&silent=true
gog host-resolved exe \t args \t workdir Windows Spawn CreateProcessAsUserW(exe,args,workdir) (direct exe, no Galaxy)
uplay Ubisoft gameId (digits) Windows uplay://launch/<gameId>/0
battlenet product code (e.g. WTCG, Fen, OSI) Windows Spawn Battle.net.exe --exec="launch <code>"
amazon Amazon Games DbSet.Id Windows amazon-games://play/<Id> (impersonate ShellExecute)
ea_offer_ids comma-joined contentID list Windows origin2://game/launch/?offerIds=<list>&autoDownload=1
command (existing) host-derived shell line Linux gamescope-nested (desktop/flatpak/itch reuse this)

5. Per-store provider catalog

Confidence is after adversarial web-verification (research → verify). All enumeration is no-auth, local, launcher-need-not-be-running unless noted.

Linux

Lutris — P0, effort M, confidence high

  • Enumerate: read-only rusqlite open of pga.db ($XDG_DATA_HOME/lutris | ~/.local/share/lutris | ~/.var/app/net.lutris.Lutris/data/lutris). SELECT id, slug, name, runner FROM games WHERE installed=1. Optionally LEFT JOIN games_categories/categories to drop the .hidden category. Open mode=ro/immutable=1 (Lutris holds it open). installed=1 matters — the DB also lists owned-but-not-installed rows.
  • Launch: lutris_idlutris lutris:rungameid/<id> (execs the game; most nesting-friendly). One-time on-box check that games.id == the rungameid int.
  • Artwork: local JPEGs keyed by slug — coverart/<slug>.jpg (→ portrait), banners/<slug>.jpg (→ header) under ~/.local/share/lutris (0.5.18+), with ~/.cache/lutris (≤0.5.17) and the Flatpak cache as fallbacks. Needs the /library/art endpoint. hero/logo stay None.
  • Notes: highest-confidence new store. A runner=='steam' row can duplicate SteamProvider — dedup is a nicety. Verify bundled-SQLite is fine for deb/rpm/flatpak.

Heroic — P0, effort M, confidence high (one provider = Epic + GOG + Amazon, art free)

  • Enumerate: parse ~/.config/heroic/store_cache/{legendary,gog,nile}_library.json (Flatpak: ~/.var/app/com.heroicgameslauncher.hgl/config/heroic/...). Data key is "library" (legendary/nile) or "games" (gog); ignore __timestamp.* siblings. Filter is_installed==true and cross-check install.install_path exists (works around the gog is_installed bug, Heroic #2691). Fall back to legendaryConfig/legendary/installed.json etc. when a cache file is absent. (Heroic uses legendaryConfig/legendary, not the standalone ~/.config/legendary.)
  • Launch: heroicheroic --no-gui "heroic://launch?appName=<app>&runner=<runner>" (argv, no shell). --no-gui does the suppression; the gui=false query param is inert/fabricated — drop it. Ship enumeration+art first, gate launch: Heroic is single-instance Electron — if already running it forwards the URI and exits, which (as gamescope's foreground child) would tear the session down while the game runs outside gamescope, uncaptured. Also Electron needs a display — fine nested in gamescope, not in a bare headless context.
  • Artwork: freeart_square → portrait, art_cover → header, art_background||art_cover → hero, art_logo → logo are already public Epic/GOG/Amazon CDN URLs. Skip non-http(s) values (sideloaded file:// art). No host endpoint.
  • Notes: do not also build separate Linux GOG/Amazon providers — native Linux GOG Galaxy doesn't exist; Heroic is the canonical Linux path for those.

Desktop (.desktop + Flatpak) — P1, effort M, confidence medium (universal catch-all)

  • Enumerate: scan {/var/lib/flatpak/exports/share/applications, ~/.local/share/flatpak/.../applications, /usr/share/applications, /usr/local/share/applications, ~/.local/share/applications}/*.desktop. Require Type=Application + Categories contains Game; skip NoDisplay/Hidden/Terminal=true and known launcher app-ids (Steam/Heroic/Lutris/Bottles/RetroArch) to avoid recursion/dupes.
  • Launch: reuse command (host-derived shell line, nested in gamescope): cleaned Exec (strip %U/%F/%f/%u/%i/%c/%k) else flatpak run <app-id>.
  • Artwork: local — resolve Icon= via the hicolor theme / flatpak exported icons → /library/art. App icons are low-res, not box art (acceptable header fallback).
  • Notes: run last and dedup by install path / drop ids already surfaced by Steam/Heroic/Lutris.

itch.io — P3, effort S, confidence medium (Linux + Windows)

  • Enumerate: read-only rusqlite of butler.db (~/.config/itch/db/butler.db; Flatpak io.itch.itch; Windows %AppData%\itch\db, per-user). JOIN cavesgames. Key on cave.ID (a game can have multiple caves; install location + verdict are per-cave). Read game title / cover_url; resolve install dir from InstallLocationID+InstallFolderName||CustomInstallFolder + the Verdict candidate. Confirm exact column names on-box.
  • Launch: command → direct binary basePath+candidate.path, only for Verdict candidates with flavor==native (html/jar/love need itch's runtime — fall back to custom).
  • Artwork: freegames.cover_url is a public itch CDN URL.

Windows

Epic Games Store — P1, effort M, confidence medium (cleanest Windows store to validate the launch wiring)

  • Enumerate: read C:\ProgramData\Epic\EpicGamesLauncher\Data\Manifests\*.item (JSON; machine-wide, SYSTEM-readable, launcher need not run). Read DisplayName, AppName, CatalogNamespace, CatalogItemId, InstallLocation, LaunchExecutable, MainGameAppName, AppCategories. Iterate the dir (filename is a random GUID). Use Playnite's EXCLUSION filter, not a positive games filter: skip AppName starting UE_; skip DLC only when AppCategories has addons && not addons/launchable; require InstallLocation exists. (The first-pass positive filter games + MainGameAppName==AppName can drop legit games.)
  • Launch: epic → Spawn EpicGamesLauncher.exe + com.epicgames.launcher://apps/<ns>%3A<cat>%3A<app>?action=launch&silent=true. Build the triple only when both namespace and CatalogItemId are present; otherwise fall back to the bare appName URI (don't set launch=None) — bare still works in Playnite today, it's just less robust. CatalogItemId is not present in every .item — verify on a real box.
  • Artwork: free — base64-decode + parse Data\Catalog\catcache.bin, index by catalogItemId, map keyImages DieselGameBoxTall→portrait, DieselGameBox→hero, DieselGameBoxLogo→logo. None on miss.
  • Notes: .item + catcache.bin are community-RE'd; silent=true may not suppress a cold-start launcher window.

GOG — P1, effort M, confidence medium

  • Enumerate: registry HKLM\SOFTWARE\WOW6432Node\GOG.com\Games\<id> (PATH/GAMENAME/gameID/EXE) or Uninstall <id>_is1 keys with Publisher=='GOG.com' (exclude GOGPACK*). Parse <PATH>\goggame-<id>.info for playTasks[isPrimary && type=='FileTask'] → exe/args/workingDir.
  • Launch: gogdirect-exe Spawn (no Galaxy dependency, dodges cold-start/anti-cheat). Optional fallback: GalaxyClient.exe /launchViaAutostart /gameId=<id> /command=runGame /path="<dir>" (note the /launchViaAutostart token; goggalaxy://openGameView/<id> only opens the page, doesn't launch).
  • Artwork: free — public no-auth GET https://api.gog.com/products/<id>?expand=imagesimages.logo2x/verticalCover/background; cache resolved URLs. (goggame-.info carries no art; the Galaxy galaxy-2.0.db is undocumented/locked — avoid.)

Xbox / Microsoft Store / Game Pass — P1, effort L, confidence medium (big Game Pass value, most plumbing)

  • Enumerate: probe each fixed drive for an XboxGames dir (default C:\XboxGames; the .GamingRoot binary layout is undocumented — just scan, don't depend on parsing it). For each <Title>\Content\MicrosoftGame.config (presence = it's a GDK game, the game-vs-app signal) read ShellVisuals.DefaultDisplayName (title), <StoreId> (12-char BigId, the art key), Identity Name, <Executable Id="Game"> (the AppId). Read the PackageFamilyName from the C:\ProgramData\Microsoft\Windows\AppRepository\Packages\<PackageFullName> directory name (strip _Version_Arch_~_PublisherHash) — never compute the PFN by hashing the publisher. AUMID = PFN!AppId.
  • Launch: aumidexplorer.exe shell:AppsFolder\<AUMID> into the interactive session. UWP activation fails from SYSTEM/session-0 — the interactive user token is load-bearing.
  • Artwork: one unofficial no-auth lookup displaycatalog.mp.microsoft.com/v7.0/products/<StoreId>?market=US&languages=en-us&fieldsTemplate=Details, map Images[] ImagePurpose Poster→portrait / SuperHeroArt→hero / Logo→logo / BoxArt→header; cache to the config dir, degrade to no-art offline. Not a stable contract.
  • Notes: misses pure-UWP (non-GDK) Store games under the ACL-locked WindowsApps — accept for v1.

Ubisoft Connect — P2, effort S, confidence medium

  • Enumerate: registry HKLM\SOFTWARE\WOW6432Node\Ubisoft\Launcher\Installs\<gameId> (both reg views), read InstallDir; title = install-dir leaf folder (primary) else the Uplay Install <gameId> Uninstall DisplayName.
  • Launch: uplayuplay://launch/<gameId>/0. Artwork: none → title-only.
  • Notes: smallest effort once the Windows URI-launch wiring exists; hive+scheme unchanged across the Origin→EA migration.

Amazon Games — P2, effort S, confidence medium

  • Enumerate: read-only rusqlite of %LocalAppData%\Amazon Games\Data\Games\Sql\GameInstallInfo.sqlite: SELECT Id,ProductTitle,InstallDirectory FROM DbSet WHERE Installed=1. Per-user path — the SYSTEM service must resolve the active session user's profile (not the SYSTEM profile).
  • Launch: amazonamazon-games://play/<Id> (impersonate-token ShellExecute; no clean exe-argv form).
  • Artwork: ProductIconUrl/ProductLogoUrl columns when present, else none.

Battle.net — P2, effort L, confidence medium (high catalog value: WoW/Diablo IV/Overwatch 2/CoD)

  • Enumerate: hand-roll a ~4-field protobuf decode of C:\ProgramData\Battle.net\Agent\product.db (product_install{ uid, product_code, settings.install_path, cached_product_state.base_product_state.installed }). Registry fallback: Uninstall keys whose UninstallString matches Battle.net.exe --uid=<uid>. product.db has no titles → maintain a ~30-entry product_code→name map (source from bnetlauncher/Lutris/Heroic; codes are case-sensitive).
  • Launch: battlenetBattle.net.exe --exec="launch <code>" (more reliable than the battlenet://<code> URI, which only hands off). Artwork: none → title-only.
  • Notes: the protobuf + name map + no-art make it L; pin the .proto and decode defensively.

EA app — P2, effort M, confidence medium (most closed/fragile — ship last)

  • Enumerate: registry HKLM\SOFTWARE\WOW6432Node\{EA Games,Origin Games}\<id> (Install Dir / DisplayName), parse <dir>\__Installer\installerdata.xml for the full <contentIDs> list + <gameTitle locale='en_US'>. Registry under-reports for EA-app (vs legacy Origin) installs — known completeness gap. Keep the AES-256 encrypted IS-file decrypt out of the default path (optional feature flag for completeness).
  • Launch: ea_offer_idsorigin2://game/launch/?offerIds=<full,comma,list>&autoDownload=1. Emit the full contentID list — a single offerId generally no longer launches under the EA app.
  • Artwork: none no-auth → title-only.

Rockstar — P3, fold into custom

  • Registry HKLM\SOFTWARE\WOW6432Node\Rockstar Games\<Title>\InstallFolder; direct-exe Spawn; no art. Tiny catalog, most titles now bought on Steam/Epic.

6. Suggested structure & phasing

Structure. Split library.rs → a library/ dir before it balloons: mod.rs (trait, wire types, LaunchAction, custom CRUD, all_games, resolve_launch, launch_command/launch_title), steam.rs, one file per provider, art.rs (ArtResolver + displaycatalog/gog-api/steamgriddb helpers), win_util.rs (HKLM subkey enumerator, read-only SQLite opener, tiny read-only XML reader). New deps: rusqlite (bundled, read-only) for lutris/itch/amazon DBs; roxmltree/quick-xml for the Windows manifests; registry via the windows crate's Win32_System_Registry feature (no new crate). Avoid prost — hand-roll the ~4 Battle.net fields.

Phase Deliverable Files
1 — Foundation (no new stores) Split library.rslibrary/; add LaunchAction + resolve_launch; factor windows/interactive.rs::spawn_in_active_session out of wgc_relay.rs; make set_launch_command real on Windows; wire launch_title at session-start post-capture; add win_util.rs + deps library/{mod,steam,launch,art,win_util}.rs; windows/interactive.rs (new); capture/windows/wgc_relay.rs; punktfunk1.rs:573; gamestream/stream.rs:122; vdisplay.rs:57; main.rs; Cargo.toml
2 — Linux Lutris + Heroic + art endpoint (P0) LutrisProvider, HeroicProvider (art free); GET /library/art/<id>/<slot> for Lutris local JPEGs; wire into all_games(); unit tests for new resolve_launch arms + guards library/{lutris,heroic,art}.rs; library/mod.rs; mgmt.rs:1138 + new route
3 — Windows Epic + GOG (P1) EpicProvider (.item + catcache art), GogProvider (registry + .info + api.gog.com art); validate windows/interactive.rs end-to-end on the RTX box library/{epic,gog,win_util,art,launch}.rs
4 — Xbox / Game Pass (P1) XboxProvider (XboxGames scan + MicrosoftGame.config + AppRepository PFN + aumid launch) + displaycatalog art with caching/offline degrade library/{xbox,art,launch}.rs
5 — Linux Desktop catch-all + easy Windows URI stores (P1/P2) DesktopProvider (last + dedup, icons via /library/art), UplayProvider, AmazonProvider (+ per-user-profile-under-SYSTEM helper) library/{desktop,uplay,amazon,win_util,art}.rs
6 — Remaining + opt-in enrichment (P2/P3) BattleNetProvider (hand-rolled protobuf + code→name map), EaAppProvider, ItchProvider; Rockstar/Bottles → custom; optional SteamGridDB v2 behind an operator key library/{battlenet,eaapp,itch,art,mod}.rs

Also generalize the web console store badge (web/src/sections/Library/view.tsx) to render per game.store.


7. Open questions

  • Art delivery auth: the streaming client connects over punktfunk/1 (QUIC), not the bearer-gated mgmt REST, yet already fetches Steam CDN URLs over plain HTTP. Should GET /library/art/* be an unauthenticated read-only image GET on the mgmt listener (bearer bypass for that path only), a separate tiny image server, or should local-art bytes ride the punktfunk/1 control plane?
  • Windows launch ordering needs on-glass RTX-box validation: confirm launching after capture is live grabs foreground+capture, and that CreateProcessAsUserW(EpicGamesLauncher.exe/steam.exe, URI-as-argv) actually starts the game per launcher (vs needing the impersonate-ShellExecute fallback).
  • Per-user-profile resolution under SYSTEM for Amazon (%LocalAppData%) and itch (%AppData%): add WTSQueryUserToken + GetUserProfileDirectoryW (or read USERPROFILE from CreateEnvironmentBlock)?
  • rusqlite bundled SQLite — acceptable for deb/rpm/flatpak and no link conflict? Otherwise fall back to lutris -l -j (fragile: single-instance D-Bus forwarding).
  • Battle.net product-code→name map source/maintenance, and product.db .proto drift across Agent versions.
  • Unofficial art sources (Xbox displaycatalog): best-effort with aggressive caching + no-art degrade, or Xbox-art local-tile-only for v1?
  • Heroic launch: ship enumeration+art only at first, or invest in direct legendary/gogdl/nile CLI launch (needs the user's on-disk auth tokens) to dodge the single-instance-Electron / gamescope-escape problem?
  • config_dir() consistency: library.rs uses an XDG/HOME-based dir; confirm the Windows SYSTEM host lands its art cache + custom store under %ProgramData%\punktfunk (there's a separate gamestream::config_dir() that already does this).
  • Should provider-generated Linux shell lines (desktop/itch) reuse the command kind (documented "operator-only") or get a distinct internal kind to keep the mgmt-UI command semantics clean?

8. Verification notes (what the adversarial pass corrected)

First-pass research was web-re-checked; corrections folded into §5 above:

  • Epic: bare-AppName URI is not universally removed (Playnite still uses it) — build the triple when ids exist, fall back to bare; use Playnite's exclusion filter, not a positive games filter.
  • EA: a single offerId no longer launches — emit the full comma-joined contentID list; registry under-reports for EA-app installs.
  • Battle.net: battlenet://<code> only hands off — use Battle.net.exe --exec="launch <code>".
  • Xbox: read the PFN from the AppRepository dir name, don't hash the publisher; .GamingRoot layout is undocumented — just scan XboxGames.
  • Heroic: gui=false is inert (--no-gui does it); single-instance Electron forwards-and-exits → gate launch.
  • Lutris: open the DB read-only; lutris -l -j fallback is fragile (single-instance D-Bus forwarding).
  • SteamGridDB: v1 is deprecated — use v2 (/api/v2, Bearer key).

Not web-confirmable / needs on-box validation: every Windows launch path (each launcher's argv handling, foreground grab, secure-desktop behavior), all registry keys / DB schemas against a live box, and rusqlite packaging.