feat(library): Windows Xbox / Game Pass store provider
XboxProvider scans each fixed drive's <drive>:\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\<PackageFullName> dir name (reduced to Name_Hash) — never computed from the publisher. Launch via the AUMID (shell:AppsFolder\<PFN>!<AppId>) 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) <noreply@anthropic.com>
This commit is contained in:
Generated
+10
@@ -2728,6 +2728,7 @@ dependencies = [
|
|||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"reis",
|
"reis",
|
||||||
|
"roxmltree",
|
||||||
"rsa",
|
"rsa",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -3055,6 +3056,15 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "rpkg-config"
|
name = "rpkg-config"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
|
|||||||
@@ -179,6 +179,9 @@ windows-service = "0.7"
|
|||||||
# Read the GOG.com install registry (HKLM\SOFTWARE\WOW6432Node\GOG.com\Games) for the GOG store
|
# 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.
|
# provider — ergonomic + correct-by-construction vs. hand-rolled Reg* FFI for subkey enumeration.
|
||||||
winreg = "0.56"
|
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
|
# 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.
|
# compiles OpenH264 (BSD-2) — no system lib, builds on MSVC; nasm on PATH adds the SIMD fast path.
|
||||||
openh264 = "0.9"
|
openh264 = "0.9"
|
||||||
|
|||||||
@@ -845,6 +845,155 @@ fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
|
|||||||
Some((cmdline, workdir))
|
Some((cmdline, workdir))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// Xbox / Microsoft Store / Game Pass (Windows) — scans the flat-file `XboxGames` install dirs
|
||||||
|
// (no auth) for GDK games (each has a Content\MicrosoftGame.config). Launch via the AUMID
|
||||||
|
// (shell:AppsFolder\<PFN>!<AppId>) in the interactive session. Cover art (displaycatalog) deferred.
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Reads installed Xbox / Game Pass / Store GDK games from the flat-file install dirs. Windows-only.
|
||||||
|
/// Best-effort: empty when no `XboxGames` dir exists.
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub struct XboxProvider;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
impl LibraryProvider for XboxProvider {
|
||||||
|
fn store(&self) -> &'static str {
|
||||||
|
"xbox"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list(&self) -> Vec<GameEntry> {
|
||||||
|
xbox_games()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan each fixed drive's default `<drive>:\XboxGames` for GDK games — the presence of
|
||||||
|
/// `Content\MicrosoftGame.config` is the game marker (so we list games, not ordinary UWP apps). A
|
||||||
|
/// custom install folder (set via the undocumented `.GamingRoot`) isn't covered; the default folder
|
||||||
|
/// is the common case. Non-GDK pure-UWP Store games (under the ACL-locked WindowsApps) are missed too.
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn xbox_games() -> Vec<GameEntry> {
|
||||||
|
let mut games = Vec::new();
|
||||||
|
for letter in b'C'..=b'Z' {
|
||||||
|
let root = PathBuf::from(format!("{}:\\XboxGames", letter as char));
|
||||||
|
let Ok(rd) = std::fs::read_dir(&root) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for entry in rd.flatten() {
|
||||||
|
let title_dir = entry.path();
|
||||||
|
let cfg = title_dir.join("Content").join("MicrosoftGame.config");
|
||||||
|
if !cfg.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(text) = std::fs::read_to_string(&cfg) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let folder = title_dir
|
||||||
|
.file_name()
|
||||||
|
.map(|f| f.to_string_lossy().into_owned());
|
||||||
|
let Some((name, app_id, title, store_id)) = xbox_parse_config(&text, folder.as_deref())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(pfn) = xbox_pfn(&name) else {
|
||||||
|
tracing::debug!(package = %name, "xbox: no AppRepository entry → can't resolve PFN, skipping");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let id_key = if store_id.is_empty() {
|
||||||
|
pfn.clone()
|
||||||
|
} else {
|
||||||
|
store_id
|
||||||
|
};
|
||||||
|
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 `<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}"))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
// Custom store (user-curated entries, persisted + CRUD'd via the mgmt API)
|
// 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<std::path::Pa
|
|||||||
"epic" => epic_launch_uri(&spec.value).map(|uri| (format!("explorer.exe \"{uri}\""), None)),
|
"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: spawn the resolved game exe directly (host-derived from goggame-<id>.info), no Galaxy.
|
||||||
"gog" => gog_spawn(&spec.value),
|
"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
|
// 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
|
// 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.
|
// input — the same trust as the operator typing it — not a client-influenced string.
|
||||||
@@ -1108,11 +1277,12 @@ pub fn all_games() -> Vec<GameEntry> {
|
|||||||
games.extend(LutrisProvider.list());
|
games.extend(LutrisProvider.list());
|
||||||
games.extend(HeroicProvider.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)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
games.extend(EpicProvider.list());
|
games.extend(EpicProvider.list());
|
||||||
games.extend(GogProvider.list());
|
games.extend(GogProvider.list());
|
||||||
|
games.extend(XboxProvider.list());
|
||||||
}
|
}
|
||||||
games.extend(load_custom().into_iter().map(GameEntry::from));
|
games.extend(load_custom().into_iter().map(GameEntry::from));
|
||||||
games.sort_by_key(|g| g.title.to_lowercase());
|
games.sort_by_key(|g| g.title.to_lowercase());
|
||||||
@@ -1357,6 +1527,20 @@ mod tests {
|
|||||||
windows_launch_for(&cmd).unwrap().0,
|
windows_launch_for(&cmd).unwrap().0,
|
||||||
"cmd.exe /c notepad.exe"
|
"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.
|
// Empty / unknown kinds → no recipe.
|
||||||
assert!(windows_launch_for(&LaunchSpec {
|
assert!(windows_launch_for(&LaunchSpec {
|
||||||
kind: "command".into(),
|
kind: "command".into(),
|
||||||
@@ -1444,4 +1628,41 @@ mod tests {
|
|||||||
assert_eq!(args, "-w");
|
assert_eq!(args, "-w");
|
||||||
assert!(wd.ends_with("bin"), "wd={wd}");
|
assert!(wd.ends_with("bin"), "wd={wd}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn xbox_parse_config_and_pfn() {
|
||||||
|
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Game configVersion="1">
|
||||||
|
<Identity Name="Microsoft.624F8B84B80" Publisher="CN=Microsoft" Version="1.0.0.0" />
|
||||||
|
<ExecutableList>
|
||||||
|
<Executable Name="gamelaunchhelper.exe" Id="Game" />
|
||||||
|
</ExecutableList>
|
||||||
|
<StoreId>9NBLGGH4R315</StoreId>
|
||||||
|
<ShellVisuals DefaultDisplayName="Halo Infinite" Square150x150Logo="x.png" />
|
||||||
|
</Game>"#;
|
||||||
|
let (name, app_id, title, store_id) = xbox_parse_config(xml, Some("HaloInfinite")).unwrap();
|
||||||
|
assert_eq!(name, "Microsoft.624F8B84B80");
|
||||||
|
assert_eq!(app_id, "Game");
|
||||||
|
assert_eq!(title, "Halo Infinite");
|
||||||
|
assert_eq!(store_id, "9NBLGGH4R315");
|
||||||
|
// An ms-resource DefaultDisplayName is unresolvable → fall back to the install folder name.
|
||||||
|
let xml2 = r#"<Game><Identity Name="Pkg.Name"/>
|
||||||
|
<ExecutableList><Executable Id="App"/></ExecutableList>
|
||||||
|
<ShellVisuals DefaultDisplayName="ms-resource:DisplayName"/></Game>"#;
|
||||||
|
let (_, app2, title2, sid2) = xbox_parse_config(xml2, Some("MyGameFolder")).unwrap();
|
||||||
|
assert_eq!(app2, "App");
|
||||||
|
assert_eq!(title2, "MyGameFolder");
|
||||||
|
assert_eq!(sid2, "");
|
||||||
|
// PackageFamilyName reduced from a PackageFullName dir name (the hash is the last segment).
|
||||||
|
assert_eq!(
|
||||||
|
pfn_from_full(
|
||||||
|
"Microsoft.624F8B84B80_1.0.0.0_x64__8wekyb3d8bbwe",
|
||||||
|
"Microsoft.624F8B84B80"
|
||||||
|
)
|
||||||
|
.as_deref(),
|
||||||
|
Some("Microsoft.624F8B84B80_8wekyb3d8bbwe")
|
||||||
|
);
|
||||||
|
assert!(pfn_from_full("NoUnderscore", "NoUnderscore").is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user