Files
punktfunk/crates/punktfunk-host/src/library.rs
T
enricobuehler 3532e35b75 fix(host/security): close audit findings S1,#1,#4,#10,#12,#7,#6,S2-S6 (Linux/cross-platform)
Remediations from design/security-review-2026-06-28.md verified on Linux
(cargo check/clippy/test green; Windows-gated paths verify in CI):

- S1 [HIGH]: bump quinn-proto 0.11.14 -> 0.11.15 (RUSTSEC-2026-0185,
  pre-auth out-of-order STREAM reassembly memory exhaustion on the
  always-on default QUIC listener).
- #1 [HIGH]: remove the unauthenticated nvhttp `GET /pin` endpoint; the
  GameStream PIN is delivered ONLY via the bearer-gated mgmt API, so a
  network client can no longer submit its own displayed PIN and self-pair.
- #4 [HIGH->MED]: gate the unauthenticated RTSP/UDP media plane on a paired
  `/launch` and bind it to the launching client's source IP (threaded
  through the HTTPS handler), so an unpaired peer can neither start capture
  on an idle host nor ride a paired client's active launch.
- #12: bound concurrent parked pairing waiters (MAX_PARKED_WAITERS) so a
  pre-auth peer can't pin unbounded 300s handshakes. +regression test.
- #10: throttle the per-packet ENet control GCM-decrypt-failed warn
  (exponential backoff) so a junk flood can't spam the log.
- #7 [MED->LOW]: serialize all process-global env mutation on the
  session-setup path under a new vdisplay::ENV_LOCK (apply_session_env /
  apply_input_env / the launch-cmd set_var / the gamescope env read), so
  concurrent native sessions can't race set_var/getenv (data-race UB ->
  host-wide DoS). Full per-session SessionContext threading remains a
  follow-up for cross-session value confusion.
- #6 [MED]: move the gamescope EIS socket relay from world-writable /tmp to
  $XDG_RUNTIME_DIR (per-user 0700) and reject a symlinked relay file, so a
  local user can't intercept (keylog) or deny the remote session's input.
- S2: a malformed client Opus mic frame now drops that frame instead of
  tearing down the shared host-lifetime virtual mic (cross-session DoS).
- S3: track held buttons/keys in capped HashSets (was unbounded Vec with
  O(n) scans) so a paired client can't grow per-session input state.
- S5: reject fps==0/absurd at the open_video chokepoint (covers Hello,
  ANNOUNCE, Reconfigure) so the encoder time_base/pts math can't div-by-0.
- S6: bound the shared mic mpsc (drop-newest when full).
- S4: cap Epic launcher-cache reads (catcache.bin/.item) so a planted giant
  can't OOM the host during library enumeration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:06:24 +00:00

