From c87ca577a38b81fe1c02c614ff03d0436879462e Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 26 Jun 2026 06:51:10 +0000 Subject: [PATCH] feat(windows-host): launch the chosen library title into the interactive session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/punktfunk-host/src/library.rs | 140 +++++++++++++++++- crates/punktfunk-host/src/main.rs | 3 + crates/punktfunk-host/src/punktfunk1.rs | 41 +++++ .../punktfunk-host/src/windows/interactive.rs | 121 +++++++++++++++ 4 files changed, 298 insertions(+), 7 deletions(-) create mode 100644 crates/punktfunk-host/src/windows/interactive.rs diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs index 7f267d8..2df0e1b 100644 --- a/crates/punktfunk-host/src/library.rs +++ b/crates/punktfunk-host/src/library.rs @@ -382,15 +382,27 @@ pub fn delete_custom(id: &str) -> Result { // 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 /// 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. /// -/// - `steam_appid` → `steam steam://rungameid/` (appid validated as digits, so the only -/// client-controlled part of the command is a number). +/// **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 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 { let spec = all_games().into_iter().find(|g| g.id == id)?.launch?; command_for(&spec) @@ -398,19 +410,92 @@ pub fn launch_command(id: &str) -> Option { /// 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 { match spec.kind.as_str() { - "steam_appid" => { - // Only digits — the appid is the sole client-influenced part of the command. - (!spec.value.is_empty() && spec.value.bytes().all(|b| b.is_ascii_digit())) - .then(|| format!("steam steam://rungameid/{}", spec.value)) - } + "steam_appid" => valid_steam_appid(&spec.value) + .then(|| format!("steam steam://rungameid/{}", 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)> { + 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 { + 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. pub fn all_games() -> Vec { let mut games = SteamProvider.list(); @@ -478,6 +563,7 @@ mod tests { assert!(art.header.unwrap().ends_with("/570/header.jpg")); } + #[cfg(not(windows))] #[test] fn launch_command_resolves_and_guards() { let steam = LaunchSpec { @@ -529,4 +615,44 @@ mod tests { assert_eq!(g.id, "custom:abc123"); 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()); + } } diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index a8bfc01..007a13f 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -30,6 +30,9 @@ mod encode; mod gamestream; mod hdr; mod inject; +#[cfg(target_os = "windows")] +#[path = "windows/interactive.rs"] +mod interactive; mod library; mod mgmt; mod mgmt_token; diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 6d5334a..166f3b1 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -571,6 +571,11 @@ async fn serve_session( // (`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. 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) { Some(cmd) => { 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" ), } + #[cfg(windows)] + let _ = id; } // 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 (seconds, frames) = (opts.seconds, opts.frames); 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 bit_depth = welcome.bit_depth; // resolved encode bit depth (8, or 10 when negotiated) let stop_stream = stop.clone(); @@ -971,6 +982,8 @@ async fn serve_session( probe_result_tx, fec_target: fec_target_dp, conn: conn_stream, + #[cfg(target_os = "windows")] + launch: launch_for_dp, }) } } @@ -2172,6 +2185,11 @@ struct SessionContext { fec_target: Arc, /// The QUIC control connection (carries host→client 0xCE source-HDR metadata mid-stream). 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, } fn virtual_stream(ctx: SessionContext) -> Result<()> { @@ -2208,6 +2226,8 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { probe_result_tx, fec_target, conn, + #[cfg(target_os = "windows")] + launch, } = ctx; tracing::info!( compositor = compositor.id(), @@ -2248,6 +2268,17 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { #[cfg(target_os = "windows")] 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; // 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. @@ -2600,6 +2631,7 @@ fn virtual_stream_relay(ctx: SessionContext) -> Result<()> { probe_result_tx, fec_target, conn: _conn, + launch, } = ctx; tracing::info!( ?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 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 // 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() { diff --git a/crates/punktfunk-host/src/windows/interactive.rs b/crates/punktfunk-host/src/windows/interactive.rs new file mode 100644 index 0000000..e980442 --- /dev/null +++ b/crates/punktfunk-host/src/windows/interactive.rs @@ -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 { + unsafe { spawn_inner(cmdline, workdir) } +} + +unsafe fn spawn_inner(cmdline: &str, workdir: Option<&Path>) -> Result { + // 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 = "winsta0\\default\0".encode_utf16().collect(); + let si = STARTUPINFOW { + cb: std::mem::size_of::() as u32, + lpDesktop: PWSTR(desktop.as_mut_ptr()), + ..Default::default() + }; + + let mut cmd: Vec = cmdline.encode_utf16().chain(std::iter::once(0)).collect(); + let workdir_w: Option> = 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) +}