From a0f6cddc7032a30e8660749fe3f135d39cf23ef0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 16 Jun 2026 07:28:05 +0000 Subject: [PATCH] feat(host/windows): WGC helper subcommand (two-process secure-desktop, step 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `m3-host wgc-helper --target-id N --gdi NAME --mode WxHxHz --bitrate K`: the USER-session half of the two-process secure-desktop design (docs/windows-secure-desktop.md). Opens WGC on the EXISTING SudoVDA output by GDI name only (never creates a virtual output — a second topology owner re-trips the ACCESS_LOST born-lost storm), encodes via NVENC, and ships framed Annex-B AUs on stdout for the SYSTEM host to relay onto the live QUIC session: `[u32 magic "PFAU"][u32 len][u64 pts_ns][u8 keyframe][data]`. tracing → stderr so stdout stays the pure AU stream. cfg-gated windows-only; Linux build unaffected. scripts/headless/win-build.cmd: the canonical box build script (sets PUNKTFUNK_BUILD_VERSION so build.rs stamps the version + the NVENC LIB path). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/main.rs | 30 ++++++++ crates/punktfunk-host/src/wgc_helper.rs | 92 +++++++++++++++++++++++++ scripts/headless/win-build.cmd | 8 +++ 3 files changed, 130 insertions(+) create mode 100644 crates/punktfunk-host/src/wgc_helper.rs create mode 100644 scripts/headless/win-build.cmd diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index cf571c0..1a3d847 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -32,6 +32,8 @@ mod native_pairing; mod pipeline; mod pwinit; mod vdisplay; +#[cfg(target_os = "windows")] +mod wgc_helper; #[cfg(target_os = "linux")] mod zerocopy; @@ -195,6 +197,34 @@ fn real_main() -> Result<()> { paired_store: None, }) } + // USER-session WGC helper (Windows two-process secure-desktop design): capture the EXISTING + // SudoVDA via WGC + NVENC, stream AUs on stdout to the SYSTEM host. Spawned by the host + // (CreateProcessAsUser), not run by hand. See docs/windows-secure-desktop.md. + #[cfg(target_os = "windows")] + Some("wgc-helper") => { + let get = |flag: &str| { + args.iter() + .skip_while(|a| *a != flag) + .nth(1) + .map(String::as_str) + }; + let (width, height, fps) = get("--mode") + .and_then(|m| { + let p: Vec = m.split('x').filter_map(|s| s.parse().ok()).collect(); + (p.len() == 3).then(|| (p[0], p[1], p[2])) + }) + .unwrap_or((1920, 1080, 60)); + wgc_helper::run(wgc_helper::HelperOptions { + target_id: get("--target-id").and_then(|s| s.parse().ok()).unwrap_or(0), + gdi_name: get("--gdi").unwrap_or("").to_string(), + width, + height, + fps, + bitrate_kbps: get("--bitrate") + .and_then(|s| s.parse().ok()) + .unwrap_or(20000), + }) + } Some("-h") | Some("--help") | Some("help") | None => { print_usage(); Ok(()) diff --git a/crates/punktfunk-host/src/wgc_helper.rs b/crates/punktfunk-host/src/wgc_helper.rs new file mode 100644 index 0000000..cd0ccdd --- /dev/null +++ b/crates/punktfunk-host/src/wgc_helper.rs @@ -0,0 +1,92 @@ +//! USER-session WGC helper (Windows) — part of the two-process secure-desktop design +//! (docs/windows-secure-desktop.md). +//! +//! WGC won't activate under the SYSTEM account, but the host must run as SYSTEM for the secure +//! desktop. So the SYSTEM host spawns THIS helper in the interactive user session +//! (`CreateProcessAsUserW`) to do the WGC capture + NVENC encode that needs the user token, and the +//! helper ships the encoded Annex-B access units back over its **stdout** pipe (which the host +//! inherits + reads). The host relays them on the live QUIC session while the normal desktop is up, +//! and switches to its own DDA encoder on the secure desktop. The helper captures the SAME SudoVDA +//! output **by GDI name only** — it never creates a virtual output / touches display topology (a +//! second topology owner would re-trigger the ACCESS_LOST born-lost storm). +//! +//! Wire framing on stdout, per AU: `[u32 len LE][u64 pts_ns LE][u8 keyframe][len bytes data]`. + +use crate::capture::{dxgi::WinCaptureTarget, wgc::WgcCapturer, Capturer}; +use crate::encode::{self, Codec}; +use anyhow::{Context, Result}; +use std::io::Write; + +pub struct HelperOptions { + pub target_id: u32, + pub gdi_name: String, + pub width: u32, + pub height: u32, + pub fps: u32, + pub bitrate_kbps: u32, +} + +/// AU framing magic + version, so the host can resync / detect a helper crash on its stdout stream. +const AU_MAGIC: u32 = 0x5046_4155; // "PFAU" + +pub fn run(opts: HelperOptions) -> Result<()> { + tracing::info!( + target_id = opts.target_id, + gdi = %opts.gdi_name, + mode = format!("{}x{}@{}", opts.width, opts.height, opts.fps), + "WGC helper starting (user session)" + ); + + // Capture the EXISTING SudoVDA output by GDI name / target id — do NOT create one (the host owns + // the virtual output + its isolate/restore; a second topology owner breaks DDA recovery). + let target = WinCaptureTarget { + adapter_luid: 0, + gdi_name: opts.gdi_name.clone(), + target_id: opts.target_id, + }; + let mut cap = + WgcCapturer::open(target, Some((opts.width, opts.height, opts.fps))).context("WGC open")?; + cap.set_active(true); + + // First frame establishes the real dimensions + whether the desktop is HDR (the encoder derives + // Main10/HDR from the frame's PixelFormat::Rgb10a2). Then open NVENC on the capture device. + let first = cap.next_frame().context("first WGC frame")?; + let (w, h) = (first.width, first.height); + let mut enc = encode::open_video( + Codec::H265, + first.format, + w, + h, + opts.fps, + opts.bitrate_kbps as u64 * 1000, + false, // not cuda + 8, // bit depth: HDR auto-upgrades to Main10 from the Rgb10a2 frame + ) + .context("open NVENC")?; + + // Binary stdout — lock it once + write framed AUs. A short write / broken pipe means the host + // (parent) went away → exit cleanly so the host's relaunch watchdog can respawn us. + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + + let mut frame = first; + loop { + enc.submit(&frame).context("encoder submit")?; + while let Some(au) = enc.poll().context("encoder poll")? { + if write_au(&mut out, &au).is_err() { + tracing::info!("WGC helper: stdout closed (host gone) — exiting"); + return Ok(()); + } + } + frame = cap.next_frame().context("WGC next frame")?; + } +} + +fn write_au(out: &mut impl Write, au: &encode::EncodedFrame) -> std::io::Result<()> { + out.write_all(&AU_MAGIC.to_le_bytes())?; + out.write_all(&(au.data.len() as u32).to_le_bytes())?; + out.write_all(&au.pts_ns.to_le_bytes())?; + out.write_all(&[au.keyframe as u8])?; + out.write_all(&au.data)?; + out.flush() +} diff --git a/scripts/headless/win-build.cmd b/scripts/headless/win-build.cmd new file mode 100644 index 0000000..f3e7317 --- /dev/null +++ b/scripts/headless/win-build.cmd @@ -0,0 +1,8 @@ +@echo off +call "C:\Program Files\Microsoft Visual Studio\18\Community\VC\Auxiliary\Build\vcvars64.bat" >nul 2>&1 +set "LIB=%LIB%;C:\Users\Public\nvenc" +set "PATH=%USERPROFILE%\.cargo\bin;%PATH%" +set "PUNKTFUNK_BUILD_VERSION=0.2.0-win-dev" +cd /d C:\Users\Public\punktfunk-native +cargo build -r -p punktfunk-host --features nvenc 2>&1 +echo BUILD_EXIT=%ERRORLEVEL%