fix(inject/windows): follow the input desktop for pen + touch injection
apple / swift (push) Successful in 1m16s
apple / screenshots (push) Successful in 6m43s
android / android (push) Failing after 7m44s
ci / web (push) Successful in 2m3s
ci / docs-site (push) Successful in 1m14s
arch / build-publish (push) Successful in 12m50s
ci / bench (push) Successful in 6m30s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
deb / build-publish (push) Successful in 9m13s
ci / rust (push) Successful in 19m44s
deb / build-publish-host (push) Successful in 9m30s
docker / deploy-docs (push) Successful in 26s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 25m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 25m10s
apple / swift (push) Successful in 1m16s
apple / screenshots (push) Successful in 6m43s
android / android (push) Failing after 7m44s
ci / web (push) Successful in 2m3s
ci / docs-site (push) Successful in 1m14s
arch / build-publish (push) Successful in 12m50s
ci / bench (push) Successful in 6m30s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
deb / build-publish (push) Successful in 9m13s
ci / rust (push) Successful in 19m44s
deb / build-publish-host (push) Successful in 9m30s
docker / deploy-docs (push) Successful in 26s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 25m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 25m10s
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Self> {
|
||||
// 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");
|
||||
|
||||
Reference in New Issue
Block a user