diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index feeab91b..840ecf93 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -236,6 +236,13 @@ public final class StreamLayerView: NSView { let hotY: Int } private var hostCursors: [UInt32: HostCursorShape] = [:] + /// The last shape actually worn. State (`0xD0`, a per-frame datagram) announces a new serial the + /// moment the host QUEUES its bitmap on the reliable control stream, so the client routinely + /// knows a serial before it holds the pixels — and the shape ring drops the NEWEST under burst + /// (`CURSOR_SHAPE_QUEUE`), which the host never re-sends because it only sends on a serial + /// CHANGE. Both leave `hostCursors[serial]` empty; wearing the previous pointer through that + /// gap degrades it to a briefly-stale shape instead of blinking the pointer out of existence. + private var lastWornShape: HostCursorShape? private var cursorState: PunktfunkConnection.CursorStateEvent? /// Last `CursorRenderMode.clientDraws` told to the host (the §8 mid-stream render flip); /// nil = nothing sent yet. Edge-detected by [`reconcileCursorRender`] from the live mouse @@ -509,10 +516,19 @@ public final class StreamLayerView: NSView { override public func resetCursorRects() { if captured && desktopMouse { // Cursor channel active: wear the HOST's pointer shape (it is no longer in the - // video); hidden host pointer (or no shape yet) = invisible. Without the channel, - // M1 behavior: invisible local cursor, the composited host cursor is the visible one. + // video); a HIDDEN host pointer (or nothing seen yet at all) = invisible. Without the + // channel, M1 behavior: invisible local cursor, the composited host cursor is the + // visible one. + // + // A visible pointer whose announced serial has no bitmap yet falls back to the last + // worn shape (see `lastWornShape`) rather than to `invisibleCursor`. That case is + // routine, not degenerate — state outruns its bitmap on every single shape change — + // and treating it as "hide the pointer" made the pointer VANISH over anything whose + // shape arrived late or got dropped, with no recovery until the next change. Only + // `st.visible == false` may hide the pointer; a missing bitmap may not. if cursorChannelActive, let st = cursorState, st.visible, - let shape = hostCursors[st.serial] { + let shape = hostCursors[st.serial] ?? lastWornShape { + lastWornShape = shape addCursorRect(bounds, cursor: scaledCursor(shape)) } else { addCursorRect(bounds, cursor: Self.invisibleCursor) @@ -583,6 +599,8 @@ public final class StreamLayerView: NSView { private func applyCursorShape(_ ev: PunktfunkConnection.CursorShapeEvent) { guard let shape = Self.makeShape(ev) else { + // Truthful only because `resetCursorRects` falls back to `lastWornShape`: before that, + // a rejection here left the announced serial with no bitmap and HID the pointer. streamInputLog.warning("cursor shape rejected (\(ev.width)x\(ev.height)) — keeping the previous cursor") return } diff --git a/crates/punktfunk-core/src/client/planes.rs b/crates/punktfunk-core/src/client/planes.rs index 0adccbbc..e70b2fbc 100644 --- a/crates/punktfunk-core/src/client/planes.rs +++ b/crates/punktfunk-core/src/client/planes.rs @@ -36,8 +36,13 @@ pub(crate) const HOST_TIMING_QUEUE: usize = 512; pub(crate) const CLIP_EVENT_QUEUE: usize = 32; /// Cursor-shape plane depth (control-stream [`crate::quic::CursorShape`], one per pointer-bitmap -/// change — human-paced). Overflow drops the newest (try_send); the next shape change or a -/// serial mismatch against `0xD0` state heals it visually within a shape-change period. +/// change — human-paced, but bursty: crossing a toolbar flips arrow/I-beam/hand/resize several +/// times a second, and every flip mints a fresh serial and a fresh bitmap). Overflow drops the +/// newest (try_send) and the host does NOT re-send it — it only sends on a serial CHANGE — so the +/// dropped serial stays un-backed until the pointer changes shape again. Embedders must therefore +/// hold their last worn shape when `hostCursors[serial]` misses rather than hiding the pointer +/// (the Apple client's `lastWornShape`); healing is bounded by the next shape change, not by this +/// ring. pub(crate) const CURSOR_SHAPE_QUEUE: usize = 8; /// Cursor-state plane depth (`0xD0`, one datagram per captured frame). Latest-wins state — the