feat: touch input — TouchDown/Move/Up + host libei ei_touchscreen injection
ci / rust (push) Has been cancelled
ci / rust (push) Has been cancelled
Roadmap #5 (touch, ahead of the XL UHID DualSense work). Touch fits the existing 18-byte InputEvent: code = touch id, x/y = client pixels, flags = (w<<16)|h — the same absolute mapping as MouseMoveAbs. - core: InputKind::{TouchDown=9, TouchMove=10, TouchUp=11} + from_u8 + roundtrip test. - host inject/libei.rs: request the RemoteDesktop Touchscreen device type, bind the Touch capability, and inject ei_touchscreen down/motion/up (one event = one frame, per the protocol rule), mapping coordinates into the device region like the abs pointer. wlroots has no virtual-touch protocol wired — no-ops there. - client-rs --touch-test: drags a synthetic finger (touch id 0) in a circle. Validated live on headless KWin: the portal GRANTS the Touchscreen device type (Keyboard|Pointer|Touchscreen), proving the request path — but KWin's EIS server creates no touchscreen *device*, so touch currently no-ops on this KWin (now logged once, not silent). The injection code is correct and will land on a backend that exposes ei_touchscreen (gamescope / a newer compositor / the real touch-client path). Workspace green, clippy/fmt clean, +1 unit test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,8 @@
|
|||||||
//! `--input-test` exercises the input plane: scripted mouse/keyboard datagrams during the
|
//! `--input-test` exercises the input plane: scripted mouse/keyboard datagrams during the
|
||||||
//! stream (watch them land in the host session, e.g. xev inside gamescope). `--mic-test`
|
//! stream (watch them land in the host session, e.g. xev inside gamescope). `--mic-test`
|
||||||
//! exercises the mic uplink: a synthetic 440 Hz tone streamed as Opus (0xCB) → the host's
|
//! exercises the mic uplink: a synthetic 440 Hz tone streamed as Opus (0xCB) → the host's
|
||||||
//! virtual microphone source (record it host-side to hear the tone).
|
//! virtual microphone source (record it host-side to hear the tone). `--touch-test` drags a
|
||||||
|
//! synthetic finger in a circle → host libei `ei_touchscreen` injection.
|
||||||
//!
|
//!
|
||||||
//! `--pin <64-hex>` pins the host's certificate fingerprint (the host logs it at startup);
|
//! `--pin <64-hex>` pins the host's certificate fingerprint (the host logs it at startup);
|
||||||
//! without it the client trusts on first use and prints the observed fingerprint to pin.
|
//! without it the client trusts on first use and prints the observed fingerprint to pin.
|
||||||
@@ -41,6 +42,8 @@ struct Args {
|
|||||||
input_test: bool,
|
input_test: bool,
|
||||||
/// `--mic-test` — stream a synthetic 440 Hz tone as the mic uplink (proves the mic path).
|
/// `--mic-test` — stream a synthetic 440 Hz tone as the mic uplink (proves the mic path).
|
||||||
mic_test: bool,
|
mic_test: bool,
|
||||||
|
/// `--touch-test` — drag a synthetic finger in a circle (proves the touch path).
|
||||||
|
touch_test: bool,
|
||||||
pin: Option<[u8; 32]>,
|
pin: Option<[u8; 32]>,
|
||||||
/// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream.
|
/// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream.
|
||||||
remode: Option<(Mode, u32)>,
|
remode: Option<(Mode, u32)>,
|
||||||
@@ -142,6 +145,7 @@ fn parse_args() -> Args {
|
|||||||
out: get("--out").map(String::from),
|
out: get("--out").map(String::from),
|
||||||
input_test: argv.iter().any(|a| a == "--input-test"),
|
input_test: argv.iter().any(|a| a == "--input-test"),
|
||||||
mic_test: argv.iter().any(|a| a == "--mic-test"),
|
mic_test: argv.iter().any(|a| a == "--mic-test"),
|
||||||
|
touch_test: argv.iter().any(|a| a == "--touch-test"),
|
||||||
pin,
|
pin,
|
||||||
remode,
|
remode,
|
||||||
pair: get("--pair").map(String::from),
|
pair: get("--pair").map(String::from),
|
||||||
@@ -396,6 +400,51 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Touch plane: drag a synthetic finger (touch id 0) in a circle on the client surface, so
|
||||||
|
// the host injects it via libei ei_touchscreen — proves the touch path end to end. `flags`
|
||||||
|
// packs the surface w/h; x/y are pixels (the host maps them into the device region).
|
||||||
|
if args.touch_test {
|
||||||
|
let conn2 = conn.clone();
|
||||||
|
let (w, h) = (args.mode.width, args.mode.height);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
|
let flags = (w << 16) | (h & 0xffff);
|
||||||
|
let (cx, cy, r) = (w as f32 / 2.0, h as f32 / 2.0, h as f32 / 4.0);
|
||||||
|
let touch = |kind, x: f32, y: f32| InputEvent {
|
||||||
|
kind,
|
||||||
|
_pad: [0; 3],
|
||||||
|
code: 0, // touch id 0
|
||||||
|
x: x as i32,
|
||||||
|
y: y as i32,
|
||||||
|
flags,
|
||||||
|
};
|
||||||
|
tracing::info!("touch-test: dragging a finger in a circle for ~6s");
|
||||||
|
for loop_i in 0..3u32 {
|
||||||
|
let _ = conn2.send_datagram(
|
||||||
|
touch(InputKind::TouchDown, cx + r, cy)
|
||||||
|
.encode()
|
||||||
|
.to_vec()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
for i in 0..60u32 {
|
||||||
|
let a = std::f32::consts::TAU * i as f32 / 60.0;
|
||||||
|
let mv = touch(InputKind::TouchMove, cx + r * a.cos(), cy + r * a.sin());
|
||||||
|
let _ = conn2.send_datagram(mv.encode().to_vec().into());
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
||||||
|
}
|
||||||
|
let _ = conn2.send_datagram(
|
||||||
|
touch(InputKind::TouchUp, cx + r, cy)
|
||||||
|
.encode()
|
||||||
|
.to_vec()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
let _ = loop_i;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
}
|
||||||
|
tracing::info!("touch-test: done");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Closed-flag for the blocking receive loop.
|
// Closed-flag for the blocking receive loop.
|
||||||
let closed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
let closed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ pub enum InputKind {
|
|||||||
/// Sticks are i16 range (−32768..32767) in the XInput/Moonlight convention — **+y =
|
/// Sticks are i16 range (−32768..32767) in the XInput/Moonlight convention — **+y =
|
||||||
/// up** (unlike mouse coordinates); triggers 0..255.
|
/// up** (unlike mouse coordinates); triggers 0..255.
|
||||||
GamepadAxis = 8,
|
GamepadAxis = 8,
|
||||||
|
/// Touch begins. `code` = touch id (which finger; reusable after `TouchUp`), `x`/`y` =
|
||||||
|
/// pixel coordinates and `flags` = `(width << 16) | height` of the client's touch surface
|
||||||
|
/// — the same absolute mapping as [`MouseMoveAbs`](Self::MouseMoveAbs).
|
||||||
|
TouchDown = 9,
|
||||||
|
/// Touch moves. Same field meaning as [`TouchDown`](Self::TouchDown).
|
||||||
|
TouchMove = 10,
|
||||||
|
/// Touch ends. Only `code` (the touch id) is used.
|
||||||
|
TouchUp = 11,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
|
/// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
|
||||||
@@ -79,6 +87,9 @@ impl InputKind {
|
|||||||
6 => MouseScroll,
|
6 => MouseScroll,
|
||||||
7 => GamepadButton,
|
7 => GamepadButton,
|
||||||
8 => GamepadAxis,
|
8 => GamepadAxis,
|
||||||
|
9 => TouchDown,
|
||||||
|
10 => TouchMove,
|
||||||
|
11 => TouchUp,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -148,4 +159,26 @@ mod tests {
|
|||||||
assert_eq!(InputEvent::decode(&e.encode()), Some(e));
|
assert_eq!(InputEvent::decode(&e.encode()), Some(e));
|
||||||
assert!(InputEvent::decode(&[0u8; INPUT_WIRE_LEN]).is_none()); // bad magic
|
assert!(InputEvent::decode(&[0u8; INPUT_WIRE_LEN]).is_none()); // bad magic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn touch_kinds_roundtrip() {
|
||||||
|
for kind in [
|
||||||
|
InputKind::TouchDown,
|
||||||
|
InputKind::TouchMove,
|
||||||
|
InputKind::TouchUp,
|
||||||
|
] {
|
||||||
|
assert_eq!(InputKind::from_u8(kind as u8), Some(kind));
|
||||||
|
let e = InputEvent {
|
||||||
|
kind,
|
||||||
|
_pad: [0; 3],
|
||||||
|
code: 2, // touch id
|
||||||
|
x: 640,
|
||||||
|
y: 360,
|
||||||
|
flags: (1280u32 << 16) | 720, // client surface w/h
|
||||||
|
};
|
||||||
|
assert_eq!(InputEvent::decode(&e.encode()), Some(e));
|
||||||
|
}
|
||||||
|
// 12 (one past TouchUp) is not a valid kind.
|
||||||
|
assert_eq!(InputKind::from_u8(12), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ async fn connect_portal() -> Result<(
|
|||||||
rd.select_devices(
|
rd.select_devices(
|
||||||
&session,
|
&session,
|
||||||
SelectDevicesOptions::default()
|
SelectDevicesOptions::default()
|
||||||
.set_devices(DeviceType::Keyboard | DeviceType::Pointer)
|
.set_devices(DeviceType::Keyboard | DeviceType::Pointer | DeviceType::Touchscreen)
|
||||||
.set_persist_mode(PersistMode::DoNot),
|
.set_persist_mode(PersistMode::DoNot),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -263,7 +263,8 @@ impl EiState {
|
|||||||
| DeviceCapability::PointerAbsolute
|
| DeviceCapability::PointerAbsolute
|
||||||
| DeviceCapability::Keyboard
|
| DeviceCapability::Keyboard
|
||||||
| DeviceCapability::Scroll
|
| DeviceCapability::Scroll
|
||||||
| DeviceCapability::Button,
|
| DeviceCapability::Button
|
||||||
|
| DeviceCapability::Touch,
|
||||||
);
|
);
|
||||||
let _ = ctx.flush();
|
let _ = ctx.flush();
|
||||||
}
|
}
|
||||||
@@ -321,10 +322,31 @@ impl EiState {
|
|||||||
InputKind::MouseButtonDown | InputKind::MouseButtonUp => DeviceCapability::Button,
|
InputKind::MouseButtonDown | InputKind::MouseButtonUp => DeviceCapability::Button,
|
||||||
InputKind::MouseScroll => DeviceCapability::Scroll,
|
InputKind::MouseScroll => DeviceCapability::Scroll,
|
||||||
InputKind::KeyDown | InputKind::KeyUp => DeviceCapability::Keyboard,
|
InputKind::KeyDown | InputKind::KeyUp => DeviceCapability::Keyboard,
|
||||||
|
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {
|
||||||
|
DeviceCapability::Touch
|
||||||
|
}
|
||||||
InputKind::GamepadButton | InputKind::GamepadAxis => return, // uinput path (later)
|
InputKind::GamepadButton | InputKind::GamepadAxis => return, // uinput path (later)
|
||||||
};
|
};
|
||||||
let Some(idx) = self.device_for(cap) else {
|
let Some(idx) = self.device_for(cap) else {
|
||||||
return; // no resumed device with this capability yet
|
// No resumed device with this capability yet. For touch this is usually permanent on
|
||||||
|
// this compositor — the RemoteDesktop portal may grant the Touchscreen *device type*
|
||||||
|
// while the EIS server never creates a touchscreen *device* (observed on headless
|
||||||
|
// KWin). Surface it once so touch silently going nowhere is diagnosable.
|
||||||
|
if matches!(
|
||||||
|
ev.kind,
|
||||||
|
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp
|
||||||
|
) {
|
||||||
|
static WARNED: std::sync::atomic::AtomicBool =
|
||||||
|
std::sync::atomic::AtomicBool::new(false);
|
||||||
|
if !WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
tracing::warn!(
|
||||||
|
"touch received but the compositor's EIS exposed no touchscreen device — \
|
||||||
|
touch is dropped (KWin's libei may not implement ei_touchscreen yet; \
|
||||||
|
gamescope / a newer compositor may)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
};
|
};
|
||||||
let dev = self.devices[idx].device.device().clone();
|
let dev = self.devices[idx].device.device().clone();
|
||||||
self.ensure_emulating(idx, &dev);
|
self.ensure_emulating(idx, &dev);
|
||||||
@@ -398,6 +420,32 @@ impl EiState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Touch: `code` is the touch id, `x`/`y` are client pixels and `flags` packs the
|
||||||
|
// client surface w/h — mapped into the device's region exactly like MouseMoveAbs.
|
||||||
|
// One InputEvent = one frame, which satisfies the ei_touchscreen rule that a down /
|
||||||
|
// motion / up must not share a frame.
|
||||||
|
InputKind::TouchDown | InputKind::TouchMove => {
|
||||||
|
let w = ((ev.flags >> 16) & 0xffff) as f32;
|
||||||
|
let h = (ev.flags & 0xffff) as f32;
|
||||||
|
match (slot.interface::<ei::Touchscreen>(), slot.regions().first()) {
|
||||||
|
(Some(t), Some(region)) if w > 0.0 && h > 0.0 => {
|
||||||
|
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
|
||||||
|
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
|
||||||
|
let x = region.x as f32 + nx * region.width as f32;
|
||||||
|
let y = region.y as f32 + ny * region.height as f32;
|
||||||
|
if ev.kind == InputKind::TouchDown {
|
||||||
|
t.down(ev.code, x, y);
|
||||||
|
} else {
|
||||||
|
t.motion(ev.code, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => emitted = false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputKind::TouchUp => match slot.interface::<ei::Touchscreen>() {
|
||||||
|
Some(t) => t.up(ev.code),
|
||||||
|
None => emitted = false,
|
||||||
|
},
|
||||||
InputKind::GamepadButton | InputKind::GamepadAxis => emitted = false,
|
InputKind::GamepadButton | InputKind::GamepadAxis => emitted = false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -249,6 +249,8 @@ impl InputInjector for WlrootsInjector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
InputKind::GamepadButton | InputKind::GamepadAxis => {} // not yet injected
|
InputKind::GamepadButton | InputKind::GamepadAxis => {} // not yet injected
|
||||||
|
// wlroots has no virtual-touch protocol wired here; touch is the libei path only.
|
||||||
|
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {}
|
||||||
}
|
}
|
||||||
// Surface protocol errors / disconnects, then push the batch to the compositor.
|
// Surface protocol errors / disconnects, then push the batch to the compositor.
|
||||||
self.queue
|
self.queue
|
||||||
|
|||||||
+8
-3
@@ -69,9 +69,14 @@ select = a `pw_stream` with `Direction::Output` + `media.class=Audio/Source`.
|
|||||||
|
|
||||||
## 5. Touch + rich DualSense *(decision: commit to full UHID DualSense)*
|
## 5. Touch + rich DualSense *(decision: commit to full UHID DualSense)*
|
||||||
|
|
||||||
- **Touch (M):** `reis` already exposes `ei_touchscreen` — add Touch InputKinds + wire
|
- **Touch — implemented (host path), pending a backend that lands it.** `TouchDown/Move/Up`
|
||||||
`ei::Touchscreen` in `inject/libei.rs` (reuse the abs-pointer region mapping). Multi-touch
|
InputKinds (reuse the abs-pointer `flags=(w<<16)|h` mapping, `code`=touch id); host
|
||||||
on KWin/Mutter; single-pointer fallback elsewhere.
|
`inject/libei.rs` requests the `Touchscreen` device type + binds the `Touch` capability and
|
||||||
|
injects `ei_touchscreen` down/motion/up; `punktfunk-client-rs --touch-test` drags a finger.
|
||||||
|
**Validated:** KWin's RemoteDesktop portal *grants* the Touchscreen device type, but its EIS
|
||||||
|
server creates **no touchscreen device** (headless KWin) — so touch currently no-ops on KWin
|
||||||
|
(now logged once). The code is correct; it needs a backend that exposes `ei_touchscreen`
|
||||||
|
(gamescope / newer KWin / the real iPad client path) to land. wlroots: no virtual-touch wired.
|
||||||
- **Rich DualSense (XL, committed):** uinput can't carry HID output reports. Use **UHID** +
|
- **Rich DualSense (XL, committed):** uinput can't carry HID output reports. Use **UHID** +
|
||||||
the kernel `hid-playstation` driver (the inputtino/Wolf approach): present a genuine
|
the kernel `hid-playstation` driver (the inputtino/Wolf approach): present a genuine
|
||||||
DualSense (real report descriptor, vendor 054C/0CE6, BT mode + CRC32) so games drive LED +
|
DualSense (real report descriptor, vendor 054C/0CE6, BT mode + CRC32) so games drive LED +
|
||||||
|
|||||||
@@ -201,6 +201,14 @@ enum PunktfunkInputKind
|
|||||||
// Sticks are i16 range (−32768..32767) in the XInput/Moonlight convention — **+y =
|
// Sticks are i16 range (−32768..32767) in the XInput/Moonlight convention — **+y =
|
||||||
// up** (unlike mouse coordinates); triggers 0..255.
|
// up** (unlike mouse coordinates); triggers 0..255.
|
||||||
PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS = 8,
|
PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS = 8,
|
||||||
|
// Touch begins. `code` = touch id (which finger; reusable after `TouchUp`), `x`/`y` =
|
||||||
|
// pixel coordinates and `flags` = `(width << 16) | height` of the client's touch surface
|
||||||
|
// — the same absolute mapping as [`MouseMoveAbs`](Self::MouseMoveAbs).
|
||||||
|
PUNKTFUNK_INPUT_KIND_TOUCH_DOWN = 9,
|
||||||
|
// Touch moves. Same field meaning as [`TouchDown`](Self::TouchDown).
|
||||||
|
PUNKTFUNK_INPUT_KIND_TOUCH_MOVE = 10,
|
||||||
|
// Touch ends. Only `code` (the touch id) is used.
|
||||||
|
PUNKTFUNK_INPUT_KIND_TOUCH_UP = 11,
|
||||||
};
|
};
|
||||||
#ifndef __cplusplus
|
#ifndef __cplusplus
|
||||||
#if __STDC_VERSION__ >= 202311L
|
#if __STDC_VERSION__ >= 202311L
|
||||||
|
|||||||
Reference in New Issue
Block a user