ecfa71212d
audit / bun-audit (push) Successful in 27s
windows-drivers / probe-and-proto (push) Successful in 49s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m10s
apple / swift (push) Successful in 1m18s
decky / build-publish (push) Successful in 34s
windows-drivers / driver-build (push) Successful in 1m37s
audit / cargo-audit (push) Successful in 3m1s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
ci / bench (push) Successful in 5m56s
windows-host / package (push) Failing after 5m17s
apple / screenshots (push) Successful in 4m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m2s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
arch / build-publish (push) Successful in 16m50s
android / android (push) Successful in 17m7s
docker / deploy-docs (push) Successful in 28s
deb / build-publish (push) Successful in 17m6s
ci / rust (push) Failing after 19m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m3s
flatpak / build-publish (push) Successful in 8m0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m14s
release / apple (push) Successful in 5m44s
Wholesale commit of every uncommitted change across the tree, at the user's explicit request — host refactor-campaign W1 (native.rs facade + native/ dir, library/ + mgmt/ splits), Android, core. These streams were mid-flight and not individually built/tested together; this supersedes the per-session HOLD markers. Consolidating so everything lands on main in one pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
364 lines
16 KiB
Rust
364 lines
16 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).
|
|
|
|
use super::custom::valid_steam_appid;
|
|
#[cfg(target_os = "linux")]
|
|
use super::heroic::heroic_command;
|
|
use super::*;
|
|
#[cfg(windows)]
|
|
use super::{epic::epic_launch_uri, gog::gog_spawn};
|
|
|
|
/// Resolve a store-qualified library id (as sent by a client in `Hello::launch`) to the shell
|
|
/// command the host should run for it — looked up in the host's OWN library so a client can only
|
|
/// pick an existing title, never inject a command. `None` = unknown id, no launch recipe, or a
|
|
/// malformed Steam appid.
|
|
///
|
|
/// **Linux only**: the resolved command is run nested inside the per-session gamescope. On Windows
|
|
/// there is no gamescope to nest into; the host launches a title into the interactive user session
|
|
/// via [`launch_title`] instead.
|
|
///
|
|
/// - `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))]
|
|
pub fn launch_command(id: &str) -> Option<String> {
|
|
let spec = all_games().into_iter().find(|g| g.id == id)?.launch?;
|
|
command_for(&spec)
|
|
}
|
|
|
|
/// Map a resolved [`LaunchSpec`] to its shell command (pure — the unit-testable core of
|
|
/// [`launch_command`], split out so the appid-validation can be tested without a Steam install).
|
|
#[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),
|
|
// 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 [`launch_command`]. 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))
|
|
}
|
|
// 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" => {
|
|
let valid = spec.value.split_once('!').is_some_and(|(pfn, app)| {
|
|
let part = |s: &str| {
|
|
!s.is_empty()
|
|
&& s.bytes()
|
|
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
|
};
|
|
part(pfn) && part(app)
|
|
});
|
|
valid.then(|| {
|
|
(
|
|
format!("explorer.exe \"shell:AppsFolder\\{}\"", spec.value),
|
|
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
|
|
}
|
|
|
|
/// 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
|
|
/// [`launch_command`] and goes through [`launch_session_command`] instead.
|
|
#[cfg(windows)]
|
|
pub fn launch_gamestream_library(id: &str) -> Result<()> {
|
|
launch_title(id)
|
|
}
|
|
|
|
/// 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 [`launch_command`], 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<()> {
|
|
let cmd = cmd.trim();
|
|
anyhow::ensure!(!cmd.is_empty(), "empty command");
|
|
let child = match compositor {
|
|
crate::vdisplay::Compositor::Gamescope => {
|
|
crate::vdisplay::launch_into_gamescope_session(cmd)?
|
|
}
|
|
_ => std::process::Command::new("sh")
|
|
.arg("-c")
|
|
.arg(cmd)
|
|
.spawn()
|
|
.context("spawn launch command")?,
|
|
};
|
|
tracing::info!(
|
|
command = %cmd,
|
|
pid = child.id(),
|
|
compositor = compositor.id(),
|
|
"launched app into the live session"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Resolve the launch command for a session app selection on Linux: a store-qualified library id
|
|
/// (from either plane) wins, else the operator-typed command. `None` = nothing to launch (or an
|
|
/// unknown/recipe-less id — warned, so a client picking a stale title sees why nothing started).
|
|
#[cfg(target_os = "linux")]
|
|
pub fn resolve_session_launch(library_id: Option<&str>, command: Option<&str>) -> Option<String> {
|
|
if let Some(id) = library_id {
|
|
match launch_command(id) {
|
|
Some(cmd) => return Some(cmd),
|
|
None => tracing::warn!(
|
|
launch_id = id,
|
|
"requested launch id not in this host's library (or no launch recipe) — ignoring"
|
|
),
|
|
}
|
|
}
|
|
command
|
|
.map(str::trim)
|
|
.filter(|c| !c.is_empty())
|
|
.map(str::to_string)
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|