feat(core+host+client): cursor channel — remote-desktop sweep M2a+M2b

The host cursor stops riding the video and becomes a real OS cursor on
the client (the Parsec/RDP model): pointer feel no longer pays the
capture→encode→network→decode→present round trip.

Wire (M2a):
- Hello grows a client_caps trailing byte (CLIENT_CAP_CURSOR) after the
  fixed display_hdr block — presence disambiguated by remaining length,
  which caps the post-HDR tail at 27 bytes (documented); Welcome answers
  HOST_CAP_CURSOR (capable-and-asked, the 444/clipboard precedent).
- CursorShape (0x50, control stream): serial + dims + hotspot + straight
  RGBA, ≤120px/side so the u16 frame always fits (128² would overshoot);
  client caches by serial — re-showing a known shape costs 14 bytes, not
  a bitmap (RDP pointer-cache for free).
- CursorState (0xD0 datagram): serial + visible/relative_hint flags +
  position, sent once per encode-loop tick — latest-wins, self-healing
  under loss, no refresh timer. relative_hint is reserved for M3.
- Client core: two new planes (control-task + datagram-task arms) →
  next_cursor_shape/next_cursor_state; connect() grows client_caps
  (C ABI passes 0 until the v11 cursor poll fns exist).

Host (M2b, Linux portal only):
- handshake::cursor_forward is THE predicate (client asked ∧ Linux ∧
  compositor ≠ gamescope) — Welcome bit and session wiring both read it.
- SessionPlan.cursor_blend goes false for a forwarding session; the
  encode loop ticks a CursorForwarder every iteration: shape-serial diff
  → control-task bridge (mirrors probe_result), state datagram → conn.
- CursorOverlay/capture CursorState carry the hotspot through
  (nearest-neighbor downscale backstop for XL cursors, unit-tested).

Presenter:
- CursorChannel drains both planes per loop iteration; shapes become
  SDL color cursors (from_surface + hotspot), applied while the desktop
  mouse model is engaged; visibility follows the host; capture/released
  hands back the system cursor. Sessions advertise the cap when they
  START in desktop mode.

Verified on .21: fmt + clippy -D warnings (7 crates) + tests green
(core 218 incl. new wire roundtrips, host 245 incl. e2e + forwarder
downscale tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 23:43:23 +02:00
co-authored by Claude Fable 5
parent 97d20e1f0a
commit 4a01bc4463
29 changed files with 894 additions and 13 deletions
+18
View File
@@ -76,6 +76,8 @@ mod handshake;
/// The mid-stream control task (plan §W1); `serve_session` spawns `control::run` after the
/// handshake to multiplex renegotiation / speed-test control messages onto the data-plane channels.
mod control;
/// Cursor-forward channel (M2): the encode loop's shape/state emission.
mod cursor_fwd;
/// The capture→encode→send data plane (plan §W1); `serve_session` dispatches the synthetic or
/// virtual source here (`synthetic_stream` / `virtual_stream`) and hands the latter a
@@ -963,6 +965,15 @@ async fn serve_session(
// accepted ack as "the active mode is now X" and fixes itself; old clients just log it.
let (reconfig_result_tx, reconfig_result_rx) =
tokio::sync::mpsc::unbounded_channel::<Reconfigured>();
// Cursor-forward bridge (M2): the encode loop diffs each frame's cursor serial and hands
// changed SHAPES here; the control task (the control stream's sole writer) sends them.
// Same shape as `probe_result_tx`. Wired even when the channel wasn't negotiated — it
// just never fires then.
let (cursor_shape_tx, cursor_shape_rx) =
tokio::sync::mpsc::unbounded_channel::<punktfunk_core::quic::CursorShape>();
// Negotiated cursor forwarding: MUST match the HOST_CAP_CURSOR bit the Welcome advertised
// (handshake::cursor_forward is the single predicate both read).
let cursor_forward = handshake::cursor_forward(hello.client_caps, compositor);
// Adaptive FEC: the control task maps each client LossReport to a recovery percent and publishes
// it here; the data-plane send loop reads + applies it per frame. Disabled (pinned) when
// PUNKTFUNK_FEC_PCT is set. Seeded with the session's starting FEC so it's a no-op until a report.
@@ -998,6 +1009,7 @@ async fn serve_session(
probe_tx,
probe_result_rx,
reconfig_result_rx,
cursor_shape_rx,
clip_enabled,
clip,
));
@@ -1383,6 +1395,8 @@ async fn serve_session(
fec_target: fec_target_dp,
conn: conn_stream,
timing_conn,
cursor_forward,
cursor_shape_tx,
probe_seq,
streamed_au,
stats: stats_dp,
@@ -2049,6 +2063,7 @@ mod tests {
0, // video_codecs (HEVC-only)
0, // preferred_codec
None, // display_hdr
0, // client_caps
None, // launch
None, // pin (TOFU)
None, // identity (host doesn't require pairing)
@@ -2219,6 +2234,7 @@ mod tests {
0, // video_codecs (0 → HEVC-only)
0, // preferred_codec (auto)
None, // display_hdr
0, // client_caps
None, // launch
None, // pin: TOFU — the operator's approval (not a PIN) authorizes this client
Some((cert, key)),
@@ -2286,6 +2302,7 @@ mod tests {
0, // video_codecs
0, // preferred_codec
None, // display_hdr
0, // client_caps
None, // launch
None,
None,
@@ -2315,6 +2332,7 @@ mod tests {
0, // video_codecs
0, // preferred_codec
None, // display_hdr
0, // client_caps
None, // launch
Some(host_fp),
Some((cert.clone(), key.clone())),