From 45c9799aa6d529323879616217c9eaaa6c7deb67 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 23 Jul 2026 15:25:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(inject/windows):=20pen=20P3=20=E2=80=94=20?= =?UTF-8?q?PT=5FPEN=20+=20PT=5FTOUCH=20synthetic=20pointer=20injection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows leg of design/pen-tablet-input.md §6, following Apollo's recipe: a per-session PT_PEN device (pressure rescaled 0..1024, polar tilt→tiltX/Y, barrel roll on rotation — Windows Ink renders Pencil Pro roll natively, barrel button, eraser via INVERTED/ERASER flags, hover) with a 40ms refresh thread against the ~100ms synthetic-pointer staleness auto-lift, plus a PT_TOUCH device closing the historical SendInput wire-touch no-op (full active-contact frames, per-id DOWN/UP edges, self-healing lost-DOWN synthesis). pen_supported now probes PT_PEN creation on Windows (1809+), which lights up HOST_CAP_PEN and the GameStream featureFlags there — Moonlight iPad + Pencil can ink on a Windows host. Frame grouping mirrors the Linux uinput backend. Co-Authored-By: Claude Fable 5 --- crates/pf-inject/Cargo.toml | 5 + .../src/inject/windows/pointer_windows.rs | 509 ++++++++++++++++++ .../pf-inject/src/inject/windows/sendinput.rs | 39 +- crates/pf-inject/src/lib.rs | 26 +- 4 files changed, 569 insertions(+), 10 deletions(-) create mode 100644 crates/pf-inject/src/inject/windows/pointer_windows.rs diff --git a/crates/pf-inject/Cargo.toml b/crates/pf-inject/Cargo.toml index 83ee194d..2da67618 100644 --- a/crates/pf-inject/Cargo.toml +++ b/crates/pf-inject/Cargo.toml @@ -61,5 +61,10 @@ windows = { version = "0.62", features = [ "Win32_System_StationsAndDesktops", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", + # Synthetic pointer devices (PT_PEN / PT_TOUCH) — the pen/touch injectors. The device + # create/destroy + POINTER_TYPE_INFO live under Controls in this metadata layout; + # InjectSyntheticPointerInput + POINTER_*_INFO under Input_Pointer. + "Win32_UI_Controls", + "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging", ] } diff --git a/crates/pf-inject/src/inject/windows/pointer_windows.rs b/crates/pf-inject/src/inject/windows/pointer_windows.rs new file mode 100644 index 00000000..51a4d88e --- /dev/null +++ b/crates/pf-inject/src/inject/windows/pointer_windows.rs @@ -0,0 +1,509 @@ +//! Windows synthetic-pointer injection (design/pen-tablet-input.md §6): a per-session `PT_PEN` +//! device carrying the pen plane's full fidelity — pressure (rescaled to Windows' 0..1024), +//! polar tilt → tiltX/tiltY, barrel roll on `rotation` (0..359 — Windows Ink renders Pencil +//! Pro roll natively), barrel button, eraser (`PEN_FLAG_INVERTED`/`ERASER`), hover — plus a +//! `PT_TOUCH` device that closes the long-standing SendInput touch no-op for wire touches. +//! +//! Both follow Apollo's proven recipe (`design/apollo-comparison.md`): synthetic pointer state +//! goes STALE if not re-injected (~100 ms), so each device runs a small refresh thread that +//! re-asserts the last frame every [`REFRESH_MS`] while the pen is in range / contacts are +//! held — a stationary stylus must not hover-out (and a held finger must not auto-lift) just +//! because no new samples arrived. `CreateSyntheticPointerDevice` needs Win10 1809+; +//! [`crate::pen_supported`] probes it, so older hosts simply never advertise pen. +//! +//! Frame grouping mirrors the Linux uinput backend: a proximity-enter is injected together +//! with its position (never at a stale point), tip edges get their own DOWN/UP frames, and a +//! range-leave is a final frame without `INRANGE`. + +// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it. +#![deny(clippy::undocumented_unsafe_blocks)] + +use anyhow::{Context, Result}; +use punktfunk_core::input::{InputEvent, InputKind}; +use punktfunk_core::quic::{ + PenSample, PenTool, PenTransition, PEN_ANGLE_UNKNOWN, PEN_BARREL1, PEN_TILT_UNKNOWN, +}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use windows::Win32::Foundation::POINT; +use windows::Win32::UI::Controls::{ + CreateSyntheticPointerDevice, DestroySyntheticPointerDevice, HSYNTHETICPOINTERDEVICE, + POINTER_FEEDBACK_DEFAULT, POINTER_TYPE_INFO, POINTER_TYPE_INFO_0, +}; +use windows::Win32::UI::Input::Pointer::{ + InjectSyntheticPointerInput, POINTER_FLAGS, POINTER_FLAG_DOWN, POINTER_FLAG_FIRSTBUTTON, + POINTER_FLAG_INCONTACT, POINTER_FLAG_INRANGE, POINTER_FLAG_NEW, POINTER_FLAG_UP, + POINTER_FLAG_UPDATE, POINTER_INFO, POINTER_PEN_INFO, POINTER_TOUCH_INFO, +}; +use windows::Win32::UI::WindowsAndMessaging::{ + GetSystemMetrics, PEN_FLAG_BARREL, PEN_FLAG_ERASER, PEN_FLAG_INVERTED, PEN_FLAG_NONE, + PEN_MASK_PRESSURE, PEN_MASK_ROTATION, PEN_MASK_TILT_X, PEN_MASK_TILT_Y, PT_PEN, PT_TOUCH, + SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, TOUCH_FLAG_NONE, + TOUCH_MASK_NONE, +}; + +/// Re-inject cadence while state is held (in-range pen / touching contacts). Apollo uses +/// 50 ms against the ~100 ms staleness window; 40 ms keeps two refreshes inside it. +const REFRESH_MS: u64 = 40; + +/// Windows pen pressure is 0..1024 (vs the wire's full-scale u16). +const WIN_PEN_PRESSURE_MAX: u32 = 1024; + +/// Map a normalized [0,1] coordinate pair onto virtual-desktop pixels — the same surface the +/// SendInput absolute mouse targets, so pen, touch, and pointer all land identically. +fn to_screen(x: f32, y: f32) -> POINT { + // SAFETY: `GetSystemMetrics` takes a constant index and reads global metrics; no memory in. + let (vx, vy, vw, vh) = unsafe { + ( + GetSystemMetrics(SM_XVIRTUALSCREEN), + GetSystemMetrics(SM_YVIRTUALSCREEN), + GetSystemMetrics(SM_CXVIRTUALSCREEN).max(1), + GetSystemMetrics(SM_CYVIRTUALSCREEN).max(1), + ) + }; + POINT { + x: vx + (x.clamp(0.0, 1.0) * (vw - 1) as f32) as i32, + y: vy + (y.clamp(0.0, 1.0) * (vh - 1) as f32) as i32, + } +} + +/// An owned `HSYNTHETICPOINTERDEVICE` (destroyed exactly once on drop). +struct Device(HSYNTHETICPOINTERDEVICE); + +// SAFETY: the handle is a plain kernel object identifier with no thread affinity — +// `InjectSyntheticPointerInput`/`DestroySyntheticPointerDevice` are documented callable from +// any thread; ownership transfer/sharing does not alias memory. +unsafe impl Send for Device {} + +impl Drop for Device { + fn drop(&mut self) { + // SAFETY: `self.0` is the device this wrapper uniquely owns; destroyed once here. + unsafe { DestroySyntheticPointerDevice(self.0) }; + } +} + +/// Probe: can this Windows build create a synthetic pen device (Win10 1809+)? +pub fn synthetic_pen_available() -> bool { + // SAFETY: FFI create with by-value args; on success the returned handle is destroyed + // immediately by the `Device` wrapper, exactly once. + match unsafe { CreateSyntheticPointerDevice(PT_PEN, 1, POINTER_FEEDBACK_DEFAULT) } { + Ok(h) => { + drop(Device(h)); + true + } + Err(_) => false, + } +} + +/// The tracked pen state a refresh tick re-asserts. +#[derive(Default)] +struct PenState { + in_range: bool, + touching: bool, + barrel: bool, + eraser: bool, + x: f32, + y: f32, + pressure: u16, + tilt_deg: u8, + azimuth_deg: u16, + roll_deg: u16, +} + +struct PenShared { + dev: Device, + state: PenState, +} + +/// One per-session virtual pen (the Windows sibling of the Linux uinput tablet — same +/// [`PenTransition`] consumer API). Wire barrel button 2 has no Windows pen equivalent +/// (one barrel + eraser is the platform model) and is ignored here. +pub struct VirtualPen { + shared: Arc>, + stop: Arc, + refresher: Option>, + // Batch-local frame grouping (single-threaded within apply_batch). + edge_down: bool, + edge_up: bool, + is_new: bool, + frame_dirty: bool, + frame_has_motion: bool, +} + +impl VirtualPen { + pub fn create() -> Result { + // SAFETY: FFI create with by-value args; the handle's sole owner becomes `Device`. + let dev = unsafe { CreateSyntheticPointerDevice(PT_PEN, 1, POINTER_FEEDBACK_DEFAULT) } + .context("CreateSyntheticPointerDevice(PT_PEN) — needs Windows 10 1809+")?; + let shared = Arc::new(Mutex::new(PenShared { + dev: Device(dev), + state: PenState::default(), + })); + let stop = Arc::new(AtomicBool::new(false)); + // The staleness guard: re-assert the last frame while in range so a stationary pen + // (native plane: between 100 ms heartbeats; GameStream: indefinitely) never hovers out. + let refresher = { + let shared = Arc::clone(&shared); + let stop = Arc::clone(&stop); + std::thread::Builder::new() + .name("pf-pen-refresh".into()) + .spawn(move || { + while !stop.load(Ordering::Relaxed) { + std::thread::sleep(std::time::Duration::from_millis(REFRESH_MS)); + let s = shared.lock().unwrap(); + if s.state.in_range { + inject_pen(&s.dev, &s.state, POINTER_FLAG_UPDATE, false); + } + } + }) + .context("spawn pen refresh thread")? + }; + tracing::info!("virtual pen created (Windows synthetic pointer, PT_PEN)"); + Ok(VirtualPen { + shared, + stop, + refresher: Some(refresher), + edge_down: false, + edge_up: false, + is_new: false, + frame_dirty: false, + frame_has_motion: false, + }) + } + + /// Apply one batch of tracker transitions — same grouping contract as the Linux backend: + /// `[ProxIn, Motion, TipDown]` is ONE frame (entry lands at its position, in contact), + /// consecutive `Motion`s split, tip edges own their frames, range-leave is a final + /// no-`INRANGE` frame. + pub fn apply_batch(&mut self, transitions: &[PenTransition]) { + for t in transitions { + match t { + PenTransition::ProximityIn { tool } => { + self.flush(); + let mut s = self.shared.lock().unwrap(); + s.state.in_range = true; + s.state.eraser = *tool == PenTool::Eraser; + drop(s); + self.is_new = true; + self.frame_dirty = true; + } + PenTransition::Motion { sample } => { + if self.frame_has_motion { + self.flush(); + } + self.set_axes(sample); + self.frame_dirty = true; + self.frame_has_motion = true; + } + PenTransition::TipDown => { + self.shared.lock().unwrap().state.touching = true; + self.edge_down = true; + self.frame_dirty = true; + } + PenTransition::ButtonsChanged { pressed, released } => { + let mut s = self.shared.lock().unwrap(); + if pressed & PEN_BARREL1 != 0 { + s.state.barrel = true; + } + if released & PEN_BARREL1 != 0 { + s.state.barrel = false; + } + drop(s); + self.frame_dirty = true; + } + PenTransition::TipUp => { + self.shared.lock().unwrap().state.touching = false; + self.edge_up = true; + self.frame_dirty = true; + self.flush(); // UP owns its frame (still INRANGE) + } + PenTransition::ProximityOut => { + self.shared.lock().unwrap().state.in_range = false; + self.frame_dirty = true; + self.flush(); // final frame without INRANGE + } + } + } + self.flush(); + } + + fn set_axes(&mut self, s: &PenSample) { + let mut sh = self.shared.lock().unwrap(); + sh.state.x = s.x; + sh.state.y = s.y; + sh.state.pressure = s.pressure; + sh.state.tilt_deg = s.tilt_deg; + sh.state.azimuth_deg = s.azimuth_deg; + sh.state.roll_deg = s.roll_deg; + } + + fn flush(&mut self) { + if !self.frame_dirty { + return; + } + let edge = if self.edge_down { + POINTER_FLAG_DOWN + } else if self.edge_up { + POINTER_FLAG_UP + } else { + POINTER_FLAG_UPDATE + }; + let s = self.shared.lock().unwrap(); + inject_pen(&s.dev, &s.state, edge, self.is_new); + drop(s); + self.edge_down = false; + self.edge_up = false; + self.is_new = false; + self.frame_dirty = false; + self.frame_has_motion = false; + } +} + +impl Drop for VirtualPen { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(h) = self.refresher.take() { + let _ = h.join(); + } + // The device itself dies with `shared` (Device::drop) — Windows releases any held + // in-range/contact state when the synthetic device is destroyed. + } +} + +/// Build + inject one pen frame from tracked state. `edge` is DOWN/UP/UPDATE; +/// `INRANGE`/`INCONTACT` derive from the state itself. +fn inject_pen(dev: &Device, st: &PenState, edge: POINTER_FLAGS, is_new: bool) { + let mut flags = edge; + if st.in_range { + flags |= POINTER_FLAG_INRANGE; + } + if st.touching { + flags |= POINTER_FLAG_INCONTACT | POINTER_FLAG_FIRSTBUTTON; + } + if is_new { + flags |= POINTER_FLAG_NEW; + } + let mut pen_flags = PEN_FLAG_NONE; + if st.barrel { + pen_flags |= PEN_FLAG_BARREL; + } + if st.eraser { + pen_flags |= PEN_FLAG_INVERTED; + if st.touching { + pen_flags |= PEN_FLAG_ERASER; + } + } + let mut pen_mask = PEN_MASK_PRESSURE; + // Contact needs a nonzero pressure to ink; hover reports 0 (Windows convention). + let pressure = if st.touching { + ((st.pressure as u32 * WIN_PEN_PRESSURE_MAX) / u16::MAX as u32).max(1) + } else { + 0 + }; + let (mut tilt_x, mut tilt_y) = (0i32, 0i32); + if st.tilt_deg != PEN_TILT_UNKNOWN && st.azimuth_deg != PEN_ANGLE_UNKNOWN { + let az = (st.azimuth_deg as f32).to_radians(); + let tilt = st.tilt_deg as f32; + tilt_x = (tilt * az.sin()).round() as i32; + tilt_y = (-tilt * az.cos()).round() as i32; + pen_mask |= PEN_MASK_TILT_X | PEN_MASK_TILT_Y; + } + let mut rotation = 0u32; + if st.roll_deg != PEN_ANGLE_UNKNOWN { + rotation = (st.roll_deg % 360) as u32; + pen_mask |= PEN_MASK_ROTATION; + } + let pt = to_screen(st.x, st.y); + let info = POINTER_TYPE_INFO { + r#type: PT_PEN, + Anonymous: POINTER_TYPE_INFO_0 { + penInfo: POINTER_PEN_INFO { + pointerInfo: POINTER_INFO { + pointerType: PT_PEN, + pointerId: 0, + pointerFlags: flags, + ptPixelLocation: pt, + ptPixelLocationRaw: pt, + ..Default::default() + }, + penFlags: pen_flags, + penMask: pen_mask, + pressure, + rotation, + tiltX: tilt_x, + tiltY: tilt_y, + }, + }, + }; + // SAFETY: `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(dev.0, &[info]) } { + tracing::trace!(error = %e, "pen inject failed (transient)"); + } +} + +/// One live wire-touch contact. +#[derive(Clone, Copy)] +struct Contact { + id: u32, + x: f32, + y: f32, +} + +/// Windows can inject at most this many simultaneous synthetic touch contacts. +const MAX_CONTACTS: usize = 10; + +struct TouchShared { + dev: Device, + contacts: Vec, +} + +/// The `PT_TOUCH` device servicing wire `TouchDown/Move/Up` events — closes the SendInput +/// touch no-op. Contacts are keyed by the wire's finger id; every frame re-injects the FULL +/// active set (the synthetic-pointer contract), and a refresh thread re-asserts held contacts +/// against the ~100 ms staleness auto-lift. +pub struct SyntheticTouch { + shared: Arc>, + stop: Arc, + refresher: Option>, +} + +impl SyntheticTouch { + pub fn create() -> Result { + // SAFETY: FFI create with by-value args; the handle's sole owner becomes `Device`. + let dev = unsafe { + CreateSyntheticPointerDevice(PT_TOUCH, MAX_CONTACTS as u32, POINTER_FEEDBACK_DEFAULT) + } + .context("CreateSyntheticPointerDevice(PT_TOUCH) — needs Windows 10 1809+")?; + let shared = Arc::new(Mutex::new(TouchShared { + dev: Device(dev), + contacts: Vec::new(), + })); + let stop = Arc::new(AtomicBool::new(false)); + let refresher = { + let shared = Arc::clone(&shared); + let stop = Arc::clone(&stop); + std::thread::Builder::new() + .name("pf-touch-refresh".into()) + .spawn(move || { + while !stop.load(Ordering::Relaxed) { + std::thread::sleep(std::time::Duration::from_millis(REFRESH_MS)); + let s = shared.lock().unwrap(); + if !s.contacts.is_empty() { + inject_touch_frame(&s.dev, &s.contacts, None); + } + } + }) + .context("spawn touch refresh thread")? + }; + tracing::info!("virtual touchscreen created (Windows synthetic pointer, PT_TOUCH)"); + Ok(SyntheticTouch { + shared, + stop, + refresher: Some(refresher), + }) + } + + /// Apply one wire touch event (`code` = finger id, pixel x/y against the + /// `flags = (w << 16) | h` reference extent, exactly like `MouseMoveAbs`). + pub fn apply(&mut self, ev: &InputEvent) { + let (w, h) = ((ev.flags >> 16) as f32, (ev.flags & 0xFFFF) as f32); + if (w < 1.0 || h < 1.0) && ev.kind != InputKind::TouchUp { + return; // the documented zero-extent drop, as for MouseMoveAbs + } + let (x, y) = (ev.x as f32 / w.max(1.0), ev.y as f32 / h.max(1.0)); + let mut s = self.shared.lock().unwrap(); + match ev.kind { + InputKind::TouchDown => { + match s.contacts.iter().position(|c| c.id == ev.code) { + Some(i) => (s.contacts[i].x, s.contacts[i].y) = (x, y), + None if s.contacts.len() < MAX_CONTACTS => { + s.contacts.push(Contact { id: ev.code, x, y }) + } + None => return, // beyond the platform max — drop, never evict a live finger + } + inject_touch_frame(&s.dev, &s.contacts, Some((ev.code, POINTER_FLAG_DOWN))); + } + InputKind::TouchMove => { + match s.contacts.iter().position(|c| c.id == ev.code) { + Some(i) => { + (s.contacts[i].x, s.contacts[i].y) = (x, y); + inject_touch_frame(&s.dev, &s.contacts, None); + } + // A move for an unknown id (its DOWN was dropped/lost): synthesize the + // contact so the stroke self-heals, like the pen plane does. + None if s.contacts.len() < MAX_CONTACTS => { + s.contacts.push(Contact { id: ev.code, x, y }); + inject_touch_frame(&s.dev, &s.contacts, Some((ev.code, POINTER_FLAG_DOWN))); + } + None => {} + } + } + InputKind::TouchUp => { + let Some(idx) = s.contacts.iter().position(|c| c.id == ev.code) else { + return; + }; + // The UP frame still carries the lifting contact (with UP flags), then it + // leaves the active set. + inject_touch_frame(&s.dev, &s.contacts, Some((ev.code, POINTER_FLAG_UP))); + s.contacts.remove(idx); + } + _ => {} + } + } +} + +impl Drop for SyntheticTouch { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(h) = self.refresher.take() { + let _ = h.join(); + } + } +} + +/// Inject the full active-contact frame; `edge` marks one contact's DOWN/UP transition +/// (everyone else is a held UPDATE). +fn inject_touch_frame(dev: &Device, contacts: &[Contact], edge: Option<(u32, POINTER_FLAGS)>) { + if contacts.is_empty() { + return; + } + let mut frame: Vec = Vec::with_capacity(contacts.len()); + for c in contacts { + let mut flags = POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT | POINTER_FLAG_UPDATE; + if let Some((id, e)) = edge { + if id == c.id { + if e == POINTER_FLAG_DOWN { + flags = POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT | POINTER_FLAG_DOWN; + } else if e == POINTER_FLAG_UP { + flags = POINTER_FLAG_UP; // contact + range end together + } + } + } + let pt = to_screen(c.x, c.y); + frame.push(POINTER_TYPE_INFO { + r#type: PT_TOUCH, + Anonymous: POINTER_TYPE_INFO_0 { + touchInfo: POINTER_TOUCH_INFO { + pointerInfo: POINTER_INFO { + pointerType: PT_TOUCH, + pointerId: c.id, + pointerFlags: flags, + ptPixelLocation: pt, + ptPixelLocationRaw: pt, + ..Default::default() + }, + touchFlags: TOUCH_FLAG_NONE, + touchMask: TOUCH_MASK_NONE, + ..Default::default() + }, + }, + }); + } + // SAFETY: `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(dev.0, &frame) } { + tracing::trace!(error = %e, "touch inject failed (transient)"); + } +} diff --git a/crates/pf-inject/src/inject/windows/sendinput.rs b/crates/pf-inject/src/inject/windows/sendinput.rs index 7aeea977..1c1a699a 100644 --- a/crates/pf-inject/src/inject/windows/sendinput.rs +++ b/crates/pf-inject/src/inject/windows/sendinput.rs @@ -46,6 +46,11 @@ const XBUTTON2: u32 = 0x0002; pub struct SendInputInjector { desktop: Option, + /// PT_TOUCH synthetic device, created on the first wire-touch event (a session that never + /// touches never creates one). `None` after a failed create (pre-1809) — touch then stays + /// the historical no-op. + touch: Option, + touch_failed: bool, } // SAFETY: `SendInputInjector` holds only an `Option` (a desktop handle). The host creates @@ -58,7 +63,11 @@ unsafe impl Send for SendInputInjector {} impl SendInputInjector { pub fn open() -> Result { - let mut me = Self { desktop: None }; + let mut me = Self { + desktop: None, + touch: None, + touch_failed: false, + }; me.reattach_input_desktop(); // best-effort tracing::info!("SendInput injector ready (Win32 KeyboardAndMouse)"); Ok(me) @@ -324,15 +333,33 @@ impl InputInjector for SendInputInjector { } self.send(&inputs) } - // Gamepad goes through the XUSB backend. Touch: no SendInput equivalent -> no-op. + // Gamepad goes through the XUSB backend. InputKind::GamepadButton | InputKind::GamepadAxis | InputKind::GamepadState | InputKind::GamepadRemove - | InputKind::GamepadArrival - | InputKind::TouchDown - | InputKind::TouchMove - | InputKind::TouchUp => Ok(()), + | InputKind::GamepadArrival => Ok(()), + // Wire touch → the PT_TOUCH synthetic pointer device (design/pen-tablet-input.md + // §6; closes the historical SendInput no-op). Lazily created — a session that never + // touches never creates one; a pre-1809 create failure latches back to the no-op. + InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => { + if self.touch.is_none() && !self.touch_failed { + match crate::pen::SyntheticTouch::create() { + Ok(t) => self.touch = Some(t), + Err(e) => { + self.touch_failed = true; + tracing::warn!( + error = %format!("{e:#}"), + "touch: synthetic pointer unavailable — wire touch stays a no-op" + ); + } + } + } + if let Some(t) = self.touch.as_mut() { + t.apply(event); + } + Ok(()) + } } } } diff --git a/crates/pf-inject/src/lib.rs b/crates/pf-inject/src/lib.rs index 7e31ca24..7394c9d7 100644 --- a/crates/pf-inject/src/lib.rs +++ b/crates/pf-inject/src/lib.rs @@ -223,8 +223,20 @@ pub fn pen_supported() -> bool { true } -/// See the Linux variant — no pen injection off Linux yet (Windows PT_PEN is design P3). -#[cfg(not(target_os = "linux"))] +/// Windows: pen (and touch) inject via synthetic pointer devices — available on Win10 1809+, +/// probed by actually creating (and immediately destroying) a PT_PEN device. Same +/// `PUNKTFUNK_PEN=0` kill-switch as Linux. The probe result also stands in for PT_TOUCH +/// (both APIs arrived together in 1809). +#[cfg(target_os = "windows")] +pub fn pen_supported() -> bool { + if std::env::var("PUNKTFUNK_PEN").as_deref() == Ok("0") { + return false; + } + pen::synthetic_pen_available() +} + +/// See the Linux/Windows variants — no pen injection elsewhere. +#[cfg(not(any(target_os = "linux", target_os = "windows")))] pub fn pen_supported() -> bool { false } @@ -399,9 +411,15 @@ pub mod gamepad { #[cfg(target_os = "linux")] #[path = "inject/linux/pen.rs"] pub mod pen; -/// Stub — pen injection needs the Linux uinput tablet (Windows PT_PEN is design P3); +/// Windows: PT_PEN/PT_TOUCH synthetic pointer devices (design/pen-tablet-input.md §6). +/// `pen::VirtualPen` here is the PT_PEN device; `pen::SyntheticTouch` backs the SendInput +/// injector's wire-touch path. +#[cfg(target_os = "windows")] +#[path = "inject/windows/pointer_windows.rs"] +pub mod pen; +/// Stub — pen injection needs the Linux uinput tablet or Windows synthetic pointers; /// `pen_supported()` is false here, so no host advertises the cap and no batches arrive. -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "windows")))] pub mod pen { use anyhow::{bail, Result}; pub struct VirtualPen;