diff --git a/crates/punktfunk-host/src/library/custom.rs b/crates/punktfunk-host/src/library/custom.rs index 1d4fe59c..911710f9 100644 --- a/crates/punktfunk-host/src/library/custom.rs +++ b/crates/punktfunk-host/src/library/custom.rs @@ -468,6 +468,14 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St "entries[{i}]: `launch.value` for kind `playnite` must be a Playnite game GUID" )); } + // `!`, both straight off `MicrosoftGame.config`. The host completes it + // into an AUMID at launch (it can read the publisher hash; the runner cannot), so the + // shape is checked here where the author can still act on the error. + if launch.kind == "xbox" && !valid_aumid(&launch.value) { + return Err(format!( + "entries[{i}]: `launch.value` for kind `xbox` must be `!`" + )); + } } if let Some(marker) = &e.detect.env_marker { if !valid_env_key(&marker.key) { diff --git a/crates/punktfunk-host/src/library/launch.rs b/crates/punktfunk-host/src/library/launch.rs index b5bb5eac..87ee45fa 100644 --- a/crates/punktfunk-host/src/library/launch.rs +++ b/crates/punktfunk-host/src/library/launch.rs @@ -184,21 +184,33 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option { - 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, - ) - }) + "aumid" => valid_aumid(&spec.value).then(|| { + ( + format!("explorer.exe \"shell:AppsFolder\\{}\"", spec.value), + None, + ) + }), + // Xbox / Game Pass from a library PLUGIN: `!`, both read straight out of + // `MicrosoftGame.config`. The host completes it into the AUMID. + // + // This kind exists because of a measured privilege asymmetry (2026-08-06): resolving the + // PackageFamilyName means enumerating `%ProgramData%\…\AppRepository\Packages`, which is + // denied to `NT AUTHORITY\LocalService` — the principal the plugin runner runs as — and + // allowed to the host, which runs as LocalSystem. So the plugin sends what it can read and + // the host reads the authoritative publisher hash itself, at launch time. + // + // Resolving here rather than caching at install time also means a package update that + // changes the hash cannot leave a stale, unlaunchable tile behind. + "xbox" => { + let (identity, app_id) = spec.value.split_once('!')?; + if !aumid_part(identity) || !aumid_part(app_id) { + return None; + } + let pfn = xbox_pfn(identity)?; + Some(( + format!("explorer.exe \"shell:AppsFolder\\{pfn}!{app_id}\""), + None, + )) } // Playnite: open the game through Playnite's own URI handler, which is what actually knows // how to start it (Playnite maps the id to whichever store owns the title). explorer.exe @@ -285,6 +297,23 @@ pub(crate) fn valid_steam_ui(value: &str) -> bool { matches!(value, "bigpicture" | "desktop") } +/// One half of an AUMID (a package family name or an app id): non-empty, and no character that +/// could break out of the `shell:AppsFolder\…` argument. Both halves are host-derived, so this is +/// belt-and-braces — but the `xbox` kind now takes an Identity straight off a plugin's wire, which +/// makes it load-bearing rather than defensive. +pub(crate) fn aumid_part(s: &str) -> bool { + !s.is_empty() + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + +/// A full `!` AUMID. +pub(crate) fn valid_aumid(value: &str) -> bool { + value + .split_once('!') + .is_some_and(|(pfn, app)| aumid_part(pfn) && aumid_part(app)) +} + /// A Playnite game id: the GUID Playnite's own database uses, and the only client-influenced part /// of a `playnite` launch. Interpolated into a URI handed to explorer.exe, so the charset is /// validated first — 8-4-4-4-12 lowercase-or-uppercase hex with dashes, nothing else. @@ -684,6 +713,26 @@ mod tests { assert!(!valid_launcher_ui("lutris; rm -rf ~")); } + /// The `xbox` kind is what a library PLUGIN can publish: the runner's principal cannot read + /// AppRepository (measured 2026-08-06), so it sends `!` and the host resolves + /// the publisher hash. The charset guard is load-bearing here — unlike `aumid`, this value + /// arrives over the wire. + #[test] + fn xbox_value_is_identity_bang_appid_and_charset_guarded() { + assert!(valid_aumid("Microsoft.Foo!Game")); + assert!(valid_aumid("A_b-c.d!App")); + // Both halves must be present and non-empty. + assert!(!valid_aumid("Microsoft.Foo")); + assert!(!valid_aumid("!Game")); + assert!(!valid_aumid("Microsoft.Foo!")); + assert!(!valid_aumid("")); + // Nothing that could break out of the `shell:AppsFolder\…` argument. + assert!(!valid_aumid("Foo\"!Game")); + assert!(!valid_aumid("Foo!Game\" & calc")); + assert!(!valid_aumid("Foo\\..\\Bar!Game")); + assert!(!valid_aumid("Foo Bar!Game")); + } + /// Windows' launcher tile opens Playnite's FULLSCREEN app. Both negatives are the point: the /// desktop app is not what a couch tile should open, and the `playnite://` handler cannot be /// used because it is registered to the desktop app (verified on .173, 2026-08-06). diff --git a/crates/punktfunk-host/src/library/xbox.rs b/crates/punktfunk-host/src/library/xbox.rs index 80cbcfe7..82db43f7 100644 --- a/crates/punktfunk-host/src/library/xbox.rs +++ b/crates/punktfunk-host/src/library/xbox.rs @@ -134,8 +134,14 @@ fn xbox_parse_config(text: &str, folder: Option<&str>) -> Option<(String, String /// 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. +/// +/// **Readable by the host, NOT by the plugin runner.** Measured on 2026-08-06: that directory is +/// `UnauthorizedAccessException` for `NT AUTHORITY\LocalService` (which the runner is), while the +/// host service runs as LocalSystem and enumerates all 348 entries. That asymmetry is why the +/// `xbox` launch kind exists — a library plugin sends the package Identity it CAN read out of +/// `MicrosoftGame.config`, and this resolves the rest at launch time (see `launch.rs`). #[cfg(windows)] -fn xbox_pfn(identity: &str) -> Option { +pub(crate) fn xbox_pfn(identity: &str) -> Option { let pkgs = PathBuf::from(std::env::var_os("ProgramData")?) .join("Microsoft") .join("Windows") diff --git a/crates/punktfunk-host/src/mgmt/library.rs b/crates/punktfunk-host/src/mgmt/library.rs index 179e91a1..21c2e525 100644 --- a/crates/punktfunk-host/src/mgmt/library.rs +++ b/crates/punktfunk-host/src/mgmt/library.rs @@ -31,8 +31,8 @@ fn check_entry_fields( &format!( "`{field}` is executed as the host user and may only be set with the \ operator's admin token — a plugin may publish entries with any host-resolved \ - launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, lutris_id, heroic, \ - playnite) \ + launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, xbox, lutris_id, \ + heroic, playnite) \ instead" ), ));