chore: consolidate all in-progress parallel-session WIP
audit / bun-audit (push) Successful in 27s
windows-drivers / probe-and-proto (push) Successful in 49s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m10s
apple / swift (push) Successful in 1m18s
decky / build-publish (push) Successful in 34s
windows-drivers / driver-build (push) Successful in 1m37s
audit / cargo-audit (push) Successful in 3m1s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
ci / bench (push) Successful in 5m56s
windows-host / package (push) Failing after 5m17s
apple / screenshots (push) Successful in 4m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m2s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
arch / build-publish (push) Successful in 16m50s
android / android (push) Successful in 17m7s
docker / deploy-docs (push) Successful in 28s
deb / build-publish (push) Successful in 17m6s
ci / rust (push) Failing after 19m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m3s
flatpak / build-publish (push) Successful in 8m0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m14s
release / apple (push) Successful in 5m44s

Wholesale commit of every uncommitted change across the tree, at the user's
explicit request — host refactor-campaign W1 (native.rs facade + native/ dir,
library/ + mgmt/ splits), Android, core. These streams were mid-flight and not
individually built/tested together; this supersedes the per-session HOLD
markers. Consolidating so everything lands on main in one pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 20:08:29 +02:00
parent 07e2836601
commit ecfa71212d
67 changed files with 9456 additions and 8062 deletions
+259
View File
@@ -0,0 +1,259 @@
//! Artwork cache + background warmer: the on-disk poster cache, the per-store fetchers, and the
//! `fetch_box_art` dispatch the management art proxy serves from. Split out of the `library` facade (plan §W5).
use super::*;
/// The persisted art cache: GameEntry id → resolved [`Artwork`]. An entry's PRESENCE means "already
/// resolved" (even an empty Artwork = fetched, none found) so the warmer never re-fetches it.
fn art_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Artwork>> {
static CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, Artwork>>,
> = std::sync::OnceLock::new();
CACHE.get_or_init(|| {
let loaded = std::fs::read_to_string(art_cache_path())
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
std::sync::Mutex::new(loaded)
})
}
/// The art cache lives in the canonical HOST config dir (`%ProgramData%\punktfunk` on Windows /
/// `~/.config/punktfunk` on Linux — 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.
pub(crate) fn cached_art(id: &str) -> Option<Artwork> {
art_cache().lock().unwrap().get(id).cloned()
}
/// Record resolved art for a library id + persist the cache (write-then-rename; best-effort).
fn store_art(id: &str, art: Artwork) {
let mut cache = art_cache().lock().unwrap();
cache.insert(id.to_string(), art);
if let Ok(json) = serde_json::to_string(&*cache) {
let path = art_cache_path();
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, json).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
}
/// Start the host-lifetime cover-art warmer: every few minutes, fetch + cache art for any library
/// entry whose store needs a network lookup (GOG / Xbox) and isn't cached yet. Idempotent — once
/// everything is cached a pass makes no network calls (and a host with only self-art stores never
/// fetches at all). Call once from `serve()`; the returned handle can be dropped to detach it.
pub fn start_art_warmer() -> std::thread::JoinHandle<()> {
std::thread::Builder::new()
.name("pf-art-warmer".into())
.spawn(|| loop {
warm_art_once();
std::thread::sleep(std::time::Duration::from_secs(300));
})
.expect("spawn art warmer thread")
}
/// One warming pass: resolve uncached GOG/Xbox art. Other stores carry their own art (Steam CDN
/// template, Heroic CDN URLs, Lutris data: URLs, custom user URLs) and are skipped.
fn warm_art_once() {
for g in all_games() {
if cached_art(&g.id).is_some() {
continue;
}
let Some((store, localid)) = g.id.split_once(':') else {
continue;
};
let art = match store {
"gog" => fetch_gog_art(localid),
// The xbox id is the StoreId when present, else the PFN (contains '_', no displaycatalog
// entry) → cache empty for those so they aren't retried every pass.
"xbox" if !localid.contains('_') => fetch_xbox_art(localid),
"xbox" => Artwork::default(),
_ => continue, // steam/heroic/lutris/custom resolve their own art
};
store_art(&g.id, art);
}
}
/// HTTP GET + parse JSON with a bounded timeout. `None` on any network/parse failure (best-effort —
/// art is non-essential, so a failure just leaves the title-only card).
fn fetch_json(url: &str) -> Option<serde_json::Value> {
let agent = ureq::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()
}
/// Fetch one image URL for the GameStream `/appasset` cover proxy, as `(bytes, content-type)`. Handles
/// `data:` URLs (Lutris inlines art that way) by decoding inline, and `http(s)` URLs by a bounded GET
/// (8 MiB cap so a hostile/huge art URL can't balloon host memory). `None` on any non-image scheme,
/// network/decoder error, or empty body. Blocking (ureq) — call off the async runtime.
pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
use base64::Engine as _;
use std::io::Read as _;
if let Some(rest) = url.strip_prefix("data:") {
// data:[<mediatype>][;base64],<payload>
let (meta, data) = rest.split_once(',')?;
let ctype = meta
.split(';')
.next()
.filter(|s| !s.is_empty())
.unwrap_or("image/jpeg")
.to_string();
let bytes = if meta.contains(";base64") {
base64::engine::general_purpose::STANDARD
.decode(data)
.ok()?
} else {
data.as_bytes().to_vec()
};
return (!bytes.is_empty()).then_some((bytes, ctype));
}
if !(url.starts_with("http://") || url.starts_with("https://")) {
return None;
}
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(10))
.build();
let resp = agent.get(url).call().ok()?;
let ctype = resp
.header("Content-Type")
.unwrap_or("image/jpeg")
.to_string();
let mut bytes = Vec::new();
resp.into_reader()
.take(8 * 1024 * 1024)
.read_to_end(&mut bytes)
.ok()?;
(!bytes.is_empty()).then_some((bytes, ctype))
}
/// Resolve + fetch the best box-art cover for a library id (the GameStream `/appasset` proxy — Moonlight
/// fetches per-app covers from the HOST, not the CDN, so we proxy the bytes). Tries the portrait (tall
/// capsule Moonlight wants) → header → hero → logo, returning the first that fetches as
/// `(bytes, content-type)`. Resolves the id against the host's OWN library. Blocking — call off the
/// async runtime (e.g. `spawn_blocking`).
pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
// Steam's `Artwork` fields are now relative proxy paths (see `steam_art`) the *client* resolves
// against the host — meaningless to `fetch_image`, which expects an absolute URL. Resolve
// those kinds directly instead of going through the URL fields.
if let Some(appid) = id
.strip_prefix("steam:")
.and_then(|s| s.parse::<u32>().ok())
{
return [
ArtKind::Portrait,
ArtKind::Header,
ArtKind::Hero,
ArtKind::Logo,
]
.into_iter()
.find_map(|kind| steam_art_bytes(appid, kind));
}
let g = all_games().into_iter().find(|g| g.id == id)?;
[g.art.portrait, g.art.header, g.art.hero, g.art.logo]
.into_iter()
.flatten()
.find_map(|url| fetch_image(&url))
}
/// Make a protocol-relative URL (`//host/...`, common in GOG + MS catalog responses) absolute https.
fn abs_url(u: &str) -> String {
u.strip_prefix("//")
.map(|rest| format!("https://{rest}"))
.unwrap_or_else(|| u.to_string())
}
/// GOG cover art via the public (no-auth) product API. Field names / URL shapes are GOG-specific and
/// best-effort (worth on-box confirmation); a wrong URL just degrades to the title card client-side.
fn fetch_gog_art(product_id: &str) -> Artwork {
let Some(v) = fetch_json(&format!(
"https://api.gog.com/products/{product_id}?expand=images"
)) else {
return Artwork::default();
};
let img = |k: &str| {
v.get("images")
.and_then(|i| i.get(k))
.and_then(|u| u.as_str())
.map(abs_url)
};
Artwork {
portrait: img("verticalCover"),
hero: img("background"),
logo: img("logo2x"),
header: img("logo"),
}
}
/// Xbox cover art via the (unofficial, no-auth) Microsoft display catalog, keyed by StoreId. Best-
/// effort: the endpoint is internal/unstable, so on drift this just yields no art (title-only).
fn fetch_xbox_art(store_id: &str) -> Artwork {
let Some(v) = fetch_json(&format!(
"https://displaycatalog.mp.microsoft.com/v7.0/products/{store_id}?market=US&languages=en-us&fieldsTemplate=Details"
)) else {
return Artwork::default();
};
let images = v
.get("Products")
.and_then(|p| p.as_array())
.and_then(|a| a.first())
.and_then(|p| p.get("LocalizedProperties"))
.and_then(|l| l.as_array())
.and_then(|a| a.first())
.and_then(|lp| lp.get("Images"))
.and_then(|i| i.as_array());
let mut art = Artwork::default();
for img in images.into_iter().flatten() {
let (Some(purpose), Some(uri)) = (
img.get("ImagePurpose").and_then(|v| v.as_str()),
img.get("Uri").and_then(|v| v.as_str()),
) else {
continue;
};
let url = abs_url(uri);
match purpose {
"Poster" => art.portrait = Some(url),
"SuperHeroArt" | "Hero" => art.hero = Some(url),
"Logo" => art.logo = Some(url),
"BoxArt" => art.header = Some(url),
_ => {}
}
}
art
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn art_kind_parses_known_names_only() {
assert_eq!(ArtKind::parse("portrait"), Some(ArtKind::Portrait));
assert_eq!(ArtKind::parse("hero"), Some(ArtKind::Hero));
assert_eq!(ArtKind::parse("logo"), Some(ArtKind::Logo));
assert_eq!(ArtKind::parse("header"), Some(ArtKind::Header));
assert_eq!(ArtKind::parse("background"), None);
}
#[test]
fn fetch_image_decodes_data_url() {
// "Hi" base64 == "SGk=" — the data: branch is pure (no network), so it's deterministic.
let (bytes, ctype) = fetch_image("data:image/png;base64,SGk=").expect("data url decodes");
assert_eq!(bytes, b"Hi");
assert_eq!(ctype, "image/png");
// A non-image scheme is rejected (no launcher art ever points at file://, but be defensive).
assert!(fetch_image("file:///etc/passwd").is_none());
// Empty payload → None (never serve a 0-byte cover).
assert!(fetch_image("data:image/png;base64,").is_none());
}
}
+159
View File
@@ -0,0 +1,159 @@
//! User-curated custom store: CRUD (add/update/delete) over the persisted custom entries the web
//! console manages, and their mapping onto the uniform `GameEntry`. Split out of the `library` facade (plan §W5).
use super::*;
/// 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)?;
emit_changed();
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)?;
emit_changed();
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)?;
emit_changed();
Ok(true)
}
/// The custom-entry mutations are the only library writes today, all operator-driven — hence
/// `source: "manual"` (RFC §4; a provider id once the provider API of RFC §8 lands).
fn emit_changed() {
crate::events::emit(crate::events::EventKind::LibraryChanged {
source: "manual".to_string(),
});
}
/// 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`]).
pub(crate) fn valid_steam_appid(value: &str) -> bool {
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
}
#[cfg(test)]
mod tests {
use super::*;
#[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");
}
}
+241
View File
@@ -0,0 +1,241 @@
//! Epic Games Store provider: installed manifests + the catalog-cache art index + launch URIs. Split out of the `library` facade (plan §W5).
use super::*;
/// Reads the Epic Games Launcher's local install manifests. Windows-only. Best-effort: empty when
/// the launcher (or its manifest dir) isn't present.
#[cfg(windows)]
pub struct EpicProvider;
#[cfg(windows)]
impl LibraryProvider for EpicProvider {
fn store(&self) -> &'static str {
"epic"
}
fn list(&self) -> Vec<GameEntry> {
let data = epic_data_dir();
let Ok(rd) = std::fs::read_dir(data.join("Manifests")) else {
return Vec::new();
};
// Parse the (best-effort) artwork cache ONCE: catalogItemId -> Artwork.
let art = epic_art_index(&data.join("Catalog").join("catcache.bin"));
let mut games = Vec::new();
for entry in rd.flatten() {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) != Some("item") {
continue;
}
// `.item` manifests are small JSON; cap the read so a planted giant can't OOM the host.
let Some(bytes) = read_capped(&p, 1024 * 1024) else {
continue;
};
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
continue;
};
if let Some(g) = epic_entry(&v, &art) {
games.push(g);
}
}
games
}
}
/// `%ProgramData%\Epic\EpicGamesLauncher\Data` (machine-wide, SYSTEM-readable).
#[cfg(windows)]
fn epic_data_dir() -> PathBuf {
std::env::var_os("ProgramData")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
.join("Epic")
.join("EpicGamesLauncher")
.join("Data")
}
/// Map one `.item` manifest to a [`GameEntry`], or `None` if it isn't a launchable game. Uses
/// Playnite's proven EXCLUSION filter (skip `UE_*` Unreal components; skip a DLC/addon unless it is
/// `addons/launchable`) rather than a positive `games`-category match, which can drop legit titles.
#[cfg(windows)]
fn epic_entry(
v: &serde_json::Value,
art: &std::collections::HashMap<String, Artwork>,
) -> Option<GameEntry> {
let s = |k: &str| v.get(k).and_then(|x| x.as_str());
let app_name = s("AppName")?.to_string();
if app_name.starts_with("UE_") {
return None; // Unreal Engine component, not a game
}
let cats: Vec<&str> = v
.get("AppCategories")
.and_then(|c| c.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str()).collect())
.unwrap_or_default();
if cats.contains(&"addons") && !cats.contains(&"addons/launchable") {
return None; // non-launchable DLC/addon
}
// Drop stale records whose install dir is gone.
let install = s("InstallLocation")?;
if !Path::new(install).is_dir() {
return None;
}
let title = s("DisplayName").unwrap_or(&app_name).to_string();
let namespace = s("CatalogNamespace").unwrap_or("");
let catalog = s("CatalogItemId").unwrap_or("");
// The robust launch form is the namespace:catalogItemId:appName triple; fall back to the bare
// appName when those ids are absent (some manifests lack them) — never drop the launch entirely.
let value = if !namespace.is_empty() && !catalog.is_empty() {
format!("{namespace}:{catalog}:{app_name}")
} else {
app_name.clone()
};
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)]
pub(crate) 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"
))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn epic_filters_and_builds_launch() {
let dir = std::env::temp_dir().join(format!("pf-epic-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let inst = dir.to_string_lossy().into_owned();
let empty = std::collections::HashMap::new();
// Normal game with the full triple → kept, triple launch value.
let game = serde_json::json!({
"AppName": "Fortnite", "DisplayName": "Fortnite", "CatalogNamespace": "fn",
"CatalogItemId": "abc123", "InstallLocation": inst.clone(),
"AppCategories": ["public", "games", "applications"]
});
let e = epic_entry(&game, &empty).expect("game kept");
assert_eq!(e.id, "epic:Fortnite");
assert_eq!(e.launch.as_ref().unwrap().value, "fn:abc123:Fortnite");
// UE component, non-launchable addon, and a missing install dir are all skipped.
let ue = serde_json::json!({"AppName":"UE_5.3","InstallLocation":inst.clone(),"AppCategories":["engines"]});
assert!(epic_entry(&ue, &empty).is_none());
let dlc =
serde_json::json!({"AppName":"DLC","InstallLocation":inst,"AppCategories":["addons"]});
assert!(epic_entry(&dlc, &empty).is_none());
let gone = serde_json::json!({"AppName":"Gone","InstallLocation":"C:\\nope-xyz","AppCategories":["games"]});
assert!(epic_entry(&gone, &empty).is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[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());
}
}
+159
View File
@@ -0,0 +1,159 @@
//! GOG Galaxy store provider: installed games from the Galaxy DB + play-task launch resolution. Split out of the `library` facade (plan §W5).
use super::art::cached_art;
use super::*;
/// Reads the GOG.com install registry + per-game `.info` files. Windows-only. Best-effort: empty
/// when GOG isn't installed.
#[cfg(windows)]
pub struct GogProvider;
#[cfg(windows)]
impl LibraryProvider for GogProvider {
fn store(&self) -> &'static str {
"gog"
}
fn list(&self) -> Vec<GameEntry> {
gog_games()
}
}
#[cfg(windows)]
fn gog_games() -> Vec<GameEntry> {
use winreg::enums::HKEY_LOCAL_MACHINE;
use winreg::RegKey;
// 32-bit GOG writes under WOW6432Node; a 64-bit process reads the explicit path directly.
let Ok(games_key) =
RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey("SOFTWARE\\WOW6432Node\\GOG.com\\Games")
else {
return Vec::new();
};
let mut out = Vec::new();
for sub in games_key.enum_keys().flatten() {
// The subkey name IS the GOG product id.
let Ok(k) = games_key.open_subkey(&sub) else {
continue;
};
let Ok(path) = k.get_value::<String, _>("PATH") else {
continue;
};
if !Path::new(&path).is_dir() {
continue;
}
let title = k
.get_value::<String, _>("GAMENAME")
.unwrap_or_else(|_| sub.clone());
// Resolve the primary play task (exe + args + workdir) from goggame-<id>.info; skip if absent.
let Some((exe, args, workdir)) = gog_play_task(&path, &sub) else {
continue;
};
let id = format!("gog:{sub}");
// Art (public api.gog.com) is resolved off the hot path by the background warmer; read
// whatever it has cached (title-only until warmed).
let art = cached_art(&id).unwrap_or_default();
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)]
pub(crate) 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))
}
#[cfg(test)]
mod tests {
use super::*;
#[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}");
}
}
+208
View File
@@ -0,0 +1,208 @@
//! Heroic (Epic/GOG) store provider: installed games from Heroic's JSON stores + CDN art. Split out of the `library` facade (plan §W5).
use super::*;
/// Reads Heroic Games Launcher's local library cache. One provider surfaces all three of Heroic's
/// backends (legendary=Epic, gog=GOG, nile=Amazon). Linux-only for now (Heroic on Windows uses a
/// different config path and the launch path isn't wired there yet).
#[cfg(target_os = "linux")]
pub struct HeroicProvider;
#[cfg(target_os = "linux")]
impl LibraryProvider for HeroicProvider {
fn store(&self) -> &'static str {
"heroic"
}
fn list(&self) -> Vec<GameEntry> {
let Some(root) = heroic_root() else {
return Vec::new();
};
let mut games = Vec::new();
// (cache file, runner id, the electron-store data key holding the games array)
for (file, runner, key) in [
("legendary_library.json", "legendary", "library"),
("gog_library.json", "gog", "games"),
("nile_library.json", "nile", "library"),
] {
let path = root.join("store_cache").join(file);
match heroic_games(&path, runner, key) {
Ok(mut g) => games.append(&mut g),
Err(e) => {
tracing::debug!(error = %e, file, "heroic store_cache not read (store unused?)")
}
}
}
games
}
}
/// The first existing Heroic config root: `$XDG_CONFIG_HOME/heroic`, classic `~/.config/heroic`, or
/// the Flatpak path.
#[cfg(target_os = "linux")]
fn heroic_root() -> Option<PathBuf> {
let mut candidates = Vec::new();
if let Some(d) = std::env::var_os("XDG_CONFIG_HOME") {
candidates.push(PathBuf::from(d).join("heroic"));
}
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
candidates.push(home.join(".config/heroic"));
candidates.push(home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic"));
}
candidates.into_iter().find(|p| p.is_dir())
}
/// Parse one runner's `store_cache/*_library.json` (an electron-store object whose `key` holds the
/// games array). Keeps only installed titles whose install dir still exists (the latter works around
/// Heroic's gog `is_installed` bug, #2691). Art comes straight from the cached public CDN URLs.
#[cfg(target_os = "linux")]
fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<GameEntry>> {
let raw = std::fs::read_to_string(path)?;
let root: serde_json::Value = serde_json::from_str(&raw)?;
let arr = root
.get(key)
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("no '{key}' array in {}", path.display()))?;
let mut games = Vec::new();
for g in arr {
if !g
.get("is_installed")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
continue; // the cache also lists owned-but-not-installed titles
}
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")]
pub(crate) 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())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn heroic_games_parses_installed_with_cdn_art() {
let dir = std::env::temp_dir().join(format!("pf-heroic-test-{}", std::process::id()));
let install = dir.join("game-install");
std::fs::create_dir_all(&install).unwrap();
let path = dir.join("legendary_library.json");
let json = format!(
r#"{{"library":[
{{"app_name":"Quail","title":"Quail","is_installed":true,
"install":{{"install_path":"{inst}"}},
"art_square":"https://cdn/quail_tall.jpg","art_cover":"https://cdn/quail_wide.jpg",
"art_logo":"file:///local/logo.png"}},
{{"app_name":"Owned","title":"Owned Only","is_installed":false,
"install":{{"install_path":"{inst}"}}}}
]}}"#,
inst = install.display()
);
std::fs::write(&path, json).unwrap();
let games = heroic_games(&path, "legendary", "library").unwrap();
std::fs::remove_dir_all(&dir).ok();
assert_eq!(games.len(), 1); // the uninstalled title is filtered out
assert_eq!(games[0].id, "heroic:legendary:Quail");
assert_eq!(games[0].title, "Quail");
assert_eq!(
games[0].art.portrait.as_deref(),
Some("https://cdn/quail_tall.jpg")
);
assert_eq!(
games[0].art.header.as_deref(),
Some("https://cdn/quail_wide.jpg")
);
assert!(games[0].art.logo.is_none()); // file:// art is dropped (client can't fetch it)
let l = games[0].launch.as_ref().unwrap();
assert_eq!(
(l.kind.as_str(), l.value.as_str()),
("heroic", "legendary:Quail")
);
}
}
+363
View File
@@ -0,0 +1,363 @@
//! Title launch: resolve a library id / raw command into an executable command line (per-store +
//! per-OS), and the gamescope-session launch helpers. Split out of the `library` facade (plan §W5).
use super::custom::valid_steam_appid;
#[cfg(target_os = "linux")]
use super::heroic::heroic_command;
use super::*;
#[cfg(windows)]
use super::{epic::epic_launch_uri, gog::gog_spawn};
/// 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
/// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go
/// through the compositor-aware [`launch_session_command`] instead.
#[cfg(windows)]
pub fn launch_gamestream_command(cmd: &str) -> Result<()> {
let cmd = cmd.trim();
anyhow::ensure!(!cmd.is_empty(), "empty command");
// 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(())
}
/// Launch a library title chosen from the **GameStream `/applist`** (the store-qualified id is carried
/// on the `AppEntry`, resolved from the numeric Moonlight appid) into the interactive Windows user
/// session ([`launch_title`]). The id is resolved against the host's OWN library, so a client can
/// only ever pick an existing title — never inject a command. Linux resolves the id via
/// [`launch_command`] and goes through [`launch_session_command`] instead.
#[cfg(windows)]
pub fn launch_gamestream_library(id: &str) -> Result<()> {
launch_title(id)
}
/// Launch a resolved shell command into the **live Linux session** for the session's compositor —
/// the one launch entry point shared by the native (punktfunk/1) and GameStream planes, called
/// AFTER capture is up so the app renders onto the streamed output. The command is host-resolved
/// (a library id via [`launch_command`], or an operator-typed apps.json/custom command) — never a
/// client-sent string. Best-effort by contract: a failure leaves the user on the (streamed)
/// desktop/session rather than tearing the stream down.
///
/// * **KWin / Mutter / wlroots** — the host runs inside the user's graphical session (the process
/// env was retargeted at it by `apply_session_env`, and the per-session virtual output is
/// promoted primary), so a plain spawn lands the app on the streamed output.
/// * **gamescope (managed / SteamOS / attach)** — the app must open *inside* the running gamescope
/// session: spawned with the session's own `DISPLAY`/Wayland env
/// ([`crate::vdisplay::launch_into_gamescope_session`]). A `steam steam://…` command additionally
/// forwards over the running Steam instance's own pipe, so the dominant Steam case is
/// env-independent.
/// * **gamescope (bare spawn)** — not routed here: the command was nested into the fresh gamescope
/// via `set_launch_command` (the caller gates on `vdisplay::launch_is_nested`).
#[cfg(target_os = "linux")]
pub fn launch_session_command(compositor: crate::vdisplay::Compositor, cmd: &str) -> Result<()> {
let cmd = cmd.trim();
anyhow::ensure!(!cmd.is_empty(), "empty command");
let child = match compositor {
crate::vdisplay::Compositor::Gamescope => {
crate::vdisplay::launch_into_gamescope_session(cmd)?
}
_ => std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.spawn()
.context("spawn launch command")?,
};
tracing::info!(
command = %cmd,
pid = child.id(),
compositor = compositor.id(),
"launched app into the live session"
);
Ok(())
}
/// Resolve the launch command for a session app selection on Linux: a store-qualified library id
/// (from either plane) wins, else the operator-typed command. `None` = nothing to launch (or an
/// unknown/recipe-less id — warned, so a client picking a stale title sees why nothing started).
#[cfg(target_os = "linux")]
pub fn resolve_session_launch(library_id: Option<&str>, command: Option<&str>) -> Option<String> {
if let Some(id) = library_id {
match launch_command(id) {
Some(cmd) => return Some(cmd),
None => tracing::warn!(
launch_id = id,
"requested launch id not in this host's library (or no launch recipe) — ignoring"
),
}
}
command
.map(str::trim)
.filter(|c| !c.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
#[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
);
}
#[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());
}
}
+158
View File
@@ -0,0 +1,158 @@
//! Lutris store provider: installed games from the Lutris SQLite DB + lutris.net CDN art. Split out of the `library` facade (plan §W5).
use super::*;
/// Reads the **local** Lutris library DB (`pga.db`) — no network. Installed titles only; cover art
/// from Lutris's on-disk cache, inlined as `data:` URLs. Linux-only (Lutris is Linux-only).
#[cfg(target_os = "linux")]
pub struct LutrisProvider;
#[cfg(target_os = "linux")]
impl LibraryProvider for LutrisProvider {
fn store(&self) -> &'static str {
"lutris"
}
fn list(&self) -> Vec<GameEntry> {
let Some(db) = lutris_db() else {
return Vec::new();
};
lutris_games(&db).unwrap_or_else(|e| {
tracing::warn!(error = %e, db = %db.display(), "lutris pga.db read failed — skipping");
Vec::new()
})
}
}
/// The first existing Lutris `pga.db`: XDG data dir, the classic `~/.local/share`, or Flatpak.
#[cfg(target_os = "linux")]
fn lutris_db() -> Option<PathBuf> {
let mut candidates = Vec::new();
if let Some(d) = std::env::var_os("XDG_DATA_HOME") {
candidates.push(PathBuf::from(d).join("lutris/pga.db"));
}
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
candidates.push(home.join(".local/share/lutris/pga.db"));
candidates.push(home.join(".var/app/net.lutris.Lutris/data/lutris/pga.db"));
}
candidates.into_iter().find(|p| p.is_file())
}
/// Installed games from a Lutris `pga.db`. Opened **read-only + immutable** (via a SQLite URI) so a
/// running Lutris holding the file can't make us block or fail, and we never write to it.
#[cfg(target_os = "linux")]
fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
use rusqlite::OpenFlags;
// `immutable=1` treats the DB as read-only-and-unchanging → no locking against a live Lutris. The
// path goes into the URI literally; a `?`/`#` in it (vanishingly rare on Linux) would mis-parse,
// so fall back to a plain read-only open in that case.
let path = db.to_string_lossy();
let conn = if path.contains('?') || path.contains('#') {
rusqlite::Connection::open_with_flags(db, OpenFlags::SQLITE_OPEN_READ_ONLY)?
} else {
rusqlite::Connection::open_with_flags(
format!("file:{path}?immutable=1"),
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
)?
};
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
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn lutris_games_reads_installed_only() {
use rusqlite::Connection;
let dir = std::env::temp_dir().join(format!("pf-lutris-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let db = dir.join("pga.db");
{
let c = Connection::open(&db).unwrap();
c.execute_batch(
"CREATE TABLE games (id INTEGER PRIMARY KEY, slug TEXT, name TEXT, installed INTEGER);
INSERT INTO games (id,slug,name,installed) VALUES (42,'elden-ring','ELDEN RING',1);
INSERT INTO games (id,slug,name,installed) VALUES (7,'owned','Owned Only',0);
INSERT INTO games (id,slug,name,installed) VALUES (9,'noname',NULL,1);",
)
.unwrap();
}
let games = lutris_games(&db).unwrap();
std::fs::remove_dir_all(&dir).ok();
// Only the installed, named row; the uninstalled + NULL-name rows are filtered out.
assert_eq!(games.len(), 1);
assert_eq!(games[0].id, "lutris:42");
assert_eq!(games[0].store, "lutris");
assert_eq!(games[0].title, "ELDEN RING");
let l = games[0].launch.as_ref().unwrap();
assert_eq!((l.kind.as_str(), l.value.as_str()), ("lutris_id", "42"));
}
}
+340
View File
@@ -0,0 +1,340 @@
//! Steam store provider: installed-title scan (local `libraryfolders.vdf` + app manifests, no
//! API key) and Steam-CDN / local-`librarycache` artwork. Split out of the `library` facade (plan §W5).
use super::art::fetch_image;
use super::*;
/// Reads the **local** Steam install — no Steam Web API key, no network. Installed titles come
/// from `steamapps/appmanifest_<appid>.acf`; extra library folders from
/// `steamapps/libraryfolders.vdf`; 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 — relative proxy paths the *client* resolves
/// against the host it just talked to (so they work the same whichever interface/port the client
/// reached the host on), backed by [`steam_art_bytes`] on the way out. Not every appid has a
/// 600×900 capsule, but `header.jpg` is effectively universal — the client falls back to it.
fn steam_art(appid: u32) -> Artwork {
let url = |kind: &str| Some(format!("/api/v1/library/art/steam:{appid}/{kind}"));
Artwork {
portrait: url("portrait"),
hero: url("hero"),
logo: url("logo"),
header: url("header"),
}
}
/// Resolve one Steam cover-art kind to bytes: the host's own local Steam cache first (exact — it's
/// literally what the user's Steam client already shows for this title), the legacy flat CDN URL
/// as a fallback. `None` when neither has it (the client then falls through to its next art
/// candidate). Blocking (disk + network) — call off the async runtime.
pub fn steam_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
steam_local_art_bytes(appid, kind).or_else(|| {
let url = format!(
"https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}/{}",
kind.cdn_filename()
);
fetch_image(&url)
})
}
/// Cap on a local librarycache file we'll read into memory — generous for a Steam-quality JPEG/PNG
/// (these run well under 2 MiB in practice) while bounding a pathological file.
const LOCAL_ART_MAX_BYTES: u64 = 8 * 1024 * 1024;
/// `appcache/librarycache/<appid>/<hash>/<filename>` across every Steam root, for whichever
/// `<hash>` subdirectory actually has this kind's file (Steam reuses one hash dir per asset
/// version, so there's normally exactly one candidate per kind).
fn steam_local_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
steam_roots()
.into_iter()
.find_map(|root| find_local_art_file(&root, appid, kind))
.and_then(|path| {
let bytes = std::fs::read(&path).ok()?;
let ctype = if path.extension().is_some_and(|e| e == "png") {
"image/png"
} else {
"image/jpeg"
};
Some((bytes, ctype.to_string()))
})
}
/// Find this kind's cached file under one Steam root's `appcache/librarycache/<appid>/<hash>/`,
/// trying each hash subdirectory (normally just one) and each candidate filename in priority
/// order. Pure path lookup — no env/HOME dependency — so it's unit-testable against a plain
/// directory fixture.
fn find_local_art_file(root: &Path, appid: u32, kind: ArtKind) -> Option<PathBuf> {
let cache_dir = root
.join("appcache")
.join("librarycache")
.join(appid.to_string());
let hash_dirs = std::fs::read_dir(&cache_dir).ok()?;
for hash_dir in hash_dirs.flatten() {
for name in kind.local_filenames() {
let path = hash_dir.path().join(name);
let Ok(meta) = std::fs::metadata(&path) else {
continue;
};
if meta.len() > 0 && meta.len() <= LOCAL_ART_MAX_BYTES {
return Some(path);
}
}
}
None
}
/// 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")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vdf_value_extracts_quoted_field() {
assert_eq!(
vdf_value("\"path\"\t\t\"/mnt/games/SteamLibrary\"", "path"),
Some("/mnt/games/SteamLibrary")
);
assert_eq!(vdf_value("\"appid\"\t\t\"570\"", "appid"), Some("570"));
assert_eq!(vdf_value("\"name\"\t\t\"Dota 2\"", "name"), Some("Dota 2"));
assert_eq!(vdf_value("\"installdir\"\t\t\"x\"", "appid"), None);
}
#[test]
fn vdf_paths_pulls_all_library_folders() {
let vdf = r#"
"libraryfolders"
{
"0"
{
"path" "/home/u/.local/share/Steam"
"apps" { "570" "123" }
}
"1"
{
"path" "/mnt/ssd/SteamLibrary"
}
}
"#;
assert_eq!(
vdf_paths(vdf),
vec![
"/home/u/.local/share/Steam".to_string(),
"/mnt/ssd/SteamLibrary".to_string()
]
);
}
#[test]
fn tools_are_filtered_but_games_kept() {
assert!(is_steam_tool(228980, "Steamworks Common Redistributables"));
assert!(is_steam_tool(1493710, "Proton Experimental"));
assert!(is_steam_tool(0, "Steam Linux Runtime 3.0 (sniper)"));
assert!(!is_steam_tool(570, "Dota 2"));
assert!(!is_steam_tool(1245620, "ELDEN RING"));
}
#[test]
fn steam_art_points_at_the_host_art_proxy() {
let art = steam_art(570);
assert_eq!(
art.portrait.as_deref(),
Some("/api/v1/library/art/steam:570/portrait")
);
assert_eq!(
art.header.as_deref(),
Some("/api/v1/library/art/steam:570/header")
);
}
#[test]
fn find_local_art_file_matches_the_hashed_librarycache_layout() {
let dir = tempfile::tempdir().unwrap();
let cache = dir
.path()
.join("appcache/librarycache/3527290/480bd879ac737921bfa2529a6fea15961267ad21");
std::fs::create_dir_all(&cache).unwrap();
std::fs::write(cache.join("library_600x900.jpg"), b"not really a jpeg").unwrap();
let found = find_local_art_file(dir.path(), 3527290, ArtKind::Portrait).unwrap();
assert_eq!(found, cache.join("library_600x900.jpg"));
// A kind with no cached file, and an appid with no cache dir at all, both miss cleanly.
assert_eq!(
find_local_art_file(dir.path(), 3527290, ArtKind::Hero),
None
);
assert_eq!(
find_local_art_file(dir.path(), 570, ArtKind::Portrait),
None
);
}
#[test]
fn find_local_art_file_prefers_the_2x_portrait() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path().join("appcache/librarycache/570/somehash");
std::fs::create_dir_all(&cache).unwrap();
std::fs::write(cache.join("library_600x900.jpg"), b"1x").unwrap();
std::fs::write(cache.join("library_600x900_2x.jpg"), b"2x").unwrap();
let found = find_local_art_file(dir.path(), 570, ArtKind::Portrait).unwrap();
assert_eq!(found, cache.join("library_600x900_2x.jpg"));
}
}
+191
View File
@@ -0,0 +1,191 @@
//! Xbox / Microsoft Store (UWP) provider: installed packages, PFN resolution, and store art. Split out of the `library` facade (plan §W5).
use super::art::cached_art;
use super::*;
/// Reads installed Xbox / Game Pass / Store GDK games from the flat-file install dirs. Windows-only.
/// Best-effort: empty when no `XboxGames` dir exists.
#[cfg(windows)]
pub struct XboxProvider;
#[cfg(windows)]
impl LibraryProvider for XboxProvider {
fn store(&self) -> &'static str {
"xbox"
}
fn list(&self) -> Vec<GameEntry> {
xbox_games()
}
}
/// Scan each fixed drive's default `<drive>:\XboxGames` for GDK games — the presence of
/// `Content\MicrosoftGame.config` is the game marker (so we list games, not ordinary UWP apps). A
/// custom install folder (set via the undocumented `.GamingRoot`) isn't covered; the default folder
/// is the common case. Non-GDK pure-UWP Store games (under the ACL-locked WindowsApps) are missed too.
#[cfg(windows)]
fn xbox_games() -> Vec<GameEntry> {
let mut games = Vec::new();
for letter in b'C'..=b'Z' {
let root = PathBuf::from(format!("{}:\\XboxGames", letter as char));
let Ok(rd) = std::fs::read_dir(&root) else {
continue;
};
for entry in rd.flatten() {
let title_dir = entry.path();
let cfg = title_dir.join("Content").join("MicrosoftGame.config");
if !cfg.is_file() {
continue;
}
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}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn xbox_parse_config_and_pfn() {
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<Game configVersion="1">
<Identity Name="Microsoft.624F8B84B80" Publisher="CN=Microsoft" Version="1.0.0.0" />
<ExecutableList>
<Executable Name="gamelaunchhelper.exe" Id="Game" />
</ExecutableList>
<StoreId>9NBLGGH4R315</StoreId>
<ShellVisuals DefaultDisplayName="Halo Infinite" Square150x150Logo="x.png" />
</Game>"#;
let (name, app_id, title, store_id) = xbox_parse_config(xml, Some("HaloInfinite")).unwrap();
assert_eq!(name, "Microsoft.624F8B84B80");
assert_eq!(app_id, "Game");
assert_eq!(title, "Halo Infinite");
assert_eq!(store_id, "9NBLGGH4R315");
// An ms-resource DefaultDisplayName is unresolvable → fall back to the install folder name.
let xml2 = r#"<Game><Identity Name="Pkg.Name"/>
<ExecutableList><Executable Id="App"/></ExecutableList>
<ShellVisuals DefaultDisplayName="ms-resource:DisplayName"/></Game>"#;
let (_, app2, title2, sid2) = xbox_parse_config(xml2, Some("MyGameFolder")).unwrap();
assert_eq!(app2, "App");
assert_eq!(title2, "MyGameFolder");
assert_eq!(sid2, "");
// PackageFamilyName reduced from a PackageFullName dir name (the hash is the last segment).
assert_eq!(
pfn_from_full(
"Microsoft.624F8B84B80_1.0.0.0_x64__8wekyb3d8bbwe",
"Microsoft.624F8B84B80"
)
.as_deref(),
Some("Microsoft.624F8B84B80_8wekyb3d8bbwe")
);
assert!(pfn_from_full("NoUnderscore", "NoUnderscore").is_none());
}
}