From 0ce2e37faf6100b0442ada9a67b6a2e49ae50e6b Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 16 Jun 2026 18:33:53 +0000 Subject: [PATCH] refactor(host/windows): clean up DDA path + add a proper Windows service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final cleanup after the DDA-parity work, plus an end-user service to replace the PsExec/VBS/scheduled-task launch chain. Cleanup (behavior-preserving): - sudovda.rs: drop the dead legacy GDI isolate_displays/restore_displays (CCD is the sole isolation path), the always-empty Monitor.isolated field, and the vestigial reassert_isolation + PUNKTFUNK_ISOLATE_DISPLAYS knob; fix stale comments. - dxgi.rs: downgrade leftover debug warns/infos (DuplicateOutput1 retry, FALLBACKS, hook-hits, AcquireNextFrame idle timeout) to debug!; remove the PUNKTFUNK_NO_CURSOR per-frame test knob. Windows service (src/service.rs, `punktfunk-host service`): - SCM supervisor (windows-service crate) that duplicates its LocalSystem token, retargets it to the active console session, and CreateProcessAsUserW's the host there (Sunshine/Apollo model) — relaunching on exit and console session switch, inside a kill-on-close job object so a service crash never orphans the host. - install/uninstall/start/stop/status subcommands: one elevated `service install` registers an auto-start LocalSystem service + firewall rules + a default host.env. - Config moves to %ProgramData%\punktfunk\host.env; config_dir() now resolves to %ProgramData%\punktfunk on Windows (replacing the APPDATA=C:\Users\Public hack), with a PUNKTFUNK_CONFIG_DIR override. Logs land in %ProgramData%\punktfunk\logs\. - merged_env_block (shared with the WGC helper) now also carries RUST_LOG. - docs/windows-service.md + scripts/windows/host.env.example; windows-host.md updated. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 18 + crates/punktfunk-host/Cargo.toml | 7 + crates/punktfunk-host/src/capture/dxgi.rs | 94 ++- .../punktfunk-host/src/capture/wgc_relay.rs | 18 +- crates/punktfunk-host/src/gamestream/mod.rs | 18 +- crates/punktfunk-host/src/main.rs | 44 +- crates/punktfunk-host/src/service.rs | 702 ++++++++++++++++++ crates/punktfunk-host/src/vdisplay/sudovda.rs | 167 +---- docs/windows-host.md | 24 +- docs/windows-service.md | 93 +++ scripts/windows/host.env.example | 36 + 11 files changed, 1020 insertions(+), 201 deletions(-) create mode 100644 crates/punktfunk-host/src/service.rs create mode 100644 docs/windows-service.md create mode 100644 scripts/windows/host.env.example diff --git a/Cargo.lock b/Cargo.lock index aeef7c6..c83919b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2688,6 +2688,7 @@ dependencies = [ "wayland-protocols-wlr", "wayland-scanner", "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", + "windows-service", "x509-parser", "xkbcommon", ] @@ -4325,6 +4326,12 @@ dependencies = [ "safe_arch", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -4557,6 +4564,17 @@ dependencies = [ "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", ] +[[package]] +name = "windows-service" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a" +dependencies = [ + "bitflags", + "widestring", + "windows-sys 0.52.0", +] + [[package]] name = "windows-strings" version = "0.5.1" diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 7b997d6..bcff7be 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -152,7 +152,14 @@ windows = { version = "0.62", features = [ # Per-monitor-v2 DPI awareness — IDXGIOutput5::DuplicateOutput1 (the modern capture path Apollo # uses; FP16/format-list, robust to overlay/format churn) requires the process to be DPI-aware. "Win32_UI_HiDpi", + # Windows service supervisor (src/service.rs): a kill-on-close job object so a service crash never + # orphans the SYSTEM host it launched into the interactive session. + "Win32_System_JobObjects", ] } +# The SCM plumbing for the `service` subcommand (define_windows_service! / dispatcher / control +# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses +# the `windows` crate above. +windows-service = "0.7" # Software H.264 encoder (GPU-less path + NVENC fallback). The default `source` feature statically # compiles OpenH264 (BSD-2) — no system lib, builds on MSVC; nasm on PATH adds the SIMD fast path. openh264 = "0.9" diff --git a/crates/punktfunk-host/src/capture/dxgi.rs b/crates/punktfunk-host/src/capture/dxgi.rs index ec6b134..33bf574 100644 --- a/crates/punktfunk-host/src/capture/dxgi.rs +++ b/crates/punktfunk-host/src/capture/dxgi.rs @@ -39,8 +39,8 @@ use windows::Win32::Graphics::Dxgi::Common::{ use windows::Win32::Graphics::Dxgi::{ CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, IDXGIOutput1, IDXGIOutput5, IDXGIOutputDuplication, IDXGIResource, DXGI_ERROR_ACCESS_LOST, DXGI_ERROR_DEVICE_REMOVED, - DXGI_ERROR_DEVICE_RESET, DXGI_ERROR_MODE_CHANGE_IN_PROGRESS, - DXGI_ERROR_INVALID_CALL, DXGI_ERROR_WAIT_TIMEOUT, DXGI_OUTDUPL_DESC, DXGI_OUTDUPL_FRAME_INFO, + DXGI_ERROR_DEVICE_RESET, DXGI_ERROR_INVALID_CALL, DXGI_ERROR_MODE_CHANGE_IN_PROGRESS, + DXGI_ERROR_WAIT_TIMEOUT, DXGI_OUTDUPL_DESC, DXGI_OUTDUPL_FRAME_INFO, DXGI_OUTDUPL_POINTER_SHAPE_INFO, DXGI_OUTDUPL_POINTER_SHAPE_TYPE_COLOR, DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR, }; @@ -217,7 +217,10 @@ unsafe fn duplicate_output( match output5.DuplicateOutput1(device, 0, &formats) { Ok(d) => { if attempt > 0 { - tracing::info!(attempt, "DuplicateOutput1 succeeded on retry (rode out old-dup teardown race)"); + tracing::debug!( + attempt, + "DuplicateOutput1 succeeded on retry (rode out old-dup teardown race)" + ); } return Ok(d); } @@ -235,7 +238,7 @@ unsafe fn duplicate_output( // legacy fallback below handles it; gentle recovery keeps it from churning. static FALLBACKS: AtomicU64 = AtomicU64::new(0); if FALLBACKS.fetch_add(1, Ordering::Relaxed) % 64 == 0 { - tracing::warn!( + tracing::debug!( error = %format!("{e:?}"), "DuplicateOutput1 unavailable — using legacy DuplicateOutput (expected on the secure desktop)" ); @@ -1212,19 +1215,20 @@ impl DuplCapturer { let device = device.context("null D3D11 device")?; let context = context.context("null D3D11 context")?; // 3) duplicate the output. Attach to the current input desktop first (as SYSTEM this can - // be the Winlogon secure desktop) so a session that starts at the lock/login screen works, - // and re-assert display isolation at OPEN time (not just in recovery): a lock/UAC switch can - // re-attach a physical monitor and route the secure desktop THERE, leaving our virtual - // output perpetually idle/lost — re-isolating forces the secure desktop back onto it. Cheap - // + idempotent (a no-op when nothing else is attached). + // be the Winlogon secure desktop) so a session that starts at the lock/login screen works. + // The SudoVDA is kept the sole desktop via the CCD isolation in sudovda::create_monitor + // (registry-persisted), so the secure desktop has nowhere to render but the output we + // capture — no per-open re-isolation needed. attach_input_desktop(); - crate::vdisplay::sudovda::reassert_isolation(&target.gdi_name); let dupl = duplicate_output(&output, &device) .context("DuplicateOutput (already duplicated by another app?)")?; // Did DXGI actually call our win32u GPU-pref hook during factory/device/dupl creation? hits==0 // here means the hook is NOT on DXGI's reparenting path on this build → reparenting can't be - // the churn cause (look at independent-flip/composition instead). - tracing::info!(hook_hits = hybrid_hook_hits(), "win32u GPU-pref hook call count after open"); + // the churn cause (look at independent-flip/composition instead). Diagnostic only. + tracing::debug!( + hook_hits = hybrid_hook_hits(), + "win32u GPU-pref hook call count after open" + ); // Kick the first frame loose: a blank virtual display is otherwise change-less. nudge_cursor_onto(&output); let dd: DXGI_OUTDUPL_DESC = dupl.GetDesc(); @@ -1468,19 +1472,15 @@ impl DuplCapturer { let mut buf = vec![0u8; info.PointerShapeBufferSize as usize]; let mut required = 0u32; let mut si = DXGI_OUTDUPL_POINTER_SHAPE_INFO::default(); - if self - .dupl - .as_ref() - .is_some_and(|d| { - d.GetFramePointerShape( - info.PointerShapeBufferSize, - buf.as_mut_ptr() as *mut c_void, - &mut required, - &mut si, - ) - .is_ok() - }) - { + if self.dupl.as_ref().is_some_and(|d| { + d.GetFramePointerShape( + info.PointerShapeBufferSize, + buf.as_mut_ptr() as *mut c_void, + &mut required, + &mut si, + ) + .is_ok() + }) { if let Some(shape) = convert_pointer_shape(&buf, &si) { tracing::info!( shape_type = si.Type, @@ -1501,12 +1501,6 @@ impl DuplCapturer { /// HDR graphics white (PUNKTFUNK_HDR_CURSOR_NITS, default 203, per BT.2408) so it isn't ~2.5× /// too dim; SDR composites the raw cursor in the display's native sRGB space. unsafe fn composite_cursor_gpu(&mut self, gpu: &ID3D11Texture2D, hdr: bool) -> Result<()> { - // Diagnostic kill-switch: skip the GPU cursor composite entirely (PUNKTFUNK_NO_CURSOR=1) to - // isolate its cost on the 3D engine. The per-frame render-target view + draw to the 5K target - // is the suspect for the high 3D usage under heavy desktop change. - if std::env::var_os("PUNKTFUNK_NO_CURSOR").is_some() { - return Ok(()); - } self.dbg_cursor += 1; if self.dbg_cursor % 240 == 1 { tracing::debug!( @@ -1619,7 +1613,12 @@ impl DuplCapturer { self.dupl = Some(dupl); let mut info = DXGI_OUTDUPL_FRAME_INFO::default(); let mut res: Option = None; - match self.dupl.as_ref().unwrap().AcquireNextFrame(16, &mut info, &mut res) { + match self + .dupl + .as_ref() + .unwrap() + .AcquireNextFrame(16, &mut info, &mut res) + { Ok(()) => { self.update_cursor(&info); if let Some(r) = res { @@ -1651,24 +1650,15 @@ impl DuplCapturer { if let Some(n) = crate::vdisplay::sudovda::resolve_gdi_name(self.target_id) { self.gdi_name = n; } - // Heavy topology work — re-attach the thread to the input desktop AND re-isolate the virtual - // output — ONLY on the actual secure (Winlogon) desktop. Entering it can re-attach a physical - // monitor and move the secure desktop off our virtual output, which re-isolation fixes. But on - // the NORMAL desktop this is just routine ACCESS_LOST churn (HDR overlay / MPO / periodic IddCx - // invalidation), and re-isolating there is a DISPLAY-TOPOLOGY CHANGE that itself invalidates the - // freshly-rebuilt duplication → a self-feeding ACCESS_LOST storm (200 rebuilds/session observed). - // Apollo isolates once at startup and its recovery just re-duplicates; match that off the secure - // desktop. (The lock screen / post-login are NOT Winlogon, so they take this light path too.) // Re-sync the capture thread to the CURRENT input desktop on EVERY rebuild — symmetric for // ENTERING and LEAVING the secure (Winlogon) desktop. This is the fix for "UAC/lock appears // fine but breaks the instant you click out of it": leaving secure used to skip this (it was // gated on is_secure_desktop()), stranding the thread on the gone Winlogon desktop. Cheap + - // leak-free now (attach_input_desktop closes its handle). reassert_isolation stays secure-only - // (it's a CCD topology mutation that would self-feed a storm on the normal desktop). + // leak-free (attach_input_desktop closes its handle). Apollo (syncThreadDesktop) does the same. + // We do NOT re-isolate the display on recovery: the CCD isolation from create_monitor is + // registry-persisted, and a CCD topology mutation here would itself invalidate the freshly-rebuilt + // duplication → a self-feeding ACCESS_LOST storm (200 rebuilds/session observed before this). attach_input_desktop(); - if crate::capture::desktop_watch::is_secure_desktop() { - crate::vdisplay::sudovda::reassert_isolation(&self.gdi_name); - } // RELEASE the old duplication FIRST (frees the output). reopen_duplication creates a NEW device // and re-DuplicateOutputs the output; if the stale duplication is still alive it holds the output // and the new one is born-lost / E_ACCESSDENIED. (On reopen failure self.dupl stays None and @@ -1722,7 +1712,12 @@ impl DuplCapturer { nudge_cursor_onto(&self.output); // kick a change so a static desktop yields its first frame let mut info = DXGI_OUTDUPL_FRAME_INFO::default(); let mut res: Option = None; - let captured = match self.dupl.as_ref().unwrap().AcquireNextFrame(120, &mut info, &mut res) { + let captured = match self + .dupl + .as_ref() + .unwrap() + .AcquireNextFrame(120, &mut info, &mut res) + { Ok(()) => { self.update_cursor(&info); match res { @@ -1796,7 +1791,8 @@ impl DuplCapturer { Err(e) if e.code() == DXGI_ERROR_WAIT_TIMEOUT => { self.dbg_timeouts += 1; if self.dbg_timeouts % 40 == 1 { - tracing::warn!( + // A static desktop produces no DDA frames, so timeouts are NORMAL idle, not an error. + tracing::debug!( timeouts = self.dbg_timeouts, first_frame = self.first_frame, "DXGI AcquireNextFrame timeout (no desktop change yet)" @@ -1884,7 +1880,7 @@ impl DuplCapturer { let now = Instant::now(); let due = self .last_rebuild - .map_or(true, |t| now.duration_since(t) >= Duration::from_millis(rebuild_ms)); + .is_none_or(|t| now.duration_since(t) >= Duration::from_millis(rebuild_ms)); if due { self.last_rebuild = Some(now); if self.recreate_dupl().is_ok() { @@ -1936,7 +1932,7 @@ impl DuplCapturer { let now = Instant::now(); let due = self .last_rebuild - .map_or(true, |t| now.duration_since(t) >= Duration::from_millis(250)); + .is_none_or(|t| now.duration_since(t) >= Duration::from_millis(250)); if due { self.last_rebuild = Some(now); if self.recreate_dupl().is_ok() { diff --git a/crates/punktfunk-host/src/capture/wgc_relay.rs b/crates/punktfunk-host/src/capture/wgc_relay.rs index 73d8f88..ddb4436 100644 --- a/crates/punktfunk-host/src/capture/wgc_relay.rs +++ b/crates/punktfunk-host/src/capture/wgc_relay.rs @@ -152,11 +152,12 @@ unsafe fn no_inherit(h: HANDLE) { let _ = SetHandleInformation(h, HANDLE_FLAG_INHERIT.0, HANDLE_FLAGS(0)); } -/// Build the helper's environment block: the user's block (so DLL/PATH/SystemRoot resolve) with this -/// (host) process's `PUNKTFUNK_*` vars overlaid, so the helper encodes with the SAME settings the -/// host runs with (`PUNKTFUNK_ENCODER=nvenc`, `PUNKTFUNK_ZEROCOPY`, …) instead of the user shell's. -/// Returns a UTF-16, double-null-terminated block suitable for `CREATE_UNICODE_ENVIRONMENT`. -unsafe fn merged_env_block(user_block: *const u16) -> Vec { +/// Build a child environment block: the target session's block (so DLL/PATH/SystemRoot resolve) 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 WGC +/// helper spawn (here) and the Windows service launching the host into the active session. +pub(crate) unsafe fn merged_env_block(user_block: *const u16) -> Vec { // Parse the user block ("VAR=VALUE\0" … "\0") into entries. let mut entries: Vec = Vec::new(); if !user_block.is_null() { @@ -174,9 +175,10 @@ unsafe fn merged_env_block(user_block: *const u16) -> Vec { p = p.offset(len + 1); } } - // Drop any PUNKTFUNK_* the user block carried, then overlay this process's PUNKTFUNK_* vars. - entries.retain(|e| !e.split('=').next().unwrap_or("").starts_with("PUNKTFUNK_")); - for (k, v) in std::env::vars().filter(|(k, _)| k.starts_with("PUNKTFUNK_")) { + // 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. diff --git a/crates/punktfunk-host/src/gamestream/mod.rs b/crates/punktfunk-host/src/gamestream/mod.rs index 12ee98d..1016563 100644 --- a/crates/punktfunk-host/src/gamestream/mod.rs +++ b/crates/punktfunk-host/src/gamestream/mod.rs @@ -201,13 +201,25 @@ pub fn serve(mgmt: crate::mgmt::Options, native: Option) }) } -/// `~/.config/punktfunk`, created on demand — host identity + (later) pairing state live here. +/// The host config dir (host identity, pairing state, mgmt token, library) — created on demand. +/// Linux: `$XDG_CONFIG_HOME/punktfunk` or `~/.config/punktfunk`. Windows: `%ProgramData%\punktfunk` +/// (machine-wide — the SYSTEM service and the interactive user share ONE dir that survives logout). +/// `PUNKTFUNK_CONFIG_DIR` overrides on both platforms (used by the Windows service config / tests). pub(crate) fn config_dir() -> PathBuf { + if let Some(dir) = std::env::var_os("PUNKTFUNK_CONFIG_DIR").filter(|s| !s.is_empty()) { + return PathBuf::from(dir); + } + // Windows: %ProgramData% (e.g. C:\ProgramData\punktfunk) — machine-wide, SYSTEM-readable, + // persists across user logout, correct for a SYSTEM service. Falls back to %APPDATA% then CWD. + #[cfg(target_os = "windows")] + let base = std::env::var_os("ProgramData") + .or_else(|| std::env::var_os("APPDATA")) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + #[cfg(not(target_os = "windows"))] let base = std::env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) - // Windows: %APPDATA% (e.g. C:\Users\X\AppData\Roaming) — cert/key/paired/uniqueid persist there. - .or_else(|| std::env::var_os("APPDATA").map(PathBuf::from)) .unwrap_or_else(|| PathBuf::from(".")); base.join("punktfunk") } diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 45fa45d..9589883 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -31,6 +31,8 @@ mod mgmt_token; mod native_pairing; mod pipeline; mod pwinit; +#[cfg(target_os = "windows")] +mod service; mod vdisplay; #[cfg(target_os = "windows")] mod wgc_helper; @@ -43,13 +45,28 @@ use m0::{Options, Source}; use std::path::PathBuf; fn main() { - // Logs go to stderr so stdout stays machine-readable (`punktfunk-host openapi > spec.json`). - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), - ) - .with_writer(std::io::stderr) - .init(); + let filter = + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()); + // `service run` is launched by the SCM with no console — log to a file instead of stderr. + #[cfg(target_os = "windows")] + let service_run = { + let a: Vec = std::env::args().skip(1).take(2).collect(); + a.first().map(String::as_str) == Some("service") + && a.get(1).map(String::as_str) == Some("run") + }; + #[cfg(not(target_os = "windows"))] + let service_run = false; + + if service_run { + #[cfg(target_os = "windows")] + service::init_file_logging(filter); + } else { + // Logs go to stderr so stdout stays machine-readable (`punktfunk-host openapi > spec.json`). + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .init(); + } if let Err(e) = real_main() { tracing::error!("{e:#}"); @@ -233,6 +250,11 @@ fn real_main() -> Result<()> { bit_depth: get("--bit-depth").and_then(|s| s.parse().ok()).unwrap_or(8), }) } + // Windows service control: install/uninstall/start/stop/status + the SCM `run` entry point. + // Replaces the ad-hoc launch chain — `service install` registers an auto-start SYSTEM service + // that launches the host into the active interactive session. + #[cfg(target_os = "windows")] + Some("service") => service::main(&args[1..]), Some("-h") | Some("--help") | Some("help") | None => { print_usage(); Ok(()) @@ -515,4 +537,12 @@ NOTES: Both 'serve --native' and 'm3-host' advertise the native service over mDNS (_punktfunk._udp) for client auto-discovery — 'punktfunk-client-rs --discover' lists them." ); + #[cfg(target_os = "windows")] + eprintln!( + "\nWINDOWS SERVICE (end-user deployment — replaces a manual launch):\n\ + \x20 punktfunk-host service install register an auto-start SYSTEM service + firewall rules\n\ + \x20 punktfunk-host service uninstall remove the service + firewall rules\n\ + \x20 punktfunk-host service start|stop|status\n\ + \x20 config: %ProgramData%\\punktfunk\\host.env" + ); } diff --git a/crates/punktfunk-host/src/service.rs b/crates/punktfunk-host/src/service.rs new file mode 100644 index 0000000..6ce5fc7 --- /dev/null +++ b/crates/punktfunk-host/src/service.rs @@ -0,0 +1,702 @@ +//! 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 --native` runs the GameStream (Moonlight) host + the native punktfunk/1 QUIC host in one +/// process — the unified host an end user wants. +const DEFAULT_HOST_CMD: &str = "serve --native"; + +/// 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 `. +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) { + 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 = 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 { + 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 { + 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::() 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 { + // 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::() 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::() as u32, + dwFlags: STARTF_USESTDHANDLES, + hStdOutput: log, + hStdError: log, + ..Default::default() + }; + let mut desktop: Vec = "winsta0\\default\0".encode_utf16().collect(); + si.lpDesktop = PWSTR(desktop.as_mut_ptr()); + + let mut cmd: Vec = 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 { + let wpath: Vec = path + .as_os_str() + .to_string_lossy() + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let sa = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() 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 with NVENC out of the box. +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\ + PUNKTFUNK_ENCODER=nvenc\n\ + PUNKTFUNK_VIDEO_SOURCE=virtual\n\ + PUNKTFUNK_SECURE_DDA=1\n\ + RUST_LOG=info\n\ + \n\ + # The host subcommand the service launches (default: serve --native).\n\ + # PUNKTFUNK_HOST_CMD=serve --native\n\ + \n\ + # Force a specific NVENC 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) +} diff --git a/crates/punktfunk-host/src/vdisplay/sudovda.rs b/crates/punktfunk-host/src/vdisplay/sudovda.rs index f976fa7..4f4db87 100644 --- a/crates/punktfunk-host/src/vdisplay/sudovda.rs +++ b/crates/punktfunk-host/src/vdisplay/sudovda.rs @@ -31,10 +31,9 @@ use windows::Win32::Devices::Display::{ }; use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID}; use windows::Win32::Graphics::Gdi::{ - ChangeDisplaySettingsExW, EnumDisplayDevicesW, EnumDisplaySettingsW, CDS_GLOBAL, CDS_NORESET, - CDS_TEST, CDS_TYPE, CDS_UPDATEREGISTRY, DEVMODEW, DISPLAY_DEVICEW, - DISPLAY_DEVICE_ATTACHED_TO_DESKTOP, DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, - DM_PELSHEIGHT, DM_PELSWIDTH, DM_POSITION, ENUM_CURRENT_SETTINGS, ENUM_DISPLAY_SETTINGS_MODE, + ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW, + DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, DM_PELSWIDTH, + ENUM_DISPLAY_SETTINGS_MODE, }; use windows::Win32::Storage::FileSystem::{ CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, @@ -57,9 +56,6 @@ const IOCTL_GET_WATCHDOG: u32 = ctl(0x803); const IOCTL_DRIVER_PING: u32 = ctl(0x888); const IOCTL_GET_VERSION: u32 = ctl(0x8FF); -// A fixed monitor identity. One session at a time today; Windows persists this monitor's layout -// across sessions by GUID, and REMOVE keys off it. (TODO: derive per-client when concurrent -// sessions land.) /// A UNIQUE-per-session SudoVDA monitor GUID. The monitor is keyed by GUID for IOCTL_ADD/REMOVE, so a /// FIXED GUID makes overlapping sessions (a client reconnecting after a freeze before the old session /// has torn down, or genuine concurrent sessions) all map to the SAME monitor — then one session's @@ -148,7 +144,7 @@ unsafe fn resolve_render_adapter_luid() -> Option { continue; } let vram = d.DedicatedVideoMemory as u64; // SudoVDA software adapter ≈ 0 → loses to the dGPU - if best.as_ref().map_or(true, |(_, v, _)| vram > *v) { + if best.as_ref().is_none_or(|(_, v, _)| vram > *v) { best = Some((d.AdapterLuid, vram, name)); } } @@ -263,7 +259,7 @@ pub(crate) unsafe fn set_advanced_color(target_id: u32, enable: bool) -> bool { s.header.adapterId = p.targetInfo.adapterId; s.header.id = p.targetInfo.id; s.Anonymous.value = enable as u32; // bit 0 = enableAdvancedColor - let rc = DisplayConfigSetDeviceInfo(&mut s.header); + let rc = DisplayConfigSetDeviceInfo(&s.header); tracing::info!( target_id, enable, @@ -382,7 +378,13 @@ fn set_active_mode(gdi_name: &str, mode: Mode) { return; } let apply = unsafe { - ChangeDisplaySettingsExW(PCWSTR(wname.as_ptr()), Some(&dm), None, CDS_UPDATEREGISTRY, None) + ChangeDisplaySettingsExW( + PCWSTR(wname.as_ptr()), + Some(&dm), + None, + CDS_UPDATEREGISTRY, + None, + ) }; if apply == DISP_CHANGE_SUCCESSFUL { tracing::info!( @@ -402,94 +404,6 @@ fn set_active_mode(gdi_name: &str, mode: Mode) { } } -/// Detach every display except `keep_gdi_name`, leaving the SudoVDA virtual output as the ONLY -/// display. This is the SudoVDA/Apollo "isolate the virtual display" move and the key to capturing -/// the secure desktop: Windows renders the login / UAC (Winlogon) desktop on the physical/primary -/// display and resets the topology when it switches there — with a physical monitor still attached -/// (e.g. an LG TV), the login lands on it and our virtual output goes perpetually ACCESS_LOST. With -/// the physical detached and the change PERSISTED to the registry, Winlogon reads "only the virtual -/// is attached" and the secure desktop has nowhere to render but the output we capture. -/// -/// Returns the displays we detached plus their saved modes so teardown can restore them. -/// -/// Superseded by the atomic CCD [`isolate_displays_ccd`] (the legacy per-device GDI detach misses -/// iGPU-attached monitors on a hybrid box and churns the topology). Retained for reference / a -/// possible fallback. -#[allow(dead_code)] -unsafe fn isolate_displays(keep_gdi_name: &str) -> Vec<(String, DEVMODEW)> { - let mut saved = Vec::new(); - let mut idx = 0u32; - loop { - let mut dd = DISPLAY_DEVICEW { - cb: size_of::() as u32, - ..Default::default() - }; - if !EnumDisplayDevicesW(PCWSTR::null(), idx, &mut dd, 0).as_bool() { - break; - } - idx += 1; - if (dd.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP).0 == 0 { - continue; // not part of the desktop — nothing to detach - } - let name = String::from_utf16_lossy(&dd.DeviceName); - let name = name.trim_end_matches('\u{0}').to_string(); - if name == keep_gdi_name { - continue; // the virtual output we want to keep - } - // Save the current mode so the teardown can re-attach this display where it was. - let mut cur = DEVMODEW { - dmSize: size_of::() as u16, - ..Default::default() - }; - let wname: Vec = name.encode_utf16().chain(std::iter::once(0)).collect(); - if EnumDisplaySettingsW(PCWSTR(wname.as_ptr()), ENUM_CURRENT_SETTINGS, &mut cur).as_bool() { - saved.push((name.clone(), cur)); - } - // A 0x0 mode removes the display from the desktop. NORESET batches; we commit once below. - let off = DEVMODEW { - dmSize: size_of::() as u16, - dmFields: DM_POSITION | DM_PELSWIDTH | DM_PELSHEIGHT, - ..Default::default() - }; - let r = ChangeDisplaySettingsExW( - PCWSTR(wname.as_ptr()), - Some(&off), - None, - CDS_UPDATEREGISTRY | CDS_NORESET | CDS_GLOBAL, - None, - ); - tracing::info!("display isolate: detaching {name} (result={})", r.0); - } - if !saved.is_empty() { - // Commit the batched detaches (NULL device + 0 flags applies the pending registry changes). - let _ = ChangeDisplaySettingsExW(PCWSTR::null(), None, None, CDS_TYPE(0), None); - tracing::info!( - "display isolate: {} display(s) detached — only {keep_gdi_name} remains", - saved.len() - ); - } - saved -} - -/// Re-attach the displays [`isolate_displays`] detached, restoring each to its saved mode. Called on -/// teardown BEFORE the virtual output is removed, so there is always at least one display. -unsafe fn restore_displays(saved: &[(String, DEVMODEW)]) { - for (name, dm) in saved { - let wname: Vec = name.encode_utf16().chain(std::iter::once(0)).collect(); - let _ = ChangeDisplaySettingsExW( - PCWSTR(wname.as_ptr()), - Some(dm), - None, - CDS_UPDATEREGISTRY | CDS_NORESET | CDS_GLOBAL, - None, - ); - } - if !saved.is_empty() { - let _ = ChangeDisplaySettingsExW(PCWSTR::null(), None, None, CDS_TYPE(0), None); - tracing::info!("display isolate: restored {} display(s)", saved.len()); - } -} - /// Saved active display topology, for restoring on teardown. type SavedConfig = (Vec, Vec); @@ -497,7 +411,7 @@ type SavedConfig = (Vec, Vec); /// doesn't export it, so define it here. const DISPLAYCONFIG_PATH_ACTIVE: u32 = 0x0000_0001; -/// Robust display isolation via the CCD API. The legacy [`isolate_displays`] (EnumDisplayDevices + +/// 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)` @@ -569,25 +483,6 @@ unsafe fn restore_displays_ccd(saved: &SavedConfig) { tracing::info!("display isolate (CCD): restored original topology rc={rc:#x}"); } -/// Re-detach physical displays so the secure (Winlogon) desktop keeps rendering to the virtual -/// output — for the in-session DXGI capture recovery (dxgi.rs `recreate_dupl`). The lock/UAC/login -/// switch can re-attach a physical monitor (the secure desktop then lands on IT and our virtual -/// output goes perpetually ACCESS_LOST — the "born-lost" storm); re-running the isolate routes the -/// secure desktop back to the virtual output, mirroring what a fresh session's `create` does (the -/// delta that makes a reconnect work where in-session recovery didn't). Idempotent + cheap: when -/// nothing besides `gdi_name` is attached, [`isolate_displays`] finds nothing to detach and commits -/// nothing — so this is safe to call on every throttled recovery tick (no display thrash). -pub(crate) fn reassert_isolation(gdi_name: &str) { - // Only when sole-display isolation is explicitly opted into (see create()): otherwise re-isolating - // would itself trigger the independent-flip storm we're avoiding. - if std::env::var("PUNKTFUNK_ISOLATE_DISPLAYS").is_err() { - return; - } - unsafe { - let _ = isolate_displays(gdi_name); - } -} - unsafe fn open_device() -> Result { let hdev = SetupDiGetClassDevsW( Some(&SUVDA_INTERFACE), @@ -646,7 +541,6 @@ struct Monitor { mode: Mode, stop: Arc, pinger: Option>, - isolated: Vec<(String, DEVMODEW)>, ccd_saved: Option, } @@ -805,7 +699,6 @@ unsafe fn create_monitor(device: isize, mode: Mode, watchdog_s: u32) -> Result = Vec::new(); // legacy GDI detach unused (CCD path below) let mut ccd_saved: Option = None; match &gdi_name { Some(n) => { @@ -827,7 +720,9 @@ unsafe fn create_monitor(device: isize, mode: Mode, watchdog_s: u32) -> Result Result()); @@ -898,7 +791,13 @@ fn mgr_ensure_device(g: &mut Mgr) -> Result { let device = unsafe { open_device()? }; let mut ver = [0u8; 4]; if unsafe { ioctl(device, IOCTL_GET_VERSION, &[], &mut ver) }.is_ok() { - tracing::info!("SudoVDA protocol {}.{}.{} (test={})", ver[0], ver[1], ver[2], ver[3]); + tracing::info!( + "SudoVDA protocol {}.{}.{} (test={})", + ver[0], + ver[1], + ver[2], + ver[3] + ); } let mut wd = [0u8; 8]; g.watchdog_s = if unsafe { ioctl(device, IOCTL_GET_WATCHDOG, &[], &mut wd) }.is_ok() { @@ -942,7 +841,10 @@ fn mgr_acquire(mode: Mode) -> Result { if changed { unsafe { mgr_reconfigure(mon, mode) }; } - tracing::info!(refs = *refs, "SudoVDA monitor reused (concurrent / reconfigure session)"); + tracing::info!( + refs = *refs, + "SudoVDA monitor reused (concurrent / reconfigure session)" + ); let pm = Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz)); let target = mon.target(); return Ok(VirtualOutput { @@ -982,7 +884,10 @@ fn mgr_acquire(mode: Mode) -> Result { /// Re-apply a (possibly new) mode to a reused monitor on reconnect, re-resolving its GDI name. unsafe fn mgr_reconfigure(mon: &mut Monitor, mode: Mode) { tracing::info!( - old = format!("{}x{}@{}", mon.mode.width, mon.mode.height, mon.mode.refresh_hz), + old = format!( + "{}x{}@{}", + mon.mode.width, mon.mode.height, mon.mode.refresh_hz + ), new = format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz), "SudoVDA: reconfiguring reused monitor to the new client mode" ); @@ -999,10 +904,16 @@ unsafe fn mgr_reconfigure(mon: &mut Monitor, mode: Mode) { fn mgr_release() { let mut g = MGR.lock().unwrap(); g.state = match std::mem::replace(&mut g.state, MgrState::Idle) { - MgrState::Active { mon, refs } if refs > 1 => MgrState::Active { mon, refs: refs - 1 }, + MgrState::Active { mon, refs } if refs > 1 => MgrState::Active { + mon, + refs: refs - 1, + }, MgrState::Active { mon, .. } => { let ms = linger_ms(); - tracing::info!(linger_ms = ms, "SudoVDA: last session left — lingering before teardown"); + tracing::info!( + linger_ms = ms, + "SudoVDA: last session left — lingering before teardown" + ); MgrState::Lingering { mon, until: Instant::now() + Duration::from_millis(ms), diff --git a/docs/windows-host.md b/docs/windows-host.md index 90ffbe5..768ee50 100644 --- a/docs/windows-host.md +++ b/docs/windows-host.md @@ -74,14 +74,26 @@ Driven by live testing with the native macOS client at the display's native **51 detaches other monitors so Winlogon renders to the virtual output) covers the case where a physical monitor is also attached. -### Running as SYSTEM, windowless (deployment) +### Running as SYSTEM (deployment) — the `PunktfunkHost` service To capture the secure desktop the host must run as **SYSTEM in the interactive Session 1** (a Session -0 service can't duplicate Session 1). Launch chain: a scheduled task (Interactive, Highest) → -`PsExec64 -s -i 1 -d wscript.exe launch.vbs` → `launch.vbs` runs `host-run.cmd` with a **hidden -window** (`WScript.Shell.Run …, 0`). This keeps the host off the captured desktop — no `cmd` windows -the user can see or accidentally close (which would kill the stream). `host-run.cmd` sets -`APPDATA=C:\Users\Public` (shared identity/pairing) + `PUNKTFUNK_ENCODER=nvenc` and runs `m3-host`. +0 service can't duplicate Session 1). The end-user deployment is the built-in Windows **service** +(`src/service.rs`) — see [`windows-service.md`](windows-service.md). One elevated command: + +```powershell +punktfunk-host service install # auto-start LocalSystem service + firewall rules + default host.env +punktfunk-host service start +``` + +The service runs in Session 0 but never captures: it duplicates its own LocalSystem token, retargets +it to the active console session, and `CreateProcessAsUserW`s the host there — supervising it across +exits and console-session switches (the Sunshine/Apollo model). Config lives in +`%ProgramData%\punktfunk\host.env`; logs in `%ProgramData%\punktfunk\logs\`. + +> **Old bring-up chain (debug only, superseded by the service):** a scheduled task (Interactive, +> Highest) → `PsExec64 -s -i 1 -d wscript.exe launch.vbs` → `host-run.cmd` (hidden window), with +> `APPDATA=C:\Users\Public` as the shared-identity hack. The service replaces all of this; the host +> now resolves its config dir to `%ProgramData%\punktfunk` directly (`PUNKTFUNK_CONFIG_DIR` overrides). ### Real-GPU test box (RTX 4090, `ssh "Enrico Bühler"@192.168.1.174`) diff --git a/docs/windows-service.md b/docs/windows-service.md new file mode 100644 index 0000000..ab94eb5 --- /dev/null +++ b/docs/windows-service.md @@ -0,0 +1,93 @@ +# Windows service (deployment) + +The `PunktfunkHost` Windows service is the end-user way to run the host on Windows. It replaces the +manual bring-up chain (a scheduled task → `PsExec64 -s -i 1` → `wscript launch.vbs` → `host-run.cmd`) +with one command, auto-start on boot, and supervision. + +## Install + +From an **elevated** (Administrator) prompt: + +```powershell +punktfunk-host service install # register auto-start LocalSystem service + firewall rules + default host.env +punktfunk-host service start # start it now (also starts automatically on every boot) +``` + +`service install` is idempotent — run it again after upgrading the exe to re-point the service at the +new binary. Register whatever location you keep the exe in (e.g. `C:\Program Files\punktfunk\`); the +service records the current exe path. + +Other subcommands: + +```powershell +punktfunk-host service stop +punktfunk-host service status +punktfunk-host service uninstall # stop + delete the service + remove its firewall rules +``` + +## How it works + +The host must run **as SYSTEM in the interactive session** (Session 1+): Desktop Duplication of the +secure desktop (UAC / lock / login) and `SendInput` need SYSTEM, and capture/injection need the +interactive session, which a plain Session-0 service is not in. + +So the service (itself in Session 0) **never captures**. On start, and whenever the active console +session changes, it: + +1. resolves the active console session (`WTSGetActiveConsoleSessionId`), +2. duplicates its own LocalSystem token and retargets it to that session (`SetTokenInformation` + `TokenSessionId`), +3. launches the host there with `CreateProcessAsUserW` (`lpDesktop = winsta0\default`), +4. supervises it: relaunches on exit/crash (with backoff) and on a console connect/disconnect. + +A kill-on-close **job object** ensures a service crash never orphans the SYSTEM host. The host in turn +spawns the WGC helper into the *user* session (see [`windows-secure-desktop.md`](windows-secure-desktop.md)) +— two nested launches. Lock/unlock are handled inside the host (the `DesktopWatcher` DDA↔WGC mux), so +the service deliberately does **not** relaunch on lock/unlock — only on a real session switch. + +This is the same model Sunshine/Apollo use. + +## Configuration + +Config lives in **`%ProgramData%\punktfunk\host.env`** (KEY=VALUE lines, `#` comments). `service +install` writes a default if none exists. Template: [`scripts/windows/host.env.example`](../scripts/windows/host.env.example). + +```ini +PUNKTFUNK_ENCODER=nvenc +PUNKTFUNK_VIDEO_SOURCE=virtual +PUNKTFUNK_SECURE_DDA=1 +RUST_LOG=info +# PUNKTFUNK_HOST_CMD=serve --native # the host subcommand the service launches (default) +``` + +The service loads these into its environment and carries `PUNKTFUNK_*` + `RUST_LOG` to the host child +(the same env-merge the WGC helper uses). Restart the service after editing: + +```powershell +punktfunk-host service stop; punktfunk-host service start +``` + +The host's identity (cert/pairing/mgmt token/library) also lives under `%ProgramData%\punktfunk` — a +machine-wide dir the SYSTEM service and the interactive user share, surviving user logout. +`PUNKTFUNK_CONFIG_DIR` overrides the location (both platforms; handy for tests). + +## Logs + +- `%ProgramData%\punktfunk\logs\service.log` — the service's own supervision log (spawn/exit/session + switches). +- `%ProgramData%\punktfunk\logs\host.log` — the host child's stdout/stderr. + +## Prerequisites + +- The host built with `--features nvenc` for NVENC (the driver ships `nvEncodeAPI64.dll`; no SDK + needed at runtime). Software encode otherwise. +- The **SudoVDA** indirect display driver installed (for `PUNKTFUNK_VIDEO_SOURCE=virtual`). +- **ViGEmBus** for virtual gamepads (optional). + +## Gotchas + +- `service install`/`uninstall` need an **elevated** prompt (the SCM rejects non-admin). +- `service run` is the SCM entry point — don't run it by hand (it errors with a hint). +- A **graceful** stop currently `TerminateProcess`es the host, so its RAII teardown (SudoVDA monitor + REMOVE) doesn't run; a stale virtual monitor can linger until the next start. A cooperative-stop + signal is a follow-up. diff --git a/scripts/windows/host.env.example b/scripts/windows/host.env.example new file mode 100644 index 0000000..48faf11 --- /dev/null +++ b/scripts/windows/host.env.example @@ -0,0 +1,36 @@ +# punktfunk host configuration (Windows) — read by the `PunktfunkHost` service. +# +# `punktfunk-host service install` writes a default copy of this to +# %ProgramData%\punktfunk\host.env +# Edit that file (not this one) and restart the service to apply: +# punktfunk-host service stop +# punktfunk-host service start +# +# Format: KEY=VALUE per line; '#' starts a comment. The service loads these into its environment +# and passes PUNKTFUNK_* and RUST_LOG through to the host it launches into the active session. + +# Hardware encode via NVENC (NVIDIA). The host must be the `--features nvenc` build. Falls back to +# the software encoder automatically if NVENC is unavailable. +PUNKTFUNK_ENCODER=nvenc + +# Video source: `virtual` creates a per-client virtual display (SudoVDA) at the client's exact +# resolution + refresh — the flagship mode. Requires the SudoVDA indirect display driver installed. +PUNKTFUNK_VIDEO_SOURCE=virtual + +# Capture the secure desktop (UAC / lock / login) so the stream survives those transitions. +PUNKTFUNK_SECURE_DDA=1 + +# Log level (info | debug | trace). Logs land in %ProgramData%\punktfunk\logs\. +RUST_LOG=info + +# The host subcommand the service launches. Default: `serve --native` (GameStream/Moonlight + the +# native punktfunk/1 QUIC host in one process). Uncomment to override. +#PUNKTFUNK_HOST_CMD=serve --native + +# Multi-GPU boxes only: force the NVENC/Desktop-Duplication GPU by Description substring. Leave +# unset on single-GPU machines (the default auto-picks the discrete adapter). +#PUNKTFUNK_RENDER_ADAPTER=4090 + +# Keep a per-client virtual display alive briefly after disconnect so a quick reconnect reuses it +# (no display connect/disconnect chime). Default 10000 ms. +#PUNKTFUNK_MONITOR_LINGER_MS=10000