//! 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 { 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 }; 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 ``'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}")) } #[cfg(test)] mod tests { use super::*; #[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()); } }