ecfa71212d
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>
192 lines
7.3 KiB
Rust
192 lines
7.3 KiB
Rust
//! 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());
|
|
}
|
|
}
|