ci / web (pull_request) Failing after 2s
ci / rust-arm64 (pull_request) Failing after 3s
ci / bun-nix (pull_request) Successful in 1m15s
apple / swift (pull_request) Successful in 1m35s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m16s
android / android (pull_request) Successful in 4m56s
ci / rust (pull_request) Successful in 9m1s
A launcher entry (design D4) has no "the game exited" moment to detect, but the lease was
still deciding its lifetime from whatever happened to be true at launch — and the two
outcomes disagree:
launcher NOT already running the spawned child stays alive -> `Child` lease
-> quitting the launcher ends the session
launcher ALREADY running the command forwards to the live instance and exits
inside SHIM_WINDOW, with no detect signals behind it
-> `Untracked` -> the session persists
Same tile, two lifetimes, chosen by something the user cannot see. Steam is the case that
settles which one is right: Big Picture is a *mode* of an already-running Steam client, not
a process — and on a Deck or SteamOS host Steam is always running — so no process signal can
express "the launcher's window closed". Heroic has the same shape for a different reason
(single-instance Electron: a second invocation forwards and exits).
So a launcher entry is now `LeaseKind::Untracked` unconditionally. The check sits AHEAD of
`nested`/`child`/`spec`, and that ordering is the fix rather than an implementation detail:
a launcher the host just started leaves a live child behind, and tracking that child is
precisely the inconsistency being removed.
`Untracked` already meant the right things downstream, so nothing else had to change: no
watcher thread, `terminate()` is a no-op ("nothing to end" — closing a session must not kill
the user's Steam), and no `GameExited` event, so a client does not bounce back to its library
when the launcher closes.
Threaded as `LaunchTarget::launcher` -> `LeaseRequest::launcher` from the entry's `role`.
Three call sites, one of which was the actual trap: the GameStream path does not build its
lease from a `LaunchTarget` at all, it goes through a `GsApp` intermediate that silently
dropped the field. An operator-typed `apps.json` command has no library entry behind it and
is never a launcher tile, so it passes `false` explicitly.
The test pins BOTH cases from the table above, plus the same request without the flag still
being `Matched` — so the assertions are the flag's doing and not an artifact of the fixture.
Gates: punktfunk-host 439 passed / 0 failed on .21 (+1).
Not covered here: nothing publishes launcher tiles until the plugins are released, so there
was no live exposure to fix — this is the semantics being made deliberate before the first
tile is ever clicked.
929 lines
43 KiB
Rust
929 lines
43 KiB
Rust
//! Title launch: resolve a library id / raw command into an executable command line (per-store +
|
|
//! per-OS), and the gamescope-session launch helpers. Split out of the `library` facade (plan §W5).
|
|
//!
|
|
//! This module owns the **whole launch side** of the library: the `kind` vocabulary, its per-kind
|
|
//! charset validators, and the per-OS resolvers. That split is deliberate and load-bearing — the
|
|
//! scanner modules beside it do *enumeration only*, so they can be lifted out into library plugins
|
|
//! without taking any launch logic with them (design/library-scanner-plugins.md D1: a client sends
|
|
//! only an entry id and the host resolves the [`LaunchSpec`] it holds, which stays true whether the
|
|
//! entry was enumerated in-process or reconciled in by a plugin).
|
|
|
|
use super::*;
|
|
|
|
/// Everything a session needs about the title it is launching, resolved in **one** library scan:
|
|
/// what to run, what to call it, and how to recognize it once it is running.
|
|
///
|
|
/// Enumerating the library touches every installed store's on-disk metadata, so the launch path
|
|
/// resolves this once at handshake time and threads it into the data plane rather than looking the
|
|
/// same id up again per use.
|
|
pub struct LaunchTarget {
|
|
/// Identity for the status surface and the `game.*` events.
|
|
pub game: crate::gamelease::GameRef,
|
|
/// This entry opens a LAUNCHER, not a game (design D4) — so there is no "the game exited"
|
|
/// moment to detect, and the lease stays untracked no matter what else is known about it.
|
|
/// See [`crate::gamelease::LeaseRequest::launcher`].
|
|
pub launcher: bool,
|
|
/// How to recognize the running game ([`DetectSpec`]); empty when the store offers nothing.
|
|
pub detect: DetectSpec,
|
|
/// The resolved shell command. `Some` on Linux (where the host runs it); `None` on Windows,
|
|
/// which launches by library id through the interactive-session spawner instead.
|
|
pub command: Option<String>,
|
|
}
|
|
|
|
/// Resolve a store-qualified library id (as sent by a client in `Hello::launch`, or carried on a
|
|
/// GameStream catalog entry) against the host's **own** library — so a client can only pick an
|
|
/// existing title, never inject a command. `None` = unknown id, or — on Linux — a title with no
|
|
/// runnable recipe.
|
|
///
|
|
/// This is the single lookup, shared by both planes: the client sends only an id, and everything the
|
|
/// session does with the title afterwards comes from what the host itself knows about it.
|
|
///
|
|
/// **Linux**: the resolved command is run by the host (nested into a per-session gamescope, or
|
|
/// spawned into the live session). **Windows** has no gamescope to nest into and resolves the
|
|
/// concrete process at launch time instead, via [`launch_title`].
|
|
pub fn resolve_launch(id: &str) -> Option<LaunchTarget> {
|
|
let entry = all_games().into_iter().find(|g| g.id == id)?;
|
|
let game = crate::gamelease::GameRef {
|
|
id: Some(entry.id.clone()),
|
|
store: Some(entry.store.clone()),
|
|
title: entry.title.clone(),
|
|
};
|
|
#[cfg(not(windows))]
|
|
{
|
|
// Linux runs the command itself, so a title without one has nothing to launch — same answer
|
|
// (and same warning path) as before this resolution existed.
|
|
let command = entry.launch.as_ref().and_then(command_for)?;
|
|
Some(LaunchTarget {
|
|
game,
|
|
launcher: entry.role == GameRole::Launcher,
|
|
detect: entry.detect,
|
|
command: Some(command),
|
|
})
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
// Windows resolves the concrete process at launch time (`launch_title`), which is also where
|
|
// a missing recipe is reported — so an entry with no Windows recipe still yields a target and
|
|
// the existing warning fires there.
|
|
Some(LaunchTarget {
|
|
game,
|
|
launcher: entry.role == GameRole::Launcher,
|
|
detect: entry.detect,
|
|
command: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Map a resolved [`LaunchSpec`] to its shell command (pure — the unit-testable core of
|
|
/// [`resolve_launch`], split out so the appid-validation can be tested without a Steam install).
|
|
///
|
|
/// - `steam_appid` → `steam steam://rungameid/<appid>` (appid validated as digits).
|
|
/// - `command` → the stored command verbatim. This string comes from the host's own custom store
|
|
/// (added by the host operator via the admin UI), never from the client, so it is trusted.
|
|
#[cfg(not(windows))]
|
|
fn command_for(spec: &LaunchSpec) -> Option<String> {
|
|
match spec.kind.as_str() {
|
|
"steam_appid" => valid_steam_appid(&spec.value)
|
|
.then(|| format!("steam steam://rungameid/{}", spec.value)),
|
|
// Lutris: a digits-only pga.db game id (same guard as steam_appid) → its run URI.
|
|
#[cfg(target_os = "linux")]
|
|
"lutris_id" => (!spec.value.is_empty() && spec.value.bytes().all(|b| b.is_ascii_digit()))
|
|
.then(|| format!("lutris lutris:rungameid/{}", spec.value)),
|
|
// Heroic: `<runner>:<appName>` → the validated heroic://launch command (see heroic_command).
|
|
#[cfg(target_os = "linux")]
|
|
"heroic" => heroic_command(&spec.value),
|
|
// A launcher entry (D4): open the Steam client itself, in Big Picture or on the desktop.
|
|
// Nested in gamescope this is the SteamOS game-mode shape.
|
|
"steam_ui" => match spec.value.as_str() {
|
|
"bigpicture" => Some("steam -gamepadui".into()),
|
|
"desktop" => Some("steam".into()),
|
|
_ => None,
|
|
},
|
|
// The other launchers' own UIs (D4). The host builds the command — a plugin only names
|
|
// which launcher — so no shell string ever crosses the wire.
|
|
#[cfg(target_os = "linux")]
|
|
"launcher_ui" => match spec.value.as_str() {
|
|
// The same resolution the `heroic` game launches use (native binary, else Flatpak), just
|
|
// without `--no-gui` and without a URI: that opens Heroic's window, which IS the tile.
|
|
"heroic" => heroic_launch_prefix(),
|
|
// Bare `lutris` opens the Lutris window; with a `lutris:rungameid/…` URI it launches a
|
|
// game instead (the `lutris_id` kind above).
|
|
"lutris" => Some("lutris".into()),
|
|
_ => None,
|
|
},
|
|
// Trusted: the command comes from the host's own custom store, never the client.
|
|
"command" => (!spec.value.trim().is_empty()).then(|| spec.value.clone()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Windows: launch a store-qualified library id into the **interactive user session** — the Windows
|
|
/// analogue of the Linux gamescope-nested [`resolve_launch`]. The id is resolved against the host's
|
|
/// OWN library (the client never sends a command), mapped to a concrete process by
|
|
/// [`windows_launch_for`], and spawned via [`crate::interactive::spawn_in_active_session`].
|
|
///
|
|
/// Wired into the data plane *after* capture is live, so the title renders onto the already-captured
|
|
/// desktop and grabs foreground.
|
|
#[cfg(windows)]
|
|
pub fn launch_title(id: &str) -> Result<()> {
|
|
let spec = all_games()
|
|
.into_iter()
|
|
.find(|g| g.id == id)
|
|
.and_then(|g| g.launch)
|
|
.ok_or_else(|| anyhow::anyhow!("no launchable library entry '{id}'"))?;
|
|
let (cmdline, workdir) = windows_launch_for(&spec).ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"library entry '{id}' has no Windows launch recipe (kind '{}')",
|
|
spec.kind
|
|
)
|
|
})?;
|
|
let pid = crate::interactive::spawn_in_active_session(&cmdline, workdir.as_deref())
|
|
.with_context(|| format!("launch '{id}' in the interactive session"))?;
|
|
tracing::info!(launch_id = id, %cmdline, pid, "launched library title in the interactive session");
|
|
Ok(())
|
|
}
|
|
|
|
/// Windows: map a resolved [`LaunchSpec`] to a `(command line, working dir)` to spawn into the
|
|
/// interactive session. Pure + unit-testable. `None` = no Windows recipe for this kind.
|
|
///
|
|
/// CreateProcessAsUserW does NO shell or protocol resolution, so the URI/flags are handed to a
|
|
/// concrete EXE as plain arguments — a (host-derived) URI string can never reach a command interpreter.
|
|
#[cfg(windows)]
|
|
fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::PathBuf>)> {
|
|
match spec.kind.as_str() {
|
|
"steam_appid" => {
|
|
if !valid_steam_appid(&spec.value) {
|
|
return None;
|
|
}
|
|
let uri = format!("steam://rungameid/{}", spec.value);
|
|
// Prefer launching Steam.exe with the URI as an argument; fall back to explorer.exe, which
|
|
// resolves the steam:// handler from the user hive. (The appid is digits-validated, so the
|
|
// only variable part of the line is a number either way.)
|
|
let cmdline = match steam_exe() {
|
|
Some(exe) => format!("\"{}\" \"{uri}\"", exe.display()),
|
|
None => format!("explorer.exe \"{uri}\""),
|
|
};
|
|
Some((cmdline, None))
|
|
}
|
|
// A launcher entry (D4): open the Steam client's own UI. Same Steam.exe-then-explorer ladder
|
|
// as `steam_appid`, and the URI is one of exactly two host-owned literals — nothing from the
|
|
// entry is interpolated at all.
|
|
"steam_ui" => {
|
|
let uri = match spec.value.as_str() {
|
|
"bigpicture" => "steam://open/bigpicture",
|
|
"desktop" => "steam://open/main",
|
|
_ => return None,
|
|
};
|
|
let cmdline = match steam_exe() {
|
|
Some(exe) => format!("\"{}\" \"{uri}\"", exe.display()),
|
|
None => format!("explorer.exe \"{uri}\""),
|
|
};
|
|
Some((cmdline, None))
|
|
}
|
|
// Epic: open the (host-built, validated) com.epicgames.launcher:// URI via explorer.exe — a
|
|
// concrete EXE that resolves the registered protocol handler as the user; the URI is a single
|
|
// argv element (no shell, no cmd /c). Same pattern as the steam explorer fallback.
|
|
"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" => 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" => valid_aumid(&spec.value).then(|| {
|
|
(
|
|
format!("explorer.exe \"shell:AppsFolder\\{}\"", spec.value),
|
|
None,
|
|
)
|
|
}),
|
|
// Xbox / Game Pass from a library PLUGIN: `<Identity>!<AppId>`, 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
|
|
// 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.
|
|
"command" => {
|
|
let v = spec.value.trim();
|
|
(!v.is_empty()).then(|| (format!("cmd.exe /c {v}"), None))
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Windows: the default Steam install's `steam.exe`, if present. A non-default Steam install dir
|
|
/// (registry `Valve\Steam\InstallPath`) isn't covered — the explorer.exe protocol fallback handles
|
|
/// that case. Mirrors [`steam_roots`]' "default Program Files dirs" approach.
|
|
#[cfg(windows)]
|
|
fn steam_exe() -> Option<std::path::PathBuf> {
|
|
for var in ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"] {
|
|
if let Some(pf) = std::env::var_os(var) {
|
|
let p = std::path::PathBuf::from(pf).join("Steam").join("steam.exe");
|
|
if p.is_file() {
|
|
return Some(p);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
// ------------------------------------------------------- per-kind launch values (host-owned ABI)
|
|
//
|
|
// Each helper below turns a store's launch VALUE — the only part a scanner (or, after extraction, a
|
|
// library plugin) supplies — into the URI/command line the host actually runs. They live here rather
|
|
// than beside the enumeration that produces the value because the host keeps owning URI construction
|
|
// and spawning no matter where the enumeration came from (D1). Every one of them is total and
|
|
// validating: an unparseable or hostile value yields `None`, never a partially-interpolated command.
|
|
|
|
/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it
|
|
/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax).
|
|
/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping
|
|
/// ([`windows_launch_for`]).
|
|
///
|
|
/// Also accepts the 64-bit non-Steam-shortcut game id ([`shortcut_gameid`]), which is likewise
|
|
/// digits — the two share the `steam_appid` kind precisely because `rungameid` takes either.
|
|
pub(crate) fn valid_steam_appid(value: &str) -> bool {
|
|
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
|
|
}
|
|
|
|
/// The 64-bit game id `steam://rungameid/` needs to launch a non-Steam shortcut: high dword = the
|
|
/// 32-bit shortcut appid, low dword = the shortcut marker `0x0200_0000`. (Handing `rungameid` the
|
|
/// bare 32-bit appid does not launch a shortcut — it must be this composed id.)
|
|
pub(crate) fn shortcut_gameid(appid: u32) -> u64 {
|
|
((appid as u64) << 32) | 0x0200_0000
|
|
}
|
|
|
|
/// The `steam_ui` launch values (D4) — which Steam UI a launcher entry opens. A closed two-value
|
|
/// enum, validated on the way IN (the reconcile payload) as well as on the way out, so an entry can
|
|
/// never carry a third value that silently resolves to nothing at launch time.
|
|
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 `<PFN>!<AppId>` 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.
|
|
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
|
|
/// UI to open, so the value is just which launcher. Steam keeps its own [`valid_steam_ui`] kind
|
|
/// because it has two (Big Picture and the desktop client), which is a genuinely different choice.
|
|
///
|
|
/// Platform-gated, because a value naming a launcher this OS cannot run is not a tile that merely
|
|
/// looks odd — it is one that fails at launch. Validated inbound too, so a plugin gets a 400 it can
|
|
/// act on instead of publishing a dead entry.
|
|
///
|
|
/// **Why a typed kind at all**, when design D4 originally said non-Steam launchers would ride the
|
|
/// `command` kind: the 2026-08-05 review made `launch.kind = "command"` operator-only (it is handed
|
|
/// to a shell), so a plugin publishing one is refused. A typed kind keeps D1's rule intact — the
|
|
/// plugin supplies a validated *value*, the host builds the command — and is the only way a scanner
|
|
/// plugin can offer a launcher tile at all.
|
|
fn launcher_ui_stores() -> &'static [&'static str] {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
&["heroic", "lutris"]
|
|
}
|
|
// 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 {
|
|
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
|
|
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
|
|
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
|
|
/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY
|
|
/// running on the box, the spawned process forwards the URI and exits, which would tear the session
|
|
/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic.
|
|
#[cfg(target_os = "linux")]
|
|
pub(crate) fn heroic_command(value: &str) -> Option<String> {
|
|
let (runner, app) = value.split_once(':')?;
|
|
if !matches!(runner, "legendary" | "gog" | "nile") {
|
|
return None;
|
|
}
|
|
// appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token.
|
|
if app.is_empty()
|
|
|| !app
|
|
.bytes()
|
|
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
|
{
|
|
return None;
|
|
}
|
|
let prefix = heroic_launch_prefix()?;
|
|
// No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName
|
|
// is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed).
|
|
Some(format!(
|
|
"{prefix} --no-gui heroic://launch?appName={app}&runner={runner}"
|
|
))
|
|
}
|
|
|
|
/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data
|
|
/// root is present. `None` ⇒ Heroic not found, so no launch command.
|
|
#[cfg(target_os = "linux")]
|
|
fn heroic_launch_prefix() -> Option<String> {
|
|
let on_path = std::env::var_os("PATH")
|
|
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file()));
|
|
if on_path {
|
|
return Some("heroic".into());
|
|
}
|
|
let flatpak = std::env::var_os("HOME")
|
|
.map(PathBuf::from)
|
|
.is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir());
|
|
flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into())
|
|
}
|
|
|
|
/// Map an `epic` LaunchSpec value to the Epic Games Launcher URI. The value is either the full
|
|
/// `<namespace>:<catalogItemId>:<appName>` triple (what the manifests carry) or a bare `appName`;
|
|
/// every part is charset-checked so the URI stays one safe argv token.
|
|
#[cfg(windows)]
|
|
pub(crate) fn epic_launch_uri(value: &str) -> Option<String> {
|
|
let ok = |s: &str| {
|
|
!s.is_empty()
|
|
&& s.bytes()
|
|
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
|
};
|
|
let inner = match value.split(':').collect::<Vec<_>>().as_slice() {
|
|
[ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"),
|
|
[app] if ok(app) => (*app).to_string(),
|
|
_ => return None,
|
|
};
|
|
Some(format!(
|
|
"com.epicgames.launcher://apps/{inner}?action=launch&silent=true"
|
|
))
|
|
}
|
|
|
|
/// Map a `gog` LaunchSpec value — the tab-separated `exe \t args \t workdir` spawn triple the scanner
|
|
/// derived from `goggame-<id>.info` — to a `(command line, working dir)`. GOG games are spawned
|
|
/// directly (no Galaxy), so the exe is quoted and the arguments ride verbatim.
|
|
#[cfg(windows)]
|
|
pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
|
|
let mut parts = value.split('\t');
|
|
let exe = parts.next().filter(|s| !s.is_empty())?;
|
|
let args = parts.next().unwrap_or("");
|
|
let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from);
|
|
let cmdline = if args.trim().is_empty() {
|
|
format!("\"{exe}\"")
|
|
} else {
|
|
format!("\"{exe}\" {args}")
|
|
};
|
|
Some((cmdline, workdir))
|
|
}
|
|
|
|
/// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the
|
|
/// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go
|
|
/// through the compositor-aware [`launch_session_command`] instead.
|
|
#[cfg(windows)]
|
|
pub fn launch_gamestream_command(cmd: &str) -> Result<()> {
|
|
let cmd = cmd.trim();
|
|
anyhow::ensure!(!cmd.is_empty(), "empty command");
|
|
// cmd.exe /c is fine here: the value is the host operator's own apps.json command, not a
|
|
// client-influenced string (same trust as the custom-store `command` kind).
|
|
let pid = crate::interactive::spawn_in_active_session(&format!("cmd.exe /c {cmd}"), None)
|
|
.context("spawn gamestream command in the interactive session")?;
|
|
tracing::info!(command = %cmd, pid, "gamestream: launched app in the interactive session");
|
|
Ok(())
|
|
}
|
|
|
|
/// Launch a library title chosen from the **GameStream `/applist`** (the store-qualified id is carried
|
|
/// on the `AppEntry`, resolved from the numeric Moonlight appid) into the interactive Windows user
|
|
/// session ([`launch_title`]). The id is resolved against the host's OWN library, so a client can
|
|
/// only ever pick an existing title — never inject a command. Linux resolves the id via
|
|
/// [`resolve_launch`] and goes through [`launch_session_command`] instead.
|
|
#[cfg(windows)]
|
|
pub fn launch_gamestream_library(id: &str) -> Result<()> {
|
|
launch_title(id)
|
|
}
|
|
|
|
/// The child a session launch produced.
|
|
///
|
|
/// Handed back to the caller so the game's lifetime can be tracked
|
|
/// (design/session-game-lifetime.md) instead of the process being forgotten the moment it starts.
|
|
#[cfg(target_os = "linux")]
|
|
pub struct SpawnedLaunch {
|
|
pub child: std::process::Child,
|
|
/// Whether the child leads its own process group — true for the plain session spawn in [`launch_session_command`], which
|
|
/// deliberately creates one so the whole wrapper tree can be signalled as a unit. False for the
|
|
/// gamescope-session spawn, which shares the host's group (see
|
|
/// [`crate::gamelease::OwnedChild::group_leader`]: a non-leader must never be signalled by
|
|
/// negative pid).
|
|
pub group_leader: bool,
|
|
}
|
|
|
|
/// Launch a resolved shell command into the **live Linux session** for the session's compositor —
|
|
/// the one launch entry point shared by the native (punktfunk/1) and GameStream planes, called
|
|
/// AFTER capture is up so the app renders onto the streamed output. The command is host-resolved
|
|
/// (a library id via [`resolve_launch`], or an operator-typed apps.json/custom command) — never a
|
|
/// client-sent string. Best-effort by contract: a failure leaves the user on the (streamed)
|
|
/// desktop/session rather than tearing the stream down.
|
|
///
|
|
/// * **KWin / Mutter / wlroots** — the host runs inside the user's graphical session (the process
|
|
/// env was retargeted at it by `apply_session_env`, and the per-session virtual output is
|
|
/// promoted primary), so a plain spawn lands the app on the streamed output.
|
|
/// * **gamescope (managed / SteamOS / attach)** — the app must open *inside* the running gamescope
|
|
/// session: spawned with the session's own `DISPLAY`/Wayland env
|
|
/// ([`crate::vdisplay::launch_into_gamescope_session`]). A `steam steam://…` command additionally
|
|
/// forwards over the running Steam instance's own pipe, so the dominant Steam case is
|
|
/// env-independent.
|
|
/// * **gamescope (bare spawn)** — not routed here: the command was nested into the fresh gamescope
|
|
/// via `set_launch_command` (the caller gates on `vdisplay::launch_is_nested`).
|
|
#[cfg(target_os = "linux")]
|
|
pub fn launch_session_command(
|
|
compositor: crate::vdisplay::Compositor,
|
|
cmd: &str,
|
|
) -> Result<SpawnedLaunch> {
|
|
use std::os::unix::process::CommandExt;
|
|
let cmd = cmd.trim();
|
|
anyhow::ensure!(!cmd.is_empty(), "empty command");
|
|
let (child, group_leader) = match compositor {
|
|
crate::vdisplay::Compositor::Gamescope => {
|
|
(crate::vdisplay::launch_into_gamescope_session(cmd)?, false)
|
|
}
|
|
_ => (
|
|
std::process::Command::new("sh")
|
|
.arg("-c")
|
|
.arg(cmd)
|
|
// Its own process group, so ending this game later signals the shell *and* the game
|
|
// it exec'd or forked — the whole tree the host started — and nothing else. Also
|
|
// detaches it from any signal the host's own group receives.
|
|
.process_group(0)
|
|
.spawn()
|
|
.context("spawn launch command")?,
|
|
true,
|
|
),
|
|
};
|
|
tracing::info!(
|
|
command = %cmd,
|
|
pid = child.id(),
|
|
compositor = compositor.id(),
|
|
"launched app into the live session"
|
|
);
|
|
Ok(SpawnedLaunch {
|
|
child,
|
|
group_leader,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[cfg(not(windows))]
|
|
#[test]
|
|
fn launch_command_resolves_and_guards() {
|
|
let steam = LaunchSpec {
|
|
kind: "steam_appid".into(),
|
|
value: "570".into(),
|
|
};
|
|
assert_eq!(
|
|
command_for(&steam).as_deref(),
|
|
Some("steam steam://rungameid/570")
|
|
);
|
|
// A non-numeric "appid" (e.g. a client trying to inject) is rejected, never interpolated.
|
|
let evil = LaunchSpec {
|
|
kind: "steam_appid".into(),
|
|
value: "570; rm -rf ~".into(),
|
|
};
|
|
assert_eq!(command_for(&evil), None);
|
|
// Custom commands (from the host's own store) pass through verbatim.
|
|
let custom = LaunchSpec {
|
|
kind: "command".into(),
|
|
value: "dolphin-emu --batch".into(),
|
|
};
|
|
assert_eq!(command_for(&custom).as_deref(), Some("dolphin-emu --batch"));
|
|
// Empty / unknown kinds → no command.
|
|
assert_eq!(
|
|
command_for(&LaunchSpec {
|
|
kind: "command".into(),
|
|
value: " ".into()
|
|
}),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
command_for(&LaunchSpec {
|
|
kind: "wat".into(),
|
|
value: "x".into()
|
|
}),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn command_for_lutris_and_heroic_guards() {
|
|
// Lutris: digits → its run URI; a non-numeric id (injection attempt) is rejected.
|
|
assert_eq!(
|
|
command_for(&LaunchSpec {
|
|
kind: "lutris_id".into(),
|
|
value: "42".into()
|
|
})
|
|
.as_deref(),
|
|
Some("lutris lutris:rungameid/42")
|
|
);
|
|
assert_eq!(
|
|
command_for(&LaunchSpec {
|
|
kind: "lutris_id".into(),
|
|
value: "42; rm -rf ~".into()
|
|
}),
|
|
None
|
|
);
|
|
// Heroic guards (independent of whether Heroic is installed): bad runner / appName → None.
|
|
assert_eq!(heroic_command("badrunner:Quail"), None);
|
|
assert_eq!(heroic_command("legendary:bad name"), None);
|
|
assert_eq!(heroic_command("nile:"), None);
|
|
// When Heroic IS resolvable (a dev box), a valid id yields the launch URI; on CI (no Heroic)
|
|
// it's None — assert the URI shape only when a launcher prefix exists.
|
|
if let Some(cmd) = heroic_command("legendary:Quail-1.2_x") {
|
|
assert!(cmd.contains("heroic://launch?appName=Quail-1.2_x&runner=legendary"));
|
|
assert!(cmd.contains("--no-gui"));
|
|
}
|
|
}
|
|
|
|
/// The `steam_ui` launcher kind (D4): a closed two-value enum, mapped to the Steam client's own
|
|
/// UI on each OS. Nothing from the entry is interpolated — the value only SELECTS between two
|
|
/// host-owned literals — so there is no injection surface at all here.
|
|
#[test]
|
|
fn steam_ui_is_a_closed_two_value_enum() {
|
|
assert!(valid_steam_ui("bigpicture"));
|
|
assert!(valid_steam_ui("desktop"));
|
|
assert!(!valid_steam_ui("gamepadui"));
|
|
assert!(!valid_steam_ui(""));
|
|
assert!(!valid_steam_ui("bigpicture; rm -rf ~"));
|
|
}
|
|
|
|
/// The `launcher_ui` kind exists because D4's original plan — non-Steam launchers riding the
|
|
/// `command` kind — stopped being available to plugins when the 2026-08-05 review made
|
|
/// `command` operator-only. A plugin names a launcher; the host builds the command.
|
|
#[test]
|
|
fn launcher_ui_accepts_only_launchers_this_host_can_open() {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
assert!(valid_launcher_ui("heroic"));
|
|
assert!(valid_launcher_ui("lutris"));
|
|
// Not wired on this OS — refused inbound rather than becoming a tile that does nothing.
|
|
assert!(!valid_launcher_ui("gog"));
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
// 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"));
|
|
}
|
|
assert!(!valid_launcher_ui(""));
|
|
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 `<Identity>!<AppId>` 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).
|
|
#[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() {
|
|
let ui = |v: &str| {
|
|
command_for(&LaunchSpec {
|
|
kind: "launcher_ui".into(),
|
|
value: v.into(),
|
|
})
|
|
};
|
|
// Bare `lutris` opens the window; the URI form is the `lutris_id` kind and launches a game.
|
|
assert_eq!(ui("lutris").as_deref(), Some("lutris"));
|
|
assert!(!ui("lutris").unwrap().contains("rungameid"));
|
|
// Heroic resolves the same way its game launches do, but with no `--no-gui` and no URI — so
|
|
// the window IS what opens. `None` on a box without Heroic, which is a correct answer.
|
|
if let Some(cmd) = ui("heroic") {
|
|
assert!(!cmd.contains("--no-gui"), "the GUI is the point: {cmd:?}");
|
|
assert!(!cmd.contains("heroic://"), "no game URI: {cmd:?}");
|
|
}
|
|
assert_eq!(ui("nonsense"), None);
|
|
assert_eq!(ui(""), None);
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
#[test]
|
|
fn steam_ui_resolves_to_the_client_ui_on_linux() {
|
|
let ui = |v: &str| {
|
|
command_for(&LaunchSpec {
|
|
kind: "steam_ui".into(),
|
|
value: v.into(),
|
|
})
|
|
};
|
|
// Big Picture is the SteamOS game-mode shape; nested in gamescope this is what `--steam`
|
|
// integration is built around.
|
|
assert_eq!(ui("bigpicture").as_deref(), Some("steam -gamepadui"));
|
|
assert_eq!(ui("desktop").as_deref(), Some("steam"));
|
|
assert_eq!(ui("nonsense"), None);
|
|
assert_eq!(ui(""), None);
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn steam_ui_resolves_to_the_client_ui_on_windows() {
|
|
let ui = |v: &str| {
|
|
windows_launch_for(&LaunchSpec {
|
|
kind: "steam_ui".into(),
|
|
value: v.into(),
|
|
})
|
|
};
|
|
let (bp, wd) = ui("bigpicture").expect("bigpicture recipe");
|
|
assert!(bp.contains("steam://open/bigpicture"), "line was {bp:?}");
|
|
assert!(wd.is_none());
|
|
let (desk, _) = ui("desktop").expect("desktop recipe");
|
|
assert!(desk.contains("steam://open/main"), "line was {desk:?}");
|
|
assert!(ui("nonsense").is_none());
|
|
assert!(ui("").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn steam_appid_validation_accepts_appids_and_shortcut_gameids() {
|
|
assert!(valid_steam_appid("570"));
|
|
// The 64-bit shortcut game id shares the `steam_appid` kind — `rungameid` takes either.
|
|
assert!(valid_steam_appid(
|
|
&shortcut_gameid(2_456_789_012).to_string()
|
|
));
|
|
assert!(!valid_steam_appid(""));
|
|
assert!(!valid_steam_appid("570; rm -rf ~"));
|
|
assert!(!valid_steam_appid("-1"));
|
|
}
|
|
|
|
/// Moved here with `shortcut_gameid` (WP1.1): the composed id is launch vocabulary, not
|
|
/// enumeration — the scanner only supplies the 32-bit appid it read out of `shortcuts.vdf`.
|
|
#[test]
|
|
fn shortcut_gameid_composes_appid_and_marker() {
|
|
let id = shortcut_gameid(0x8000_0000);
|
|
assert_eq!(id >> 32, 0x8000_0000, "high dword is the shortcut appid");
|
|
assert_eq!(id & 0xFFFF_FFFF, 0x0200_0000, "low dword is the marker");
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn epic_launch_uri_triple_bare_and_guard() {
|
|
assert_eq!(
|
|
epic_launch_uri("fn:abc:Fortnite").as_deref(),
|
|
Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true")
|
|
);
|
|
assert_eq!(
|
|
epic_launch_uri("Fortnite").as_deref(),
|
|
Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true")
|
|
);
|
|
assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected
|
|
assert!(epic_launch_uri("").is_none());
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn gog_spawn_parses_and_guards() {
|
|
let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
|
|
assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip");
|
|
assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3")));
|
|
let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap();
|
|
assert_eq!(cmd2, "\"C:\\g.exe\"");
|
|
assert!(wd2.is_none());
|
|
assert!(gog_spawn("").is_none());
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn windows_launch_for_maps_and_guards() {
|
|
// Steam: a digits-only appid → a steam:// URI line (via Steam.exe or explorer.exe, depending
|
|
// on the box) with no working dir.
|
|
let steam = LaunchSpec {
|
|
kind: "steam_appid".into(),
|
|
value: "570".into(),
|
|
};
|
|
let (line, wd) = windows_launch_for(&steam).expect("steam recipe");
|
|
assert!(line.contains("steam://rungameid/570"), "line was {line:?}");
|
|
assert!(wd.is_none());
|
|
// A non-numeric "appid" (a client trying to inject) is rejected, never interpolated.
|
|
let evil = LaunchSpec {
|
|
kind: "steam_appid".into(),
|
|
value: "570\" & calc".into(),
|
|
};
|
|
assert!(windows_launch_for(&evil).is_none());
|
|
// Operator command → cmd /c passthrough (trusted host input).
|
|
let cmd = LaunchSpec {
|
|
kind: "command".into(),
|
|
value: "notepad.exe".into(),
|
|
};
|
|
assert_eq!(
|
|
windows_launch_for(&cmd).unwrap().0,
|
|
"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.
|
|
assert!(windows_launch_for(&LaunchSpec {
|
|
kind: "command".into(),
|
|
value: " ".into()
|
|
})
|
|
.is_none());
|
|
assert!(windows_launch_for(&LaunchSpec {
|
|
kind: "wat".into(),
|
|
value: "x".into()
|
|
})
|
|
.is_none());
|
|
}
|
|
}
|