feat(windows-host): launch the chosen library title into the interactive session

Make the no-op Windows `set_launch_command` real. New `windows/interactive.rs`
`spawn_in_active_session` (WTSGetActiveConsoleSessionId → WTSQueryUserToken →
CreateProcessAsUserW(winsta0\default) under the LOGGED-IN USER token, factored from
the wgc_relay primitive) + `library::launch_title` resolving a store-qualified id to
a concrete process via `windows_launch_for` (steam_appid → Steam.exe/explorer.exe
steam:// URI; command → cmd.exe /c). Threaded as `SessionContext.launch` into both
native data-plane paths (`virtual_stream`, `virtual_stream_relay`) and fired after
capture is live so the title renders onto the captured desktop and grabs foreground.

Security invariant intact: the client sends only the store-qualified id; the host
resolves the recipe from its own library and the URI/flags are handed to a concrete
EXE as plain args (never cmd /c of a client string). Linux unchanged (gamescope
nesting via the handshake PUNKTFUNK_GAMESCOPE_APP path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 06:51:10 +00:00
parent e68b7330ae
commit c87ca577a3
4 changed files with 298 additions and 7 deletions
+133 -7
View File
@@ -382,15 +382,27 @@ pub fn delete_custom(id: &str) -> Result<bool> {
// Unified library // Unified library
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
/// 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`]).
fn valid_steam_appid(value: &str) -> bool {
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
}
/// Resolve a store-qualified library id (as sent by a client in `Hello::launch`) to the shell /// 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 /// 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 /// pick an existing title, never inject a command. `None` = unknown id, no launch recipe, or a
/// malformed Steam appid. /// malformed Steam appid.
/// ///
/// - `steam_appid` → `steam steam://rungameid/<appid>` (appid validated as digits, so the only /// **Linux only**: the resolved command is run nested inside the per-session gamescope. On Windows
/// client-controlled part of the command is a number). /// 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 /// - `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. /// (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> { pub fn launch_command(id: &str) -> Option<String> {
let spec = all_games().into_iter().find(|g| g.id == id)?.launch?; let spec = all_games().into_iter().find(|g| g.id == id)?.launch?;
command_for(&spec) command_for(&spec)
@@ -398,19 +410,92 @@ pub fn launch_command(id: &str) -> Option<String> {
/// Map a resolved [`LaunchSpec`] to its shell command (pure — the unit-testable core of /// 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). /// [`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> { fn command_for(spec: &LaunchSpec) -> Option<String> {
match spec.kind.as_str() { match spec.kind.as_str() {
"steam_appid" => { "steam_appid" => valid_steam_appid(&spec.value)
// Only digits — the appid is the sole client-influenced part of the command. .then(|| format!("steam steam://rungameid/{}", spec.value)),
(!spec.value.is_empty() && spec.value.bytes().all(|b| b.is_ascii_digit()))
.then(|| format!("steam steam://rungameid/{}", spec.value))
}
// Trusted: the command comes from the host's own custom store, never the client. // Trusted: the command comes from the host's own custom store, never the client.
"command" => (!spec.value.trim().is_empty()).then(|| spec.value.clone()), "command" => (!spec.value.trim().is_empty()).then(|| spec.value.clone()),
_ => None, _ => 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))
}
// 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
}
/// The full library: every store's titles merged + the custom entries, sorted by title. /// The full library: every store's titles merged + the custom entries, sorted by title.
pub fn all_games() -> Vec<GameEntry> { pub fn all_games() -> Vec<GameEntry> {
let mut games = SteamProvider.list(); let mut games = SteamProvider.list();
@@ -478,6 +563,7 @@ mod tests {
assert!(art.header.unwrap().ends_with("/570/header.jpg")); assert!(art.header.unwrap().ends_with("/570/header.jpg"));
} }
#[cfg(not(windows))]
#[test] #[test]
fn launch_command_resolves_and_guards() { fn launch_command_resolves_and_guards() {
let steam = LaunchSpec { let steam = LaunchSpec {
@@ -529,4 +615,44 @@ mod tests {
assert_eq!(g.id, "custom:abc123"); assert_eq!(g.id, "custom:abc123");
assert_eq!(g.store, "custom"); assert_eq!(g.store, "custom");
} }
#[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"
);
// 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());
}
} }
+3
View File
@@ -30,6 +30,9 @@ mod encode;
mod gamestream; mod gamestream;
mod hdr; mod hdr;
mod inject; mod inject;
#[cfg(target_os = "windows")]
#[path = "windows/interactive.rs"]
mod interactive;
mod library; mod library;
mod mgmt; mod mgmt;
mod mgmt_token; mod mgmt_token;
+41
View File
@@ -571,6 +571,11 @@ async fn serve_session(
// (`what's left` §3), resolve the command into the per-session VirtualDisplay via // (`what's left` §3), resolve the command into the per-session VirtualDisplay via
// `set_launch_command` (as the GameStream path now does) so sessions can't stomp each other. // `set_launch_command` (as the GameStream path now does) so sessions can't stomp each other.
if let Some(id) = hello.launch.as_deref() { if let Some(id) = hello.launch.as_deref() {
// Linux: resolve the id to a gamescope-nested command and stash it in the env the
// gamescope backend reads. Windows has no gamescope to nest into — the data plane launches
// the title into the interactive user session via `library::launch_title` once capture is
// live (threaded as `SessionContext.launch` below), so there is nothing to do here.
#[cfg(not(windows))]
match crate::library::launch_command(id) { match crate::library::launch_command(id) {
Some(cmd) => { Some(cmd) => {
tracing::info!(launch_id = id, command = %cmd, "launching library title"); tracing::info!(launch_id = id, command = %cmd, "launching library title");
@@ -581,6 +586,8 @@ async fn serve_session(
"client requested a launch id not in this host's library — ignoring" "client requested a launch id not in this host's library — ignoring"
), ),
} }
#[cfg(windows)]
let _ = id;
} }
// Resolve the client's gamepad-backend preference (pure env/cfg check — no probing // Resolve the client's gamepad-backend preference (pure env/cfg check — no probing
@@ -912,6 +919,10 @@ async fn serve_session(
let source = opts.source; let source = opts.source;
let (seconds, frames) = (opts.seconds, opts.frames); let (seconds, frames) = (opts.seconds, opts.frames);
let mode = hello.mode; let mode = hello.mode;
// Windows: the store-qualified launch id, threaded into the data plane so the title can be
// launched into the interactive session once capture is live (no gamescope nesting on Windows).
#[cfg(target_os = "windows")]
let launch_for_dp = hello.launch.clone();
let bitrate_kbps = welcome.bitrate_kbps; // resolved encoder bitrate (Hello clamped, or default) let bitrate_kbps = welcome.bitrate_kbps; // resolved encoder bitrate (Hello clamped, or default)
let bit_depth = welcome.bit_depth; // resolved encode bit depth (8, or 10 when negotiated) let bit_depth = welcome.bit_depth; // resolved encode bit depth (8, or 10 when negotiated)
let stop_stream = stop.clone(); let stop_stream = stop.clone();
@@ -971,6 +982,8 @@ async fn serve_session(
probe_result_tx, probe_result_tx,
fec_target: fec_target_dp, fec_target: fec_target_dp,
conn: conn_stream, conn: conn_stream,
#[cfg(target_os = "windows")]
launch: launch_for_dp,
}) })
} }
} }
@@ -2172,6 +2185,11 @@ struct SessionContext {
fec_target: Arc<AtomicU8>, fec_target: Arc<AtomicU8>,
/// The QUIC control connection (carries host→client 0xCE source-HDR metadata mid-stream). /// The QUIC control connection (carries host→client 0xCE source-HDR metadata mid-stream).
conn: quinn::Connection, conn: quinn::Connection,
/// Windows: the store-qualified library id to launch into the interactive user session once
/// capture is live (no gamescope nesting on Windows). `None` = no launch requested. Linux uses the
/// gamescope `PUNKTFUNK_GAMESCOPE_APP` path resolved at handshake, so this field is Windows-only.
#[cfg(target_os = "windows")]
launch: Option<String>,
} }
fn virtual_stream(ctx: SessionContext) -> Result<()> { fn virtual_stream(ctx: SessionContext) -> Result<()> {
@@ -2208,6 +2226,8 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
probe_result_tx, probe_result_tx,
fec_target, fec_target,
conn, conn,
#[cfg(target_os = "windows")]
launch,
} = ctx; } = ctx;
tracing::info!( tracing::info!(
compositor = compositor.id(), compositor = compositor.id(),
@@ -2248,6 +2268,17 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let _composed_flip = crate::capture::composed_flip::ForceComposedFlip::start(); let _composed_flip = crate::capture::composed_flip::ForceComposedFlip::start();
// Windows: capture is live (and composition forced) — launch the requested library title into the
// interactive user session so it renders onto the captured desktop and grabs foreground. Linux
// nests its launch in gamescope instead (the handshake `PUNKTFUNK_GAMESCOPE_APP` path). Best-effort:
// a launch failure (no recipe for the kind, no interactive user) leaves the user on the desktop.
#[cfg(target_os = "windows")]
if let Some(id) = launch.as_deref() {
if let Err(e) = crate::library::launch_title(id) {
tracing::warn!(launch_id = id, error = %e, "could not launch requested library title");
}
}
let perf = crate::config::config().perf; let perf = crate::config::config().perf;
// Microburst cap (applied in send_loop/paced_submit): a frame ≤ this bursts out immediately; // Microburst cap (applied in send_loop/paced_submit): a frame ≤ this bursts out immediately;
// only a bigger frame's overflow is spread. PUNKTFUNK_PACE_BURST_KB overrides the 128 KB default. // only a bigger frame's overflow is spread. PUNKTFUNK_PACE_BURST_KB overrides the 128 KB default.
@@ -2600,6 +2631,7 @@ fn virtual_stream_relay(ctx: SessionContext) -> Result<()> {
probe_result_tx, probe_result_tx,
fec_target, fec_target,
conn: _conn, conn: _conn,
launch,
} = ctx; } = ctx;
tracing::info!( tracing::info!(
?mode, ?mode,
@@ -2657,6 +2689,15 @@ fn virtual_stream_relay(ctx: SessionContext) -> Result<()> {
let (mut _keepalive, mut relay, mut target, mut effective_hz) = build(&mut vd, mode)?; let (mut _keepalive, mut relay, mut target, mut effective_hz) = build(&mut vd, mode)?;
let mut cur_mode = mode; let mut cur_mode = mode;
// Capture is live (the WGC helper is relaying) — launch the requested library title into the
// interactive user session so it renders onto the captured desktop and grabs foreground.
// Best-effort: a failure (no recipe for the kind, no interactive user) leaves the user on the desktop.
if let Some(id) = launch.as_deref() {
if let Err(e) = crate::library::launch_title(id) {
tracing::warn!(launch_id = id, error = %e, "could not launch requested library title");
}
}
// O3.1: optionally observe the IDD-push ring alongside WGC (WGC = the presentation trigger) to // O3.1: optionally observe the IDD-push ring alongside WGC (WGC = the presentation trigger) to
// confirm the 0257 driver pushes frames into a HOST-created ring. Diagnostic only; gated. // confirm the 0257 driver pushes frames into a HOST-created ring. Diagnostic only; gated.
if std::env::var_os("PUNKTFUNK_IDD_PUSH_OBSERVE").is_some() { if std::env::var_os("PUNKTFUNK_IDD_PUSH_OBSERVE").is_some() {
@@ -0,0 +1,121 @@
//! Launch a process into the interactive user session from the SYSTEM host.
//!
//! The Windows host runs as a LocalSystem SCM service. To *launch* a game/launcher so it renders onto
//! the captured desktop — and so the user's protocol handlers (`HKCU\Software\Classes`), UWP/appx
//! activation, and each store's auth/entitlement context resolve — the process must run in the
//! interactive session under the **logged-in user's** token, not SYSTEM and not session 0.
//!
//! This is the same `WTSGetActiveConsoleSessionId → WTSQueryUserToken → DuplicateTokenEx →
//! CreateProcessAsUserW(winsta0\\default)` primitive the WGC helper relay uses
//! ([`crate::capture::wgc_relay`]), factored out for the library launch path
//! ([`crate::library::launch_title`]).
//!
//! IMPORTANT — use the **user** token (`WTSQueryUserToken`), NOT a session-retargeted SYSTEM token
//! (the host-spawn in [`crate::service`] duplicates the SYSTEM token and only changes its session id;
//! that is correct for launching *our own* streamer, but a store launcher needs the real user's token
//! for activation + auth). The host process itself stays SYSTEM.
use anyhow::{bail, Context, Result};
use std::path::Path;
use windows::core::{PCWSTR, PWSTR};
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::Security::{
DuplicateTokenEx, SecurityImpersonation, TokenPrimary, TOKEN_ALL_ACCESS,
};
use windows::Win32::System::Environment::{CreateEnvironmentBlock, DestroyEnvironmentBlock};
use windows::Win32::System::RemoteDesktop::{WTSGetActiveConsoleSessionId, WTSQueryUserToken};
use windows::Win32::System::Threading::{
CreateProcessAsUserW, CREATE_UNICODE_ENVIRONMENT, PROCESS_INFORMATION, STARTUPINFOW,
};
/// Spawn `cmdline` in the active console session, under the logged-in user's token, on the
/// interactive desktop (`winsta0\default`). Returns the new process id.
///
/// Fire-and-forget: the launched game/launcher outlives this call, so the host does not track the
/// child — its handles are closed before returning (the process keeps running). The environment is
/// the user's block merged with the host's `PUNKTFUNK_*`/`RUST_LOG` (same merge the WGC helper uses),
/// so `host.env` settings propagate.
///
/// Requires the host to run as SYSTEM (`WTSQueryUserToken` needs `SE_TCB`). Fails when no interactive
/// user is logged on (a pre-login / freshly-booted box can stream the login desktop but cannot
/// auto-launch a store title until someone signs in).
pub fn spawn_in_active_session(cmdline: &str, workdir: Option<&Path>) -> Result<u32> {
unsafe { spawn_inner(cmdline, workdir) }
}
unsafe fn spawn_inner(cmdline: &str, workdir: Option<&Path>) -> Result<u32> {
// The user token of the active console session (requires the host to be SYSTEM).
let session = WTSGetActiveConsoleSessionId();
if session == 0xFFFF_FFFF {
bail!("no active console session (no interactive user is logged on)");
}
let mut user_token = HANDLE::default();
WTSQueryUserToken(session, &mut user_token)
.context("WTSQueryUserToken (host must be SYSTEM; needs a logged-on interactive user)")?;
// A primary token for CreateProcessAsUserW.
let mut primary = HANDLE::default();
let dup = DuplicateTokenEx(
user_token,
TOKEN_ALL_ACCESS,
None,
SecurityImpersonation,
TokenPrimary,
&mut primary,
);
let _ = CloseHandle(user_token);
dup.context("DuplicateTokenEx(TokenPrimary)")?;
// The user's environment block (PATH/USERPROFILE/SystemRoot for handler + DLL resolution), MERGED
// with the host's PUNKTFUNK_*/RUST_LOG vars — same shared helper the WGC helper + service spawns use.
let mut env_block: *mut core::ffi::c_void = std::ptr::null_mut();
let _ = CreateEnvironmentBlock(&mut env_block, Some(primary), false);
let merged_env = crate::capture::wgc_relay::merged_env_block(env_block as *const u16);
if !env_block.is_null() {
let _ = DestroyEnvironmentBlock(env_block);
}
// The game/launcher must appear on the interactive desktop the host is capturing.
let mut desktop: Vec<u16> = "winsta0\\default\0".encode_utf16().collect();
let si = STARTUPINFOW {
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
lpDesktop: PWSTR(desktop.as_mut_ptr()),
..Default::default()
};
let mut cmd: Vec<u16> = cmdline.encode_utf16().chain(std::iter::once(0)).collect();
let workdir_w: Option<Vec<u16>> = workdir.map(|d| {
d.as_os_str()
.to_string_lossy()
.encode_utf16()
.chain(std::iter::once(0))
.collect()
});
let cwd = match &workdir_w {
Some(w) => PCWSTR(w.as_ptr()),
None => PCWSTR::null(),
};
let mut pi = PROCESS_INFORMATION::default();
let created = CreateProcessAsUserW(
Some(primary),
None,
Some(PWSTR(cmd.as_mut_ptr())),
None,
None,
false, // no handle inheritance — fire-and-forget GUI launch, no stdio relay
CREATE_UNICODE_ENVIRONMENT,
Some(merged_env.as_ptr() as *const core::ffi::c_void),
cwd,
&si,
&mut pi,
);
let _ = CloseHandle(primary);
created.context("CreateProcessAsUserW (interactive-session launch)")?;
let pid = pi.dwProcessId;
// We don't supervise the child (it owns its own window/lifetime) — close the handles the API gave us.
let _ = CloseHandle(pi.hProcess);
let _ = CloseHandle(pi.hThread);
Ok(pid)
}