From aed0bf0c2ac4122b4def9363dc72b68211f7db33 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 26 Jun 2026 07:49:03 +0000 Subject: [PATCH] feat(library): Windows Xbox / Game Pass store provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XboxProvider scans each fixed drive's :\XboxGames for GDK games (presence of Content\MicrosoftGame.config marks a game vs. an ordinary UWP app), parsing title / Identity name / Executable Id / StoreId via roxmltree. The PackageFamilyName is READ from the AppRepository\Packages\ dir name (reduced to Name_Hash) — never computed from the publisher. Launch via the AUMID (shell:AppsFolder\!) through explorer in the interactive user session (UWP activation needs the user token, which spawn_in_active_session already provides). Cover art (displaycatalog) is deferred → title-only. Known v1 gaps: custom .GamingRoot install folders + non-GDK pure-UWP Store games (under the ACL-locked WindowsApps) aren't enumerated. New windows_launch_for `aumid` arm; XboxProvider wired into all_games() under cfg(windows). Dep: roxmltree (Windows). Windows unit tests cover MicrosoftGame.config parsing (incl. the ms-resource title fallback), the PackageFullName→PFN reduction, and the aumid launch. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 10 ++ crates/punktfunk-host/Cargo.toml | 3 + crates/punktfunk-host/src/library.rs | 223 ++++++++++++++++++++++++++- 3 files changed, 235 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 578fc85..9e53558 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2728,6 +2728,7 @@ dependencies = [ "rand 0.8.6", "rcgen", "reis", + "roxmltree", "rsa", "rusqlite", "rustls", @@ -3055,6 +3056,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rpkg-config" version = "0.1.2" diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 68b927a..3bb4383 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -179,6 +179,9 @@ windows-service = "0.7" # Read the GOG.com install registry (HKLM\SOFTWARE\WOW6432Node\GOG.com\Games) for the GOG store # provider — ergonomic + correct-by-construction vs. hand-rolled Reg* FFI for subkey enumeration. winreg = "0.56" +# Parse each Xbox/Game-Pass game's MicrosoftGame.config (GDK manifest XML) for the Xbox store +# provider — a small read-only DOM is all we need (Identity/Executable/ShellVisuals/StoreId). +roxmltree = "0.21" # Software H.264 encoder (GPU-less path + NVENC fallback). The default `source` feature statically # compiles OpenH264 (BSD-2) — no system lib, builds on MSVC; nasm on PATH adds the SIMD fast path. openh264 = "0.9" diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs index a01dbda..aaf024f 100644 --- a/crates/punktfunk-host/src/library.rs +++ b/crates/punktfunk-host/src/library.rs @@ -845,6 +845,155 @@ fn gog_spawn(value: &str) -> Option<(String, Option)> { 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\!) 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 { + xbox_games() + } +} + +/// Scan each fixed drive's default `:\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 { + 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 + }; + games.push(GameEntry { + id: format!("xbox:{id_key}"), + store: "xbox".into(), + title, + // displaycatalog.mp.microsoft.com cover art (no key, but unofficial + needs an HTTP + // fetch + cache off the hot path) is deferred → title-only (rendered gracefully). + art: Artwork::default(), + 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 ``'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\` 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 { + 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 { + let hash = dir_name.rsplit('_').next()?; + (!hash.is_empty() && hash != dir_name).then(|| format!("{identity}_{hash}")) +} + // --------------------------------------------------------------------------------------- // Custom store (user-curated entries, persisted + CRUD'd via the mgmt API) // --------------------------------------------------------------------------------------- @@ -1071,6 +1220,26 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option epic_launch_uri(&spec.value).map(|uri| (format!("explorer.exe \"{uri}\""), None)), // GOG: spawn the resolved game exe directly (host-derived from goggame-.info), no Galaxy. "gog" => gog_spawn(&spec.value), + // Xbox/Game Pass: activate the UWP/GDK package by its AUMID (!) 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. @@ -1108,11 +1277,12 @@ pub fn all_games() -> Vec { games.extend(LutrisProvider.list()); games.extend(HeroicProvider.list()); } - // Windows store providers (their launchers are Windows-only): Epic + GOG. + // 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()); @@ -1357,6 +1527,20 @@ mod tests { 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(), @@ -1444,4 +1628,41 @@ mod tests { assert_eq!(args, "-w"); assert!(wd.ends_with("bin"), "wd={wd}"); } + + #[cfg(windows)] + #[test] + fn xbox_parse_config_and_pfn() { + let xml = r#" + + + + + + 9NBLGGH4R315 + +"#; + 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#" + + "#; + 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()); + } }