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
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user