fix(inject/windows): compact wire touch ids into slots — Moonlight native touch injected nothing
apple / swift (push) Successful in 1m28s
ci / web (push) Successful in 1m20s
ci / docs-site (push) Successful in 1m12s
release / apple (push) Successful in 9m24s
ci / bench (push) Successful in 6m28s
android / android (push) Successful in 11m59s
decky / build-publish (push) Successful in 22s
arch / build-publish (push) Successful in 12m30s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
apple / screenshots (push) Successful in 6m53s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
deb / build-publish (push) Successful in 9m36s
deb / build-publish-host (push) Successful in 11m24s
ci / rust (push) Successful in 25m11s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m59s
docker / deploy-docs (push) Successful in 25s

On-glass report (iPad Moonlight vs the Windows host): pen inked, native touch
did nothing. Cause: POINTER_INFO.pointerId was the client's raw wire id —
Moonlight sends arbitrary large pointerIds, and synthetic-pointer injection
rejects them (Apollo compacts ids into dense slots for exactly this reason);
the failure was logged at trace, i.e. invisibly. Wire ids now map to the
lowest free slot for the contact's lifetime, and the FIRST injection failure
of a device logs at WARN (pen too) so an inert input plane is never silent
again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 16:48:57 +02:00
co-authored by Claude Fable 5
parent c0f792ee8d
commit bf2c4456bb
@@ -113,6 +113,8 @@ struct PenState {
struct PenShared { struct PenShared {
dev: Device, dev: Device,
state: PenState, state: PenState,
/// First injection failure logs at WARN (see `TouchShared::fail_warned`).
fail_warned: bool,
} }
/// One per-session virtual pen (the Windows sibling of the Linux uinput tablet — same /// One per-session virtual pen (the Windows sibling of the Linux uinput tablet — same
@@ -138,6 +140,7 @@ impl VirtualPen {
let shared = Arc::new(Mutex::new(PenShared { let shared = Arc::new(Mutex::new(PenShared {
dev: Device(dev), dev: Device(dev),
state: PenState::default(), state: PenState::default(),
fail_warned: false,
})); }));
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
// The staleness guard: re-assert the last frame while in range so a stationary pen // The staleness guard: re-assert the last frame while in range so a stationary pen
@@ -150,9 +153,9 @@ impl VirtualPen {
.spawn(move || { .spawn(move || {
while !stop.load(Ordering::Relaxed) { while !stop.load(Ordering::Relaxed) {
std::thread::sleep(std::time::Duration::from_millis(REFRESH_MS)); std::thread::sleep(std::time::Duration::from_millis(REFRESH_MS));
let s = shared.lock().unwrap(); let s = &mut *shared.lock().unwrap();
if s.state.in_range { if s.state.in_range {
inject_pen(&s.dev, &s.state, POINTER_FLAG_UPDATE, false); inject_pen(s, POINTER_FLAG_UPDATE, false);
} }
} }
}) })
@@ -248,9 +251,7 @@ impl VirtualPen {
} else { } else {
POINTER_FLAG_UPDATE POINTER_FLAG_UPDATE
}; };
let s = self.shared.lock().unwrap(); inject_pen(&mut self.shared.lock().unwrap(), edge, self.is_new);
inject_pen(&s.dev, &s.state, edge, self.is_new);
drop(s);
self.edge_down = false; self.edge_down = false;
self.edge_up = false; self.edge_up = false;
self.is_new = false; self.is_new = false;
@@ -272,7 +273,8 @@ impl Drop for VirtualPen {
/// Build + inject one pen frame from tracked state. `edge` is DOWN/UP/UPDATE; /// Build + inject one pen frame from tracked state. `edge` is DOWN/UP/UPDATE;
/// `INRANGE`/`INCONTACT` derive from the state itself. /// `INRANGE`/`INCONTACT` derive from the state itself.
fn inject_pen(dev: &Device, st: &PenState, edge: POINTER_FLAGS, is_new: bool) { fn inject_pen(sh: &mut PenShared, edge: POINTER_FLAGS, is_new: bool) {
let st = &sh.state;
let mut flags = edge; let mut flags = edge;
if st.in_range { if st.in_range {
flags |= POINTER_FLAG_INRANGE; flags |= POINTER_FLAG_INRANGE;
@@ -335,28 +337,52 @@ fn inject_pen(dev: &Device, st: &PenState, edge: POINTER_FLAGS, is_new: bool) {
}, },
}, },
}; };
// SAFETY: `dev.0` is the live device this wrapper owns; the one-element array is a live // 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 // 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. // failure (desktop switch) is healed by the next refresh tick re-asserting state.
if let Err(e) = unsafe { InjectSyntheticPointerInput(dev.0, &[info]) } { if let Err(e) = unsafe { InjectSyntheticPointerInput(sh.dev.0, &[info]) } {
if !sh.fail_warned {
sh.fail_warned = true;
tracing::warn!(error = %e, "pen inject failed");
} else {
tracing::trace!(error = %e, "pen inject failed (transient)"); tracing::trace!(error = %e, "pen inject failed (transient)");
} }
} else {
sh.fail_warned = false;
}
} }
/// One live wire-touch contact. /// One live wire-touch contact. `slot` is the SMALL, DENSE pointer id handed to Windows —
/// synthetic-pointer injection rejects arbitrary large ids, and clients (Moonlight's
/// `pointerId` especially) send exactly those, so wire ids compact into the lowest free slot
/// for the contact's lifetime (Apollo's slot-contiguity rule; the on-glass symptom of passing
/// wire ids through was pen working while every touch silently failed to inject).
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
struct Contact { struct Contact {
id: u32, id: u32,
slot: u32,
x: f32, x: f32,
y: f32, y: f32,
} }
/// Lowest slot not held by a live contact.
fn free_slot(contacts: &[Contact]) -> u32 {
let mut slot = 0u32;
while contacts.iter().any(|c| c.slot == slot) {
slot += 1;
}
slot
}
/// Windows can inject at most this many simultaneous synthetic touch contacts. /// Windows can inject at most this many simultaneous synthetic touch contacts.
const MAX_CONTACTS: usize = 10; const MAX_CONTACTS: usize = 10;
struct TouchShared { struct TouchShared {
dev: Device, dev: Device,
contacts: Vec<Contact>, contacts: Vec<Contact>,
/// First injection failure logs at WARN (an on-glass "touch does nothing" must be
/// visible in the host log); repeats stay at trace.
fail_warned: bool,
} }
/// The `PT_TOUCH` device servicing wire `TouchDown/Move/Up` events — closes the SendInput /// The `PT_TOUCH` device servicing wire `TouchDown/Move/Up` events — closes the SendInput
@@ -379,6 +405,7 @@ impl SyntheticTouch {
let shared = Arc::new(Mutex::new(TouchShared { let shared = Arc::new(Mutex::new(TouchShared {
dev: Device(dev), dev: Device(dev),
contacts: Vec::new(), contacts: Vec::new(),
fail_warned: false,
})); }));
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let refresher = { let refresher = {
@@ -389,9 +416,9 @@ impl SyntheticTouch {
.spawn(move || { .spawn(move || {
while !stop.load(Ordering::Relaxed) { while !stop.load(Ordering::Relaxed) {
std::thread::sleep(std::time::Duration::from_millis(REFRESH_MS)); std::thread::sleep(std::time::Duration::from_millis(REFRESH_MS));
let s = shared.lock().unwrap(); let s = &mut *shared.lock().unwrap();
if !s.contacts.is_empty() { if !s.contacts.is_empty() {
inject_touch_frame(&s.dev, &s.contacts, None); inject_touch_frame(s, None);
} }
} }
}) })
@@ -413,29 +440,41 @@ impl SyntheticTouch {
return; // the documented zero-extent drop, as for MouseMoveAbs 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 (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(); let s = &mut *self.shared.lock().unwrap();
match ev.kind { match ev.kind {
InputKind::TouchDown => { InputKind::TouchDown => {
match s.contacts.iter().position(|c| c.id == ev.code) { match s.contacts.iter().position(|c| c.id == ev.code) {
Some(i) => (s.contacts[i].x, s.contacts[i].y) = (x, y), Some(i) => (s.contacts[i].x, s.contacts[i].y) = (x, y),
None if s.contacts.len() < MAX_CONTACTS => { None if s.contacts.len() < MAX_CONTACTS => {
s.contacts.push(Contact { id: ev.code, x, y }) let slot = free_slot(&s.contacts);
s.contacts.push(Contact {
id: ev.code,
slot,
x,
y,
})
} }
None => return, // beyond the platform max — drop, never evict a live finger 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))); inject_touch_frame(s, Some((ev.code, POINTER_FLAG_DOWN)));
} }
InputKind::TouchMove => { InputKind::TouchMove => {
match s.contacts.iter().position(|c| c.id == ev.code) { match s.contacts.iter().position(|c| c.id == ev.code) {
Some(i) => { Some(i) => {
(s.contacts[i].x, s.contacts[i].y) = (x, y); (s.contacts[i].x, s.contacts[i].y) = (x, y);
inject_touch_frame(&s.dev, &s.contacts, None); inject_touch_frame(s, None);
} }
// A move for an unknown id (its DOWN was dropped/lost): synthesize the // A move for an unknown id (its DOWN was dropped/lost): synthesize the
// contact so the stroke self-heals, like the pen plane does. // contact so the stroke self-heals, like the pen plane does.
None if s.contacts.len() < MAX_CONTACTS => { None if s.contacts.len() < MAX_CONTACTS => {
s.contacts.push(Contact { id: ev.code, x, y }); let slot = free_slot(&s.contacts);
inject_touch_frame(&s.dev, &s.contacts, Some((ev.code, POINTER_FLAG_DOWN))); s.contacts.push(Contact {
id: ev.code,
slot,
x,
y,
});
inject_touch_frame(s, Some((ev.code, POINTER_FLAG_DOWN)));
} }
None => {} None => {}
} }
@@ -446,7 +485,7 @@ impl SyntheticTouch {
}; };
// The UP frame still carries the lifting contact (with UP flags), then it // The UP frame still carries the lifting contact (with UP flags), then it
// leaves the active set. // leaves the active set.
inject_touch_frame(&s.dev, &s.contacts, Some((ev.code, POINTER_FLAG_UP))); inject_touch_frame(s, Some((ev.code, POINTER_FLAG_UP)));
s.contacts.remove(idx); s.contacts.remove(idx);
} }
_ => {} _ => {}
@@ -463,9 +502,10 @@ impl Drop for SyntheticTouch {
} }
} }
/// Inject the full active-contact frame; `edge` marks one contact's DOWN/UP transition /// Inject the full active-contact frame; `edge` marks one contact's DOWN/UP transition (by
/// (everyone else is a held UPDATE). /// WIRE id — everyone else is a held UPDATE). Windows sees the compacted `slot` ids only.
fn inject_touch_frame(dev: &Device, contacts: &[Contact], edge: Option<(u32, POINTER_FLAGS)>) { fn inject_touch_frame(sh: &mut TouchShared, edge: Option<(u32, POINTER_FLAGS)>) {
let contacts = &sh.contacts;
if contacts.is_empty() { if contacts.is_empty() {
return; return;
} }
@@ -488,7 +528,7 @@ fn inject_touch_frame(dev: &Device, contacts: &[Contact], edge: Option<(u32, POI
touchInfo: POINTER_TOUCH_INFO { touchInfo: POINTER_TOUCH_INFO {
pointerInfo: POINTER_INFO { pointerInfo: POINTER_INFO {
pointerType: PT_TOUCH, pointerType: PT_TOUCH,
pointerId: c.id, pointerId: c.slot,
pointerFlags: flags, pointerFlags: flags,
ptPixelLocation: pt, ptPixelLocation: pt,
ptPixelLocationRaw: pt, ptPixelLocationRaw: pt,
@@ -501,9 +541,16 @@ fn inject_touch_frame(dev: &Device, contacts: &[Contact], edge: Option<(u32, POI
}, },
}); });
} }
// SAFETY: `dev.0` is the live owned device; `frame` is a live Vec the call only reads. // 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. // Best-effort — a transient failure heals on the next event/refresh re-assertion.
if let Err(e) = unsafe { InjectSyntheticPointerInput(dev.0, &frame) } { if let Err(e) = unsafe { InjectSyntheticPointerInput(sh.dev.0, &frame) } {
if !sh.fail_warned {
sh.fail_warned = true;
tracing::warn!(error = %e, contacts = frame.len(), "touch inject failed");
} else {
tracing::trace!(error = %e, "touch inject failed (transient)"); tracing::trace!(error = %e, "touch inject failed (transient)");
} }
} else {
sh.fail_warned = false;
}
} }