1889 lines
76 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.
//!
//! 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.
//!
//! 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.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use utoipa::ToSchema;
/// 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
/// no 600×900 capsule (common for older Steam apps).
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct Artwork {
/// Vertical capsule / poster (Steam `library_600x900.jpg`). Best for a grid.
#[serde(skip_serializing_if = "Option::is_none")]
pub portrait: Option<String>,
/// Wide background (Steam `library_hero.jpg`).
#[serde(skip_serializing_if = "Option::is_none")]
pub hero: Option<String>,
/// Transparent title logo (Steam `logo.png`).
#[serde(skip_serializing_if = "Option::is_none")]
pub logo: Option<String>,
/// Horizontal header (Steam `header.jpg`) — the universal fallback.
#[serde(skip_serializing_if = "Option::is_none")]
pub header: Option<String>,
}
/// How the host would launch a title (consumed by the session launcher in a later step). Kept
/// open-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/<value>`;
/// `command` → run `<value>` nested in a gamescope session.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct LaunchSpec {
/// `"steam_appid"` or `"command"`.
#[schema(example = "steam_appid")]
pub kind: String,
/// The appid (for `steam_appid`) or the shell command (for `command`).
pub value: String,
}
/// One title in the unified library, regardless of which store it came from.
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct GameEntry {
/// Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.
#[schema(example = "steam:570")]
pub id: String,
/// Which store surfaced it: `"steam"` or `"custom"`.
#[schema(example = "steam")]
pub store: String,
pub title: String,
pub art: Artwork,
/// How the host would launch it, when known.
#[serde(skip_serializing_if = "Option::is_none")]
pub launch: Option<LaunchSpec>,
}
/// 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
// ---------------------------------------------------------------------------------------
/// 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`; artwork from the public Steam CDN by appid.
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, String> = Default::default();
for steamapps in steam_library_dirs() {
for (appid, name) in scan_manifests(&steamapps) {
by_appid.entry(appid).or_insert(name); // first library wins; dedups shared appids
}
}
by_appid
.into_iter()
.filter(|(appid, name)| !is_steam_tool(*appid, name))
.map(|(appid, title)| GameEntry {
id: format!("steam:{appid}"),
store: "steam".into(),
title,
art: steam_art(appid),
launch: Some(LaunchSpec {
kind: "steam_appid".into(),
value: appid.to_string(),
}),
})
.collect()
}
}
/// The Steam CDN poster/hero/logo/header for an appid (public, no auth). 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 base = format!("https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}");
Artwork {
portrait: Some(format!("{base}/library_600x900.jpg")),
hero: Some(format!("{base}/library_hero.jpg")),
logo: Some(format!("{base}/logo.png")),
header: Some(format!("{base}/header.jpg")),
}
}
/// 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('"')?])
}
/// Scan a `steamapps` dir for `appmanifest_*.acf` files → (appid, name) of installed titles.
fn scan_manifests(steamapps: &Path) -> Vec<(u32, String)> {
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) {
out.push((appid, name.to_string()));
}
}
}
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")
}
// ---------------------------------------------------------------------------------------
// Lutris (Linux) — reads the local `pga.db` (no auth, no network). One provider covers
// everything Lutris manages: Wine/Proton games, GOG/Epic/Battle.net installs, emulators.
// ---------------------------------------------------------------------------------------
/// 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,
)?
};
let mut stmt = conn.prepare(
"SELECT id, slug, name FROM games \
WHERE installed = 1 AND name IS NOT NULL AND name <> '' \
ORDER BY name COLLATE NOCASE",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, String>(2)?,
))
})?;
let mut games = Vec::new();
for (id, slug, name) in rows.flatten() {
games.push(GameEntry {
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(),
}),
});
}
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 _;
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
}
// ---------------------------------------------------------------------------------------
// Heroic (Linux) — Epic + GOG + Amazon in one provider. Reads Heroic's `store_cache` JSON
// (no auth); cover art is already public Epic/GOG/Amazon CDN URLs the client fetches directly.
// ---------------------------------------------------------------------------------------
/// 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
}
let install_ok = g
.get("install")
.and_then(|i| i.get("install_path"))
.and_then(|p| p.as_str())
.is_some_and(|p| Path::new(p).is_dir());
if !install_ok {
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 {
id: format!("heroic:{runner}:{app_name}"),
store: "heroic".into(),
title,
art,
launch: Some(LaunchSpec {
kind: "heroic".into(),
value: format!("{runner}:{app_name}"),
}),
});
}
Ok(games)
}
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY
/// running on the box, the spawned process forwards the URI and exits, which would tear the session
/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic.
#[cfg(target_os = "linux")]
fn heroic_command(value: &str) -> Option<String> {
let (runner, app) = value.split_once(':')?;
if !matches!(runner, "legendary" | "gog" | "nile") {
return None;
}
// appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token.
if app.is_empty()
|| !app
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
{
return None;
}
let prefix = heroic_launch_prefix()?;
// No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName
// is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed).
Some(format!(
"{prefix} --no-gui heroic://launch?appName={app}&runner={runner}"
))
}
/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data
/// root is present. `None` ⇒ Heroic not found, so no launch command.
#[cfg(target_os = "linux")]
fn heroic_launch_prefix() -> Option<String> {
let on_path = std::env::var_os("PATH")
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file()));
if on_path {
return Some("heroic".into());
}
let flatpak = std::env::var_os("HOME")
.map(PathBuf::from)
.is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir());
flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into())
}
// ---------------------------------------------------------------------------------------
// Epic Games Store (Windows) — reads the launcher's local `.item` manifests under ProgramData
// (no auth, launcher need not run). Cover art from the base64 `catcache.bin` (public Epic CDN).
// ---------------------------------------------------------------------------------------
/// 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()
};
Some(GameEntry {
id: format!("epic:{app_name}"),
store: "epic".into(),
title,
art: art.get(catalog).cloned().unwrap_or_default(),
launch: Some(LaunchSpec {
kind: "epic".into(),
value,
}),
})
}
/// 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
}
/// Build the `com.epicgames.launcher://` launch URI from a stored launch value — the triple
/// `<namespace>:<catalogItemId>:<appName>` (colons URL-encoded), or a bare `<appName>` fallback.
/// Each part is charset-validated (host-derived, but belt-and-suspenders) so no shell/URI injection.
#[cfg(windows)]
fn epic_launch_uri(value: &str) -> Option<String> {
let ok = |s: &str| {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
};
let inner = match value.split(':').collect::<Vec<_>>().as_slice() {
[ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"),
[app] if ok(app) => (*app).to_string(),
_ => return None,
};
Some(format!(
"com.epicgames.launcher://apps/{inner}?action=launch&silent=true"
))
}
// ---------------------------------------------------------------------------------------
// GOG (Windows) — registry-indexed installs + each game's `goggame-<id>.info` for a direct-exe
// launch (no Galaxy needed, dodges its cold-start/anti-cheat). Art (api.gog.com) is a follow-up.
// ---------------------------------------------------------------------------------------
/// 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();
out.push(GameEntry {
id,
store: "gog".into(),
title,
art,
launch: Some(LaunchSpec {
kind: "gog".into(),
value: format!("{exe}\t{args}\t{workdir}"),
}),
});
}
out
}
/// 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`.
#[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())?;
let exe = Path::new(install).join(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())
.map(|w| Path::new(install).join(w))
.unwrap_or_else(|| Path::new(install).to_path_buf());
Some((
exe.to_string_lossy().into_owned(),
args,
workdir.to_string_lossy().into_owned(),
))
}
/// Build the spawn `(command line, working dir)` for a `gog` launch value (`exe \t args \t workdir`,
/// all host-resolved from the operator's own disk). Direct exe — no shell, no Galaxy.
#[cfg(windows)]
fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
let mut parts = value.split('\t');
let exe = parts.next().filter(|s| !s.is_empty())?;
let args = parts.next().unwrap_or("");
let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from);
let cmdline = if args.trim().is_empty() {
format!("\"{exe}\"")
} else {
format!("\"{exe}\" {args}")
};
Some((cmdline, workdir))
}
// ---------------------------------------------------------------------------------------
// Xbox / Microsoft Store / Game Pass (Windows) — scans the flat-file `XboxGames` install dirs
// (no auth) for GDK games (each has a Content\MicrosoftGame.config). Launch via the AUMID
// (shell:AppsFolder\<PFN>!<AppId>) in the interactive session. Cover art (displaycatalog) deferred.
// ---------------------------------------------------------------------------------------
/// 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;
}
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 {
id,
store: "xbox".into(),
title,
art,
launch: Some(LaunchSpec {
kind: "aumid".into(),
value: format!("{pfn}!{app_id}"),
}),
});
}
}
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.
#[cfg(windows)]
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}"))
}
// ---------------------------------------------------------------------------------------
// Cover-art resolver + cache (shared by the Windows GOG + Xbox providers, which have no local
// art). A disk cache is the source of truth read by all_games() (so the list/launch path never
// blocks on the network); a host-lifetime background warmer fetches uncached art (GOG's public
// api.gog.com + Xbox's displaycatalog, both no-auth) and persists it. Cross-platform so the
// HTTP/JSON code is compiled + checked everywhere; the warmer simply finds nothing to fetch on a
// host whose stores all carry their own art (Steam CDN / Heroic CDN / Lutris data: URLs).
// ---------------------------------------------------------------------------------------
/// 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 — gamestream::config_dir, NOT the legacy XDG/HOME `config_dir`
/// below that the custom store still uses).
fn art_cache_path() -> PathBuf {
crate::gamestream::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.
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::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(10))
.build();
let body = agent.get(url).call().ok()?.into_string().ok()?;
serde_json::from_str(&body).ok()
}
/// 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
}
// ---------------------------------------------------------------------------------------
// Custom store (user-curated entries, persisted + CRUD'd via the mgmt API)
// ---------------------------------------------------------------------------------------
/// A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API
/// returns and the web console edits.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CustomEntry {
/// Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).
pub id: String,
pub title: String,
#[serde(default)]
pub art: Artwork,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub launch: Option<LaunchSpec>,
}
/// Request body to create or replace a custom entry (no `id` — the host owns it).
#[derive(Clone, Debug, Deserialize, ToSchema)]
pub struct CustomInput {
pub title: String,
#[serde(default)]
pub art: Artwork,
#[serde(default)]
pub launch: Option<LaunchSpec>,
}
impl From<CustomEntry> for GameEntry {
fn from(c: CustomEntry) -> Self {
GameEntry {
id: format!("custom:{}", c.id),
store: "custom".into(),
title: c.title,
art: c.art,
launch: c.launch,
}
}
}
fn config_dir() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.unwrap_or_else(|| PathBuf::from("."))
.join("punktfunk")
}
fn custom_path() -> PathBuf {
config_dir().join("library.json")
}
/// Load the custom entries (empty + non-fatal if the file is absent or malformed).
pub fn load_custom() -> Vec<CustomEntry> {
match std::fs::read_to_string(custom_path()) {
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| {
tracing::warn!(error = %e, "library.json malformed — ignoring custom entries");
Vec::new()
}),
Err(_) => Vec::new(),
}
}
fn save_custom(entries: &[CustomEntry]) -> Result<()> {
let dir = config_dir();
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let json = serde_json::to_string_pretty(entries)?;
// Write-then-rename so a crash mid-write never truncates the catalog.
let tmp = custom_path().with_extension("json.tmp");
std::fs::write(&tmp, json).with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, custom_path()).context("rename library.json")?;
Ok(())
}
/// 12 hex chars from the title + wall-clock nanos — collision-free in practice, no uuid dep.
fn new_id(title: &str) -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6])
}
/// Create a custom entry, returning it with its assigned id.
pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
let mut entries = load_custom();
let entry = CustomEntry {
id: new_id(&input.title),
title: input.title,
art: input.art,
launch: input.launch,
};
entries.push(entry.clone());
save_custom(&entries)?;
Ok(entry)
}
/// Replace a custom entry's fields (id preserved). `None` ⇒ no entry with that id.
pub fn update_custom(id: &str, input: CustomInput) -> Result<Option<CustomEntry>> {
let mut entries = load_custom();
let Some(slot) = entries.iter_mut().find(|e| e.id == id) else {
return Ok(None);
};
slot.title = input.title;
slot.art = input.art;
slot.launch = input.launch;
let updated = slot.clone();
save_custom(&entries)?;
Ok(Some(updated))
}
/// Delete a custom entry. `false` ⇒ no entry with that id.
pub fn delete_custom(id: &str) -> Result<bool> {
let mut entries = load_custom();
let before = entries.len();
entries.retain(|e| e.id != id);
if entries.len() == before {
return Ok(false);
}
save_custom(&entries)?;
Ok(true)
}
// ---------------------------------------------------------------------------------------
// Unified library
// ---------------------------------------------------------------------------------------
/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it
/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax).
/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping
/// ([`windows_launch_for`]).
fn valid_steam_appid(value: &str) -> bool {
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
}
/// Resolve a store-qualified library id (as sent by a client in `Hello::launch`) to the shell
/// command the host should run for it — looked up in the host's OWN library so a client can only
/// pick an existing title, never inject a command. `None` = unknown id, no launch recipe, or a
/// malformed Steam appid.
///
/// **Linux only**: the resolved command is run nested inside the per-session gamescope. On Windows
/// there is no gamescope to nest into; the host launches a title into the interactive user session
/// via [`launch_title`] instead.
///
/// - `steam_appid` → `steam steam://rungameid/<appid>` (appid validated as digits).
/// - `command` → the stored command verbatim. This string comes from the host's own custom store
/// (added by the host operator via the admin UI), never from the client, so it is trusted.
#[cfg(not(windows))]
pub fn launch_command(id: &str) -> Option<String> {
let spec = all_games().into_iter().find(|g| g.id == id)?.launch?;
command_for(&spec)
}
/// Map a resolved [`LaunchSpec`] to its shell command (pure — the unit-testable core of
/// [`launch_command`], split out so the appid-validation can be tested without a Steam install).
#[cfg(not(windows))]
fn command_for(spec: &LaunchSpec) -> Option<String> {
match spec.kind.as_str() {
"steam_appid" => valid_steam_appid(&spec.value)
.then(|| format!("steam steam://rungameid/{}", spec.value)),
// Lutris: a digits-only pga.db game id (same guard as steam_appid) → its run URI.
#[cfg(target_os = "linux")]
"lutris_id" => (!spec.value.is_empty() && spec.value.bytes().all(|b| b.is_ascii_digit()))
.then(|| format!("lutris lutris:rungameid/{}", spec.value)),
// Heroic: `<runner>:<appName>` → the validated heroic://launch command (see heroic_command).
#[cfg(target_os = "linux")]
"heroic" => heroic_command(&spec.value),
// Trusted: the command comes from the host's own custom store, never the client.
"command" => (!spec.value.trim().is_empty()).then(|| spec.value.clone()),
_ => None,
}
}
/// Windows: launch a store-qualified library id into the **interactive user session** — the Windows
/// analogue of the Linux gamescope-nested [`launch_command`]. The id is resolved against the host's
/// OWN library (the client never sends a command), mapped to a concrete process by
/// [`windows_launch_for`], and spawned via [`crate::interactive::spawn_in_active_session`].
///
/// Wired into the data plane *after* capture is live, so the title renders onto the already-captured
/// desktop and grabs foreground.
#[cfg(windows)]
pub fn launch_title(id: &str) -> Result<()> {
let spec = all_games()
.into_iter()
.find(|g| g.id == id)
.and_then(|g| g.launch)
.ok_or_else(|| anyhow::anyhow!("no launchable library entry '{id}'"))?;
let (cmdline, workdir) = windows_launch_for(&spec).ok_or_else(|| {
anyhow::anyhow!(
"library entry '{id}' has no Windows launch recipe (kind '{}')",
spec.kind
)
})?;
let pid = crate::interactive::spawn_in_active_session(&cmdline, workdir.as_deref())
.with_context(|| format!("launch '{id}' in the interactive session"))?;
tracing::info!(launch_id = id, %cmdline, pid, "launched library title in the interactive session");
Ok(())
}
/// Windows: map a resolved [`LaunchSpec`] to a `(command line, working dir)` to spawn into the
/// interactive session. Pure + unit-testable. `None` = no Windows recipe for this kind.
///
/// CreateProcessAsUserW does NO shell or protocol resolution, so the URI/flags are handed to a
/// concrete EXE as plain arguments — a (host-derived) URI string can never reach a command interpreter.
#[cfg(windows)]
fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::PathBuf>)> {
match spec.kind.as_str() {
"steam_appid" => {
if !valid_steam_appid(&spec.value) {
return None;
}
let uri = format!("steam://rungameid/{}", spec.value);
// Prefer launching Steam.exe with the URI as an argument; fall back to explorer.exe, which
// resolves the steam:// handler from the user hive. (The appid is digits-validated, so the
// only variable part of the line is a number either way.)
let cmdline = match steam_exe() {
Some(exe) => format!("\"{}\" \"{uri}\"", exe.display()),
None => format!("explorer.exe \"{uri}\""),
};
Some((cmdline, None))
}
// Epic: open the (host-built, validated) com.epicgames.launcher:// URI via explorer.exe — a
// concrete EXE that resolves the registered protocol handler as the user; the URI is a single
// argv element (no shell, no cmd /c). Same pattern as the steam explorer fallback.
"epic" => epic_launch_uri(&spec.value).map(|uri| (format!("explorer.exe \"{uri}\""), None)),
// GOG: spawn the resolved game exe directly (host-derived from goggame-<id>.info), no Galaxy.
"gog" => gog_spawn(&spec.value),
// Xbox/Game Pass: activate the UWP/GDK package by its AUMID (<PFN>!<AppId>) via explorer's
// shell:AppsFolder — which runs in the interactive user session (UWP activation fails as
// SYSTEM/session-0; spawn_in_active_session uses the user token). Guard the charset (the value
// is host-derived from MicrosoftGame.config + AppRepository, but belt-and-suspenders).
"aumid" => {
let valid = spec.value.split_once('!').is_some_and(|(pfn, app)| {
let part = |s: &str| {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
};
part(pfn) && part(app)
});
valid.then(|| {
(
format!("explorer.exe \"shell:AppsFolder\\{}\"", spec.value),
None,
)
})
}
// Operator-typed custom command (host-owned, never client-set): run it through the shell in the
// interactive session. `cmd.exe /c` is acceptable here precisely because the value is operator
// input — the same trust as the operator typing it — not a client-influenced string.
"command" => {
let v = spec.value.trim();
(!v.is_empty()).then(|| (format!("cmd.exe /c {v}"), None))
}
_ => None,
}
}
/// 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.
#[cfg(windows)]
fn steam_exe() -> Option<std::path::PathBuf> {
for var in ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"] {
if let Some(pf) = std::env::var_os(var) {
let p = std::path::PathBuf::from(pf).join("Steam").join("steam.exe");
if p.is_file() {
return Some(p);
}
}
}
None
}
/// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the live
/// session, AFTER capture is up. Used by the GameStream path for the backends that DON'T nest the
/// command via [`VirtualDisplay::set_launch_command`]: Windows (no gamescope) and Linux
/// kwin/mutter/wlroots (which stream the existing desktop). The caller skips this for Linux gamescope,
/// which already nested it. On Windows it runs in the interactive USER session (the host is SYSTEM);
/// on Linux the host is already inside the user's graphical session, so a plain spawn lands the app on
/// the streamed (primary) output.
#[cfg(any(windows, target_os = "linux"))]
pub fn launch_gamestream_command(cmd: &str) -> Result<()> {
let cmd = cmd.trim();
anyhow::ensure!(!cmd.is_empty(), "empty command");
#[cfg(windows)]
{
// cmd.exe /c is fine here: the value is the host operator's own apps.json command, not a
// client-influenced string (same trust as the custom-store `command` kind).
let pid = crate::interactive::spawn_in_active_session(&format!("cmd.exe /c {cmd}"), None)
.context("spawn gamestream command in the interactive session")?;
tracing::info!(command = %cmd, pid, "gamestream: launched app in the interactive session");
Ok(())
}
#[cfg(target_os = "linux")]
{
let child = std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.spawn()
.context("spawn gamestream command")?;
tracing::info!(command = %cmd, pid = child.id(), "gamestream: launched app into the session");
Ok(())
}
}
/// The full library: every store's titles merged + the custom entries, sorted by title.
pub fn all_games() -> Vec<GameEntry> {
let mut games = 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")]
{
games.extend(LutrisProvider.list());
games.extend(HeroicProvider.list());
}
// Windows store providers (their launchers are Windows-only): Epic + GOG + Xbox/Game Pass.
#[cfg(windows)]
{
games.extend(EpicProvider.list());
games.extend(GogProvider.list());
games.extend(XboxProvider.list());
}
games.extend(load_custom().into_iter().map(GameEntry::from));
games.sort_by_key(|g| g.title.to_lowercase());
games
}
#[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_uses_cdn_by_appid() {
let art = steam_art(570);
assert_eq!(
art.portrait.as_deref(),
Some("https://cdn.cloudflare.steamstatic.com/steam/apps/570/library_600x900.jpg")
);
assert!(art.header.unwrap().ends_with("/570/header.jpg"));
}
#[cfg(not(windows))]
#[test]
fn launch_command_resolves_and_guards() {
let steam = LaunchSpec {
kind: "steam_appid".into(),
value: "570".into(),
};
assert_eq!(
command_for(&steam).as_deref(),
Some("steam steam://rungameid/570")
);
// A non-numeric "appid" (e.g. a client trying to inject) is rejected, never interpolated.
let evil = LaunchSpec {
kind: "steam_appid".into(),
value: "570; rm -rf ~".into(),
};
assert_eq!(command_for(&evil), None);
// Custom commands (from the host's own store) pass through verbatim.
let custom = LaunchSpec {
kind: "command".into(),
value: "dolphin-emu --batch".into(),
};
assert_eq!(command_for(&custom).as_deref(), Some("dolphin-emu --batch"));
// Empty / unknown kinds → no command.
assert_eq!(
command_for(&LaunchSpec {
kind: "command".into(),
value: " ".into()
}),
None
);
assert_eq!(
command_for(&LaunchSpec {
kind: "wat".into(),
value: "x".into()
}),
None
);
}
#[test]
fn custom_entry_maps_to_game_entry() {
let g: GameEntry = CustomEntry {
id: "abc123".into(),
title: "My ROM".into(),
art: Artwork::default(),
launch: None,
}
.into();
assert_eq!(g.id, "custom:abc123");
assert_eq!(g.store, "custom");
}
#[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"));
}
#[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")
);
}
#[cfg(target_os = "linux")]
#[test]
fn command_for_lutris_and_heroic_guards() {
// Lutris: digits → its run URI; a non-numeric id (injection attempt) is rejected.
assert_eq!(
command_for(&LaunchSpec {
kind: "lutris_id".into(),
value: "42".into()
})
.as_deref(),
Some("lutris lutris:rungameid/42")
);
assert_eq!(
command_for(&LaunchSpec {
kind: "lutris_id".into(),
value: "42; rm -rf ~".into()
}),
None
);
// Heroic guards (independent of whether Heroic is installed): bad runner / appName → None.
assert_eq!(heroic_command("badrunner:Quail"), None);
assert_eq!(heroic_command("legendary:bad name"), None);
assert_eq!(heroic_command("nile:"), None);
// When Heroic IS resolvable (a dev box), a valid id yields the launch URI; on CI (no Heroic)
// it's None — assert the URI shape only when a launcher prefix exists.
if let Some(cmd) = heroic_command("legendary:Quail-1.2_x") {
assert!(cmd.contains("heroic://launch?appName=Quail-1.2_x&runner=legendary"));
assert!(cmd.contains("--no-gui"));
}
}
#[cfg(windows)]
#[test]
fn windows_launch_for_maps_and_guards() {
// Steam: a digits-only appid → a steam:// URI line (via Steam.exe or explorer.exe, depending
// on the box) with no working dir.
let steam = LaunchSpec {
kind: "steam_appid".into(),
value: "570".into(),
};
let (line, wd) = windows_launch_for(&steam).expect("steam recipe");
assert!(line.contains("steam://rungameid/570"), "line was {line:?}");
assert!(wd.is_none());
// A non-numeric "appid" (a client trying to inject) is rejected, never interpolated.
let evil = LaunchSpec {
kind: "steam_appid".into(),
value: "570\" & calc".into(),
};
assert!(windows_launch_for(&evil).is_none());
// Operator command → cmd /c passthrough (trusted host input).
let cmd = LaunchSpec {
kind: "command".into(),
value: "notepad.exe".into(),
};
assert_eq!(
windows_launch_for(&cmd).unwrap().0,
"cmd.exe /c notepad.exe"
);
// Xbox AUMID → explorer shell:AppsFolder activation; a value without '!' is rejected.
let aumid = LaunchSpec {
kind: "aumid".into(),
value: "Microsoft.X_8wekyb3d8bbwe!Game".into(),
};
assert_eq!(
windows_launch_for(&aumid).unwrap().0,
"explorer.exe \"shell:AppsFolder\\Microsoft.X_8wekyb3d8bbwe!Game\""
);
assert!(windows_launch_for(&LaunchSpec {
kind: "aumid".into(),
value: "no-bang".into()
})
.is_none());
// Empty / unknown kinds → no recipe.
assert!(windows_launch_for(&LaunchSpec {
kind: "command".into(),
value: " ".into()
})
.is_none());
assert!(windows_launch_for(&LaunchSpec {
kind: "wat".into(),
value: "x".into()
})
.is_none());
}
#[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();
}
#[cfg(windows)]
#[test]
fn epic_launch_uri_triple_bare_and_guard() {
assert_eq!(
epic_launch_uri("fn:abc:Fortnite").as_deref(),
Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true")
);
assert_eq!(
epic_launch_uri("Fortnite").as_deref(),
Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true")
);
assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected
assert!(epic_launch_uri("").is_none());
}
#[cfg(windows)]
#[test]
fn gog_spawn_parses_and_guards() {
let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip");
assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3")));
let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap();
assert_eq!(cmd2, "\"C:\\g.exe\"");
assert!(wd2.is_none());
assert!(gog_spawn("").is_none());
}
#[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}");
}
#[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());
}
}