From 24a24734eb779b768234c1bc10d1e24b2d89ec36 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 24 Jul 2026 00:28:05 +0200 Subject: [PATCH] fix(inject/windows): follow the input desktop for pen + touch injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same field report as the display-write fix, other half of the symptom: with a UAC consent prompt up — one the user could SEE in the stream, because capture already renders the secure desktop (326d6e17) — pen and touch did nothing, while mouse and keyboard kept working. The split was exactly which paths knew the input desktop can move. sendinput.rs reattaches, so mouse/keyboard reached the prompt. pointer_windows.rs had no desktop handling at all, so every InjectSyntheticPointerInput came back ERROR_ACCESS_DENIED: 22:20:31 virtual pen created (PT_PEN) 22:20:31 pen inject failed error=Zugriff verweigert (0x80070005) 22:20:42 touch inject failed error=Zugriff verweigert (0x80070005) contacts=1 Measured on glass before fixing, to find out what it actually takes: device on Default, thread on Default -> 0x80070005 (the field failure) device on Default, thread on INPUT -> OK device on INPUT, thread on INPUT -> OK The middle row is load-bearing: the synthetic pointer device is NOT desktop-affine, so rebinding the thread suffices and the device is never destroyed and recreated across a desktop switch — which would have dropped in-flight contacts and the pen's in-range state mid-stroke. Injection now retries once bound to the input desktop. The binding is scoped, not persistent like sendinput's: inject_pen/inject_touch_frame run on TWO threads (the caller's apply_batch and the refresh threads), and the batch caller is a shared task thread that must not be left parked on a Winlogon desktop that disappears when the prompt is dismissed. The first-failure WARN now carries the rejected sample (flags, pen flags/mask, pressure, rotation, tilt, position). A 0x80070057 INVALID_PARAMETER was seen once BEFORE any prompt existed and is still unexplained; the ranges all look sound on inspection (coordinates clamped to the virtual screen, roll/azimuth u16 so the modulo cannot go negative, tilt bounded 0..=90), so catching the actual offending sample is the way to find it. Verified on glass: with a consent prompt up, pen and touch now reach it — zero inject failures where every prior session failed immediately. A UAC prompt can now be dismissed from an iPad with the Pencil. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/inject/windows/pointer_windows.rs | 118 +++++++++++++++++- 1 file changed, 115 insertions(+), 3 deletions(-) diff --git a/crates/pf-inject/src/inject/windows/pointer_windows.rs b/crates/pf-inject/src/inject/windows/pointer_windows.rs index aa038492..5133d588 100644 --- a/crates/pf-inject/src/inject/windows/pointer_windows.rs +++ b/crates/pf-inject/src/inject/windows/pointer_windows.rs @@ -26,6 +26,11 @@ use punktfunk_core::quic::{ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use windows::Win32::Foundation::POINT; +use windows::Win32::System::StationsAndDesktops::{ + CloseDesktop, GetThreadDesktop, OpenInputDesktop, SetThreadDesktop, DESKTOP_ACCESS_FLAGS, + DESKTOP_CONTROL_FLAGS, HDESK, +}; +use windows::Win32::System::Threading::GetCurrentThreadId; use windows::Win32::UI::Controls::{ CreateSyntheticPointerDevice, DestroySyntheticPointerDevice, HSYNTHETICPOINTERDEVICE, POINTER_FEEDBACK_DEFAULT, POINTER_TYPE_INFO, POINTER_TYPE_INFO_0, @@ -46,6 +51,101 @@ use windows::Win32::UI::WindowsAndMessaging::{ /// 50 ms against the ~100 ms staleness window; 40 ms keeps two refreshes inside it. const REFRESH_MS: u64 = 40; +/// `GENERIC_ALL` for the desktop open — the `windows` crate models desktop rights as their own +/// flag type and doesn't re-export the generic ones (same constant `sendinput.rs` uses). +const DESKTOP_GENERIC_ALL: u32 = 0x1000_0000; + +/// This thread's binding to the input desktop, restored on drop. +/// +/// `SendInput` (`sendinput.rs`) solves the same problem by binding its DEDICATED injector thread +/// once and keeping it. That can't be borrowed here: `inject_pen`/`inject_touch_frame` are driven +/// from TWO threads — the caller's `apply_batch` and the `pf-pen-refresh`/`pf-touch-refresh` +/// staleness threads — and the batch caller is a shared task thread, which must not be left parked +/// on a `Winlogon` desktop that disappears when the prompt is dismissed. So the binding is scoped +/// to the retry: `GetThreadDesktop` hands back a BORROWED handle (never closed), and only the +/// `OpenInputDesktop` handle is closed, after the thread has moved back off it. +struct InputDesktopBinding { + previous: HDESK, + input: HDESK, +} + +impl InputDesktopBinding { + fn bind() -> Option { + // SAFETY: FFI calls taking by-value args only. `OpenInputDesktop` yields an owned `HDESK` + // solely on `Ok`; it is either installed by `SetThreadDesktop` (then owned by this guard, + // closed exactly once in `Drop`) or closed here on failure — closed once on every path, + // never used after. `SetThreadDesktop` rebinds only the calling thread, which owns no + // windows or hooks, so it cannot fail on that account. + unsafe { + let previous = GetThreadDesktop(GetCurrentThreadId()).ok()?; + let input = OpenInputDesktop( + DESKTOP_CONTROL_FLAGS(0), + false, + DESKTOP_ACCESS_FLAGS(DESKTOP_GENERIC_ALL), + ) + .ok()?; + if SetThreadDesktop(input).is_err() { + let _ = CloseDesktop(input); + return None; + } + Some(Self { previous, input }) + } + } +} + +impl Drop for InputDesktopBinding { + fn drop(&mut self) { + // SAFETY: `previous` is the borrowed desktop this thread started on, `input` the handle + // this guard uniquely owns. The thread moves back FIRST, so the handle is not the thread's + // desktop when closed — closed exactly once, never used after. + unsafe { + let _ = SetThreadDesktop(self.previous); + let _ = CloseDesktop(self.input); + } + } +} + +/// Inject one synthetic-pointer frame, following the input desktop if Windows refuses it. +/// +/// While a UAC consent prompt (or the lock / logon screen) owns input, the secure desktop does, and +/// injection from the host's own `WinSta0\Default` thread comes back `ERROR_ACCESS_DENIED` — which +/// is why pen and touch went dead on a prompt a user could SEE in the stream (capture already +/// renders it, and `SendInput` mouse/keyboard already followed the switch, so only these two were +/// left behind). Field-reported 2026-07-23; the exact rc reproduced in a probe the same night. +/// +/// Measured then, with a real consent prompt up and a SYSTEM host in the console session: +/// +/// ```text +/// device created on Default, thread on Default -> 0x80070005 (the field failure) +/// device created on Default, thread on INPUT desktop -> OK +/// device created on INPUT, thread on INPUT desktop -> OK +/// ``` +/// +/// The middle row is the load-bearing one: the synthetic pointer device is NOT desktop-affine, so +/// rebinding the THREAD is sufficient and the device never has to be destroyed and recreated +/// across a desktop switch (which would drop in-flight contacts and the pen's in-range state). +/// +/// # Safety +/// `dev` must be a live synthetic-pointer device and `frame` a live slice for the call. +unsafe fn inject_following_desktop( + dev: HSYNTHETICPOINTERDEVICE, + frame: &[POINTER_TYPE_INFO], +) -> windows::core::Result<()> { + // SAFETY: per this fn's contract — `dev` is live and `frame` outlives the call, which only + // reads it. Best-effort, exactly as the direct call was. + match InjectSyntheticPointerInput(dev, frame) { + Ok(()) => Ok(()), + Err(first) => { + // Only a desktop switch is worth a rebind; anything else would just fail identically. + let Some(_binding) = InputDesktopBinding::bind() else { + return Err(first); + }; + // SAFETY: same live `dev`/`frame`, re-issued with this thread on the input desktop. + InjectSyntheticPointerInput(dev, frame) + } + } +} + /// Windows pen pressure is 0..1024 (vs the wire's full-scale u16). const WIN_PEN_PRESSURE_MAX: u32 = 1024; @@ -340,10 +440,22 @@ fn inject_pen(sh: &mut PenShared, edge: POINTER_FLAGS, is_new: bool) { // SAFETY: `sh.dev.0` is the live device this wrapper owns; the one-element array is a live // stack value the call only reads. Best-effort like every injector write — a transient // failure (desktop switch) is healed by the next refresh tick re-asserting state. - if let Err(e) = unsafe { InjectSyntheticPointerInput(sh.dev.0, &[info]) } { + if let Err(e) = unsafe { inject_following_desktop(sh.dev.0, &[info]) } { if !sh.fail_warned { sh.fail_warned = true; - tracing::warn!(error = %e, "pen inject failed"); + tracing::warn!( + error = %e, + flags = format!("{:#x}", flags.0), + pen_flags = format!("{pen_flags:#x}"), + pen_mask = format!("{pen_mask:#x}"), + pressure, + rotation, + tilt_x, + tilt_y, + x = pt.x, + y = pt.y, + "pen inject failed" + ); } else { tracing::trace!(error = %e, "pen inject failed (transient)"); } @@ -543,7 +655,7 @@ fn inject_touch_frame(sh: &mut TouchShared, edge: Option<(u32, POINTER_FLAGS)>) } // SAFETY: `sh.dev.0` is the live owned device; `frame` is a live Vec the call only reads. // Best-effort — a transient failure heals on the next event/refresh re-assertion. - if let Err(e) = unsafe { InjectSyntheticPointerInput(sh.dev.0, &frame) } { + if let Err(e) = unsafe { inject_following_desktop(sh.dev.0, &frame) } { if !sh.fail_warned { sh.fail_warned = true; tracing::warn!(error = %e, contacts = frame.len(), "touch inject failed");