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:
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user