refactor(windows-host): confine platform code under windows/ + linux/ folders (Goal-1 stage 6)
Move 36 platform-specific files into per-module `windows/` and `linux/` subfolders (and the
shared HID codecs into `inject/proto/`):
capture/{windows,linux}/ encode/{windows,linux}/ inject/{windows,linux,proto}/
audio/{windows,linux}/ vdisplay/{windows,linux}/
src/windows/ (service, wgc_helper, win_adapter, win_display)
src/linux/ (dmabuf_fence, drm_sync, zerocopy/)
Done with `#[path]`, NOT a module rename: every file moves into its folder while the
`crate::*::*` module names stay FLAT, so all caller paths and every internal `super::`/`crate::`
reference are unchanged — only the parent `mod` decls gained `#[path = "..."]`. This is the
codebase's existing pattern (inject's gamepad_windows) and makes the move byte-identical in
behaviour with ZERO reference churn, far lower risk than collapsing to a single
`crate::capture::windows::` namespace (that deeper rename is an optional follow-on; this delivers
the cfg-sprawl folder confinement the stage is about). Done LAST, after the semantic stages, so
the path churn didn't fight them.
Verified: Linux cargo check + clippy (-D warnings) clean; my mod-decl changes fmt-clean (the 3
remaining fmt diffs are pre-existing local-rustfmt-version skew that moved with their files); all
36 `#[path]` targets exist; no internal `#[path]`/`include!`/file-child-mod in any moved file
(the inline `mod X {` blocks are self-contained). Box build to follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,708 @@
|
||||
//! Windows service: a SYSTEM supervisor that launches the streaming host into the **active
|
||||
//! interactive console session** and keeps it tracking session switches — the end-user replacement
|
||||
//! for the ad-hoc PsExec / VBS / scheduled-task launch chain used during bring-up.
|
||||
//!
|
||||
//! Why a supervisor and not just "run the host as a service": the host must run **as SYSTEM in the
|
||||
//! interactive session** (session 1+). Desktop Duplication of the secure (Winlogon/UAC/lock) desktop
|
||||
//! and `SendInput` both need SYSTEM; capture and injection both need the *interactive* session, which
|
||||
//! a plain session-0 service is not in. So this service (itself in session 0) never captures — it
|
||||
//! duplicates its own LocalSystem token, retargets it to the active console session, and
|
||||
//! `CreateProcessAsUserW`s the host there. This is the Sunshine/Apollo model. The host in turn spawns
|
||||
//! the WGC helper into the *user* session (see `capture::wgc_relay`) — two nested launches.
|
||||
//!
|
||||
//! Subcommands (Windows only):
|
||||
//! ```text
|
||||
//! punktfunk-host service run SCM entry point (registered as binPath; not run by hand)
|
||||
//! punktfunk-host service install register an auto-start LocalSystem service + firewall rules
|
||||
//! punktfunk-host service uninstall stop + delete the service + remove firewall rules
|
||||
//! punktfunk-host service start|stop|status convenience wrappers over the SCM
|
||||
//! ```
|
||||
//! Config lives in `%ProgramData%\punktfunk\host.env` (the Windows analogue of `scripts/host.env`),
|
||||
//! loaded into the service's environment and carried to the host child. Logs land in
|
||||
//! `%ProgramData%\punktfunk\logs\`.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::ffi::{c_void, OsString};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicIsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use windows::core::{PCWSTR, PWSTR};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, WAIT_OBJECT_0};
|
||||
use windows::Win32::Security::{
|
||||
DuplicateTokenEx, SecurityImpersonation, SetTokenInformation, TokenPrimary, TokenSessionId,
|
||||
SECURITY_ATTRIBUTES, TOKEN_ADJUST_DEFAULT, TOKEN_ADJUST_SESSIONID, TOKEN_ALL_ACCESS,
|
||||
TOKEN_ASSIGN_PRIMARY, TOKEN_DUPLICATE, TOKEN_QUERY,
|
||||
};
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
CreateFileW, FILE_APPEND_DATA, FILE_GENERIC_WRITE, FILE_SHARE_READ, FILE_SHARE_WRITE,
|
||||
FILE_WRITE_DATA, OPEN_ALWAYS,
|
||||
};
|
||||
use windows::Win32::System::Environment::{CreateEnvironmentBlock, DestroyEnvironmentBlock};
|
||||
use windows::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
|
||||
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_BREAKAWAY_OK,
|
||||
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
};
|
||||
use windows::Win32::System::RemoteDesktop::WTSGetActiveConsoleSessionId;
|
||||
use windows::Win32::System::Threading::{
|
||||
CreateEventW, CreateProcessAsUserW, GetCurrentProcess, OpenProcessToken, ResetEvent, SetEvent,
|
||||
TerminateProcess, WaitForMultipleObjects, CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT,
|
||||
INFINITE, PROCESS_INFORMATION, STARTF_USESTDHANDLES, STARTUPINFOW,
|
||||
};
|
||||
|
||||
/// SCM service name (the key under HKLM\SYSTEM\CurrentControlSet\Services). Stable identity.
|
||||
const SERVICE_NAME: &str = "PunktfunkHost";
|
||||
const SERVICE_DISPLAY: &str = "punktfunk streaming host";
|
||||
const SERVICE_DESCRIPTION: &str =
|
||||
"Low-latency desktop/game streaming host. Launches the punktfunk host into the active session.";
|
||||
|
||||
/// The host subcommand the service launches, overridable via `PUNKTFUNK_HOST_CMD` in host.env.
|
||||
/// `serve --gamestream` runs the native punktfunk/1 QUIC host (always on) PLUS the GameStream
|
||||
/// (Moonlight) compat planes — the unified host a Windows end user typically wants (Moonlight is the
|
||||
/// common Windows client). Drop `--gamestream` for a secure native-only host (no plain-HTTP pairing /
|
||||
/// legacy GCM nonce reuse — security-review #5/#9; native clients only).
|
||||
const DEFAULT_HOST_CMD: &str = "serve --gamestream";
|
||||
|
||||
/// Event handles shared between the SCM control handler (which signals them) and the supervision loop
|
||||
/// (which waits on them). Stored as raw `isize` so the `'static + Send` handler can reach them without
|
||||
/// a non-`Send` `HANDLE` capture. Set once in `run_service`.
|
||||
static STOP_EVENT: AtomicIsize = AtomicIsize::new(0);
|
||||
static SESSION_EVENT: AtomicIsize = AtomicIsize::new(0);
|
||||
|
||||
fn load_event(a: &AtomicIsize) -> HANDLE {
|
||||
HANDLE(a.load(Ordering::Relaxed) as *mut c_void)
|
||||
}
|
||||
|
||||
/// Dispatch `service <sub>`.
|
||||
pub fn main(args: &[String]) -> Result<()> {
|
||||
match args.first().map(String::as_str) {
|
||||
Some("run") => run(),
|
||||
Some("install") => install(),
|
||||
Some("uninstall") => uninstall(),
|
||||
Some("start") => sc(&["start", SERVICE_NAME]),
|
||||
Some("stop") => sc(&["stop", SERVICE_NAME]),
|
||||
Some("status") => sc(&["query", SERVICE_NAME]),
|
||||
_ => {
|
||||
eprintln!(
|
||||
"punktfunk-host service — Windows service control\n\n\
|
||||
USAGE:\n\
|
||||
\x20 punktfunk-host service install register the auto-start service + firewall rules\n\
|
||||
\x20 punktfunk-host service uninstall stop + remove the service + firewall rules\n\
|
||||
\x20 punktfunk-host service start start the service now\n\
|
||||
\x20 punktfunk-host service stop stop the service\n\
|
||||
\x20 punktfunk-host service status query the service\n\n\
|
||||
Config: %ProgramData%\\punktfunk\\host.env Logs: %ProgramData%\\punktfunk\\logs\\"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Logging ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `%ProgramData%\punktfunk\logs\service.log` — the service's own (supervision) log. The host child's
|
||||
/// stdout/stderr are redirected to `host.log` in the same dir.
|
||||
pub fn service_log_path() -> PathBuf {
|
||||
let dir = crate::gamestream::config_dir().join("logs");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
dir.join("service.log")
|
||||
}
|
||||
|
||||
fn host_log_path() -> PathBuf {
|
||||
let dir = crate::gamestream::config_dir().join("logs");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
dir.join("host.log")
|
||||
}
|
||||
|
||||
/// Initialise tracing to the service log file (the SCM gives the service no console/stderr). Falls
|
||||
/// back to stderr if the file can't be opened. Called from `main()` only for `service run`.
|
||||
pub fn init_file_logging(filter: tracing_subscriber::EnvFilter) {
|
||||
match std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(service_log_path())
|
||||
{
|
||||
Ok(file) => {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_ansi(false)
|
||||
.with_writer(move || file.try_clone().expect("clone service log handle"))
|
||||
.init();
|
||||
}
|
||||
Err(_) => {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── host.env config ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn host_env_path() -> PathBuf {
|
||||
crate::gamestream::config_dir().join("host.env")
|
||||
}
|
||||
|
||||
/// Load `%ProgramData%\punktfunk\host.env` (KEY=VALUE lines, `#` comments) into this process's
|
||||
/// environment, so the host child inherits `PUNKTFUNK_*` / `RUST_LOG` via the merged env block.
|
||||
fn load_host_env() {
|
||||
let path = host_env_path();
|
||||
let Ok(contents) = std::fs::read_to_string(&path) else {
|
||||
tracing::info!(path = %path.display(), "no host.env (using defaults)");
|
||||
return;
|
||||
};
|
||||
let mut n = 0;
|
||||
for line in contents.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
let (k, v) = (k.trim(), v.trim().trim_matches('"'));
|
||||
if !k.is_empty() {
|
||||
std::env::set_var(k, v);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(path = %path.display(), vars = n, "loaded host.env");
|
||||
}
|
||||
|
||||
// ── service run (SCM entry point) ────────────────────────────────────────────────────────────────
|
||||
|
||||
windows_service::define_windows_service!(ffi_service_main, service_main);
|
||||
|
||||
fn run() -> Result<()> {
|
||||
// Blocks until the service stops; the SCM then calls `service_main` on its own thread.
|
||||
windows_service::service_dispatcher::start(SERVICE_NAME, ffi_service_main).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"service_dispatcher failed ({e}). `service run` is launched by the Service Control \
|
||||
Manager, not by hand — use `punktfunk-host service install` then `service start`."
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn service_main(_args: Vec<OsString>) {
|
||||
if let Err(e) = run_service() {
|
||||
tracing::error!("service exited with error: {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
fn run_service() -> Result<()> {
|
||||
use windows_service::service::{
|
||||
ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
|
||||
ServiceType,
|
||||
};
|
||||
use windows_service::service_control_handler::{self, ServiceControlHandlerResult};
|
||||
|
||||
// Two manual-reset events: STOP (set once, never reset) and SESSION (set on a console
|
||||
// connect/disconnect, reset by the supervisor after it reacts).
|
||||
let stop =
|
||||
unsafe { CreateEventW(None, true, false, PCWSTR::null()) }.context("CreateEvent stop")?;
|
||||
let session = unsafe { CreateEventW(None, true, false, PCWSTR::null()) }
|
||||
.context("CreateEvent session")?;
|
||||
STOP_EVENT.store(stop.0 as isize, Ordering::Relaxed);
|
||||
SESSION_EVENT.store(session.0 as isize, Ordering::Relaxed);
|
||||
|
||||
// The control handler captures nothing — it reaches the events through the statics, so it stays
|
||||
// `Fn + Send + 'static`. Session lock/unlock are handled inside the host (DesktopWatcher), so we
|
||||
// only flag console connect/disconnect/logon — the events that change the active session.
|
||||
let handler = move |control| -> ServiceControlHandlerResult {
|
||||
match control {
|
||||
ServiceControl::Stop | ServiceControl::Preshutdown | ServiceControl::Shutdown => {
|
||||
unsafe { SetEvent(load_event(&STOP_EVENT)) }.ok();
|
||||
ServiceControlHandlerResult::NoError
|
||||
}
|
||||
ServiceControl::SessionChange(param) => {
|
||||
use windows_service::service::SessionChangeReason::*;
|
||||
if matches!(
|
||||
param.reason,
|
||||
ConsoleConnect | ConsoleDisconnect | SessionLogon
|
||||
) {
|
||||
unsafe { SetEvent(load_event(&SESSION_EVENT)) }.ok();
|
||||
}
|
||||
ServiceControlHandlerResult::NoError
|
||||
}
|
||||
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
|
||||
_ => ServiceControlHandlerResult::NotImplemented,
|
||||
}
|
||||
};
|
||||
let status_handle = service_control_handler::register(SERVICE_NAME, handler)
|
||||
.context("register service control handler")?;
|
||||
|
||||
let accepted = ServiceControlAccept::STOP
|
||||
| ServiceControlAccept::PRESHUTDOWN
|
||||
| ServiceControlAccept::SESSION_CHANGE;
|
||||
let running = ServiceStatus {
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
current_state: ServiceState::Running,
|
||||
controls_accepted: accepted,
|
||||
exit_code: ServiceExitCode::Win32(0),
|
||||
checkpoint: 0,
|
||||
wait_hint: Duration::default(),
|
||||
process_id: None,
|
||||
};
|
||||
status_handle
|
||||
.set_service_status(running.clone())
|
||||
.context("set RUNNING")?;
|
||||
tracing::info!("punktfunk service started — supervising host in the active console session");
|
||||
|
||||
load_host_env();
|
||||
let result = supervise(stop, session);
|
||||
|
||||
// Report STOPPED regardless of how supervise returned.
|
||||
let _ = status_handle.set_service_status(ServiceStatus {
|
||||
current_state: ServiceState::Stopped,
|
||||
controls_accepted: ServiceControlAccept::empty(),
|
||||
..running
|
||||
});
|
||||
unsafe {
|
||||
let _ = CloseHandle(stop);
|
||||
let _ = CloseHandle(session);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The supervision loop: (re)launch the host into the active console session and wait on
|
||||
/// [stop, session-change, child-exit], relaunching on child exit and on a console-session switch.
|
||||
fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
|
||||
let exe = std::env::current_exe().context("current_exe")?;
|
||||
let host_cmd = std::env::var("PUNKTFUNK_HOST_CMD").unwrap_or_else(|_| DEFAULT_HOST_CMD.into());
|
||||
let cmdline = format!("\"{}\" {host_cmd}", exe.to_string_lossy());
|
||||
let workdir: Vec<u16> = exe
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
// Kill-on-close job so a service crash never orphans the SYSTEM host; BREAKAWAY_OK lets the host
|
||||
// still spawn the WGC helper.
|
||||
let job = unsafe { make_job() }.context("create job object")?;
|
||||
|
||||
let mut restarts: u32 = 0;
|
||||
loop {
|
||||
if wait_one(stop, 0) {
|
||||
break;
|
||||
}
|
||||
let session = unsafe { WTSGetActiveConsoleSessionId() };
|
||||
if session == 0xFFFF_FFFF {
|
||||
// No interactive session yet (boot / fully logged out). Wait, but wake on stop/session.
|
||||
tracing::info!("no active console session — waiting");
|
||||
if wait_any(&[stop, session_ev], 3000) == Some(0) {
|
||||
break;
|
||||
}
|
||||
unsafe { ResetEvent(session_ev) }.ok();
|
||||
continue;
|
||||
}
|
||||
|
||||
let pi = match unsafe { spawn_host(session, &cmdline, &workdir, job) } {
|
||||
Ok(pi) => pi,
|
||||
Err(e) => {
|
||||
tracing::error!("failed to launch host into session {session}: {e:#}");
|
||||
if wait_one(stop, 3000) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
tracing::info!(pid = pi.dwProcessId, session, cmd = %host_cmd, "host launched");
|
||||
|
||||
// Wait on stop / session-change / child-exit.
|
||||
let reason = wait_any(&[stop, session_ev, pi.hProcess], INFINITE);
|
||||
match reason {
|
||||
Some(0) => {
|
||||
// Stop: terminate the child and exit.
|
||||
unsafe {
|
||||
let _ = TerminateProcess(pi.hProcess, 0);
|
||||
let _ = CloseHandle(pi.hProcess);
|
||||
let _ = CloseHandle(pi.hThread);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Some(1) => {
|
||||
// Session change: relaunch only if the active console session actually moved.
|
||||
unsafe { ResetEvent(session_ev) }.ok();
|
||||
let now = unsafe { WTSGetActiveConsoleSessionId() };
|
||||
if now != session {
|
||||
tracing::info!(
|
||||
old = session,
|
||||
new = now,
|
||||
"console session changed — relaunching host"
|
||||
);
|
||||
unsafe {
|
||||
let _ = TerminateProcess(pi.hProcess, 0);
|
||||
let _ = CloseHandle(pi.hProcess);
|
||||
let _ = CloseHandle(pi.hThread);
|
||||
}
|
||||
restarts = 0;
|
||||
continue;
|
||||
}
|
||||
// Same session (e.g. a stray notification) — keep waiting on the same child.
|
||||
let r = wait_any(&[stop, pi.hProcess], INFINITE);
|
||||
unsafe {
|
||||
let _ = TerminateProcess(pi.hProcess, 0);
|
||||
let _ = CloseHandle(pi.hProcess);
|
||||
let _ = CloseHandle(pi.hThread);
|
||||
}
|
||||
if r == Some(0) {
|
||||
break;
|
||||
}
|
||||
// child exited → fall through to relaunch
|
||||
}
|
||||
_ => {
|
||||
// Child exited on its own — relaunch (with a small crash-loop backoff).
|
||||
tracing::warn!("host process exited — relaunching");
|
||||
unsafe {
|
||||
let _ = CloseHandle(pi.hProcess);
|
||||
let _ = CloseHandle(pi.hThread);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
restarts += 1;
|
||||
let backoff = restarts.min(10) * 500; // 0.5s..5s
|
||||
if wait_one(stop, backoff) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
unsafe {
|
||||
// Dropping the job (KILL_ON_JOB_CLOSE) reaps any straggler in it.
|
||||
let _ = CloseHandle(job);
|
||||
}
|
||||
tracing::info!("supervision loop ended");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `true` if `h` is signalled within `ms`.
|
||||
fn wait_one(h: HANDLE, ms: u32) -> bool {
|
||||
unsafe { WaitForMultipleObjects(&[h], false, ms) == WAIT_OBJECT_0 }
|
||||
}
|
||||
|
||||
/// Wait on several handles; returns the index of the first signalled, or `None` on timeout.
|
||||
fn wait_any(handles: &[HANDLE], ms: u32) -> Option<usize> {
|
||||
let r = unsafe { WaitForMultipleObjects(handles, false, ms) };
|
||||
let idx = r.0.wrapping_sub(WAIT_OBJECT_0.0);
|
||||
(idx < handles.len() as u32).then_some(idx as usize)
|
||||
}
|
||||
|
||||
/// A kill-on-close + breakaway-ok job object.
|
||||
unsafe fn make_job() -> Result<HANDLE> {
|
||||
let job = CreateJobObjectW(None, PCWSTR::null()).context("CreateJobObjectW")?;
|
||||
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
||||
info.BasicLimitInformation.LimitFlags =
|
||||
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK;
|
||||
SetInformationJobObject(
|
||||
job,
|
||||
JobObjectExtendedLimitInformation,
|
||||
&info as *const _ as *const c_void,
|
||||
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
)
|
||||
.context("SetInformationJobObject")?;
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
/// Launch the host as SYSTEM into `session_id`'s interactive desktop. Returns the child handles.
|
||||
unsafe fn spawn_host(
|
||||
session_id: u32,
|
||||
cmdline: &str,
|
||||
workdir: &[u16],
|
||||
job: HANDLE,
|
||||
) -> Result<PROCESS_INFORMATION> {
|
||||
// 1) A primary SYSTEM token retargeted to the active console session: duplicate THIS process's
|
||||
// (LocalSystem) token, then set its session id. SYSTEM holds SE_TCB so SetTokenInformation
|
||||
// (TokenSessionId) is permitted.
|
||||
let mut proc_token = HANDLE::default();
|
||||
OpenProcessToken(
|
||||
GetCurrentProcess(),
|
||||
TOKEN_DUPLICATE
|
||||
| TOKEN_QUERY
|
||||
| TOKEN_ASSIGN_PRIMARY
|
||||
| TOKEN_ADJUST_DEFAULT
|
||||
| TOKEN_ADJUST_SESSIONID,
|
||||
&mut proc_token,
|
||||
)
|
||||
.context("OpenProcessToken (service must run as SYSTEM)")?;
|
||||
|
||||
let mut primary = HANDLE::default();
|
||||
let dup = DuplicateTokenEx(
|
||||
proc_token,
|
||||
TOKEN_ALL_ACCESS,
|
||||
None,
|
||||
SecurityImpersonation,
|
||||
TokenPrimary,
|
||||
&mut primary,
|
||||
);
|
||||
let _ = CloseHandle(proc_token);
|
||||
dup.context("DuplicateTokenEx(TokenPrimary)")?;
|
||||
|
||||
SetTokenInformation(
|
||||
primary,
|
||||
TokenSessionId,
|
||||
&session_id as *const u32 as *const c_void,
|
||||
std::mem::size_of::<u32>() as u32,
|
||||
)
|
||||
.context("SetTokenInformation(TokenSessionId)")?;
|
||||
|
||||
// 2) The session's environment block, merged with this process's PUNKTFUNK_*/RUST_LOG (so the
|
||||
// host runs with host.env's settings, not a bare block). Same merge the WGC helper uses.
|
||||
let mut env_block: *mut c_void = std::ptr::null_mut();
|
||||
let _ = CreateEnvironmentBlock(&mut env_block, Some(primary), false);
|
||||
let merged = crate::capture::wgc_relay::merged_env_block(env_block as *const u16);
|
||||
if !env_block.is_null() {
|
||||
let _ = DestroyEnvironmentBlock(env_block);
|
||||
}
|
||||
|
||||
// 3) Redirect the host's stdout+stderr to host.log (inheritable handle).
|
||||
let log = open_log_handle(&host_log_path())?;
|
||||
|
||||
let mut si = STARTUPINFOW {
|
||||
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
|
||||
dwFlags: STARTF_USESTDHANDLES,
|
||||
hStdOutput: log,
|
||||
hStdError: log,
|
||||
..Default::default()
|
||||
};
|
||||
let mut desktop: Vec<u16> = "winsta0\\default\0".encode_utf16().collect();
|
||||
si.lpDesktop = PWSTR(desktop.as_mut_ptr());
|
||||
|
||||
let mut cmd: Vec<u16> = cmdline.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let cwd = (!workdir.is_empty()).then_some(PCWSTR(workdir.as_ptr()));
|
||||
let mut pi = PROCESS_INFORMATION::default();
|
||||
|
||||
let created = CreateProcessAsUserW(
|
||||
Some(primary),
|
||||
None,
|
||||
Some(PWSTR(cmd.as_mut_ptr())),
|
||||
None,
|
||||
None,
|
||||
true, // inherit the log handle
|
||||
CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW,
|
||||
Some(merged.as_ptr() as *const c_void),
|
||||
cwd.unwrap_or(PCWSTR::null()),
|
||||
&si,
|
||||
&mut pi,
|
||||
);
|
||||
|
||||
let _ = CloseHandle(log); // the child owns its inherited copy
|
||||
let _ = CloseHandle(primary);
|
||||
created.context("CreateProcessAsUserW(host)")?;
|
||||
|
||||
// Best-effort: keep the host inside the kill-on-close job.
|
||||
let _ = AssignProcessToJobObject(job, pi.hProcess);
|
||||
Ok(pi)
|
||||
}
|
||||
|
||||
/// Open `path` for appending, as an INHERITABLE handle (so the child can use it as stdout/stderr).
|
||||
unsafe fn open_log_handle(path: &std::path::Path) -> Result<HANDLE> {
|
||||
let wpath: Vec<u16> = path
|
||||
.as_os_str()
|
||||
.to_string_lossy()
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let sa = SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: std::ptr::null_mut(),
|
||||
bInheritHandle: true.into(),
|
||||
};
|
||||
// Append (no FILE_WRITE_DATA → all writes go to EOF), so each relaunch's OPEN_ALWAYS reopen
|
||||
// accumulates instead of truncating from offset 0. This mirrors Rust's own `OpenOptions::append`
|
||||
// access mask (FILE_GENERIC_WRITE minus WRITE_DATA, plus APPEND_DATA + SYNCHRONIZE/READ_CONTROL);
|
||||
// bare FILE_APPEND_DATA alone produced a child handle that silently dropped writes.
|
||||
let access = (FILE_GENERIC_WRITE.0 & !FILE_WRITE_DATA.0) | FILE_APPEND_DATA.0;
|
||||
let h = CreateFileW(
|
||||
PCWSTR(wpath.as_ptr()),
|
||||
access,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
Some(&sa),
|
||||
OPEN_ALWAYS,
|
||||
windows::Win32::Storage::FileSystem::FILE_FLAGS_AND_ATTRIBUTES(0),
|
||||
None,
|
||||
)
|
||||
.context("CreateFileW(host.log)")?;
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
// ── install / uninstall ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn install() -> Result<()> {
|
||||
use windows_service::service::{
|
||||
ServiceAccess, ServiceErrorControl, ServiceInfo, ServiceStartType, ServiceType,
|
||||
};
|
||||
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
|
||||
|
||||
let exe = std::env::current_exe().context("current_exe")?;
|
||||
let manager = ServiceManager::local_computer(
|
||||
None::<&str>,
|
||||
ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
|
||||
)
|
||||
.context("open Service Control Manager (run from an elevated/Administrator prompt)")?;
|
||||
|
||||
let info = ServiceInfo {
|
||||
name: OsString::from(SERVICE_NAME),
|
||||
display_name: OsString::from(SERVICE_DISPLAY),
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
start_type: ServiceStartType::AutoStart,
|
||||
error_control: ServiceErrorControl::Normal,
|
||||
executable_path: exe.clone(),
|
||||
launch_arguments: vec![OsString::from("service"), OsString::from("run")],
|
||||
dependencies: vec![],
|
||||
account_name: None, // None = LocalSystem
|
||||
account_password: None,
|
||||
};
|
||||
|
||||
// Create, or reconfigure if it already exists (idempotent install/upgrade).
|
||||
match manager.create_service(&info, ServiceAccess::CHANGE_CONFIG | ServiceAccess::START) {
|
||||
Ok(svc) => {
|
||||
let _ = svc.set_description(SERVICE_DESCRIPTION);
|
||||
println!("Created service '{SERVICE_NAME}' (auto-start, LocalSystem).");
|
||||
}
|
||||
Err(windows_service::Error::Winapi(e))
|
||||
if e.raw_os_error() == Some(1073 /* ERROR_SERVICE_EXISTS */) =>
|
||||
{
|
||||
let svc = manager
|
||||
.open_service(SERVICE_NAME, ServiceAccess::CHANGE_CONFIG)
|
||||
.context("open existing service to reconfigure")?;
|
||||
svc.change_config(&info)
|
||||
.context("reconfigure existing service")?;
|
||||
let _ = svc.set_description(SERVICE_DESCRIPTION);
|
||||
println!("Reconfigured existing service '{SERVICE_NAME}'.");
|
||||
}
|
||||
Err(e) => return Err(e).context("create service"),
|
||||
}
|
||||
|
||||
ensure_default_host_env()?;
|
||||
add_firewall_rules();
|
||||
|
||||
println!(
|
||||
"\nInstalled. Config: {}\nLogs: {}\n\nStart now with: punktfunk-host service start",
|
||||
host_env_path().display(),
|
||||
crate::gamestream::config_dir().join("logs").display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn uninstall() -> Result<()> {
|
||||
use windows_service::service::ServiceAccess;
|
||||
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
|
||||
|
||||
let _ = sc(&["stop", SERVICE_NAME]); // best-effort stop first
|
||||
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
|
||||
.context("open Service Control Manager (run elevated)")?;
|
||||
let svc = manager
|
||||
.open_service(SERVICE_NAME, ServiceAccess::DELETE)
|
||||
.context("open service for delete")?;
|
||||
svc.delete().context("delete service")?;
|
||||
remove_firewall_rules();
|
||||
println!("Removed service '{SERVICE_NAME}' and its firewall rules.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a default `host.env` if none exists, so a fresh install streams out of the box. The encoder
|
||||
/// defaults to `auto` — the host picks NVENC (NVIDIA) / AMF (AMD) / QSV (Intel) from the GPU vendor.
|
||||
fn ensure_default_host_env() -> Result<()> {
|
||||
let path = host_env_path();
|
||||
if path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).ok();
|
||||
}
|
||||
let default = "# punktfunk host configuration (read by the Windows service).\n\
|
||||
# KEY=VALUE per line; '#' comments. Restart the service after editing:\n\
|
||||
# punktfunk-host service stop && punktfunk-host service start\n\
|
||||
\n\
|
||||
# Encode backend: auto (default) detects the GPU vendor — NVIDIA->nvenc, AMD->amf, Intel->qsv.\n\
|
||||
# Force one with nvenc | amf | qsv | sw (software H.264). amf/qsv need an FFmpeg-built host.\n\
|
||||
PUNKTFUNK_ENCODER=auto\n\
|
||||
PUNKTFUNK_VIDEO_SOURCE=virtual\n\
|
||||
PUNKTFUNK_SECURE_DDA=1\n\
|
||||
RUST_LOG=info\n\
|
||||
\n\
|
||||
# The host subcommand the service launches (default: serve --gamestream = native + Moonlight\n\
|
||||
# compat). Use `serve` for a SECURE native-only host (no GameStream #5/#9 surface).\n\
|
||||
# PUNKTFUNK_HOST_CMD=serve --gamestream\n\
|
||||
\n\
|
||||
# Force a specific render GPU by name substring (multi-GPU boxes only):\n\
|
||||
# PUNKTFUNK_RENDER_ADAPTER=4090\n";
|
||||
std::fs::write(&path, default).with_context(|| format!("write {}", path.display()))?;
|
||||
println!("Wrote default config: {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── firewall + sc helpers ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Inbound firewall rules for the streaming ports (best-effort; logs but never fails the install).
|
||||
fn add_firewall_rules() {
|
||||
// (name suffix, protocol, ports)
|
||||
let rules = [
|
||||
("TCP", "TCP", "47984,47989,48010,47990"),
|
||||
("UDP", "UDP", "47998-48010,9777,5353"),
|
||||
];
|
||||
for (suffix, proto, ports) in rules {
|
||||
let name = format!("punktfunk {suffix}");
|
||||
let ok = run_quiet(
|
||||
"netsh",
|
||||
&[
|
||||
"advfirewall",
|
||||
"firewall",
|
||||
"add",
|
||||
"rule",
|
||||
&format!("name={name}"),
|
||||
"dir=in",
|
||||
"action=allow",
|
||||
&format!("protocol={proto}"),
|
||||
&format!("localport={ports}"),
|
||||
],
|
||||
);
|
||||
if ok {
|
||||
println!("Firewall rule added: {name} ({ports})");
|
||||
} else {
|
||||
eprintln!("warning: could not add firewall rule '{name}' (add it manually if needed)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_firewall_rules() {
|
||||
for suffix in ["TCP", "UDP"] {
|
||||
let name = format!("punktfunk {suffix}");
|
||||
let _ = run_quiet(
|
||||
"netsh",
|
||||
&[
|
||||
"advfirewall",
|
||||
"firewall",
|
||||
"delete",
|
||||
"rule",
|
||||
&format!("name={name}"),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run an `sc.exe` command, passing its output through (used by start/stop/status).
|
||||
fn sc(args: &[&str]) -> Result<()> {
|
||||
let status = std::process::Command::new("sc")
|
||||
.args(args)
|
||||
.status()
|
||||
.context("run sc.exe")?;
|
||||
if !status.success() {
|
||||
bail!("sc {} failed ({status})", args.join(" "));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a command discarding output; return whether it succeeded.
|
||||
fn run_quiet(cmd: &str, args: &[&str]) -> bool {
|
||||
std::process::Command::new(cmd)
|
||||
.args(args)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
//! 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::{Read, Write};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct HelperOptions {
|
||||
pub target_id: u32,
|
||||
pub gdi_name: String,
|
||||
pub width: u32,
|
||||
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,
|
||||
gdi = %opts.gdi_name,
|
||||
mode = format!("{}x{}@{}", opts.width, opts.height, opts.fps),
|
||||
"WGC helper starting (user session)"
|
||||
);
|
||||
|
||||
// This thread does WGC capture + video-processor convert + NVENC submit — the GPU-submitting hot
|
||||
// path. Elevate its OS priority so a CPU-heavy game can't deschedule it and delay submission (which
|
||||
// would leave our HIGH GPU priority with nothing queued to prioritise). Apollo's capture thread is
|
||||
// likewise CRITICAL.
|
||||
crate::punktfunk1::boost_thread_priority(true);
|
||||
|
||||
// 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);
|
||||
|
||||
// O3 present-trigger experiment: spawn a thread that PRESENTS a D3D swapchain to the virtual
|
||||
// display (a present SOURCE), testing whether that — unlike WGC's READ — makes the OS assign the
|
||||
// driver's IddCx swap-chain (so the driver's run_core runs + can push). Gated; diagnostic.
|
||||
if std::env::var_os("PUNKTFUNK_PRESENT_TRIGGER").is_some() {
|
||||
let (w, h) = (opts.width, opts.height);
|
||||
std::thread::Builder::new()
|
||||
.name("pf-present-trigger".into())
|
||||
.spawn(move || {
|
||||
tracing::info!("present-trigger: starting D3D present loop on the virtual display");
|
||||
if let Err(e) = unsafe { present_trigger(w, h) } {
|
||||
tracing::warn!("present-trigger error: {e:#}");
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
// 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
|
||||
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();
|
||||
let mut out = stdout.lock();
|
||||
|
||||
// FIXED-CADENCE encode loop (mirrors the single-process `punktfunk1::virtual_stream` loop). The
|
||||
// host runs as SYSTEM and relays our AUs; to deliver a STEADY `fps` to the client (the "fixed 240"
|
||||
// goal) we must NOT gate on WGC's content-driven FrameArrived — `WgcCapturer::next_frame` blocks up
|
||||
// to its ~8 ms static-repeat timeout when the desktop is quiet, capping a barely-changing desktop
|
||||
// ~125 fps regardless of the GPU. Instead we pace to `1/fps` and take the FRESHEST frame with the
|
||||
// non-blocking `try_latest`, repeating the last one when nothing newer arrived. Depth-1: NVENC's
|
||||
// `poll` (lock_bitstream) blocks until the just-submitted frame is encoded, so exactly one frame is
|
||||
// in flight per iteration. A deeper pipeline was measured to only stack latency under a
|
||||
// GPU-saturating game (the encodes serialize on the contended GPU anyway) — the in-game lever is
|
||||
// the GPU scheduling priority the SYSTEM host stamps on us, not pipeline depth.
|
||||
let interval = std::time::Duration::from_secs_f64(1.0 / opts.fps.max(1) as f64);
|
||||
|
||||
let perf = crate::config::config().perf;
|
||||
let mut frames = 0u64;
|
||||
let mut repeats = 0u64; // frames where no newer capture had arrived (duplicate re-encode)
|
||||
let mut cap_ns = 0u64; // time in try_latest (capture + video-processor convert)
|
||||
let mut encode_ns = 0u64; // time blocked in lock_bitstream
|
||||
let mut write_ns = 0u64; // time writing the AU to the stdout pipe (relay backpressure)
|
||||
let mut window = std::time::Instant::now();
|
||||
|
||||
// `frame` is held across iterations and repeated when `try_latest` has nothing newer, so a static
|
||||
// desktop still clocks `fps`. The capturer's held-set / output ring keep its texture alive across
|
||||
// the repeat; reassigning `frame` on a fresh capture drops the prior one (already drained by poll).
|
||||
let mut frame = first;
|
||||
let mut next = std::time::Instant::now();
|
||||
loop {
|
||||
if kf.swap(false, Ordering::Relaxed) {
|
||||
enc.request_keyframe();
|
||||
}
|
||||
// Freshest captured frame, or repeat the last (no new composition: static desktop / between a
|
||||
// game's presents). Non-blocking, so the cadence is OURS, not WGC's event rate.
|
||||
let t0 = std::time::Instant::now();
|
||||
match cap.try_latest().context("WGC try_latest")? {
|
||||
Some(f) => frame = f,
|
||||
None => repeats += 1,
|
||||
}
|
||||
if perf {
|
||||
cap_ns += t0.elapsed().as_nanos() as u64;
|
||||
}
|
||||
enc.submit(&frame).context("encoder submit")?;
|
||||
// Drain the just-submitted frame. NVENC's poll blocks in lock_bitstream until it's encoded, so
|
||||
// this returns exactly one AU (then None) — depth-1, no accumulation.
|
||||
loop {
|
||||
let p0 = std::time::Instant::now();
|
||||
let polled = enc.poll().context("encoder poll")?;
|
||||
if perf {
|
||||
encode_ns += p0.elapsed().as_nanos() as u64;
|
||||
}
|
||||
let Some(au) = polled else { break };
|
||||
let w0 = std::time::Instant::now();
|
||||
let wrote = write_au(&mut out, &au);
|
||||
if perf {
|
||||
write_ns += w0.elapsed().as_nanos() as u64;
|
||||
}
|
||||
if wrote.is_err() {
|
||||
tracing::info!("WGC helper: stdout closed (host gone) — exiting");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Pace to this frame's due time. If we're already past it (encode couldn't keep up under a
|
||||
// GPU-saturating game), skip the sleep and re-baseline so we don't spiral into catch-up.
|
||||
next += interval;
|
||||
match next.checked_duration_since(std::time::Instant::now()) {
|
||||
Some(d) => std::thread::sleep(d),
|
||||
None => next = std::time::Instant::now(),
|
||||
}
|
||||
|
||||
if perf {
|
||||
frames += 1;
|
||||
let since = window.elapsed();
|
||||
if since.as_secs() >= 2 {
|
||||
let secs = since.as_secs_f64();
|
||||
let per = |ns: u64| format!("{:.2}", ns as f64 / frames as f64 / 1e6);
|
||||
tracing::info!(
|
||||
fps = format!("{:.1}", frames as f64 / secs),
|
||||
repeats,
|
||||
cap_ms = per(cap_ns),
|
||||
encode_ms = per(encode_ns),
|
||||
write_ms = per(write_ns),
|
||||
"WGC helper perf (fixed-cadence depth-1; encode_ms=lock_bitstream; repeats=duplicated frames)"
|
||||
);
|
||||
frames = 0;
|
||||
repeats = 0;
|
||||
cap_ns = 0;
|
||||
encode_ns = 0;
|
||||
write_ns = 0;
|
||||
window = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// O3 present-trigger experiment (see the gated call in `run`). Creates a small swapchain-backed
|
||||
/// window on the virtual display (the CCD-isolated primary) and presents continuously — an active
|
||||
/// present SOURCE on the display — to test whether that makes the OS assign the driver's IddCx
|
||||
/// swap-chain (which WGC's read does not). Runs forever on its own thread.
|
||||
///
|
||||
/// # Safety
|
||||
/// Win32/D3D11 FFI; called once on a dedicated helper thread.
|
||||
unsafe fn present_trigger(disp_w: u32, disp_h: u32) -> Result<()> {
|
||||
use windows::core::{w, Interface};
|
||||
use windows::Win32::Foundation::{HMODULE, HWND, LPARAM, LRESULT, WPARAM};
|
||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE;
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11RenderTargetView,
|
||||
ID3D11Texture2D, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC};
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
IDXGIAdapter, IDXGIDevice, IDXGIFactory2, DXGI_PRESENT, DXGI_SWAP_CHAIN_DESC1,
|
||||
DXGI_SWAP_EFFECT_FLIP_DISCARD, DXGI_USAGE_RENDER_TARGET_OUTPUT,
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CreateWindowExW, DefWindowProcW, DispatchMessageW, PeekMessageW, RegisterClassW,
|
||||
ShowWindow, MSG, PM_REMOVE, SW_SHOWNOACTIVATE, WNDCLASSW, WS_EX_NOACTIVATE, WS_EX_TOPMOST,
|
||||
WS_POPUP, WS_VISIBLE,
|
||||
};
|
||||
|
||||
unsafe extern "system" fn wndproc(h: HWND, m: u32, wp: WPARAM, lp: LPARAM) -> LRESULT {
|
||||
DefWindowProcW(h, m, wp, lp)
|
||||
}
|
||||
|
||||
let hinst: HMODULE = GetModuleHandleW(None)?;
|
||||
let cls = w!("pfPresentTrigger");
|
||||
let wc = WNDCLASSW {
|
||||
lpfnWndProc: Some(wndproc),
|
||||
hInstance: hinst.into(),
|
||||
lpszClassName: cls,
|
||||
..Default::default()
|
||||
};
|
||||
RegisterClassW(&wc);
|
||||
// Small window at the top-left of the (primary = virtual) display so it barely obscures the
|
||||
// captured desktop; topmost + no-activate so it doesn't steal focus.
|
||||
let win_w = disp_w.min(96) as i32;
|
||||
let win_h = disp_h.min(96) as i32;
|
||||
let hwnd: HWND = CreateWindowExW(
|
||||
WS_EX_TOPMOST | WS_EX_NOACTIVATE,
|
||||
cls,
|
||||
w!("pf-present"),
|
||||
WS_POPUP | WS_VISIBLE,
|
||||
0,
|
||||
0,
|
||||
win_w,
|
||||
win_h,
|
||||
None,
|
||||
None,
|
||||
Some(hinst.into()),
|
||||
None,
|
||||
)?;
|
||||
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
|
||||
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
D3D11CreateDevice(
|
||||
None,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
None,
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)?;
|
||||
let device = device.context("present-trigger d3d11 device")?;
|
||||
let context = context.context("present-trigger d3d11 context")?;
|
||||
|
||||
let dxgi_dev: IDXGIDevice = device.cast()?;
|
||||
let adapter: IDXGIAdapter = dxgi_dev.GetAdapter()?;
|
||||
let factory: IDXGIFactory2 = adapter.GetParent()?;
|
||||
let scd = DXGI_SWAP_CHAIN_DESC1 {
|
||||
Width: win_w as u32,
|
||||
Height: win_h as u32,
|
||||
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT,
|
||||
BufferCount: 2,
|
||||
SwapEffect: DXGI_SWAP_EFFECT_FLIP_DISCARD,
|
||||
..Default::default()
|
||||
};
|
||||
let swapchain = factory.CreateSwapChainForHwnd(&device, hwnd, &scd, None, None)?;
|
||||
tracing::info!("present-trigger: swapchain created on the virtual display; presenting");
|
||||
|
||||
let mut frame = 0u32;
|
||||
loop {
|
||||
let mut msg = MSG::default();
|
||||
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
||||
let _ = DispatchMessageW(&msg);
|
||||
}
|
||||
let back: ID3D11Texture2D = swapchain.GetBuffer(0)?;
|
||||
let mut rtv: Option<ID3D11RenderTargetView> = None;
|
||||
device.CreateRenderTargetView(&back, None, Some(&mut rtv))?;
|
||||
let rtv = rtv.context("present-trigger rtv")?;
|
||||
let c = (frame % 120) as f32 / 120.0;
|
||||
context.ClearRenderTargetView(&rtv, &[c, 0.1, 0.2, 1.0]);
|
||||
let _ = swapchain.Present(1, DXGI_PRESENT(0));
|
||||
frame = frame.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Backend-neutral DXGI adapter selection.
|
||||
//!
|
||||
//! The discrete render-GPU LUID picker used to live in the SudoVDA backend (`vdisplay::sudovda`) — a
|
||||
//! historical accident, since it is display-utility, not SudoVDA-specific. It lives here so the capturers
|
||||
//! (IDD-push) and the pf-vdisplay backend depend on it as a *peer* instead of reaching into the SudoVDA
|
||||
//! module — breaking that circular reach-in so SudoVDA can eventually be dropped without losing this
|
||||
//! helper (audit §9 / Goal 2). This is the plan's `windows/adapter.rs`.
|
||||
|
||||
use windows::Win32::Foundation::LUID;
|
||||
|
||||
/// Pick the discrete render GPU LUID: the adapter with the most `DedicatedVideoMemory`, skipping
|
||||
/// WARP / Basic-Render and the SudoVDA software adapter (≈0 VRAM). `PUNKTFUNK_RENDER_ADAPTER=<substring>`
|
||||
/// forces a match by Description (Apollo's `adapter_name`). Used by the IDD direct-push capturer (to
|
||||
/// create its shared textures on the same discrete GPU it pins, where NVENC runs) and SET_RENDER_ADAPTER.
|
||||
///
|
||||
/// # Safety
|
||||
/// Creates + enumerates a DXGI factory; the COM calls run in the caller's apartment (the existing callers
|
||||
/// already satisfy this).
|
||||
pub(crate) unsafe fn resolve_render_adapter_luid() -> Option<LUID> {
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIFactory1};
|
||||
let want = crate::config::config()
|
||||
.render_adapter
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty());
|
||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
|
||||
let mut best: Option<(LUID, u64, String)> = None;
|
||||
let mut i = 0u32;
|
||||
while let Ok(a) = factory.EnumAdapters1(i) {
|
||||
i += 1;
|
||||
let Ok(d) = a.GetDesc1() else { continue };
|
||||
let name = String::from_utf16_lossy(&d.Description);
|
||||
let name = name.trim_end_matches('\u{0}').to_string();
|
||||
let lname = name.to_ascii_lowercase();
|
||||
if lname.contains("basic render") || lname.contains("warp") {
|
||||
continue; // never pin to the software rasterizer
|
||||
}
|
||||
if let Some(w) = &want {
|
||||
if lname.contains(&w.to_ascii_lowercase()) {
|
||||
tracing::info!(
|
||||
adapter = name,
|
||||
"render adapter chosen by PUNKTFUNK_RENDER_ADAPTER"
|
||||
);
|
||||
return Some(d.AdapterLuid);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let vram = d.DedicatedVideoMemory as u64; // SudoVDA software adapter ≈ 0 → loses to the dGPU
|
||||
if best.as_ref().is_none_or(|(_, v, _)| vram > *v) {
|
||||
best = Some((d.AdapterLuid, vram, name));
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some((luid, vram, name)) => {
|
||||
tracing::info!(
|
||||
adapter = name,
|
||||
vram_mb = vram / (1024 * 1024),
|
||||
"render adapter chosen (max VRAM)"
|
||||
);
|
||||
Some(luid)
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("no suitable render adapter found for SET_RENDER_ADAPTER");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
//! Backend-neutral Windows display utilities — the CCD (QueryDisplayConfig) + GDI helpers shared by the
|
||||
//! virtual-display backends (pf-vdisplay, SudoVDA) and the capturers (IDD-push, WGC, DDA): GDI-name
|
||||
//! resolution, advanced-color (HDR) get/set, active-mode set, and CCD topology isolate/restore.
|
||||
//!
|
||||
//! These are display-utility, NOT SudoVDA-specific (a pf-vdisplay monitor's target_id is a real OS target
|
||||
//! id, so they operate identically), so they live here rather than in the SudoVDA backend — breaking the
|
||||
//! circular reach-in where the capturers + the pf-vdisplay backend reached into `vdisplay::sudovda` for
|
||||
//! them, so SudoVDA can eventually be dropped without losing them (audit §9 / Goal 2). The plan's
|
||||
//! `windows/display_ccd.rs`. Moved verbatim from `vdisplay::sudovda`.
|
||||
|
||||
use std::mem::size_of;
|
||||
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Devices::Display::{
|
||||
DisplayConfigGetDeviceInfo, DisplayConfigSetDeviceInfo, GetDisplayConfigBufferSizes,
|
||||
QueryDisplayConfig, SetDisplayConfig, DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO,
|
||||
DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_SET_ADVANCED_COLOR_STATE,
|
||||
DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_PATH_INFO,
|
||||
DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE, DISPLAYCONFIG_SOURCE_DEVICE_NAME, QDC_ONLY_ACTIVE_PATHS,
|
||||
SDC_ALLOW_CHANGES, SDC_APPLY, SDC_FORCE_MODE_ENUMERATION, SDC_SAVE_TO_DATABASE,
|
||||
SDC_USE_SUPPLIED_DISPLAY_CONFIG,
|
||||
};
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW,
|
||||
DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, DM_PELSWIDTH,
|
||||
ENUM_CURRENT_SETTINGS, ENUM_DISPLAY_SETTINGS_MODE,
|
||||
};
|
||||
|
||||
use crate::vdisplay::Mode;
|
||||
|
||||
/// Resolve the `\\.\DisplayN` GDI name for a SudoVDA target id via the CCD API. Returns `None`
|
||||
/// until the OS activates the target into the desktop topology (needs a real WDDM GPU; on a
|
||||
/// GPU-less box this stays `None` even though ADD succeeded).
|
||||
pub(crate) unsafe fn resolve_gdi_name(target_id: u32) -> Option<String> {
|
||||
let mut np = 0u32;
|
||||
let mut nm = 0u32;
|
||||
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() {
|
||||
return None;
|
||||
}
|
||||
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
|
||||
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
|
||||
if QueryDisplayConfig(
|
||||
QDC_ONLY_ACTIVE_PATHS,
|
||||
&mut np,
|
||||
paths.as_mut_ptr(),
|
||||
&mut nm,
|
||||
modes.as_mut_ptr(),
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
for p in paths.iter().take(np as usize) {
|
||||
if p.targetInfo.id == target_id {
|
||||
let mut src = DISPLAYCONFIG_SOURCE_DEVICE_NAME::default();
|
||||
src.header.r#type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME;
|
||||
src.header.size = size_of::<DISPLAYCONFIG_SOURCE_DEVICE_NAME>() as u32;
|
||||
src.header.adapterId = p.sourceInfo.adapterId;
|
||||
src.header.id = p.sourceInfo.id;
|
||||
if DisplayConfigGetDeviceInfo(&mut src.header) == 0 {
|
||||
let name = String::from_utf16_lossy(&src.viewGdiDeviceName);
|
||||
return Some(name.trim_end_matches('\u{0}').to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The virtual display's CURRENT active resolution `(width, height)` via the GDI/CCD API, or `None` if the
|
||||
/// target isn't an active display yet / the query fails. The IDD-push capturer sizes its ring to this
|
||||
/// ACTUAL mode and polls it to recreate the ring when it changes — a fullscreen game can change the
|
||||
/// virtual display's mode out from under the session-negotiated one (game-capture bug GB1).
|
||||
///
|
||||
/// # Safety
|
||||
/// Calls the GDI/CCD APIs; safe to call from any thread.
|
||||
pub(crate) unsafe fn active_resolution(target_id: u32) -> Option<(u32, u32)> {
|
||||
let gdi = resolve_gdi_name(target_id)?;
|
||||
let wname: Vec<u16> = gdi.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let mut dm = DEVMODEW {
|
||||
dmSize: size_of::<DEVMODEW>() as u16,
|
||||
..Default::default()
|
||||
};
|
||||
let ok = EnumDisplaySettingsW(PCWSTR(wname.as_ptr()), ENUM_CURRENT_SETTINGS, &mut dm).as_bool();
|
||||
if !ok || dm.dmPelsWidth == 0 || dm.dmPelsHeight == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((dm.dmPelsWidth, dm.dmPelsHeight))
|
||||
}
|
||||
|
||||
/// Toggle the SudoVDA target's advanced-color (HDR) state via the CCD API. Disabling HDR while on the
|
||||
/// secure (Winlogon) desktop makes it render SDR/composed so DXGI Desktop Duplication can capture it
|
||||
/// (the HDR fullscreen independent-flip otherwise storms `ACCESS_LOST` → black); re-enable on return so
|
||||
/// WGC keeps HDR on the normal desktop. Returns true on a successful `DisplayConfigSetDeviceInfo`.
|
||||
pub(crate) unsafe fn set_advanced_color(target_id: u32, enable: bool) -> bool {
|
||||
let mut np = 0u32;
|
||||
let mut nm = 0u32;
|
||||
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() {
|
||||
return false;
|
||||
}
|
||||
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
|
||||
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
|
||||
if QueryDisplayConfig(
|
||||
QDC_ONLY_ACTIVE_PATHS,
|
||||
&mut np,
|
||||
paths.as_mut_ptr(),
|
||||
&mut nm,
|
||||
modes.as_mut_ptr(),
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for p in paths.iter().take(np as usize) {
|
||||
if p.targetInfo.id == target_id {
|
||||
let mut s = DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE::default();
|
||||
s.header.r#type = DISPLAYCONFIG_DEVICE_INFO_SET_ADVANCED_COLOR_STATE;
|
||||
s.header.size = size_of::<DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE>() as u32;
|
||||
s.header.adapterId = p.targetInfo.adapterId;
|
||||
s.header.id = p.targetInfo.id;
|
||||
s.Anonymous.value = enable as u32; // bit 0 = enableAdvancedColor
|
||||
let rc = DisplayConfigSetDeviceInfo(&s.header);
|
||||
tracing::info!(
|
||||
target_id,
|
||||
enable,
|
||||
rc,
|
||||
"SudoVDA set advanced-color (HDR) state"
|
||||
);
|
||||
return rc == 0;
|
||||
}
|
||||
}
|
||||
tracing::warn!(
|
||||
target_id,
|
||||
"set_advanced_color: target not found in active paths"
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
/// Read the SudoVDA target's CURRENT advanced-color (HDR) state via the CCD API — i.e. whether HDR is
|
||||
/// actually ON for the virtual display right now (e.g. because the user toggled it in Windows display
|
||||
/// settings). The capture/encode pipeline follows the monitor's real colorspace (WGC → FP16 → NVENC
|
||||
/// Main10 BT.2020 PQ), so this is the authoritative "is this an HDR session" signal — NOT the
|
||||
/// handshake-negotiated bit depth. Returns false if the target isn't found / the query fails.
|
||||
pub(crate) unsafe fn advanced_color_enabled(target_id: u32) -> bool {
|
||||
let mut np = 0u32;
|
||||
let mut nm = 0u32;
|
||||
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() {
|
||||
return false;
|
||||
}
|
||||
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
|
||||
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
|
||||
if QueryDisplayConfig(
|
||||
QDC_ONLY_ACTIVE_PATHS,
|
||||
&mut np,
|
||||
paths.as_mut_ptr(),
|
||||
&mut nm,
|
||||
modes.as_mut_ptr(),
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for p in paths.iter().take(np as usize) {
|
||||
if p.targetInfo.id == target_id {
|
||||
let mut info = DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO::default();
|
||||
info.header.r#type = DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO;
|
||||
info.header.size = size_of::<DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO>() as u32;
|
||||
info.header.adapterId = p.targetInfo.adapterId;
|
||||
info.header.id = p.targetInfo.id;
|
||||
if DisplayConfigGetDeviceInfo(&mut info.header) == 0 {
|
||||
// value bit 1 = advancedColorEnabled (bit 0 = advancedColorSupported).
|
||||
return (info.Anonymous.value & 0x2) != 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Force the freshly-added SudoVDA monitor to the client's exact `WxH@Hz`. The ADD IOCTL only
|
||||
/// ADVERTISES the mode; Windows otherwise activates an IDD target at a 1280x720 default, so the
|
||||
/// ACTIVE mode (what DXGI Desktop Duplication captures) must be set explicitly. CDS_TEST first so a
|
||||
/// mode the driver didn't advertise just leaves the default instead of erroring the session.
|
||||
// pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD/GDI mode-set helper
|
||||
// (a pf-vdisplay monitor's GDI name is a real OS device name, so it works unchanged).
|
||||
pub(crate) fn set_active_mode(gdi_name: &str, mode: Mode) {
|
||||
let wname: Vec<u16> = gdi_name.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
|
||||
// Enumerate the modes the driver actually advertises for this output and pick the best match for
|
||||
// the requested RESOLUTION: the exact refresh if present, else the highest advertised refresh
|
||||
// <= requested, else the highest available at that resolution. The SudoVDA ADD IOCTL advertises
|
||||
// the client mode, but a very high pixel rate (e.g. 5120x1440@240 = 1.77 Gpix/s) can be clamped
|
||||
// or absent — falling back to a lower refresh AT THE SAME RESOLUTION keeps the client's
|
||||
// resolution (what the user sees) instead of collapsing to the 1280x720/1920x1080 OS default.
|
||||
let mut at_res: Vec<u32> = Vec::new();
|
||||
let mut res_set: std::collections::BTreeSet<(u32, u32)> = std::collections::BTreeSet::new();
|
||||
let mut i = 0u32;
|
||||
loop {
|
||||
let mut dm = DEVMODEW {
|
||||
dmSize: size_of::<DEVMODEW>() as u16,
|
||||
..Default::default()
|
||||
};
|
||||
let ok = unsafe {
|
||||
EnumDisplaySettingsW(
|
||||
PCWSTR(wname.as_ptr()),
|
||||
ENUM_DISPLAY_SETTINGS_MODE(i),
|
||||
&mut dm,
|
||||
)
|
||||
}
|
||||
.as_bool();
|
||||
if !ok {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
res_set.insert((dm.dmPelsWidth, dm.dmPelsHeight));
|
||||
if dm.dmPelsWidth == mode.width && dm.dmPelsHeight == mode.height {
|
||||
at_res.push(dm.dmDisplayFrequency);
|
||||
}
|
||||
}
|
||||
let chosen_hz = if at_res.contains(&mode.refresh_hz) {
|
||||
mode.refresh_hz
|
||||
} else if let Some(hz) = at_res
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&hz| hz <= mode.refresh_hz)
|
||||
.max()
|
||||
{
|
||||
hz
|
||||
} else if let Some(hz) = at_res.iter().copied().max() {
|
||||
hz
|
||||
} else {
|
||||
mode.refresh_hz // resolution not advertised at all; attempt anyway (likely -> OS default)
|
||||
};
|
||||
if at_res.is_empty() {
|
||||
tracing::warn!(
|
||||
"{gdi_name}: driver advertises no {}x{} mode (top advertised: {:?}); attempting @{} anyway",
|
||||
mode.width,
|
||||
mode.height,
|
||||
res_set.iter().rev().take(8).collect::<Vec<_>>(),
|
||||
mode.refresh_hz
|
||||
);
|
||||
} else if chosen_hz != mode.refresh_hz {
|
||||
tracing::info!(
|
||||
"{gdi_name}: {}x{}@{} not advertised; using {}x{}@{} (advertised refreshes here: {:?})",
|
||||
mode.width,
|
||||
mode.height,
|
||||
mode.refresh_hz,
|
||||
mode.width,
|
||||
mode.height,
|
||||
chosen_hz,
|
||||
at_res
|
||||
);
|
||||
}
|
||||
|
||||
// Set ONLY this output's mode in place (size/refresh/bpp; NO DM_POSITION). Do NOT promote it to
|
||||
// PRIMARY here and do NOT write a GLOBAL topology: promoting the IDD to primary at (0,0) while the
|
||||
// box's leftover basic display is still active contests the topology and storms
|
||||
// DXGI_ERROR_MODE_CHANGE_IN_PROGRESS (measured live). The IDD is made the sole → primary →
|
||||
// DWM-composited display by the CCD isolation in create() (which deactivates the other display
|
||||
// first), so a sole display is already primary and needs no CDS_SET_PRIMARY here.
|
||||
let dm = DEVMODEW {
|
||||
dmSize: size_of::<DEVMODEW>() as u16,
|
||||
dmFields: DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_BITSPERPEL,
|
||||
dmBitsPerPel: 32,
|
||||
dmPelsWidth: mode.width,
|
||||
dmPelsHeight: mode.height,
|
||||
dmDisplayFrequency: chosen_hz,
|
||||
..Default::default()
|
||||
};
|
||||
let test = unsafe {
|
||||
ChangeDisplaySettingsExW(PCWSTR(wname.as_ptr()), Some(&dm), None, CDS_TEST, None)
|
||||
};
|
||||
if test != DISP_CHANGE_SUCCESSFUL {
|
||||
tracing::warn!(
|
||||
result = test.0,
|
||||
"{gdi_name}: driver rejected {}x{}@{} (mode not advertised?) — leaving OS default",
|
||||
mode.width,
|
||||
mode.height,
|
||||
chosen_hz
|
||||
);
|
||||
return;
|
||||
}
|
||||
let apply = unsafe {
|
||||
ChangeDisplaySettingsExW(
|
||||
PCWSTR(wname.as_ptr()),
|
||||
Some(&dm),
|
||||
None,
|
||||
CDS_UPDATEREGISTRY,
|
||||
None,
|
||||
)
|
||||
};
|
||||
if apply == DISP_CHANGE_SUCCESSFUL {
|
||||
tracing::info!(
|
||||
"{gdi_name}: active mode set to {}x{}@{}",
|
||||
mode.width,
|
||||
mode.height,
|
||||
chosen_hz
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
result = apply.0,
|
||||
"{gdi_name}: failed to apply {}x{}@{}",
|
||||
mode.width,
|
||||
mode.height,
|
||||
chosen_hz
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Saved active display topology, for restoring on teardown.
|
||||
// pub(crate) so vdisplay::pf_vdisplay's Monitor can hold the same saved-topology type.
|
||||
pub(crate) type SavedConfig = (Vec<DISPLAYCONFIG_PATH_INFO>, Vec<DISPLAYCONFIG_MODE_INFO>);
|
||||
|
||||
/// `DISPLAYCONFIG_PATH_ACTIVE` (wingdi.h) — the `flags` bit marking a path active. The `windows` crate
|
||||
/// doesn't export it, so define it here.
|
||||
const DISPLAYCONFIG_PATH_ACTIVE: u32 = 0x0000_0001;
|
||||
|
||||
/// Robust display isolation via the CCD API. The naive GDI approach (EnumDisplayDevices +
|
||||
/// ChangeDisplaySettings) MISSES displays on a hybrid box — an iGPU-attached physical monitor isn't
|
||||
/// flagged `ATTACHED_TO_DESKTOP` in the GDI enum, so it's never detached and the secure desktop /
|
||||
/// lock screen lands on IT while our virtual output freezes. `QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS)`
|
||||
/// sees every active path; we deactivate all of them EXCEPT the SudoVDA target's, leaving the virtual
|
||||
/// display as the sole desktop so ALL content (incl. Winlogon) renders to it. Apollo isolates the same
|
||||
/// way (CCD). Returns the original active config to restore on teardown.
|
||||
// pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD isolation helper
|
||||
// (it operates on a real OS target id — a pf-vdisplay monitor's target_id qualifies).
|
||||
pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option<SavedConfig> {
|
||||
let mut np = 0u32;
|
||||
let mut nm = 0u32;
|
||||
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() {
|
||||
return None;
|
||||
}
|
||||
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
|
||||
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
|
||||
if QueryDisplayConfig(
|
||||
QDC_ONLY_ACTIVE_PATHS,
|
||||
&mut np,
|
||||
paths.as_mut_ptr(),
|
||||
&mut nm,
|
||||
modes.as_mut_ptr(),
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
paths.truncate(np as usize);
|
||||
modes.truncate(nm as usize);
|
||||
let saved = (paths.clone(), modes.clone());
|
||||
let mut others = 0u32;
|
||||
for p in paths.iter_mut() {
|
||||
if p.targetInfo.id == keep_target_id {
|
||||
continue;
|
||||
}
|
||||
if p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 {
|
||||
p.flags &= !DISPLAYCONFIG_PATH_ACTIVE; // mark this path inactive
|
||||
others += 1;
|
||||
}
|
||||
}
|
||||
if others == 0 {
|
||||
// The virtual path shows active in the CCD database (from set_active_mode's legacy
|
||||
// ChangeDisplaySettingsExW), but a legacy mode-set does NOT drive the IddCx adapter's
|
||||
// EVT_IDD_CX_ADAPTER_COMMIT_MODES — and without COMMIT_MODES the OS never calls
|
||||
// ASSIGN_SWAPCHAIN, so the driver never receives composed frames. Force an explicit CCD
|
||||
// SetDisplayConfig commit of the (sole) virtual path so the IddCx path actually activates.
|
||||
// SDC_FORCE_MODE_ENUMERATION makes the OS re-enumerate + re-commit even though the CCD DB
|
||||
// already lists the path active.
|
||||
let rc = SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY
|
||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||
| SDC_ALLOW_CHANGES
|
||||
| SDC_SAVE_TO_DATABASE
|
||||
| SDC_FORCE_MODE_ENUMERATION,
|
||||
);
|
||||
tracing::info!("display isolate (CCD): forced CCD re-commit of sole virtual path {keep_target_id} rc={rc:#x} (drives IddCx COMMIT_MODES → ASSIGN_SWAPCHAIN)");
|
||||
return Some(saved);
|
||||
}
|
||||
let rc = SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY
|
||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||
| SDC_ALLOW_CHANGES
|
||||
| SDC_FORCE_MODE_ENUMERATION,
|
||||
);
|
||||
if rc == 0 {
|
||||
tracing::info!("display isolate (CCD): deactivated {others} other display(s) — SudoVDA target {keep_target_id} is now the sole desktop");
|
||||
} else {
|
||||
tracing::warn!("display isolate (CCD): SetDisplayConfig failed rc={rc:#x} (tried to deactivate {others} path(s))");
|
||||
}
|
||||
Some(saved)
|
||||
}
|
||||
|
||||
/// Restore the topology saved by [`isolate_displays_ccd`] (teardown, before the virtual output is
|
||||
/// removed), re-activating the displays we deactivated.
|
||||
// pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD restore helper.
|
||||
pub(crate) unsafe fn restore_displays_ccd(saved: &SavedConfig) {
|
||||
let (paths, modes) = saved;
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
let rc = SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
|
||||
);
|
||||
tracing::info!("display isolate (CCD): restored original topology rc={rc:#x}");
|
||||
}
|
||||
Reference in New Issue
Block a user