diff --git a/crates/punktfunk-host/src/library/custom.rs b/crates/punktfunk-host/src/library/custom.rs index 67f5e8a1..1d4fe59c 100644 --- a/crates/punktfunk-host/src/library/custom.rs +++ b/crates/punktfunk-host/src/library/custom.rs @@ -461,6 +461,13 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St launch.value )); } + // The value is interpolated into a `playnite://` URI, so it is charset-checked here as + // well as at launch time — same reasoning as the two kinds above. + if launch.kind == "playnite" && !valid_playnite_id(&launch.value) { + return Err(format!( + "entries[{i}]: `launch.value` for kind `playnite` must be a Playnite game GUID" + )); + } } 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 9e3e053f..b5bb5eac 100644 --- a/crates/punktfunk-host/src/library/launch.rs +++ b/crates/punktfunk-host/src/library/launch.rs @@ -200,6 +200,31 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option valid_playnite_id(&spec.value).then(|| { + ( + format!("explorer.exe \"playnite://playnite/start/{}\"", spec.value), + None, + ) + }), + // A launcher entry (D4) on Windows: today that is Playnite's Fullscreen app, spawned + // directly (its `playnite://` handler opens the DESKTOP app, so no URI can do this). The + // value is the literal "playnite" — nothing from the entry reaches the command line — and + // the working directory is Playnite's own install dir, as a .NET app expects. + "launcher_ui" => match spec.value.as_str() { + "playnite" => playnite_fullscreen_exe().map(|exe| { + let dir = exe.parent().map(std::path::Path::to_path_buf); + (format!("\"{}\"", exe.display()), dir) + }), + _ => 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. @@ -260,6 +285,21 @@ pub(crate) fn valid_steam_ui(value: &str) -> bool { matches!(value, "bigpicture" | "desktop") } +/// 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. +pub(crate) fn valid_playnite_id(value: &str) -> bool { + let groups = [8usize, 4, 4, 4, 12]; + let mut parts = value.split('-'); + for want in groups { + match parts.next() { + Some(p) if p.len() == want && p.bytes().all(|b| b.is_ascii_hexdigit()) => {} + _ => return false, + } + } + parts.next().is_none() +} + /// The launcher UIs **this host** can open, as `launcher_ui` values (D4). /// /// One kind for every launcher but Steam, rather than one kind each: they all have exactly a single @@ -280,17 +320,71 @@ fn launcher_ui_stores() -> &'static [&'static str] { { &["heroic", "lutris"] } - // Windows launchers (Epic, GOG Galaxy, the Xbox app) are not wired yet — each needs its own - // verified activation, and an unverified guess would ship a tile that does nothing. - #[cfg(not(target_os = "linux"))] + // Playnite's activation is verified (2026-08-06, on the .173 box); Epic, GOG Galaxy and the + // Xbox app are still unwired — each needs its own verified activation, and an unverified guess + // would ship a tile that does nothing. + #[cfg(windows)] + { + &["playnite"] + } + #[cfg(not(any(target_os = "linux", windows)))] { &[] } } /// Is this a `launcher_ui` value this host can resolve? +/// +/// On Windows, Playnite is validated by *resolution* rather than by being on the list: a host +/// without Playnite installed refuses the entry (a 400 the plugin author can act on) instead of +/// publishing a tile that does nothing when a user clicks it. pub(crate) fn valid_launcher_ui(value: &str) -> bool { - launcher_ui_stores().contains(&value) + if !launcher_ui_stores().contains(&value) { + return false; + } + #[cfg(windows)] + if value == "playnite" { + return playnite_fullscreen_exe().is_some(); + } + true +} + +/// Windows: Playnite's **Fullscreen** app, if this host can find it. +/// +/// Fullscreen rather than Desktop for two reasons: a launcher tile is opened from a couch over a +/// stream, and — verified on 2026-08-06 — the registered `playnite://` protocol handler points at +/// `Playnite.DesktopApp.exe`, so a URI cannot open fullscreen mode at all. The exe is launched +/// directly, which is also why nothing here is interpolated from the entry: the whole value is the +/// literal `"playnite"`. +/// +/// Playnite installs per-user by default, so the install directory comes from its own uninstall +/// entry (HKCU first, then HKLM for a machine-wide install), falling back to the default +/// `%LOCALAPPDATA%\Playnite`. `None` when nothing resolves, which is what refuses the tile. +#[cfg(windows)] +fn playnite_fullscreen_exe() -> Option { + use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}; + use winreg::RegKey; + const KEY: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Playnite"; + const EXE: &str = "Playnite.FullscreenApp.exe"; + + let from_registry = [HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE] + .into_iter() + .find_map(|root| { + RegKey::predef(root) + .open_subkey(KEY) + .ok()? + .get_value::("InstallLocation") + .ok() + }) + .map(std::path::PathBuf::from); + + from_registry + .into_iter() + .chain( + std::env::var_os("LOCALAPPDATA").map(|l| std::path::PathBuf::from(l).join("Playnite")), + ) + .map(|dir| dir.join(EXE)) + .find(|p| p.is_file()) } /// Map a `heroic` LaunchSpec value (`:`) to the Heroic launch command, run nested in @@ -566,9 +660,23 @@ mod tests { // Not wired on this OS — refused inbound rather than becoming a tile that does nothing. assert!(!valid_launcher_ui("gog")); } - #[cfg(not(target_os = "linux"))] + #[cfg(windows)] { - // No Windows/macOS launcher UIs are wired yet, so every value is refused. + // Playnite is accepted only when this host can actually FIND its Fullscreen app: + // validation is resolution, so a box without Playnite refuses the entry rather than + // publishing a tile that does nothing when clicked. + assert_eq!( + valid_launcher_ui("playnite"), + playnite_fullscreen_exe().is_some() + ); + // The Linux launchers, and the Windows ones whose activation is still unverified + // (Epic, GOG Galaxy, the Xbox app), stay refused. + assert!(!valid_launcher_ui("heroic")); + assert!(!valid_launcher_ui("gog")); + } + #[cfg(not(any(target_os = "linux", windows)))] + { + // No launcher UIs are wired on this OS, so every value is refused. assert!(!valid_launcher_ui("heroic")); assert!(!valid_launcher_ui("gog")); } @@ -576,6 +684,34 @@ mod tests { assert!(!valid_launcher_ui("lutris; rm -rf ~")); } + /// 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). + #[cfg(windows)] + #[test] + fn playnite_launcher_opens_the_fullscreen_app() { + let ui = |v: &str| { + windows_launch_for(&LaunchSpec { + kind: "launcher_ui".into(), + value: v.into(), + }) + }; + // A launcher this host cannot open is refused, whatever the OS. + assert!(ui("gog").is_none()); + assert!(ui("heroic").is_none()); + assert!(ui("").is_none()); + + // The rest only means anything on a box that actually has Playnite. + let Some(exe) = playnite_fullscreen_exe() else { + return; + }; + let (cmd, dir) = ui("playnite").expect("resolvable when the exe was found"); + assert!(cmd.contains("Playnite.FullscreenApp.exe"), "{cmd}"); + assert!(!cmd.contains("DesktopApp"), "{cmd}"); + assert!(!cmd.contains("playnite://"), "{cmd}"); + assert_eq!(dir.as_deref(), exe.parent()); + } + #[cfg(target_os = "linux")] #[test] fn launcher_ui_opens_the_launcher_itself() { diff --git a/crates/punktfunk-host/src/mgmt/library.rs b/crates/punktfunk-host/src/mgmt/library.rs index 25030b8c..179e91a1 100644 --- a/crates/punktfunk-host/src/mgmt/library.rs +++ b/crates/punktfunk-host/src/mgmt/library.rs @@ -31,7 +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) \ + launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, lutris_id, heroic, \ + playnite) \ instead" ), ));