From aca5aa9993ce621fa51173f222eb22c1200ca27c Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 23 Jul 2026 13:30:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(host+inject):=20pen=20P1=20=E2=80=94=20per?= =?UTF-8?q?-session=20uinput=20virtual=20tablet,=20HOST=5FCAP=5FPEN=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux injection leg of design/pen-tablet-input.md: 0xCC/0x05 pen batches now route through the per-session PenTracker into a lazily-created 'Punktfunk Pen' uinput tablet (BTN_TOOL_PEN/RUBBER, pressure, tilt-from-polar, ABS_Z barrel roll, hover distance, INPUT_PROP_DIRECT) — compositors pick it up via libinput and hand apps zwp_tablet_v2 with full fidelity. The host now advertises HOST_CAP_PEN when /dev/uinput is accessible (PUNKTFUNK_PEN=0 kill-switch); transitions group into SYN frames so proximity-enter carries its position. Stroke failsafe: clients heartbeat ≤100ms while in range (documented wire contract — capture APIs are silent for a stationary pen); 200ms of silence force-releases. 'punktfunk-host pen-test' draws a pressure-ramped sine stroke through the real tracker→uinput chain, no client needed. Co-Authored-By: Claude Fable 5 --- crates/pf-inject/src/inject/linux/pen.rs | 396 ++++++++++++++++++ crates/pf-inject/src/lib.rs | 50 +++ crates/punktfunk-core/src/client/mod.rs | 5 + crates/punktfunk-core/src/quic/pen.rs | 12 +- crates/punktfunk-host/src/devtest.rs | 69 +++ crates/punktfunk-host/src/main.rs | 5 + crates/punktfunk-host/src/native.rs | 8 + crates/punktfunk-host/src/native/handshake.rs | 9 + crates/punktfunk-host/src/native/input.rs | 101 ++++- docs/embedding-the-c-abi.md | 8 +- include/punktfunk_core.h | 12 +- 11 files changed, 662 insertions(+), 13 deletions(-) create mode 100644 crates/pf-inject/src/inject/linux/pen.rs diff --git a/crates/pf-inject/src/inject/linux/pen.rs b/crates/pf-inject/src/inject/linux/pen.rs new file mode 100644 index 00000000..51d58a91 --- /dev/null +++ b/crates/pf-inject/src/inject/linux/pen.rs @@ -0,0 +1,396 @@ +//! Virtual tablet ("Punktfunk Pen"): a uinput stylus device carrying the pen plane's full +//! fidelity — pressure, tilt, barrel roll, hover distance, eraser, barrel buttons +//! (design/pen-tablet-input.md §5). +//! +//! Deliberately a **uinput device, not a compositor-protocol citizen**: no virtual-tablet +//! protocol exists in EI, the RemoteDesktop portal, KWin `fake_input`, or wlroots — while +//! every compositor consumes evdev tablets via libinput and forwards them to apps over +//! `zwp_tablet_v2` with nothing to configure. udev's `input_id` builtin classifies the device +//! from its capabilities (`BTN_TOOL_PEN` + `ABS_X/Y` ⇒ `ID_INPUT_TABLET`), so Krita/GIMP/ +//! Xournal++ see a real pen. Output mapping is the compositor's own tablet-mapping default +//! (single output ⇒ correct; multi-monitor pinning is the documented follow-up, which is why +//! the device carries a stable, distinctive identity to key rules on). +//! +//! The consumer feeds it [`PenTransition`]s straight from the core's +//! [`PenTracker`](punktfunk_core::quic::PenTracker); this file only translates transitions to +//! evdev events and groups them into SYN frames so proximity-enter carries its position in the +//! same frame (libinput would otherwise report an entry at a stale point). +//! +//! ioctl numbers/struct layouts mirror `gamepad.rs` (verified against the same kernel +//! generation); each backend file stays self-contained by convention. + +use anyhow::{bail, Result}; +use punktfunk_core::quic::{PenSample, PenTool, PenTransition, PEN_BARREL1, PEN_BARREL2}; +use std::os::fd::{AsRawFd, OwnedFd}; + +// ioctls (x86_64). +const UI_DEV_CREATE: libc::c_ulong = 0x5501; +const UI_DEV_DESTROY: libc::c_ulong = 0x5502; +const UI_DEV_SETUP: libc::c_ulong = 0x405c_5503; +const UI_ABS_SETUP: libc::c_ulong = 0x401c_5504; +const UI_SET_EVBIT: libc::c_ulong = 0x4004_5564; +const UI_SET_KEYBIT: libc::c_ulong = 0x4004_5565; +const UI_SET_PROPBIT: libc::c_ulong = 0x4004_556e; + +// input-event-codes.h subset. +const EV_SYN: u16 = 0x00; +const EV_KEY: u16 = 0x01; +const EV_ABS: u16 = 0x03; +const SYN_REPORT: u16 = 0; +const ABS_X: u16 = 0x00; +const ABS_Y: u16 = 0x01; +/// Barrel roll rides ABS_Z (the Wacom Art-Pen rotation convention); libinput normalizes the +/// declared min..max onto its 0..360° rotation axis. +const ABS_Z: u16 = 0x02; +const ABS_PRESSURE: u16 = 0x18; +const ABS_DISTANCE: u16 = 0x19; +const ABS_TILT_X: u16 = 0x1a; +const ABS_TILT_Y: u16 = 0x1b; +const BTN_TOOL_PEN: u16 = 0x140; +const BTN_TOOL_RUBBER: u16 = 0x141; +const BTN_TOUCH: u16 = 0x14a; +const BTN_STYLUS: u16 = 0x14b; +const BTN_STYLUS2: u16 = 0x14c; +/// The pen writes on the display it is mapped to (a "screen tablet"), not a desk pad — +/// libinput then maps the full ABS range onto the output rect, exactly the wire's normalized +/// coordinate contract. +const INPUT_PROP_DIRECT: libc::c_int = 0x01; + +/// Full-scale wire pressure (u16) → the declared 0..4095 axis. +const PRESSURE_SHIFT: u32 = 4; +/// Wire hover distance (u16, 0xFFFF = unknown) → the declared 0..1023 axis. +const DISTANCE_SHIFT: u32 = 6; +const ABS_RANGE: f32 = 65535.0; + +#[repr(C)] +struct InputId { + bustype: u16, + vendor: u16, + product: u16, + version: u16, +} + +#[repr(C)] +struct UinputSetup { + id: InputId, + name: [u8; 80], + ff_effects_max: u32, +} + +#[repr(C)] +#[derive(Default, Clone, Copy)] +struct AbsInfo { + value: i32, + minimum: i32, + maximum: i32, + fuzz: i32, + flat: i32, + resolution: i32, +} + +#[repr(C)] +struct UinputAbsSetup { + code: u16, + _pad: u16, + absinfo: AbsInfo, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct InputEventRaw { + time: libc::timeval, + type_: u16, + code: u16, + value: i32, +} + +fn ioctl_int(fd: i32, req: libc::c_ulong, arg: libc::c_int, what: &str) -> Result<()> { + // SAFETY: every caller passes a UI_SET_*/UI_DEV_* request whose argument the kernel reads + // as a plain int; `fd` is a live uinput fd owned by the caller. No memory is handed over. + if unsafe { libc::ioctl(fd, req, arg) } < 0 { + bail!("{what}: {}", std::io::Error::last_os_error()); + } + Ok(()) +} + +fn ioctl_ptr(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result<()> { + // SAFETY: every caller passes a pointer to a live, initialized `#[repr(C)]` struct matching + // the request's expected layout (UI_DEV_SETUP/UI_ABS_SETUP); the kernel reads it during the + // call and retains nothing. + if unsafe { libc::ioctl(fd, req, arg) } < 0 { + bail!("{what}: {}", std::io::Error::last_os_error()); + } + Ok(()) +} + +/// The active tool's evdev key, one in proximity at a time (Wacom semantics — the core's +/// [`PenTracker`](punktfunk_core::quic::PenTracker) already re-enters proximity on a tool +/// switch, so this only tracks which key to release on `ProximityOut`). +fn tool_key(tool: PenTool) -> u16 { + match tool { + PenTool::Eraser => BTN_TOOL_RUBBER, + // Unknown = a newer client's future tool — nearest ink-capable behavior is the pen. + PenTool::Pen | PenTool::Unknown => BTN_TOOL_PEN, + } +} + +/// One per-session virtual tablet. Created lazily on the first pen batch (a session that never +/// draws never creates a device), destroyed with the session (Drop → `UI_DEV_DESTROY`). +pub struct VirtualPen { + fd: OwnedFd, + /// The tool key currently held in proximity (release target for `ProximityOut`). + tool: u16, + /// Whether the current SYN frame already carries a Motion — the frame-split trigger for + /// consecutive samples in one batch. + frame_has_motion: bool, + /// Whether the current SYN frame has any unflushed events. + frame_dirty: bool, +} + +impl VirtualPen { + pub fn create() -> Result { + use std::os::fd::FromRawFd; + // SAFETY: `c"/dev/uinput"` is a 'static NUL-terminated C string literal; `open` reads it + // as a path, returns a fresh fd (or -1) and retains nothing. + let raw = unsafe { + libc::open( + c"/dev/uinput".as_ptr(), + libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC, + ) + }; + if raw < 0 { + bail!( + "open /dev/uinput: {} (install the udev rule granting the 'input' group access \ + — see scripts/60-punktfunk.rules — and add the user to the 'input' group)", + std::io::Error::last_os_error() + ); + } + // SAFETY: `raw >= 0` here, a freshly-opened fd owned nowhere else; `OwnedFd` becomes the + // unique owner and closes it exactly once on drop. + let fd = unsafe { OwnedFd::from_raw_fd(raw) }; + + ioctl_int(raw, UI_SET_EVBIT, EV_KEY as i32, "UI_SET_EVBIT(EV_KEY)")?; + ioctl_int(raw, UI_SET_EVBIT, EV_ABS as i32, "UI_SET_EVBIT(EV_ABS)")?; + for key in [ + BTN_TOOL_PEN, + BTN_TOOL_RUBBER, + BTN_TOUCH, + BTN_STYLUS, + BTN_STYLUS2, + ] { + ioctl_int(raw, UI_SET_KEYBIT, key as i32, "UI_SET_KEYBIT")?; + } + ioctl_int( + raw, + UI_SET_PROPBIT, + INPUT_PROP_DIRECT, + "UI_SET_PROPBIT(DIRECT)", + )?; + + // Position spans the full u16 range; `resolution` (units/mm) only feeds libinput's mm + // math (nothing pen-relevant), but tablets without one trip its missing-resolution + // fixup — 100 declares a plausible ~655 mm drawing surface. + let pos = AbsInfo { + minimum: 0, + maximum: 65535, + resolution: 100, + ..Default::default() + }; + // Tilt in degrees from vertical, per evdev convention; resolution = units/radian (57 + // ⇔ 1 unit = 1°, what the Wacom driver declares). + let tilt = AbsInfo { + minimum: -90, + maximum: 90, + resolution: 57, + ..Default::default() + }; + for (code, info) in [ + (ABS_X, pos), + (ABS_Y, pos), + ( + ABS_PRESSURE, + AbsInfo { + minimum: 0, + maximum: 4095, + ..Default::default() + }, + ), + ( + ABS_DISTANCE, + AbsInfo { + minimum: 0, + maximum: 1023, + ..Default::default() + }, + ), + (ABS_TILT_X, tilt), + (ABS_TILT_Y, tilt), + ( + // Barrel roll: libinput maps the declared range linearly onto 0..360°. + ABS_Z, + AbsInfo { + minimum: 0, + maximum: 359, + ..Default::default() + }, + ), + ] { + let mut a = UinputAbsSetup { + code, + _pad: 0, + absinfo: info, + }; + ioctl_ptr(raw, UI_ABS_SETUP, &mut a, "UI_ABS_SETUP")?; + } + + // A stable, distinctive identity (pid.codes open-source VID) so compositor + // tablet-mapping rules — GNOME's per-`vendor:product` gsettings path, sway + // `map_to_output` — can target exactly this device. + let mut setup = UinputSetup { + id: InputId { + bustype: 0x0006, // BUS_VIRTUAL + vendor: 0x1209, + product: 0x5046, // "PF" + version: 1, + }, + name: [0; 80], + ff_effects_max: 0, + }; + let name = b"Punktfunk Pen"; + setup.name[..name.len()].copy_from_slice(name); + ioctl_ptr(raw, UI_DEV_SETUP, &mut setup, "UI_DEV_SETUP")?; + ioctl_int(raw, UI_DEV_CREATE, 0, "UI_DEV_CREATE")?; + tracing::info!("virtual tablet created (Punktfunk Pen, uinput)"); + + Ok(VirtualPen { + fd, + tool: BTN_TOOL_PEN, + frame_has_motion: false, + frame_dirty: false, + }) + } + + fn emit(&self, type_: u16, code: u16, value: i32) { + let ev = InputEventRaw { + time: libc::timeval { + tv_sec: 0, + tv_usec: 0, + }, + type_, + code, + value, + }; + // SAFETY: `ev` is a live local `#[repr(C)]` all-integer struct (no padding: timeval=16 + + // u16 + u16 + i32 = 24), so every byte is initialized; the slice spans exactly `ev`'s + // bytes and is used immediately below with no concurrent mutation. + let bytes = unsafe { + std::slice::from_raw_parts( + &ev as *const _ as *const u8, + std::mem::size_of::(), + ) + }; + // Best-effort like the gamepad path: a full kernel queue drops the event; pen samples + // are state-full, so the next frame re-syncs axes (and the tracker re-syncs state). + // SAFETY: `self.fd` stays open for the synchronous call; `write` only reads + // `bytes.len()` bytes from the still-live local and retains nothing. + let _ = unsafe { + libc::write( + self.fd.as_raw_fd(), + bytes.as_ptr() as *const libc::c_void, + bytes.len(), + ) + }; + } + + fn flush(&mut self) { + if self.frame_dirty { + self.emit(EV_SYN, SYN_REPORT, 0); + self.frame_dirty = false; + self.frame_has_motion = false; + } + } + + fn motion(&mut self, s: &PenSample) { + self.emit(EV_ABS, ABS_X, (s.x * ABS_RANGE) as i32); + self.emit(EV_ABS, ABS_Y, (s.y * ABS_RANGE) as i32); + self.emit(EV_ABS, ABS_PRESSURE, (s.pressure >> PRESSURE_SHIFT) as i32); + if s.distance != punktfunk_core::quic::PEN_DISTANCE_UNKNOWN { + self.emit(EV_ABS, ABS_DISTANCE, (s.distance >> DISTANCE_SHIFT) as i32); + } + // Polar → tiltX/tiltY needs both angles; azimuth clockwise from north, so east (90°) + // tilts +X and south (180°, toward the user) tilts +Y — the evdev/W3C signs. + if s.tilt_deg != punktfunk_core::quic::PEN_TILT_UNKNOWN + && s.azimuth_deg != punktfunk_core::quic::PEN_ANGLE_UNKNOWN + { + let az = (s.azimuth_deg as f32).to_radians(); + let tilt = s.tilt_deg as f32; + self.emit(EV_ABS, ABS_TILT_X, (tilt * az.sin()).round() as i32); + self.emit(EV_ABS, ABS_TILT_Y, (-tilt * az.cos()).round() as i32); + } + if s.roll_deg != punktfunk_core::quic::PEN_ANGLE_UNKNOWN { + self.emit(EV_ABS, ABS_Z, (s.roll_deg % 360) as i32); + } + self.frame_dirty = true; + self.frame_has_motion = true; + } + + /// Apply one decoded batch's transitions (the core tracker's output, in its documented + /// order), grouping them into SYN frames: a frame closes before a `ProximityIn` (an entry + /// is a new instant — and must carry its own position, not inherit the stale frame) and + /// before a second `Motion` (consecutive samples are consecutive instants), plus a final + /// close. So `[ProxIn, Motion, TipDown]` lands as ONE frame — libinput reports the entry + /// already at the right point with contact — while a drag batch's `[Motion, Motion]` + /// stays two. + pub fn apply_batch(&mut self, transitions: &[PenTransition]) { + for t in transitions { + match t { + PenTransition::ProximityIn { tool } => { + self.flush(); + self.tool = tool_key(*tool); + self.emit(EV_KEY, self.tool, 1); + self.frame_dirty = true; + } + PenTransition::Motion { sample } => { + if self.frame_has_motion { + self.flush(); + } + self.motion(sample); + } + PenTransition::TipDown => { + self.emit(EV_KEY, BTN_TOUCH, 1); + self.frame_dirty = true; + } + PenTransition::ButtonsChanged { pressed, released } => { + for (bit, key) in [(PEN_BARREL1, BTN_STYLUS), (PEN_BARREL2, BTN_STYLUS2)] { + if pressed & bit != 0 { + self.emit(EV_KEY, key, 1); + self.frame_dirty = true; + } + if released & bit != 0 { + self.emit(EV_KEY, key, 0); + self.frame_dirty = true; + } + } + } + PenTransition::TipUp => { + self.emit(EV_KEY, BTN_TOUCH, 0); + self.emit(EV_ABS, ABS_PRESSURE, 0); + self.frame_dirty = true; + } + PenTransition::ProximityOut => { + self.emit(EV_KEY, self.tool, 0); + self.frame_dirty = true; + } + } + } + self.flush(); + } +} + +impl Drop for VirtualPen { + fn drop(&mut self) { + // SAFETY: `self.fd` is still open (OwnedFd closes only after this body returns); + // UI_DEV_DESTROY takes no pointer argument. Errors are moot on teardown. + let _ = unsafe { libc::ioctl(self.fd.as_raw_fd(), UI_DEV_DESTROY, 0) }; + } +} diff --git a/crates/pf-inject/src/lib.rs b/crates/pf-inject/src/lib.rs index 62ba0e61..7e31ca24 100644 --- a/crates/pf-inject/src/lib.rs +++ b/crates/pf-inject/src/lib.rs @@ -197,6 +197,38 @@ pub fn text_input_supported() -> bool { false } +/// Whether this host can inject full-fidelity stylus input (`HOST_CAP_PEN` — +/// design/pen-tablet-input.md): Linux only, via the [`pen::VirtualPen`] uinput tablet, so the +/// probe is "can we open /dev/uinput" (the same permission the virtual gamepads need) plus the +/// `PUNKTFUNK_PEN=0` operator kill-switch. Consulted at Welcome time; clients without the bit +/// keep folding pen into touch/pointer. Windows PT_PEN synthetic pointers are the design's P3. +#[cfg(target_os = "linux")] +pub fn pen_supported() -> bool { + if std::env::var("PUNKTFUNK_PEN").as_deref() == Ok("0") { + return false; + } + // SAFETY: 'static NUL-terminated path literal; `open` returns a fresh fd (or -1) and + // retains nothing. + let fd = unsafe { + libc::open( + c"/dev/uinput".as_ptr(), + libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return false; + } + // SAFETY: `fd >= 0` is the fd opened above, owned by no one else; closed exactly once here. + unsafe { libc::close(fd) }; + true +} + +/// See the Linux variant — no pen injection off Linux yet (Windows PT_PEN is design P3). +#[cfg(not(target_os = "linux"))] +pub fn pen_supported() -> bool { + false +} + #[path = "inject/service.rs"] mod service; pub use service::InjectorService; @@ -362,6 +394,24 @@ pub mod gamepad { pub fn pump_rumble(&mut self, _send: impl FnMut(u16, u16, u16)) {} } } +/// Linux: the "Punktfunk Pen" uinput virtual tablet (design/pen-tablet-input.md §5) — the +/// per-session stylus device the native pen plane injects through. +#[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); +/// `pen_supported()` is false here, so no host advertises the cap and no batches arrive. +#[cfg(not(target_os = "linux"))] +pub mod pen { + use anyhow::{bail, Result}; + pub struct VirtualPen; + impl VirtualPen { + pub fn create() -> Result { + bail!("no pen injection backend on this platform") + } + pub fn apply_batch(&mut self, _transitions: &[punktfunk_core::quic::PenTransition]) {} + } +} #[cfg(target_os = "linux")] #[path = "inject/linux/kwin_fake_input.rs"] mod kwin_fake_input; diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index 0751c432..62a5136c 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -1091,6 +1091,11 @@ impl NativeClient { /// Best-effort like every datagram: a lost batch self-heals on the next one (the samples /// carry full state, the host diffs — see [`crate::quic::PenTracker`]). /// + /// **Heartbeat contract**: while the pen is in range or touching, repeat the last sample + /// at least every ~100 ms even when nothing changed (capture APIs are silent for a + /// stationary pen) — the host force-releases the stroke after + /// [`crate::quic::PEN_TOUCH_TIMEOUT_MS`] of silence as its dead-client failsafe. + /// /// Requires the host to have advertised [`crate::quic::HOST_CAP_PEN`]; toward an older /// host this returns `Unsupported` (embedders keep their pen-as-touch fallback instead of /// spraying 240 Hz datagrams the host drops unread). diff --git a/crates/punktfunk-core/src/quic/pen.rs b/crates/punktfunk-core/src/quic/pen.rs index dcb09a1f..bbe2311f 100644 --- a/crates/punktfunk-core/src/quic/pen.rs +++ b/crates/punktfunk-core/src/quic/pen.rs @@ -56,11 +56,13 @@ pub const PEN_SAMPLE_WIRE_LEN: usize = 21; /// `[0xCC][0x05][flags][count][u16 seq LE]` — bytes before the first sample. const PEN_HEADER_LEN: usize = 6; -/// Host-side failsafe (design/pen-tablet-input.md §2): a tracker still touching after this many -/// ms without a sample force-releases ([`PenTracker::force_release`]) — a client that died -/// mid-stroke must not leave the host's virtual pen inked-down forever. Far above any real -/// send cadence (a touching pen streams samples continuously), so it never fires on a live -/// slow stroke. +/// Host-side failsafe (design/pen-tablet-input.md §2): a tracker still in range after this +/// many ms without a sample force-releases ([`PenTracker::force_release`]) — a client that +/// died mid-stroke must not leave the host's virtual pen inked-down forever. This makes the +/// **client heartbeat a wire contract**: capture APIs only fire on change, so a stationary +/// pen is naturally silent — senders MUST repeat the last sample at least every ~100 ms while +/// the pen is in range or touching (it re-decodes as pure Motion, harmless), keeping a live +/// stationary stroke two heartbeats clear of the deadline. pub const PEN_TOUCH_TIMEOUT_MS: u32 = 200; /// Which end of the stylus (or which mapped mode) a sample describes. A tool *switch* while in diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 4595a3e0..7c5f2192 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -8,6 +8,75 @@ use anyhow::Context; use anyhow::Result; +/// Draw a scripted stylus stroke through the REAL pen chain — wire-shaped samples → the core +/// [`PenTracker`](punktfunk_core::quic::PenTracker) → the "Punktfunk Pen" uinput tablet — so +/// full-fidelity pen injection is validated without any client (design/pen-tablet-input.md P1): +/// hover in from the left, tip down, a sine stroke across the mapped output with a pressure +/// ramp + tilt sweep, tip up, hover out. Observe in Krita/GIMP with a pressure brush, or +/// `sudo libinput debug-events` (expect `TABLET_TOOL_PROXIMITY/TIP/AXIS`). +#[cfg(target_os = "linux")] +pub fn pen_test() -> Result<()> { + use punktfunk_core::quic::{ + PenBatch, PenSample, PenTracker, PenTransition, PEN_IN_RANGE, PEN_TOUCHING, + }; + use std::time::Duration; + + let mut dev = crate::inject::pen::VirtualPen::create()?; + let mut tracker = PenTracker::default(); + let mut out: Vec = Vec::new(); + // Compositors need a beat to enumerate the new evdev node before events count. + std::thread::sleep(Duration::from_secs(2)); + + let mut seq = 0u16; + let mut send = |tracker: &mut PenTracker, out: &mut Vec, s: PenSample| { + out.clear(); + tracker.apply(&PenBatch::new(seq, &[s]), out); + seq = seq.wrapping_add(1); + dev.apply_batch(out); + }; + + tracing::info!("pen-test: hover in, then a 3 s pressure-ramped sine stroke"); + let hover = |x: f32| PenSample { + state: PEN_IN_RANGE, + x, + y: 0.5, + distance: 300, + ..Default::default() + }; + for i in 0..20 { + send(&mut tracker, &mut out, hover(0.05 + i as f32 * 0.005)); + std::thread::sleep(Duration::from_millis(10)); + } + const STEPS: u32 = 360; + for i in 0..=STEPS { + let t = i as f32 / STEPS as f32; + send( + &mut tracker, + &mut out, + PenSample { + state: PEN_IN_RANGE | PEN_TOUCHING, + x: 0.15 + 0.7 * t, + y: 0.5 + 0.2 * (t * std::f32::consts::TAU * 2.0).sin(), + // Ramp 10 % → 100 % so a pressure brush visibly widens along the stroke. + pressure: (6553.0 + 58982.0 * t) as u16, + distance: 0, + tilt_deg: 25 + (20.0 * t) as u8, + azimuth_deg: ((90.0 + 180.0 * t) as u16) % 360, + roll_deg: ((360.0 * t) as u16) % 360, + ..Default::default() + }, + ); + std::thread::sleep(Duration::from_millis(8)); + } + for i in 0..10 { + send(&mut tracker, &mut out, hover(0.85 + i as f32 * 0.005)); + std::thread::sleep(Duration::from_millis(10)); + } + send(&mut tracker, &mut out, PenSample::default()); // state 0 = out of range + tracing::info!("pen-test: done (stroke drawn, pen out of range) — device destroyed on exit"); + Ok(()) +} + /// Inject a scripted mouse + keyboard pattern through the session's input backend (libei on /// KWin/GNOME, wlr on Sway). Lets us validate input injection without a Moonlight client. #[cfg(target_os = "linux")] diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index afb8d9d7..f7f877ff 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -307,6 +307,11 @@ fn real_main() -> Result<()> { // Standalone input-injection smoke test (no client needed): open the session's input // backend and inject a scripted mouse/keyboard pattern. Watch a focused app / `wev`. Some("input-test") => devtest::input_test(), + // Standalone stylus smoke test (no client needed): create the "Punktfunk Pen" virtual + // tablet and draw a pressure-ramped stroke through the real tracker→uinput chain. + // Watch in Krita/GIMP (pressure brush) or `libinput debug-events`. + #[cfg(target_os = "linux")] + Some("pen-test") => devtest::pen_test(), // Zero-copy FFI/GPU probe: init the EGL importer + CUDA context (no capture needed). #[cfg(target_os = "linux")] Some("zerocopy-probe") => zerocopy::probe(), diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index a2335051..6d940f1b 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -1066,6 +1066,14 @@ async fn serve_session( if rich_tx.send(ClientInput::Rich(rich)).is_err() { break; } + } else if let Some(pen) = punktfunk_core::quic::PenBatch::decode(&d) { + // 0xCC kind 0x05 — the stylus plane (RichInput::decode returns None for it by + // design; see punktfunk_core::quic::pen). Routed to the same input thread, + // which owns the per-session tracker + virtual tablet. + rich_count += 1; + if rich_tx.send(ClientInput::Pen(pen)).is_err() { + break; + } } else if let Some(mut ev) = InputEvent::decode(&d) { input_count += 1; // Wire hygiene: KEY_FLAG_SEMANTIC_VK is an in-process tag (GameStream ingest diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index 22181b48..65b9607d 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -494,6 +494,15 @@ pub(super) async fn negotiate( punktfunk_core::quic::HOST_CAP_CURSOR } else { 0 + } + // Full-fidelity stylus (0xCC/0x05 pen batches → the per-session uinput tablet): + // Linux with /dev/uinput access, minus the PUNKTFUNK_PEN=0 kill-switch. Clients + // without the bit keep folding pen into touch/pointer (and NativeClient::send_pen + // refuses toward us if we don't set it). + | if crate::inject::pen_supported() { + punktfunk_core::quic::HOST_CAP_PEN + } else { + 0 }, // The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha // client; toward everyone else cipher 0 keeps the Welcome byte-identical to the diff --git a/crates/punktfunk-host/src/native/input.rs b/crates/punktfunk-host/src/native/input.rs index 8b90e463..a71cde1c 100644 --- a/crates/punktfunk-host/src/native/input.rs +++ b/crates/punktfunk-host/src/native/input.rs @@ -523,6 +523,87 @@ pub(super) enum ClientInput { Event(InputEvent), /// The 0xCC plane: touchpad contacts + motion samples. Rich(punktfunk_core::quic::RichInput), + /// The 0xCC/0x05 stylus plane: state-full pen sample batches, diffed into a per-session + /// virtual tablet (design/pen-tablet-input.md). + Pen(punktfunk_core::quic::PenBatch), +} + +/// The per-session stylus lane: the core [`PenTracker`](punktfunk_core::quic::PenTracker) +/// diffs state-full batches into transitions, applied to a lazily-created uinput tablet +/// ([`crate::inject::pen::VirtualPen`]) — a session that never draws never creates a device, +/// and the device dies with the session (kernel removes the tablet, apps see the pen unplug). +struct PenSession { + tracker: punktfunk_core::quic::PenTracker, + dev: Option, + /// Creation failed once — don't retry per batch (240 Hz of ioctl churn + log spam); the + /// tracker still consumes batches so its state stays coherent. + create_failed: bool, + last_rx: std::time::Instant, + /// Reused transition buffer (a batch yields at most a few). + out: Vec, +} + +impl PenSession { + fn new() -> PenSession { + PenSession { + tracker: punktfunk_core::quic::PenTracker::default(), + dev: None, + create_failed: false, + last_rx: std::time::Instant::now(), + out: Vec::new(), + } + } + + fn apply(&mut self, batch: &punktfunk_core::quic::PenBatch) { + self.last_rx = std::time::Instant::now(); + if self.dev.is_none() && !self.create_failed { + match crate::inject::pen::VirtualPen::create() { + Ok(d) => self.dev = Some(d), + Err(e) => { + // Shouldn't happen when the Welcome advertised HOST_CAP_PEN off the same + // uinput probe — but permissions can change between then and first ink. + self.create_failed = true; + tracing::warn!( + error = %format!("{e:#}"), + "pen: virtual tablet creation failed — dropping pen input this session" + ); + } + } + } + self.out.clear(); + self.tracker.apply(batch, &mut self.out); + if let Some(dev) = self.dev.as_mut() { + dev.apply_batch(&self.out); + } + } + + /// The dead-client failsafe ([`PEN_TOUCH_TIMEOUT_MS`](punktfunk_core::quic::PEN_TOUCH_TIMEOUT_MS)): + /// clients repeat the last sample (≤100 ms) while the pen is in range — a stationary + /// touching pen included — so silence past the timeout means the client is gone, and the + /// host must not leave the stroke inked-down. Called every input-loop wake; the loop caps + /// its recv timeout at 100 ms while the pen is active so this actually gets to run. + fn check_timeout(&mut self) { + if self.tracker.is_active() + && self.last_rx.elapsed().as_millis() + >= punktfunk_core::quic::PEN_TOUCH_TIMEOUT_MS as u128 + { + tracing::debug!("pen: sample stream went silent — force-releasing the stroke"); + self.release_all(); + } + } + + /// Lift buttons/tip/proximity in order (a no-op when idle). Session end + the timeout. + fn release_all(&mut self) { + self.out.clear(); + self.tracker.force_release(&mut self.out); + if let Some(dev) = self.dev.as_mut() { + dev.apply_batch(&self.out); + } + } + + fn active(&self) -> bool { + self.tracker.is_active() + } } /// Default TTL stamped on a non-zero rumble envelope (0xCA v2): how long the client renders the @@ -640,8 +721,17 @@ pub(super) fn input_thread( const MAX_HELD: usize = 256; let mut held_buttons: std::collections::HashSet = std::collections::HashSet::new(); let mut held_keys: std::collections::HashSet = std::collections::HashSet::new(); + let mut pen = PenSession::new(); loop { - match rx.recv_timeout(pads.feedback_poll_interval()) { + // While a pen is in range/touching, wake at least every 100 ms so the stroke failsafe + // (PenSession::check_timeout) has real granularity against its 200 ms deadline. + let poll = if pen.active() { + pads.feedback_poll_interval() + .min(std::time::Duration::from_millis(100)) + } else { + pads.feedback_poll_interval() + }; + match rx.recv_timeout(poll) { // Rich input (touchpad / motion) is applied the moment it arrives; the single channel // wakes for gyro samples instead of making them wait out the feedback poll interval. Ok(ClientInput::Rich(rich)) => { @@ -673,6 +763,9 @@ pub(super) fn input_thread( } pads.apply_rich(rich); } + // Stylus batches apply on arrival like rich input — the tracker synthesizes the + // transitions, the lazily-created virtual tablet renders them. + Ok(ClientInput::Pen(batch)) => pen.apply(&batch), Ok(ClientInput::Event(ev)) => match ev.kind { InputKind::GamepadButton | InputKind::GamepadAxis => { // A bad index / unknown axis just doesn't update a pad — fall through (no @@ -774,6 +867,9 @@ pub(super) fn input_thread( Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, } + // Pen stroke failsafe: a silent sample stream past the deadline force-releases (see + // PenSession::check_timeout — clients heartbeat while the pen is down). + pen.check_timeout(); // Service feedback every iteration (≤1 ms for Triton, ≤4 ms otherwise; games block on // EVIOCSFF, and HID handshakes must be answered promptly). Rumble → the universal 0xCA // plane; rich/raw HID feedback → 0xCD. @@ -863,6 +959,9 @@ pub(super) fn input_thread( } } } + // Pen: lift anything still inked (buttons → tip → proximity), then the device dies with + // this thread (VirtualPen::drop → UI_DEV_DESTROY; apps see the tablet unplug). + pen.release_all(); // Session ended (client gone). Release anything still held through the host-lifetime injector — // its EIS connection (and any implicit grab Mutter holds for our pressed button) outlives this // session, so without this a button pressed at disconnect stays latched and breaks clicks for diff --git a/docs/embedding-the-c-abi.md b/docs/embedding-the-c-abi.md index 25f169a7..c7d76014 100644 --- a/docs/embedding-the-c-abi.md +++ b/docs/embedding-the-c-abi.md @@ -466,8 +466,12 @@ punktfunk_connection_send_pen(c, &s, 1); ``` While hovering, send `PUNKTFUNK_PEN_IN_RANGE` samples (with `distance` if you have it); when the -pen leaves range, send one final sample with `state = 0` so the host releases proximity — -though a host-side timeout releases a vanished client's stroke regardless. +pen leaves range, send one final sample with `state = 0` so the host releases proximity. + +**Heartbeat**: while the pen is in range or touching, repeat the last sample at least every +~100 ms even when nothing changed — pen capture APIs are silent for a stationary pen, and the +host force-releases the stroke after 200 ms without samples (its dead-client failsafe). Run a +timer alongside your touch callbacks. --- diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index ae0e6767..8c44a5cb 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -1094,11 +1094,13 @@ #endif #if defined(PUNKTFUNK_FEATURE_QUIC) -// Host-side failsafe (design/pen-tablet-input.md §2): a tracker still touching after this many -// ms without a sample force-releases ([`PenTracker::force_release`]) — a client that died -// mid-stroke must not leave the host's virtual pen inked-down forever. Far above any real -// send cadence (a touching pen streams samples continuously), so it never fires on a live -// slow stroke. +// Host-side failsafe (design/pen-tablet-input.md §2): a tracker still in range after this +// many ms without a sample force-releases ([`PenTracker::force_release`]) — a client that +// died mid-stroke must not leave the host's virtual pen inked-down forever. This makes the +// **client heartbeat a wire contract**: capture APIs only fire on change, so a stationary +// pen is naturally silent — senders MUST repeat the last sample at least every ~100 ms while +// the pen is in range or touching (it re-decodes as pure Motion, harmless), keeping a live +// stationary stroke two heartbeats clear of the deadline. #define PEN_TOUCH_TIMEOUT_MS 200 #endif