Files
punktfunk/crates/punktfunk-host/src/library/launch.rs
T
enricobuehler e9da37aaf6
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
feat(host/gamelease): a launcher tile's session stops depending on invisible state
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.
2026-08-06 18:35:09 +02:00

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());
}
}