Files
punktfunk/crates/punktfunk-host/src/windows/interactive.rs
T
enricobuehler 3d9b329084 fix(host): name the lid-closed/no-frames failure — display-write decode, console-session guard, driver-truth attach diagnostics
Field report (Windows laptop, lid closed, Tailscale): v0.12.0's activation
fix works — the pf-vdisplay target activates in ~200ms — but the session
still dies at the first-frame gate: 'driver_status=1 but no frame published
within 4s'. Triage showed three independent blind spots; this names all of
them at their source instead of guessing downstream:

- pf-win-display: decode ChangeDisplaySettingsExW failures (-1 FAILED — a
  display write rejected, the wrong/remote-session signature — vs -2 BADMODE,
  which the old 'mode not advertised?' text conflated), and WARN on every
  non-zero SetDisplayConfig rc in the CCD isolate even when verification
  passes vacuously (the lid-closed case: nothing else active, so the INFO
  swallowed rc=0x5 ERROR_ACCESS_DENIED while the load-bearing COMMIT_MODES →
  ASSIGN_SWAPCHAIN re-commit silently never applied). Access-denied rcs get
  the remedy appended (console session / installed service).

- host: console-session guard (interactive::console_session_mismatch) — a
  host outside the active console session (a hand-launched host after an RDP
  round-trip) fails every display write, reads the wrong session's GDI view,
  and its SendInput compose kicks go nowhere. Named ERROR at vdisplay
  acquire + appended to the first-frame timeout, instead of the misleading
  generic failure. (The idd_push diagnosis half of this landed in 9a36ea21;
  this commit adds the proto helpers + session guard it references, healing
  the windows-cfg build.)

- proto + driver: while OPENED, driver_status_detail now carries a live
  packed word (bit31 live-marker | offered 15-bit | mismatch-dropped 16-bit)
  maintained by the publisher, so the host's first-frame timeout can tell
  apart: never-attached (no swap-chain worker ran), attached-but-DWM-composed-
  zero-frames (undamaged/powered-off desktop, kicks blocked on the secure
  desktop), and composed-but-every-frame-mismatched (ring sized from a stale/
  foreign-session GDI mode). Zero layout change, old drivers read as 'no
  detail'; unit-tested pack/unpack in pf-driver-proto.

Verified on winbox: cargo check + clippy -p punktfunk-host -p pf-win-display
-p pf-driver-proto EXIT 0, drivers ws cargo check -p pf-vdisplay EXIT 0
(Version_Number=10.0.26100.0), cargo fmt --all --check clean; pf-driver-proto
tests 13/13 pass locally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 10:47:47 +02:00

192 lines
9.1 KiB
Rust

//! 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 standard `WTSGetActiveConsoleSessionId → WTSQueryUserToken → DuplicateTokenEx →
//! CreateProcessAsUserW(winsta0\\default)` primitive, used 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.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
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,
};
/// `Some((own_session, console_session))` when this process is NOT in the active console session —
/// the state in which every display write (`SetDisplayConfig` / `ChangeDisplaySettingsExW`) fails
/// `ERROR_ACCESS_DENIED`, GDI reads describe the wrong session's displays, and `SendInput` compose
/// kicks go nowhere. Field signature: a hand-launched host whose session later went remote (an RDP
/// round-trip) — the installed service relaunches the host when the console session moves, a
/// hand-launched one stays trapped. `None` in the console session, and `None` when the check
/// itself can't answer (no console session attached — boot / session transition — or the session
/// query failed): the guard exists to NAME a known-bad state, so an unknown state stays quiet.
pub fn console_session_mismatch() -> Option<(u32, u32)> {
use windows::Win32::System::RemoteDesktop::ProcessIdToSessionId;
use windows::Win32::System::Threading::GetCurrentProcessId;
let mut own: u32 = 0;
// SAFETY: `own` is a live local out-param for this synchronous call; no pointer escapes it.
if unsafe { ProcessIdToSessionId(GetCurrentProcessId(), &mut own) }.is_err() {
return None;
}
// SAFETY: takes no arguments and returns the console session id by value.
let console = unsafe { WTSGetActiveConsoleSessionId() };
(console != 0xFFFF_FFFF && own != console).then_some((own, console))
}
/// 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` (see [`merged_env_block`]),
/// 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> {
// SAFETY: `spawn_inner` is unsafe only for its Win32 FFI; it has no caller-side preconditions — it
// validates the session/token itself and owns every handle it opens — so calling it is always sound.
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 = 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)
}
/// Build the environment block for a process launched into the interactive session: the target
/// session's block (`user_block`, from `CreateEnvironmentBlock`) with this process's `PUNKTFUNK_*`
/// vars overlaid, so the child runs with the SAME settings this process has
/// (`PUNKTFUNK_ENCODER=nvenc`, `PUNKTFUNK_ZEROCOPY`, …) instead of the target shell's. Returns a
/// UTF-16, double-null-terminated block suitable for `CREATE_UNICODE_ENVIRONMENT`. Shared by the
/// interactive library launch (here) and the Windows service launching the host into the active
/// session ([`crate::service`]).
///
/// # Safety
/// `user_block` must be either null or a valid pointer to a UTF-16, double-null-terminated
/// environment block (the `CreateEnvironmentBlock` output), readable for its whole length.
pub(crate) unsafe fn merged_env_block(user_block: *const u16) -> Vec<u16> {
// Parse the user block ("VAR=VALUE\0" … "\0") into entries.
let mut entries: Vec<String> = Vec::new();
if !user_block.is_null() {
let mut p = user_block;
loop {
let mut len = 0isize;
while *p.offset(len) != 0 {
len += 1;
}
if len == 0 {
break; // the trailing empty string = end of block
}
let slice = std::slice::from_raw_parts(p, len as usize);
entries.push(String::from_utf16_lossy(slice));
p = p.offset(len + 1);
}
}
// Overlay "our" settings — PUNKTFUNK_* and RUST_LOG — dropping whatever the target block had.
let is_ours = |k: &str| k.starts_with("PUNKTFUNK_") || k == "RUST_LOG";
entries.retain(|e| !is_ours(e.split('=').next().unwrap_or("")));
for (k, v) in std::env::vars().filter(|(k, _)| is_ours(k)) {
entries.push(format!("{k}={v}"));
}
// Serialize back to a UTF-16 double-null-terminated block.
let mut block: Vec<u16> = Vec::new();
for e in entries {
block.extend(e.encode_utf16());
block.push(0);
}
block.push(0);
block
}