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:
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user