refactor(host/windows): clean up DDA path + add a proper Windows service
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 <noreply@anthropic.com>
This commit is contained in:
Generated
+18
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<IDXGIResource> = 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<IDXGIResource> = 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() {
|
||||
|
||||
@@ -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<u16> {
|
||||
/// 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<u16> {
|
||||
// Parse the user block ("VAR=VALUE\0" … "\0") into entries.
|
||||
let mut entries: Vec<String> = Vec::new();
|
||||
if !user_block.is_null() {
|
||||
@@ -174,9 +175,10 @@ unsafe fn merged_env_block(user_block: *const u16) -> Vec<u16> {
|
||||
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.
|
||||
|
||||
@@ -201,13 +201,25 @@ pub fn serve(mgmt: crate::mgmt::Options, native: Option<crate::m3::NativeServe>)
|
||||
})
|
||||
}
|
||||
|
||||
/// `~/.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")
|
||||
}
|
||||
|
||||
@@ -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<String> = 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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <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 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)
|
||||
}
|
||||
@@ -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<LUID> {
|
||||
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::<DISPLAY_DEVICEW>() 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::<DEVMODEW>() as u16,
|
||||
..Default::default()
|
||||
};
|
||||
let wname: Vec<u16> = 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::<DEVMODEW>() 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<u16> = 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<DISPLAYCONFIG_PATH_INFO>, Vec<DISPLAYCONFIG_MODE_INFO>);
|
||||
|
||||
@@ -497,7 +411,7 @@ type SavedConfig = (Vec<DISPLAYCONFIG_PATH_INFO>, Vec<DISPLAYCONFIG_MODE_INFO>);
|
||||
/// 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<HANDLE> {
|
||||
let hdev = SetupDiGetClassDevsW(
|
||||
Some(&SUVDA_INTERFACE),
|
||||
@@ -646,7 +541,6 @@ struct Monitor {
|
||||
mode: Mode,
|
||||
stop: Arc<AtomicBool>,
|
||||
pinger: Option<JoinHandle<()>>,
|
||||
isolated: Vec<(String, DEVMODEW)>,
|
||||
ccd_saved: Option<SavedConfig>,
|
||||
}
|
||||
|
||||
@@ -805,7 +699,6 @@ unsafe fn create_monitor(device: isize, mode: Mode, watchdog_s: u32) -> Result<M
|
||||
break;
|
||||
}
|
||||
}
|
||||
let isolated: Vec<(String, DEVMODEW)> = Vec::new(); // legacy GDI detach unused (CCD path below)
|
||||
let mut ccd_saved: Option<SavedConfig> = None;
|
||||
match &gdi_name {
|
||||
Some(n) => {
|
||||
@@ -827,7 +720,9 @@ unsafe fn create_monitor(device: isize, mode: Mode, watchdog_s: u32) -> Result<M
|
||||
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_err() {
|
||||
ccd_saved = unsafe { isolate_displays_ccd(ao.target_id) };
|
||||
} else {
|
||||
tracing::info!("display isolation skipped (PUNKTFUNK_NO_ISOLATE) — IDD stays extended");
|
||||
tracing::info!(
|
||||
"display isolation skipped (PUNKTFUNK_NO_ISOLATE) — IDD stays extended"
|
||||
);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens
|
||||
}
|
||||
@@ -845,7 +740,6 @@ unsafe fn create_monitor(device: isize, mode: Mode, watchdog_s: u32) -> Result<M
|
||||
mode,
|
||||
stop,
|
||||
pinger: Some(pinger),
|
||||
isolated,
|
||||
ccd_saved,
|
||||
})
|
||||
}
|
||||
@@ -876,7 +770,6 @@ impl Monitor {
|
||||
if let Some(saved) = &self.ccd_saved {
|
||||
restore_displays_ccd(saved);
|
||||
}
|
||||
restore_displays(&self.isolated);
|
||||
let rp = RemoveParams { guid: self.guid };
|
||||
let rp_bytes =
|
||||
std::slice::from_raw_parts(&rp as *const _ as *const u8, size_of::<RemoveParams>());
|
||||
@@ -898,7 +791,13 @@ fn mgr_ensure_device(g: &mut Mgr) -> Result<isize> {
|
||||
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<VirtualOutput> {
|
||||
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<VirtualOutput> {
|
||||
/// 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),
|
||||
|
||||
+18
-6
@@ -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`)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user