feat(host/windows): two-process secure-desktop step 4 — spawn helper + relay AUs

The SYSTEM host now sources the normal-desktop video from a user-session WGC
helper instead of capturing in-process (WGC won't activate as SYSTEM). New
`capture/wgc_relay.rs`: `HelperRelay::spawn` launches `m3-host wgc-helper` in the
interactive user session via CreateProcessAsUserW (WTSQueryUserToken →
DuplicateTokenEx(TokenPrimary) → lpDesktop="winsta0\\default", CREATE_NO_WINDOW)
with three anonymous pipes — stdout (framed Annex-B AUs → parsed back to
RelayAu), stdin (control: force-keyframe), stderr (helper logs → host tracing).
The host holds the SudoVDA keepalive (sole isolation/topology owner); the helper
captures by GDI name only.

m3.rs: `virtual_stream` dispatches to the new `virtual_stream_relay` when
`should_use_helper()` (running as SYSTEM, or PUNKTFUNK_FORCE_HELPER; disable with
PUNKTFUNK_NO_HELPER). The relay loop feeds the existing send thread — same
FEC/seal/paced-send path. Reconfigure rebuilds the output + re-spawns the helper;
keyframe requests forward over the control pipe; helper pts_ns (same-machine
monotonic clock) is used directly as capture_ns. Disconnect ends the stream
(step 6 adds the relaunch watchdog).

wgc_helper.rs: reads the stdin control byte to request an IDR; --bit-depth flag
threaded through so SDR 10-bit (Main10) negotiation reaches the helper's encoder.

cfg-gated windows-only; Linux/macOS build unaffected. Step 5 (DesktopWatcher mux
to host DDA on the Winlogon secure desktop) is next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 07:43:20 +00:00
parent 7a814b5f18
commit 9f50b3930d
6 changed files with 635 additions and 3 deletions
+38 -3
View File
@@ -15,7 +15,9 @@
use crate::capture::{dxgi::WinCaptureTarget, wgc::WgcCapturer, Capturer};
use crate::encode::{self, Codec};
use anyhow::{Context, Result};
use std::io::Write;
use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
pub struct HelperOptions {
pub target_id: u32,
@@ -24,11 +26,18 @@ pub struct HelperOptions {
pub height: u32,
pub fps: u32,
pub bitrate_kbps: u32,
/// Negotiated encode bit depth (8, or 10 = HEVC Main10). HDR auto-upgrades to 10 from the
/// captured frame's `Rgb10a2` format regardless.
pub bit_depth: u8,
}
/// AU framing magic + version, so the host can resync / detect a helper crash on its stdout stream.
const AU_MAGIC: u32 = 0x5046_4155; // "PFAU"
/// Control byte the host writes on our stdin to force the next frame to be an IDR. Must match
/// `wgc_relay::CTL_KEYFRAME`.
const CTL_KEYFRAME: u8 = 0x01;
pub fn run(opts: HelperOptions) -> Result<()> {
tracing::info!(
target_id = opts.target_id,
@@ -59,11 +68,34 @@ pub fn run(opts: HelperOptions) -> Result<()> {
h,
opts.fps,
opts.bitrate_kbps as u64 * 1000,
false, // not cuda
8, // bit depth: HDR auto-upgrades to Main10 from the Rgb10a2 frame
false, // not cuda
opts.bit_depth, // 8, or 10 = Main10 (HDR auto-upgrades from the Rgb10a2 frame regardless)
)
.context("open NVENC")?;
// Control channel: the host writes a single byte on our stdin to force an IDR (client decode
// recovery), mirroring `enc.request_keyframe()` in the single-process path. A reader thread sets
// a flag the encode loop checks; stdin EOF (host gone) just stops the thread.
let kf = Arc::new(AtomicBool::new(false));
{
let kf = kf.clone();
std::thread::Builder::new()
.name("wgc-helper-ctl".into())
.spawn(move || {
let mut stdin = std::io::stdin();
let mut byte = [0u8; 1];
while let Ok(n) = stdin.read(&mut byte) {
if n == 0 {
break; // host closed our stdin
}
if byte[0] == CTL_KEYFRAME {
kf.store(true, Ordering::Relaxed);
}
}
})
.ok();
}
// 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();
@@ -71,6 +103,9 @@ pub fn run(opts: HelperOptions) -> Result<()> {
let mut frame = first;
loop {
if kf.swap(false, Ordering::Relaxed) {
enc.request_keyframe();
}
enc.submit(&frame).context("encoder submit")?;
while let Some(au) = enc.poll().context("encoder poll")? {
if write_au(&mut out, &au).is_err() {