From 739fa74e6848cada8a38a6ee8ff37af5014523e3 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 26 Jun 2026 06:53:09 +0000 Subject: [PATCH] =?UTF-8?q?docs(library):=20game-store=20provider=20design?= =?UTF-8?q?=20(Xbox/Epic/EA,=20Heroic/Lutris,=20=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web-researched + adversarially-verified design for extending library.rs with more store providers: the LibraryProvider extension point, the two cross-cutting pieces (Windows interactive-session launch wiring + a layered artwork strategy), new LaunchSpec kinds, per-store enumeration/launch/art recipes with priority/effort/ confidence, a phased plan, and the verification corrections. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/game-library-stores.md | 377 ++++++++++++++++++++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 docs/game-library-stores.md diff --git a/docs/game-library-stores.md b/docs/game-library-stores.md new file mode 100644 index 0000000..ce03d89 --- /dev/null +++ b/docs/game-library-stores.md @@ -0,0 +1,377 @@ +# 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`](../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. + +```rust +pub trait LibraryProvider { + fn store(&self) -> &'static str; // "steam", ... + fn list(&self) -> Vec; // best-effort: empty (not Err) if the store is absent +} +pub struct GameEntry { id: String /* ":" */, store, title, art: Artwork, launch: Option } +pub struct Artwork { portrait, hero, logo, header: Option } // 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`](../crates/punktfunk-host/src/mgmt.rs) `:1138` — serves `/library`; OpenAPI-generated TS client picks up new stores as data. +- [`web/src/sections/Library/view.tsx`](../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`](../crates/punktfunk-host/src/punktfunk1.rs) `:573` (native) and [`gamestream/stream.rs`](../crates/punktfunk-host/src/gamestream/stream.rs) `:122` (Moonlight). + +> The legacy GameStream `apps.json` ([`gamestream/apps.rs`](../crates/punktfunk-host/src/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`](../crates/punktfunk-host/src/vdisplay.rs)). + 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: + +```rust +enum LaunchAction { Shell(String), Spawn { exe: PathBuf, args: Vec, workdir: Option } } +fn resolve_launch(&LaunchSpec) -> Option // cfg-aware +fn launch_command(id) -> Option // 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`](../crates/punktfunk-host/src/capture/windows/wgc_relay.rs) +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/?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 + `, **off by default**) to fill gaps. Not no-auth; never blocks listing. +6. **None** → existing title-only card. + +**New endpoint:** `GET /library/art//` (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/` (nests in gamescope) | +| `heroic` | `:` | Linux argv `heroic --no-gui "heroic://launch?appName=&runner="` | +| `aumid` | `!` | Windows Spawn `explorer.exe "shell:AppsFolder\"` (interactive session) | +| `epic` | `::` | Windows Spawn `EpicGamesLauncher.exe` + `com.epicgames.launcher://apps/%3A%3A?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//0` | +| `battlenet` | product code (e.g. `WTCG`, `Fen`, `OSI`) | Windows Spawn `Battle.net.exe --exec="launch "` | +| `amazon` | Amazon Games `DbSet.Id` | Windows `amazon-games://play/` (impersonate ShellExecute) | +| `ea_offer_ids` | comma-joined contentID list | Windows `origin2://game/launch/?offerIds=&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_id` → `lutris lutris:rungameid/` (execs the game; most nesting-friendly). + One-time on-box check that `games.id` == the `rungameid` int. +- **Artwork:** **local** JPEGs keyed by slug — `coverart/.jpg` (→ portrait), `banners/.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:** `heroic` → `heroic --no-gui "heroic://launch?appName=&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:** **free** — `art_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 `. +- **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 `caves`→`games`. **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:** **free** — `games.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/%3A%3A?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\` (PATH/GAMENAME/gameID/EXE) or + Uninstall `_is1` keys with `Publisher=='GOG.com'` (exclude `GOGPACK*`). Parse + `\goggame-.info` for `playTasks[isPrimary && type=='FileTask']` → exe/args/workingDir. +- **Launch:** `gog` → **direct-exe** Spawn (no Galaxy dependency, dodges cold-start/anti-cheat). Optional + fallback: `GalaxyClient.exe /launchViaAutostart /gameId= /command=runGame /path=""` (note the + `/launchViaAutostart` token; `goggalaxy://openGameView/` only **opens the page**, doesn't launch). +- **Artwork:** **free** — public no-auth `GET https://api.gog.com/products/?expand=images` → + `images.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 + `\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:** `aumid` → `explorer.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:** `uplay` → `uplay://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:** `amazon` → `amazon-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:** `battlenet` → `Battle.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_ids` → `origin2://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.rs` → `library/`; 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.