feat(host+inject): pen P1 — per-session uinput virtual tablet, HOST_CAP_PEN live

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 16:18:31 +02:00
co-authored by Claude Fable 5
parent 248e1cbf08
commit aca5aa9993
11 changed files with 662 additions and 13 deletions
+5
View File
@@ -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).
+7 -5
View File
@@ -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