feat(host/library): Playnite can publish again, and gets a fullscreen tile

The Playnite plugin emits `kind: "command"` for every game (a `start "" "playnite://…"`
shell line). The 2026-08-05 review made `command` operator-only, and `privileged_field`
refuses a PROVIDER reconcile carrying one — so on this branch the published
`@punktfunk/plugin-playnite@0.3.0` cannot publish anything at all. Not a launcher tile:
not one game. That is a regression against a shipped plugin, and it is the same hole
`launcher_ui` was created to close, one kind further along.

Two kinds, both host-owned so D1 holds — the plugin supplies a validated VALUE and
never a command line:

  playnite     valued by the game's GUID; resolves to
               explorer.exe "playnite://playnite/start/<guid>", the same
               protocol-via-a-concrete-EXE shape the `epic` kind uses. GUID-validated
               on the way in (so a bad value is a 400 the plugin author can act on)
               and again at launch.

  launcher_ui  now accepts "playnite" on Windows, resolving to
               Playnite.FullscreenApp.exe with Playnite's own install dir as the
               working directory.

Fullscreen, not Desktop, is the whole point of a couch tile — and it is also why this
one cannot ride the URI the games use: probed on .173, Playnite's registered
`playnite://` handler is bound to Playnite.DesktopApp.exe, so no URI opens fullscreen
mode. The exe is spawned directly, with the install dir read from Playnite's own
uninstall entry (HKCU, then HKLM for a machine-wide install), falling back to
%LOCALAPPDATA%\Playnite.

`valid_launcher_ui("playnite")` is answered by RESOLUTION rather than by a static list:
a host without Playnite installed refuses the entry instead of publishing a tile that
does nothing when a user clicks it. That is the same instinct that left Epic, GOG
Galaxy and the Xbox app off the list — each still needs its own verified activation,
and a guess would ship exactly that dead tile.

Gates: punktfunk-host 436 passed / 0 failed on .21 (the Linux arms), and the Windows
arms compiled and their library tests run on .173.
This commit is contained in:
2026-08-06 14:35:48 +02:00
parent 883c317872
commit 8ff2c2e1c6
3 changed files with 151 additions and 7 deletions
@@ -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) {
+142 -6
View File
@@ -200,6 +200,31 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::Pa
)
})
}
// 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
// resolves the registered protocol as the user — the same pattern as the `epic` kind — and
// the id is GUID-validated, so the only variable part of the line is 36 hex-and-dash chars.
//
// This kind exists because the plugin used to publish `kind: "command"` (a `start ""` shell
// line). The 2026-08-05 review made `command` operator-only, which refuses a plugin's whole
// reconcile — so without a typed kind the Playnite plugin cannot publish anything at all.
"playnite" => 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<std::path::PathBuf> {
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::<String, _>("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 (`<runner>:<appName>`) 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() {
+2 -1
View File
@@ -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"
),
));