Much of design/ described work that has since shipped. Trim each doc to
its durable rationale + still-open items (the code is the source of truth
for shipped detail; git history holds the full originals).
- Shipped plans -> status stubs: stats-capture, gamestream-host-plan,
apple-stage2-presenter, windows-service.
- Trimmed completed-out / open-kept: implementation-plan, hdr-pipeline,
host-latency, gpu-contention (fixed stale status table), game-library,
linux-setup (fixed m0->spike + stale zero-copy claim),
session-aware-host-followups, windows-client-bootstrap,
windows-dualsense-{scoping,game-detection}, windows-virtual-display,
security-review (per-finding status table; #12 still open),
apollo-comparison (shipped backlog collapsed to one-liners).
- Windows-host cluster consolidated: windows-host.md -> redirect into
windows-host-rewrite.md (whose stale scorecard is corrected -- goal1 is
merged, M4 done); windows-secure-desktop.md archived (now a fallback
behind IDD-push primary).
- Kept evergreen: ci.md, gamescope-multiuser.md, windows-build-and-packaging.md.
- New design/README.md: per-doc status table + consolidated open-items
roll-up so nothing is tracked in only one buried doc.
- Repoint 5 code comments to the archived secure-desktop doc path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
20 KiB
Game library: more game stores
Status: Phases 1–4 SHIPPED — 6
LibraryProviderimpls (Steam, Lutris, Heroic, Epic, GOG, Xbox) incrates/punktfunk-host/src/library.rs(1869 lines; commits5f8c6b6Lutris+Heroic,b657452Epic+GOG,aed0bf0Xbox,5acc12dshared art cache,7e9023fGameStream/Windows+non-gamescope launch wiring,203ad80web store badges). Phases 5–6 (the remaining 6 providers + the/library/artendpoint) are pending. This doc is trimmed to design rationale + open items; the shipped code is the source of truth.
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. The extension point
The library lives in library.rs and is a plug-in system:
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 }
The "read the launcher's own on-disk files, no auth" approach is the gold standard we replicate per store. Launcher-need-not-be-running unless noted.
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_commandis 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, aswgc_relaydoes) — notwindows/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.
CreateProcessAsUserWdoes no shell/protocol resolution — nevercmd /c, never a bare URI. For schemes with no exe-argv form (amazon-games://,origin2://), add an impersonate-tokenShellExecuteExfallback (ImpersonateLoggedOnUseron 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:
WTSQueryUserTokenfails 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:
- Steam → public Steam CDN by appid (unchanged, client fetches directly).
- Stores that already hold public CDN URLs → emit verbatim, no host endpoint: Heroic
store_cacheart_*(Epic/GOG/Amazon CDN), itchcover_url, GOG via publicapi.gog.com/products/<id>?expand=images(one cached lookup), Epic via localcatcache.binkeyImages. - Xbox → one unofficial no-auth
displaycatalog.mp.microsoft.comlookup by StoreId, cached, degrade to no-art offline. (Not a stable contract — tolerate drift.) - Genuinely-local art (Lutris
coverart/bannersJPEGs, Flatpak/.desktop icons, Bottles) → a new host-served endpoint is required, becauseArtworkcarries URLs the client fetches and a file on the host has no public URL. - 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. - None → existing title-only card.
New endpoint (still pending): 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. 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
All enumeration is no-auth, local. Confidence is after adversarial web-verification.
Shipped (phases 1–4)
| store | OS | enumerate | launch kind | art |
|---|---|---|---|---|
| Steam | both | local .acf/.vdf |
steam_appid |
Steam CDN (client-direct) |
| Lutris | Linux | read-only pga.db (installed=1) |
lutris_id |
local JPEGs (needs /library/art, still pending) |
| Heroic | Linux | store_cache/{legendary,gog,nile}_library.json |
heroic |
free public CDN (art_*) |
| Epic | Windows | …\Manifests\*.item |
epic |
local catcache.bin keyImages |
| GOG | Windows | registry + goggame-<id>.info |
gog (direct-exe) |
api.gog.com/products/<id>?expand=images |
| Xbox / Game Pass | Windows | XboxGames\*\Content\MicrosoftGame.config + AppRepository PFN |
aumid |
unofficial displaycatalog lookup |
The hard-won corrections folded into these (keep when revisiting): Epic uses Playnite's exclusion
filter (skip UE_, DLC addons w/o addons/launchable), builds the namespace:catalog:app triple when
ids exist else falls back to the bare appName URI (don't set launch=None); GOG launches the
direct exe (dodges Galaxy cold-start/anti-cheat); Xbox reads the PackageFamilyName from the
AppRepository\Packages\<PackageFullName> dir name (never hash the publisher), scans XboxGames rather
than parse the undocumented .GamingRoot, and UWP aumid activation is load-bearing on the interactive
user token; Heroic gui=false is inert (--no-gui does it) and single-instance Electron forwards-and-exits
(launch was gated). Misses pure-UWP (non-GDK) Store games under ACL-locked WindowsApps — accepted for v1.
Remaining providers (phases 5–6)
Desktop (.desktop + Flatpak) — Linux, 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. RequireType=Application+CategoriescontainsGame; skipNoDisplay/Hidden/Terminal=trueand known launcher app-ids (Steam/Heroic/Lutris/Bottles/RetroArch) to avoid recursion/dupes. - Launch: reuse
command(host-derived shell line, nested in gamescope): cleanedExec(strip%U/%F/%f/%u/%i/%c/%k) elseflatpak 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 — Linux + Windows, P3, effort S, confidence medium
- Enumerate: read-only
rusqliteofbutler.db(~/.config/itch/db/butler.db; Flatpakio.itch.itch; Windows%AppData%\itch\db, per-user). JOINcaves→games. Key oncave.ID(a game can have multiple caves; install location + verdict are per-cave). Read game title /cover_url; resolve install dir fromInstallLocationID+InstallFolderName||CustomInstallFolder+ the Verdict candidate. Confirm exact column names on-box. - Launch:
command→ direct binarybasePath+candidate.path, only for Verdict candidates withflavor==native(html/jar/love need itch's runtime — fall back to custom). - Artwork: free —
games.cover_urlis a public itch CDN URL.
Ubisoft Connect — Windows, P2, effort S, confidence medium
- Enumerate: registry
HKLM\SOFTWARE\WOW6432Node\Ubisoft\Launcher\Installs\<gameId>(both reg views), readInstallDir; title = install-dir leaf folder (primary) else theUplay Install <gameId>UninstallDisplayName. - 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 — Windows, P2, effort S, confidence medium
- Enumerate: read-only
rusqliteof%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/ProductLogoUrlcolumns when present, else none.
Battle.net — Windows, 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 whoseUninstallStringmatchesBattle.net.exe --uid=<uid>.product.dbhas no titles → maintain a ~30-entryproduct_code→name map (source from bnetlauncher/Lutris/Heroic; codes are case-sensitive). - Launch:
battlenet→Battle.net.exe --exec="launch <code>"(more reliable than thebattlenet://<code>URI, which only hands off). Artwork: none → title-only. - Notes: the protobuf + name map + no-art make it L; pin the
.protoand decode defensively.
EA app — Windows, 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.xmlfor 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 encryptedIS-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. Structure & phasing
Structure (still pending refactor). Split the 1869-line library.rs → a library/ dir before it
balloons further: 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). Deps in play: 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.
- Phases 1–4 — DONE: launch abstraction + Windows interactive-session spawn; Steam/Lutris/Heroic
providers + Linux art; Epic/GOG providers; Xbox / Game Pass provider; shared art warmer + cache; web
store badge generalized per
game.store. - Phase 5 — future: Linux Desktop catch-all (last + dedup, icons via
/library/art), Ubisoft (UplayProvider), Amazon (AmazonProvider+ per-user-profile-under-SYSTEM helper); land theGET /library/art/<id>/<slot>endpoint that Lutris/Desktop local art still needs. - Phase 6 — future: Battle.net (hand-rolled protobuf + code→name map), EA app, itch.io; Rockstar/ Bottles → custom; optional SteamGridDB v2 enrichment behind an operator key.
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%): addWTSQueryUserToken+GetUserProfileDirectoryW(or readUSERPROFILEfromCreateEnvironmentBlock)? rusqlitebundled SQLite — acceptable for deb/rpm/flatpak and no link conflict? Otherwise fall back tolutris -l -j(fragile: single-instance D-Bus forwarding).- Battle.net product-code→name map source/maintenance, and
product.db.protodrift 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.rsuses an XDG/HOME-based dir; confirm the Windows SYSTEM host lands its art cache + custom store under%ProgramData%\punktfunk(there's a separategamestream::config_dir()that already does this).- Should provider-generated Linux shell lines (
desktop/itch) reuse thecommandkind (documented "operator-only") or get a distinct internal kind to keep the mgmt-UIcommandsemantics clean?
Open items (what's left)
- 6 remaining providers: Desktop/Flatpak (Linux), itch.io (Linux+Windows), Ubisoft Connect, Amazon Games, Battle.net, EA app (recipes in §5).
GET /library/art/<entryId>/<slot>mgmt endpoint — still missing; Lutris local JPEGs (and the future Desktop icons) have no public URL without it.- Refactor
library.rs(1869 lines) into alibrary/directory (structure in §6). - The 8 open questions in §7.
- Optional SteamGridDB v2 enrichment behind an operator key (off by default; never blocks listing).