diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index 60061f66..a0372cc9 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -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) diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index b91127ab..9eb6e7a2 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -1049,15 +1049,24 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result .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