From 326d6e17c8d64fa4f609d46cbf804bf96b581998 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 23 Jul 2026 13:28:22 +0200 Subject: [PATCH] fix(windows/capture): stand the IddCx hardware cursor down while the secure desktop is up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.18.0 regression: UAC consent and Winlogon (lock/logon) stopped appearing in streams. The cursor channel's IddCx hardware-cursor declare — re-issued by the driver on every swap-chain assign — keeps the path out of the OS's software-cursor mode, which is the only mode the secure desktop renders through; DWM then never presents UAC/Winlogon into our swap-chain and the stream repeats the last normal-desktop frame for the whole interaction. The GDI cursor poller now classifies the input desktop on its reattach cadence (UOI_NAME != Default = secure, 250 ms cadence instead of 2 s), and the capturer edge-triggers the existing-but-unused proto-v6 IOCTL_SET_CURSOR_FORWARD flip: OFF at secure entry (the driver stops its per-assign re-declare; the host facade forces the same-mode re-commit that actualises the software-cursor default, under the vdisplay manager lock via the new force_recommit()), back ON at dismissal for channel sessions. Channel-session open resets the driver's persisted desired state to ON so a session that died mid-secure-desktop can't leave the next one adopting UNdeclared (the §8.6 cross-session composite trap); orderly teardown does the same. The flip closure is built for every Windows session — a channel-less session can reuse a driver monitor whose earlier-session cursor worker still re-declares, and NOT_FOUND from never-declared targets is ignored. Co-Authored-By: Claude Fable 5 --- crates/pf-capture/src/lib.rs | 12 ++ crates/pf-capture/src/windows/idd_push.rs | 103 +++++++++++++++++- .../src/windows/idd_push/cursor_poll.rs | 85 +++++++++++++-- .../src/vdisplay/windows/manager.rs | 15 +++ crates/punktfunk-host/src/capture.rs | 31 ++++++ 5 files changed, 234 insertions(+), 12 deletions(-) diff --git a/crates/pf-capture/src/lib.rs b/crates/pf-capture/src/lib.rs index abd046ee..3fc198d4 100644 --- a/crates/pf-capture/src/lib.rs +++ b/crates/pf-capture/src/lib.rs @@ -405,6 +405,16 @@ pub type CursorChannelSender = std::sync::Arc< dyn Fn(&pf_driver_proto::control::SetCursorChannelRequest) -> Result<()> + Send + Sync, >; +/// The mid-stream cursor-render flip (`IOCTL_SET_CURSOR_FORWARD`, proto v6) as a host-facade +/// closure — same contract as [`CursorChannelSender`]. `bool` = declare the IddCx hardware +/// cursor (`true`) or stand it down (`false`; the host facade additionally forces the same-mode +/// re-commit that actualises the OS's software-cursor default). The capturer drives this from +/// its secure-desktop watch: UAC/Winlogon render only through the software-cursor path, so a +/// path pinned to the hardware cursor never presents them (the 0.18.0 secure-desktop +/// regression). +#[cfg(target_os = "windows")] +pub type CursorForwardSender = std::sync::Arc Result<()> + Send + Sync>; + // One-time PipeWire library init, shared by the video (portal) and audio capture threads. #[cfg(target_os = "linux")] pub mod pwinit; @@ -491,6 +501,7 @@ pub fn open_idd_push( keepalive: Box, sender: FrameChannelSender, cursor_sender: Option, + cursor_forward: Option, ) -> std::result::Result, (anyhow::Error, Box)> { idd_push::IddPushCapturer::open( target, @@ -501,6 +512,7 @@ pub fn open_idd_push( keepalive, sender, cursor_sender, + cursor_forward, ) .map(|c| Box::new(c) as Box) } diff --git a/crates/pf-capture/src/windows/idd_push.rs b/crates/pf-capture/src/windows/idd_push.rs index 50941ca3..e4b06ed4 100644 --- a/crates/pf-capture/src/windows/idd_push.rs +++ b/crates/pf-capture/src/windows/idd_push.rs @@ -408,6 +408,16 @@ pub struct IddPushCapturer { /// shared slot) destroys the driver's cursor worker — the section here survives, so the /// channel is re-delivered on ring recreates. cursor_sender: Option, + /// The cursor-render flip sender (`IOCTL_SET_CURSOR_FORWARD`) — the secure-desktop guard's + /// actuator. UAC/Winlogon render only through the OS's software-cursor path (its default on + /// every mode commit); with our hardware cursor declared (and re-declared on every + /// swap-chain assign) that path never comes back, and the secure desktop never presents — + /// the stream freezes on the last normal-desktop frame for the whole UAC/lock interaction. + /// [`Self::poll_secure_desktop`] flips the declare off/on at the secure-desktop edges. + cursor_forward: Option, + /// The secure-desktop guard's edge state: `true` = the poller reports a secure input + /// desktop and the declare is currently stood down. + secure_active: bool, /// The CAPTURE mouse model is active — the HOST composites the pointer into the frame /// (see cursor_blend.rs for why DWM cannot: a declared IddCx hardware cursor is forever). composite_cursor: bool, @@ -657,7 +667,6 @@ impl IddPushCapturer { /// instead of tearing the display down (audit §5.1 — no more 20 s black bail). "Failure" includes the /// driver not attaching to the ring within a few seconds (e.g. a hybrid-GPU render mismatch). #[allow(clippy::too_many_arguments)] - #[allow(clippy::too_many_arguments)] pub fn open( target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>, @@ -667,6 +676,7 @@ impl IddPushCapturer { keepalive: Box, sender: crate::FrameChannelSender, cursor_sender: Option, + cursor_forward: Option, ) -> std::result::Result)> { // The stall-attribution listener (idempotent): started with the first IDD-push capturer so // the stall log can correlate DWM holes with OS display events for the session's lifetime. @@ -679,6 +689,7 @@ impl IddPushCapturer { pyrowave, sender, cursor_sender, + cursor_forward, ) { Ok(mut me) => { me._keepalive = keepalive; @@ -697,6 +708,7 @@ impl IddPushCapturer { pyrowave: bool, sender: crate::FrameChannelSender, cursor_sender: Option, + cursor_forward: Option, ) -> Result { // The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the // selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor @@ -720,6 +732,7 @@ impl IddPushCapturer { luid, sender.clone(), cursor_sender.clone(), + cursor_forward.clone(), ) { Ok(me) => Ok(me), Err(e) => { @@ -754,6 +767,7 @@ impl IddPushCapturer { drv, sender, cursor_sender, + cursor_forward, ) .context("IDD-push rebind to the driver's reported render adapter") } @@ -770,6 +784,7 @@ impl IddPushCapturer { luid: LUID, sender: crate::FrameChannelSender, cursor_sender: Option, + cursor_forward: Option, ) -> Result { let (pw, ph, _hz) = preferred .context("IDD push needs the negotiated mode (WxH) to size the shared ring")?; @@ -1046,6 +1061,17 @@ impl IddPushCapturer { .unwrap_or((0, 0, i32::MAX, i32::MAX)); cursor_poll::CursorPoller::spawn(target.target_id, rect) }); + // Heal the driver's persisted cursor-forward state: a session that died on the + // secure desktop (client drops at the lock screen — the common case) leaves the + // per-target desired state `false`, and the NEXT session's channel delivery would + // adopt UNdeclared (the exact cross-session composite trap of §8.6). A fresh + // session always starts declared; the secure-desktop guard re-disables if the + // secure desktop is (still) up, via its first `poll_secure_desktop` edge. + if let (Some(_), Some(fwd)) = (cursor_shared.as_ref(), cursor_forward.as_ref()) { + if let Err(e) = fwd(true) { + tracing::debug!("cursor-forward reset at open failed (pre-v6 driver?): {e:#}"); + } + } tracing::info!( target_id = target.target_id, @@ -1108,6 +1134,8 @@ impl IddPushCapturer { cursor_shared, cursor_poll, cursor_sender, + cursor_forward, + secure_active: false, composite_cursor: composite_forced, composite_forced, cursor_blend: None, @@ -1883,8 +1911,71 @@ impl IddPushCapturer { Some((tex, srv)) } + /// The secure-desktop guard (the 0.18.0 UAC/Winlogon regression). UAC consent and Winlogon + /// live on the SECURE desktop, which the OS renders through the software-cursor path — its + /// per-mode-commit default. With this session's IddCx hardware cursor declared (and + /// re-declared by the driver on every swap-chain assign), that path never materialises, the + /// secure desktop never presents into our swap-chain, and the stream freezes on the last + /// normal-desktop frame for the entire UAC/lock interaction. On the poller's secure edge: + /// stand the declare down (`SET_CURSOR_FORWARD` off — the driver stops its per-assign + /// re-declare — plus the host facade's forced same-mode re-commit that actualises the + /// software cursor); on dismissal, re-declare. Runs on the capture/encode thread every tick + /// (it must keep running while frames are stalled — that is exactly the state it exits). + fn poll_secure_desktop(&mut self) { + let Some(fwd) = self.cursor_forward.as_ref() else { + return; + }; + // Sessions with a declare possibly in play: the channel session that declared it, and + // the forced-composite session whose (reused) driver monitor may still run an earlier + // session's cursor worker. A plain session on a clean target has no poller — no guard. + if self.cursor_shared.is_none() && !self.composite_forced { + return; + } + let secure = self + .cursor_poll + .as_ref() + .is_some_and(|p| p.secure_desktop()); + if secure == self.secure_active { + return; + } + self.secure_active = secure; + if secure { + tracing::info!( + target_id = self.target_id, + "secure desktop (UAC/Winlogon) active — standing the IddCx hardware-cursor \ + declare down so the OS software-cursor path can render it" + ); + if let Err(e) = fwd(false) { + tracing::warn!( + "secure-desktop cursor-forward stand-down failed (secure content may stay \ + invisible this session): {e:#}" + ); + } + } else { + tracing::info!( + target_id = self.target_id, + "secure desktop dismissed — restoring the cursor render model" + ); + // Re-declare only for the session that RUNS the cursor channel; a forced-composite + // session never wanted the declare (leaving the driver's desired state off also + // stops a reused worker's per-assign re-declares for good — the next channel + // session's open-time reset re-arms it). + if self.cursor_shared.is_some() { + if let Err(e) = fwd(true) { + tracing::warn!( + "secure-desktop cursor-forward re-enable failed (client-drawn cursor \ + may double with a composited one): {e:#}" + ); + } + } + } + } + fn try_consume(&mut self) -> Result> { self.log_driver_status_once(); + // The secure-desktop guard first: while UAC/Winlogon is up there may be NO fresh frames + // at all — this edge is what brings them back. + self.poll_secure_desktop(); // Follow the display: a "Use HDR" flip recreates the ring at the matching format. self.poll_display_hdr(); // Recover-or-drop (GB1): if a descriptor change triggered a recreate but no fresh frame has resumed @@ -2486,6 +2577,16 @@ fn warn_444_hdr_downgrade_once() { impl Drop for IddPushCapturer { fn drop(&mut self) { + // A channel session ending while the secure-desktop guard is engaged must not leave the + // driver's per-target desired state off — the next session's channel delivery would + // adopt UNdeclared and silently run the composite model (§8.6's cross-session trap). + // The open-time reset also covers this (host-crash case); this is the orderly-teardown + // belt. + if self.secure_active && self.cursor_shared.is_some() { + if let Some(fwd) = self.cursor_forward.as_ref() { + let _ = fwd(true); + } + } self.slots.clear(); // The shared header section (`MappedSection`), the frame-ready `event` (`OwnedHandle`) and the // broker's WUDFHost process handle free themselves via RAII (unmap view, then close handle) — diff --git a/crates/pf-capture/src/windows/idd_push/cursor_poll.rs b/crates/pf-capture/src/windows/idd_push/cursor_poll.rs index b393d70f..8d9651bc 100644 --- a/crates/pf-capture/src/windows/idd_push/cursor_poll.rs +++ b/crates/pf-capture/src/windows/idd_push/cursor_poll.rs @@ -29,8 +29,8 @@ use windows::Win32::Graphics::Gdi::{ BI_RGB, DIB_RGB_COLORS, HBITMAP, HDC, }; use windows::Win32::System::StationsAndDesktops::{ - CloseDesktop, OpenInputDesktop, SetThreadDesktop, DESKTOP_ACCESS_FLAGS, DESKTOP_CONTROL_FLAGS, - HDESK, + CloseDesktop, GetUserObjectInformationW, OpenInputDesktop, SetThreadDesktop, + DESKTOP_ACCESS_FLAGS, DESKTOP_CONTROL_FLAGS, HDESK, UOI_NAME, }; use windows::Win32::UI::HiDpi::{ SetThreadDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, @@ -63,6 +63,11 @@ struct Shape { pub(super) struct CursorPoller { slot: Arc>>, stop: Arc, + /// The input desktop is a SECURE desktop (Winlogon — UAC consent / lock / logon). Classified + /// on every reattach; the capturer polls it to stand the IddCx hardware-cursor declare down + /// while the secure desktop needs the software-cursor path to render (see + /// `IddPushCapturer::poll_secure_desktop`). + secure: Arc, thread: Option>, } @@ -75,7 +80,11 @@ impl CursorPoller { const INTERVAL: Duration = Duration::from_millis(4); /// Unconditional input-desktop reattach cadence — catches secure-desktop (UAC/lock) switches /// without a failure signal (`GetCursorInfo` on a stale desktop *succeeds* with stale data). - const REATTACH: Duration = Duration::from_secs(2); + /// 250 ms, not the original 2 s: the reattach now also feeds [`Self::secure_desktop`], which + /// gates when the secure desktop becomes VISIBLE in the stream (the hardware-cursor + /// stand-down) — a 2 s freeze at every UAC prompt is user-visible, ~4 `OpenInputDesktop` + /// syscalls/s are not. + const REATTACH: Duration = Duration::from_millis(250); /// Spawn the poller for the virtual display `target_id`. `rect` = the target's desktop rect /// (`source_desktop_rect` order: x, y, w, h) — cursor positions are desktop-global; the @@ -84,15 +93,21 @@ impl CursorPoller { pub(super) fn spawn(target_id: u32, rect: (i32, i32, i32, i32)) -> Self { let slot: Arc>> = Arc::new(Mutex::new(None)); let stop = Arc::new(AtomicBool::new(false)); - let (slot_t, stop_t) = (slot.clone(), stop.clone()); + let secure = Arc::new(AtomicBool::new(false)); + let (slot_t, stop_t, secure_t) = (slot.clone(), stop.clone(), secure.clone()); let thread = std::thread::Builder::new() .name("pf-cursor-poll".into()) - .spawn(move || run(target_id, rect, &slot_t, &stop_t)) + .spawn(move || run(target_id, rect, &slot_t, &stop_t, &secure_t)) .ok(); if thread.is_none() { tracing::warn!("cursor poller thread spawn failed — cursor falls back to driver shm"); } - Self { slot, stop, thread } + Self { + slot, + stop, + secure, + thread, + } } /// The latest overlay snapshot (`None` until the first successful shape rasterisation). @@ -100,6 +115,12 @@ impl CursorPoller { self.slot.lock().unwrap_or_else(|p| p.into_inner()).clone() } + /// Whether the input desktop is currently a SECURE desktop (UAC consent / Winlogon lock or + /// logon). Latched by the poll thread on its reattach cadence (≤ [`Self::REATTACH`] stale). + pub(super) fn secure_desktop(&self) -> bool { + self.secure.load(Ordering::Relaxed) + } + /// Whether the worker thread is (still) alive — `false` degrades the capturer to the shm read. pub(super) fn alive(&self) -> bool { self.thread.as_ref().is_some_and(|t| !t.is_finished()) @@ -121,6 +142,7 @@ fn run( rect: (i32, i32, i32, i32), slot: &Mutex>, stop: &AtomicBool, + secure: &AtomicBool, ) { // Physical-pixel coordinates on this thread regardless of the process's DPI awareness: // `rect` comes from CCD (always physical), and a DPI-virtualized `GetCursorInfo` position @@ -130,7 +152,8 @@ fn run( let _ = unsafe { SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) }; let mut desktop = DesktopBinding::default(); - desktop.reattach(); // best-effort: already on winsta0\default if this fails + // best-effort: already on winsta0\default if this fails + publish_secure(secure, desktop.reattach()); let mut last_attach = Instant::now(); let mut shape: Option = None; @@ -143,7 +166,7 @@ fn run( std::thread::sleep(CursorPoller::INTERVAL); if last_attach.elapsed() >= CursorPoller::REATTACH { last_attach = Instant::now(); - desktop.reattach(); + publish_secure(secure, desktop.reattach()); } let mut ci = CURSORINFO { @@ -155,7 +178,7 @@ fn run( if unsafe { GetCursorInfo(&mut ci) }.is_err() { // Desktop went away under us (secure-desktop switch mid-call) — rebind and retry // next tick; the slot keeps its last snapshot meanwhile. - desktop.reattach(); + publish_secure(secure, desktop.reattach()); last_attach = Instant::now(); continue; } @@ -218,13 +241,25 @@ fn run( } } +/// Store a reattach's secure-desktop verdict (`None` = classification unavailable — keep the +/// previous state rather than flapping the capturer's hardware-cursor stand-down). +fn publish_secure(secure: &AtomicBool, verdict: Option) { + if let Some(s) = verdict { + secure.store(s, Ordering::Relaxed); + } +} + /// The thread's owned input-desktop handle — the [`SendInputInjector`] reattach model /// (`pf-inject` sendinput.rs): keep the current binding, swap on demand, close exactly once. #[derive(Default)] struct DesktopBinding(Option); impl DesktopBinding { - fn reattach(&mut self) { + /// Rebind to the CURRENT input desktop. Returns whether that desktop is a SECURE one + /// (`UOI_NAME` != "Default": "Winlogon" during UAC consent / lock / logon) — `None` when the + /// input desktop could not be opened, in which case the binding (and the caller's secure + /// state) stays put. + fn reattach(&mut self) -> Option { const GENERIC_ALL: u32 = 0x1000_0000; // SAFETY: `OpenInputDesktop`/`SetThreadDesktop`/`CloseDesktop` take only by-value args. // `OpenInputDesktop` yields an owned `HDESK` only on `Ok`; it is either installed (and the @@ -238,6 +273,7 @@ impl DesktopBinding { DESKTOP_ACCESS_FLAGS(GENERIC_ALL), ) { Ok(h) => { + let secure = desktop_is_secure(h); if SetThreadDesktop(h).is_ok() { if let Some(old) = self.0.replace(h) { let _ = CloseDesktop(old); @@ -245,13 +281,40 @@ impl DesktopBinding { } else { let _ = CloseDesktop(h); } + Some(secure) } - Err(_) => { /* not privileged for this desktop; stay put */ } + Err(_) => None, // not privileged for this desktop; stay put } } } } +/// `UOI_NAME` of `h` != "Default" — i.e. the input desktop is Winlogon (UAC consent / lock / +/// logon) or a screen-saver desktop, both of which need the OS's software-cursor render path. +/// Unnameable desktops read as NOT secure: the only in-contract failure is a too-small buffer, +/// and misreading secure-as-normal merely keeps today's behavior for a beat. +fn desktop_is_secure(h: HDESK) -> bool { + let mut name = [0u16; 64]; // "Default"/"Winlogon"/"Screen-saver" all fit with room to spare + let mut needed = 0u32; + // SAFETY: `h` is the live desktop handle the caller just opened; `name`/`needed` are live + // out-params sized exactly as passed; the call writes at most `nlength` bytes. + let ok = unsafe { + GetUserObjectInformationW( + windows::Win32::Foundation::HANDLE(h.0), + UOI_NAME, + Some(name.as_mut_ptr().cast()), + (name.len() * 2) as u32, + Some(&mut needed), + ) + }; + if ok.is_err() { + return false; + } + let len = name.iter().position(|&c| c == 0).unwrap_or(name.len()); + let name = String::from_utf16_lossy(&name[..len]); + !name.eq_ignore_ascii_case("Default") +} + impl Drop for DesktopBinding { fn drop(&mut self) { if let Some(h) = self.0.take() { diff --git a/crates/pf-vdisplay/src/vdisplay/windows/manager.rs b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs index e41f2a56..def7f9b8 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/manager.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs @@ -300,6 +300,21 @@ pub fn control_device_handle() -> Option { VDM.get().and_then(VirtualDisplayManager::device_handle) } +/// Re-commit the CURRENT display config under the manager `state` lock (the sole-topology-mutator +/// contract of [`force_mode_reenumeration`]). The secure-desktop guard's actuator: the OS only +/// reverts a path to its software-cursor default ON a mode commit, so standing the hardware-cursor +/// declare down (`IOCTL_SET_CURSOR_FORWARD` off) needs this nudge for UAC/Winlogon to actually +/// render. `false` (no-op) before the first backend open — no monitors exist to re-commit for. +pub fn force_recommit() -> bool { + let Some(m) = VDM.get() else { + return false; + }; + let _guard = m.state.lock().unwrap(); + // SAFETY: `force_mode_reenumeration`'s contract is "call under the manager `state` lock"; + // held above. The call reads + re-applies the current CCD config over owned locals. + unsafe { pf_win_display::win_display::force_mode_reenumeration() } +} + /// Best-effort "is this WUDFHost pid still alive?" — the monitor-liveness probe for the JOIN path. /// `OpenProcess` failing (pid reaped) or the process being signaled ⇒ dead. Pid reuse could /// theoretically alias a fresh process and read "alive"; the joining session then just retries into diff --git a/crates/punktfunk-host/src/capture.rs b/crates/punktfunk-host/src/capture.rs index a0af452c..04205be2 100644 --- a/crates/punktfunk-host/src/capture.rs +++ b/crates/punktfunk-host/src/capture.rs @@ -175,6 +175,36 @@ pub fn capture_virtual_output( }, ) as pf_capture::CursorChannelSender }); + // The secure-desktop guard's actuator (`IOCTL_SET_CURSOR_FORWARD`): the capturer flips the + // driver's hardware-cursor declare off while UAC/Winlogon is up (the secure desktop renders + // only through the OS's software-cursor path) and back on at dismissal. The stand-down needs + // the same-mode re-commit that actualises the software-cursor default — driven here because + // topology commits belong under the vdisplay manager's lock, which pf-capture cannot take. + // Built for EVERY session (not just `want.hw_cursor`): a channel-less session can reuse a + // driver monitor whose cursor worker (an earlier session's) is still live and re-declaring — + // the flip is the only way to stop it; on a never-declared target the driver answers + // NOT_FOUND, which the capturer logs and ignores. + let target_id = target.target_id; + let cursor_forward: Option = Some({ + std::sync::Arc::new(move |enable: bool| { + let req = pf_driver_proto::control::SetCursorForwardRequest { + target_id, + enable: enable as u32, + }; + // SAFETY: `control_raw` is the pf-vdisplay control handle resolved above; it is + // never closed for the process lifetime (`send_cursor_forward`'s precondition). + unsafe { + crate::vdisplay::driver::send_cursor_forward( + windows::Win32::Foundation::HANDLE(control_raw as *mut core::ffi::c_void), + &req, + )?; + } + if !enable { + crate::vdisplay::manager::force_recommit(); + } + Ok(()) + }) as pf_capture::CursorForwardSender + }); pf_capture::open_idd_push( target, pref, @@ -184,6 +214,7 @@ pub fn capture_virtual_output( keep, sender, cursor_sender, + cursor_forward, ) .map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)")) }