From 92b7f73ae4f845a1ca224896421b4133727ea38e Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Mon, 10 Aug 2026 22:09:54 +0200 Subject: [PATCH] fix(universal-control): arm on KDE, and smooth the cursor over adb-TCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Universal Control never armed on Plasma. Two separate incompatibilities with xdg-desktop-portal-kde, the first masking the second: 1. `barrier_id = 0` is rejected outright. `inputcapture.cpp` does if (id == 0) { "Invalid barrier id"; failedBarriers.append(id); } BEFORE any geometry check. The InputCapture spec puts no constraint on the id and Mutter accepts 0, so this broke every KDE session while looking like a geometry problem. Now `BARRIER_ID = 1`. 2. KWin only arms a barrier spanning the WHOLE screen edge — `inputcapturebarrier.cpp` requires `y1 == geometry.y() && y2 == geometry.bottom()` and returns `BetweenScreensOrDoesNotFill` otherwise. The 400 px corner strip (`SEGMENT_LEN`) is therefore refused. We now retry across the full edge when the compositor refuses the partial one, rebinding the armed `span` so `entry_point` / `laptop_point` still map crossings against the barrier that actually armed. Mutter keeps the corner strip; KDE trades corner avoidance for working at all. `barrier_pos` is split out so the retry builds its line the same way the first attempt did. The failure was diagnosable only from the portal's own journal ("Invalid barrier id 0") — our side could see nothing beyond a non-empty `failed_barriers`, which is what `barrier_refused` already reported. Also: fill gaps in traffic to the phone while it holds the pointer. Android parks the Wi-Fi radio between packets and the AP buffers ours until its next wake — measured 150–350 ms on an idle 5 GHz link (RSSI -52, 866 Mbps) versus 2–13 ms awake, which made the cursor stutter badly whenever adb rides TCP rather than USB. Driven off the existing 2 ms flush tick and gated on `last_tx`, so it sends nothing while real motion is flowing and stops the moment the pointer leaves. Covers `pending` too, so the crossing lands on an already-awake radio instead of paying the wake penalty on its first motion. `last_tx` is deliberately NOT `last_motion`: that one is the acceleration curve's `dt`. This is a mitigation for a transport limitation, not a fix — USB adb remains materially smoother. --- .../src-tauri/src/universal_control.rs | 88 ++++++++++++++++--- 1 file changed, 77 insertions(+), 11 deletions(-) diff --git a/linux/ui-tauri/src-tauri/src/universal_control.rs b/linux/ui-tauri/src-tauri/src/universal_control.rs index 0115e2d..19cffae 100644 --- a/linux/ui-tauri/src-tauri/src/universal_control.rs +++ b/linux/ui-tauri/src-tauri/src/universal_control.rs @@ -424,15 +424,8 @@ async fn capture_loop() -> Result<(), Box> { Edge::Top | Edge::Bottom => (x, w), Edge::Left | Edge::Right => (y, h), }; - let span = barrier_span(seg, full); - let (s0, sl) = span; - let pos = match edge { - Edge::Left => (x, s0, x, s0 + sl - 1), - Edge::Right => (x + w, s0, x + w, s0 + sl - 1), - Edge::Top => (s0, y, s0 + sl - 1, y), - Edge::Bottom => (s0, y + h, s0 + sl - 1, y + h), - }; - let barriers = vec![Barrier::new(0, pos)]; + let mut span = barrier_span(seg, full); + let mut pos = barrier_pos(edge, (x, y, w, h), span); tracing::info!( "universal-control: barrier at {pos:?} ({} edge, {} of it)", edge_name(edge), @@ -444,10 +437,31 @@ async fn capture_loop() -> Result<(), Box> { ); // A barrier the compositor refuses to arm is silent otherwise: we would log // "armed", then wait forever for an activation that cannot come. - let set = ic - .set_pointer_barriers(&session, &barriers, zones.zone_set()) + let mut set = ic + .set_pointer_barriers(&session, &[Barrier::new(BARRIER_ID, pos)], zones.zone_set()) .await? .response()?; + // Not every compositor takes a partial edge. KWin only arms a barrier that + // spans the WHOLE screen edge — xdg-desktop-portal-kde's + // `inputcapturebarrier.cpp` rejects anything else with + // `BetweenScreensOrDoesNotFill` (it requires `y1 == geometry.y() && y2 == + // geometry.bottom()`), so the short corner strip below is refused outright + // and Universal Control never arms on Plasma. Mutter accepts it, which is + // why this went unnoticed. Fall back to the full edge instead of giving up: + // the corner-avoidance is a nicety, crossing at all is the feature. + if !set.failed_barriers().is_empty() && seg != Segment::Full { + tracing::warn!( + "universal-control: compositor refused the partial {} barrier {pos:?}; \ + retrying across the whole edge", + edge_name(edge) + ); + span = full; + pos = barrier_pos(edge, (x, y, w, h), span); + set = ic + .set_pointer_barriers(&session, &[Barrier::new(BARRIER_ID, pos)], zones.zone_set()) + .await? + .response()?; + } if !set.failed_barriers().is_empty() { return Err(format!("barrier_refused|{} {pos:?}", edge_name(edge)).into()); } @@ -547,6 +561,19 @@ async fn capture_loop() -> Result<(), Box> { // 2 ms (~500 Hz) — low latency, still coalesces bursts so the socket never // backs up (per-event flooding caused stutter). let mut flush = tokio::time::interval(Duration::from_millis(2)); + // Longest gap we allow in traffic to the phone while it holds the pointer. + // + // Android parks the Wi-Fi radio between packets, and the AP then buffers + // ours until the phone's next wake — measured at 150–350 ms on an otherwise + // idle link (vs ~2–13 ms awake), which is exactly what makes the cursor + // stutter when adb rides TCP instead of USB. Real motion already keeps the + // radio awake; this only fills the gaps when the hand pauses, so it costs + // nothing while you are actually moving. + const KEEPALIVE_GAP: Duration = Duration::from_millis(10); + // Last time ANYTHING was written to the phone. Deliberately separate from + // `last_motion`, which is the acceleration curve's `dt` — feeding keepalives + // into that would corrupt the curve we divide out. + let mut last_tx = Instant::now(); loop { tokio::select! { @@ -813,6 +840,7 @@ async fn capture_loop() -> Result<(), Box> { send_y -= ey; last_motion = Instant::now(); crate::mirror_inject::send(&format!("P {ex} {ey}", ex = ex as i32, ey = ey as i32)); + last_tx = Instant::now(); } // Dead reckoning, clamped exactly like Android clamps // its own pointer, so `px`/`py` stay in step with what @@ -1010,6 +1038,19 @@ async fn capture_loop() -> Result<(), Box> { overpush = 0.0; set_cursor(false); } + // Wi-Fi power-save keepalive (see KEEPALIVE_GAP). Covers both + // `active` (pointer on the phone) and `pending` (push being + // measured) so the radio is already awake when the crossing + // commits — otherwise the very first motion after crossing pays + // the wake penalty, which is the jolt you feel on arrival. + // Driven off the flush tick and gated on elapsed time, so it + // cannot leak: the moment both flags clear, it stops. + // An empty line is a no-op for the injector — `process_line` + // switches on `line[0]`, and '\0' matches no command. + if (active || pending) && last_tx.elapsed() >= KEEPALIVE_GAP { + crate::mirror_inject::send(""); + last_tx = Instant::now(); + } }, } } @@ -1148,6 +1189,31 @@ fn abandon_pos( /// the edge — at which point "leave the rest to the dock" quietly stops being /// true, which is the only reason a segment exists. Length stays at least 1 /// because a zero-length barrier is not a barrier. +/// Id for the single pointer barrier we register. +/// +/// MUST NOT be 0. The InputCapture portal spec puts no constraint on the id, and +/// Mutter happily takes 0, but xdg-desktop-portal-kde rejects it out of hand — +/// `inputcapture.cpp` does `if (id == 0) { "Invalid barrier id"; failed; }` +/// *before* looking at the geometry at all. With id 0 the barrier is silently +/// refused on every KDE session, so Universal Control never arms and pushing at +/// the screen edge does nothing. +const BARRIER_ID: u32 = 1; + +/// The barrier line for `span` along `edge` of the monitor rect `(x, y, w, h)`. +/// +/// Split out of the caller so the KWin full-edge retry builds its line the same +/// way the first attempt did, rather than duplicating the four-way match. +fn barrier_pos(edge: Edge, rect: (i32, i32, i32, i32), span: (i32, i32)) -> (i32, i32, i32, i32) { + let (x, y, w, h) = rect; + let (s0, sl) = span; + match edge { + Edge::Left => (x, s0, x, s0 + sl - 1), + Edge::Right => (x + w, s0, x + w, s0 + sl - 1), + Edge::Top => (s0, y, s0 + sl - 1, y), + Edge::Bottom => (s0, y + h, s0 + sl - 1, y + h), + } +} + fn barrier_span(seg: Segment, full: (i32, i32)) -> (i32, i32) { if seg == Segment::Full { return full;