fix(clients/cursor): the host must not composite a pointer under a released client's own cursor
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 1m9s
android / android (pull_request) Successful in 4m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 1m36s
ci / web (pull_request) Successful in 4m31s
ci / docs-site (pull_request) Successful in 4m43s
ci / rust-arm64 (pull_request) Successful in 6m50s
ci / rust (pull_request) Failing after 12m8s

Streaming a KDE desktop showed two cursors: the one the user was moving, and a
second one sitting underneath it that never moved. It was not KDE's — KWin 6.7.3
in cursor-as-metadata mode calls `setRenderCursor(false)` on every recorded buffer
and hands the cursor item to an exclusive `ItemTreeView`, so `shouldRenderItem()`
skips it and no pointer is ever painted into that stream. It was ours.

Both clients declared the render model as `captured && desktop`, so ANY released
pointer handed compositing back to the host. But releasing does not remove the
local cursor — it restores the ordinary window arrow over the video. The host then
blends its own pointer in underneath, and since a released client forwards no
motion, nothing drives it: it stays frozen wherever the host pointer was last left.
Caught live on the host with the render-model diag:

    cursor diag: client_draws=false blended=true live=Some((-1, 622, true))

x = -1 — parked on the streamed output's left edge, unchanged sample after sample,
while the user moved their own cursor around freely. Engaging capture flipped it to
`client_draws=true blended=false` and the duplicate vanished, which is why it only
looked "stuck when not dragging": dragging means engaged, and engaged was the one
state that behaved.

The host may composite ONLY while the client holds a grabbed, hidden pointer — the
capture model, engaged — which is the single state with no local cursor on screen.
Released now counts as "the client draws it": the host stops compositing and keeps
forwarding shape/state over the channel (the forwarder ticks on this side of the
flip), so re-engaging is seamless and the client's cached shape stays warm.
This commit is contained in:
2026-08-06 15:03:11 +02:00
parent 79ee308a7a
commit 2a67c02f7e
2 changed files with 32 additions and 12 deletions
@@ -538,14 +538,25 @@ public final class StreamLayerView: NSView {
}
}
/// Tell the host who renders the pointer (the §8 mid-stream render flip): we draw it only
/// while the DESKTOP model is engaged (the local OS cursor wears the host shape); under
/// the capture model and while released the host composites it into the video (full
/// fidelity, the pre-channel look). One edge-detected reconciler, called from every
/// Tell the host who renders the pointer (the §8 mid-stream render flip). The host may
/// composite one into the video ONLY while we are holding a grabbed, hidden pointer the
/// capture model, engaged. That is the one state with no local cursor on screen.
///
/// Every other state leaves a normal OS cursor visible over the video: the desktop model
/// draws it wearing the host's shape, and a RELEASED view shows the plain arrow. A
/// host-composited pointer then appears *underneath* it as a second cursor and, because a
/// released view forwards no motion, one that never moves. On glass that reads as a frozen
/// duplicate stuck wherever the host pointer was last left (verified: `client_draws=false
/// blended=true live=(-1, 622)` parked on the streamed output's left edge while the user
/// moved their own cursor around freely).
///
/// So "released" counts as WE draw it: the host stops compositing, the client keeps
/// receiving shape/state over the channel (the forwarder only ticks on this side of the
/// flip), and re-engaging is seamless. One edge-detected reconciler, called from every
/// transition (chord, engage/release, session start).
private func reconcileCursorRender() {
guard cursorChannelActive, let connection else { return }
let clientDraws = captured && desktopMouse
let clientDraws = !captured || desktopMouse
guard sentClientDraws != clientDraws else { return }
sentClientDraws = clientDraws
connection.setCursorRender(clientDraws: clientDraws)
+16 -7
View File
@@ -1049,15 +1049,24 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
.as_ref()
.is_some_and(|cap| cap.captured() && cap.desktop());
chan.pump(c, &mouse, desktop_active, fit_scale);
// §8 mid-stream render flip: tell the host who renders the pointer whenever
// the local model changes. Desktop-active = we draw it (host excludes +
// forwards); anything else — the capture model OR a released pointer — the
// host composites it into the video (full fidelity, the pre-channel look).
// §8 mid-stream render flip: tell the host who renders the pointer whenever the
// local model changes. The host may composite one ONLY while we hold a grabbed,
// hidden pointer — the capture model, engaged — because that is the one state
// with no local cursor on screen. Note this is deliberately NOT `desktop_active`:
// a RELEASED pointer leaves the ordinary window cursor visible over the video,
// and a host-composited pointer then sits UNDER it as a second cursor that never
// moves (released forwards no motion), which reads on glass as a frozen
// duplicate. Released therefore counts as "we draw it" — the host stops
// compositing and keeps forwarding shape/state, so re-engaging is seamless.
// One edge-detected reconciler covers the chord, the M3 auto-flip, and
// engage/release alike.
if chan.negotiated() && st.sent_client_draws != Some(desktop_active) {
st.sent_client_draws = Some(desktop_active);
let _ = c.set_cursor_render(desktop_active);
let client_draws = match st.capture.as_ref() {
Some(cap) => !cap.captured() || cap.desktop(),
None => true,
};
if chan.negotiated() && st.sent_client_draws != Some(client_draws) {
st.sent_client_draws = Some(client_draws);
let _ = c.set_cursor_render(client_draws);
}
}
// M3 — host-driven mode flip: `relative_hint` set = a host app grabbed/hid the