From ec8ca9a5350ee111dc51c98b866db9e75b430707 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 22 Jul 2026 10:02:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(apple):=20cursor=20channel=20on=20the=20Ma?= =?UTF-8?q?c=20client=20=E2=80=94=20ABI=20v11=20+=20NSCursor=20rendering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mac client now exercises the full cursor channel against capable hosts: desktop-mode sessions advertise CLIENT_CAP_CURSOR, the host stops compositing the pointer, and StreamView draws the forwarded shapes as the real NSCursor — plus the M3 host-driven model flip. - ABI v11: punktfunk_connect_ex9 (adds client_caps; ex7/ex8 pass 0 — Android untouched), PunktfunkCursorShape/State + the two next_cursor_* poll fns (audio-style borrow contract), and PUNKTFUNK_CLIENT_CAP_CURSOR. Fixed in passing: the first insertion split next_host_timing's cfg/no_mangle attributes off the fn, which silently dropped the symbol from the header AND dylib — caught by the Swift build, reattached with its docs. - PunktfunkConnection: clientCaps connect param, hostSupportsCursor, CursorShapeEvent/CursorStateEvent + nextCursorShape/nextCursorState (one cursor thread drains both planes; cursorLock joins the close() ladder). - SessionModel: desktop-mode sessions (DefaultsKey.mouseMode) connect with the cursor cap on macOS. - StreamView: shape cache (serial → NSCursor, straight-alpha RGBA via CGImage), resetCursorRects wears the HOST shape while the desktop model is engaged (hidden host pointer ⇒ invisible), latest-wins state pump, and the M3 auto-flip — edge-triggered on relative_hint, ⌃⌥⇧M sets an override latch cleared by the next host edge, leaving relative warps the pointer to the host position via the inverse letterbox mapping (CGWarpMouseCursorPosition, CursorCapture's coordinate convention). Verified: swift build + full test suites green (xcframework rebuilt at ABI v11, signed); core 218 tests + clippy -D warnings on Linux (.21). Co-Authored-By: Claude Fable 5 --- .../Session/SessionModel.swift | 15 +- .../Connection/PunktfunkConnection.swift | 92 +++++++- .../PunktfunkKit/Views/StreamView.swift | 153 +++++++++++++- crates/punktfunk-core/src/abi.rs | 197 +++++++++++++++++- crates/punktfunk-core/src/lib.rs | 2 +- include/punktfunk_core.h | 90 +++++++- 6 files changed, 531 insertions(+), 18 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index dd158a68..19a2ca7e 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -302,13 +302,26 @@ final class SessionModel: ObservableObject { if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported { videoCodecs |= PunktfunkConnection.codecPyroWave } + // Cursor channel (remote-desktop-sweep M2, macOS): sessions STARTING in the desktop + // mouse model advertise local cursor rendering — the host then stops compositing + // the pointer and forwards shape/state, which StreamView draws as the real + // NSCursor. Capture-mode sessions keep today's composited pointer. + #if os(macOS) + let clientCaps: UInt8 = + (MouseInputMode( + rawValue: UserDefaults.standard.string(forKey: DefaultsKey.mouseMode) ?? "") + ?? .capture) == .desktop ? 0x01 : 0 + #else + let clientCaps: UInt8 = 0 + #endif let result = Result { try PunktfunkConnection( host: host.address, port: host.port, width: width, height: height, refreshHz: hz, pinSHA256: pin, identity: identity, compositor: compositor, gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps, audioChannels: audioChannels, - videoCodecs: videoCodecs, preferredCodec: preferredCodec, launchID: launchID, + videoCodecs: videoCodecs, preferredCodec: preferredCodec, + clientCaps: clientCaps, launchID: launchID, // Delegated approval: the host holds this connect open until the operator approves // it (~180 s) — outwait that window so a slow approval still lands here. Normal // connects keep the snappy default. diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 3a275a7a..2067ae0b 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -221,6 +221,9 @@ public final class PunktfunkConnection { /// core). The clip *sends* (`clipControl`/`clipOffer`/`clipServe`…) share this lock too: /// they're quick non-blocking enqueues, and a single lock keeps close() ordering simple. private let clipboardLock = NSLock() + /// Serializes the (single) cursor pull thread against close() — both cursor planes are + /// drained by ONE thread, so one lock covers them. + private let cursorLock = NSLock() /// Negotiated session mode (host-confirmed). public private(set) var width: UInt32 = 0 @@ -381,6 +384,84 @@ public final class PunktfunkConnection { public var hostSupportsClipboard: Bool { hostCaps & UInt8(PUNKTFUNK_HOST_CAP_CLIPBOARD) != 0 } + + /// The host answered `HOST_CAP_CURSOR`: it stopped compositing the pointer and forwards + /// shape/state on the cursor planes — the client MUST draw the cursor locally. + public var hostSupportsCursor: Bool { + hostCaps & 0x04 != 0 + } + + /// One forwarded host-cursor shape (the cursor channel, ABI v11): straight-alpha RGBA, + /// `rgba.count == width * height * 4`, hotspot within the bitmap. Cache by `serial` — + /// states reference shapes by it and a re-shown serial never resends pixels. + public struct CursorShapeEvent: Sendable { + public let serial: UInt32 + public let width: Int + public let height: Int + public let hotX: Int + public let hotY: Int + public let rgba: Data + } + + /// Per-host-tick cursor state: position (host video px, the pointer/hotspot point), + /// visibility, and the host-driven relative-mode hint (an app grabbed/hid the pointer ⇒ + /// run captured relative; clear ⇒ absolute, reappearing at `x`/`y`). Latest-wins. + public struct CursorStateEvent: Sendable { + public let serial: UInt32 + public let visible: Bool + public let relativeHint: Bool + public let x: Int32 + public let y: Int32 + } + + /// Pull the next forwarded cursor SHAPE (nil = timeout). Only a session connected with + /// `clientCaps` cursor bit against a `hostSupportsCursor` host receives any. Drain shape + /// AND state from ONE dedicated cursor thread (they share a lock). + public func nextCursorShape(timeoutMs: UInt32 = 0) throws -> CursorShapeEvent? { + cursorLock.lock() + defer { cursorLock.unlock() } + guard let h = liveHandle() else { throw PunktfunkClientError.closed } + var out = PunktfunkCursorShape() + let rc = punktfunk_connection_next_cursor_shape(h, &out, timeoutMs) + switch rc { + case statusOK: + // Copy out of the ABI borrow (valid until the next shape call) immediately. + let bytes = out.rgba.map { Data(bytes: $0, count: Int(out.len)) } ?? Data() + return CursorShapeEvent( + serial: out.serial, width: Int(out.w), height: Int(out.h), + hotX: Int(out.hot_x), hotY: Int(out.hot_y), rgba: bytes) + case statusNoFrame: + return nil + case statusClosed: + throw PunktfunkClientError.closed + default: + throw PunktfunkClientError.status(rc) + } + } + + /// Pull the next cursor STATE (nil = timeout). Latest-wins — drain the queue and apply + /// only the newest. Same thread + gate as [`nextCursorShape`]. + public func nextCursorState(timeoutMs: UInt32 = 0) throws -> CursorStateEvent? { + cursorLock.lock() + defer { cursorLock.unlock() } + guard let h = liveHandle() else { throw PunktfunkClientError.closed } + var out = PunktfunkCursorState() + let rc = punktfunk_connection_next_cursor_state(h, &out, timeoutMs) + switch rc { + case statusOK: + return CursorStateEvent( + serial: out.serial, + visible: out.flags & 0x01 != 0, + relativeHint: out.flags & 0x02 != 0, + x: out.x, y: out.y) + case statusNoFrame: + return nil + case statusClosed: + throw PunktfunkClientError.closed + default: + throw PunktfunkClientError.status(rc) + } + } /// The resolved codec as a `VideoCodec` (H.264 / HEVC / AV1) — drives the bitstream framing /// (Annex-B NAL parsing vs the AV1 OBU repack). public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) } @@ -417,6 +498,7 @@ public final class PunktfunkConnection { audioChannels: UInt8 = 2, videoCodecs: UInt8 = 0x02, // PUNKTFUNK_CODEC_HEVC — the codecs this client can decode preferredCodec: UInt8 = 0, // 0 = auto; else PUNKTFUNK_CODEC_* soft preference + clientCaps: UInt8 = 0, // ABI v11: PUNKTFUNK_CLIENT_CAP_CURSOR = render the host cursor locally launchID: String? = nil, timeoutMs: UInt32 = 10_000 ) throws { @@ -436,18 +518,18 @@ public final class PunktfunkConnection { withOptionalCString(launchID) { launch in if let pin = pinSHA256 { return pin.withUnsafeBytes { p in - punktfunk_connect_ex8( + punktfunk_connect_ex9( cs, port, width, height, refreshHz, compositor.rawValue, gamepad.rawValue, bitrateKbps, videoCaps, audioChannels, - videoCodecs, preferredCodec, launch, + videoCodecs, preferredCodec, clientCaps, launch, p.bindMemory(to: UInt8.self).baseAddress, &observed, cert, key, timeoutMs, &connectStatus) } } - return punktfunk_connect_ex8( + return punktfunk_connect_ex9( cs, port, width, height, refreshHz, compositor.rawValue, gamepad.rawValue, bitrateKbps, videoCaps, audioChannels, - videoCodecs, preferredCodec, launch, + videoCodecs, preferredCodec, clientCaps, launch, nil, &observed, cert, key, timeoutMs, &connectStatus) } } @@ -1059,10 +1141,12 @@ public final class PunktfunkConnection { feedbackLock.lock() statsLock.lock() clipboardLock.lock() + cursorLock.lock() abiLock.lock() let h = handle handle = nil abiLock.unlock() + cursorLock.unlock() clipboardLock.unlock() statsLock.unlock() feedbackLock.unlock() diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index c48b0d1a..afa72c40 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -219,6 +219,16 @@ public final class StreamLayerView: NSView { /// flipped live by ⌃⌥⇧M. A live flip re-engages capture in the new model so /// disassociation + the abs/rel choice swap atomically. Main-thread only. private var desktopMouse = false + /// Cursor channel (M2): the host forwards shape/state and WE draw the pointer. Active + /// when the Welcome carried `HOST_CAP_CURSOR` (only sessions that advertised the client + /// cap get it). Shapes cache by serial; state is latest-wins. Main-thread only. + private var cursorChannelActive = false + private var hostCursors: [UInt32: NSCursor] = [:] + private var cursorState: PunktfunkConnection.CursorStateEvent? + /// M3 hint tracking: edge-triggered so a manual ⌃⌥⇧M isn't fought — the override latch + /// holds until the HOST's intent next changes. + private var lastHint: Bool? + private var hintOverride = false /// One-shot auto-engage request (stream start, trust confirmed) — attempted as soon /// as the view is in a window with real bounds, then dropped, so it can never fire /// surprisingly later (e.g. on a resize). @@ -480,12 +490,136 @@ public final class StreamLayerView: NSView { /// globally via `CursorCapture` (the pointer can't leave the view there). override public func resetCursorRects() { if captured && desktopMouse { - addCursorRect(bounds, cursor: Self.invisibleCursor) + // 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. + if cursorChannelActive, let st = cursorState, st.visible, + let host = hostCursors[st.serial] { + addCursorRect(bounds, cursor: host) + } else { + addCursorRect(bounds, cursor: Self.invisibleCursor) + } } else { super.resetCursorRects() } } + /// Flip the mouse model with the atomic release/re-engage swap; `reappearAt` (host video + /// px — the M3 hand-back position) warps the local pointer so leaving relative lands the + /// cursor exactly where the host last had it. + private func setDesktopMouse(_ on: Bool, reappearAt: (x: Int32, y: Int32)?) { + guard desktopMouse != on else { return } + let wasCaptured = captured + if wasCaptured { releaseCapture() } + desktopMouse = on + if wasCaptured { engageCapture(fromClick: false) } + window?.invalidateCursorRects(for: self) + if on, let p = reappearAt, let sp = cgScreenPoint(forHostX: p.x, p.y) { + CGWarpMouseCursorPosition(sp) + } + } + + /// The single cursor pull thread (both planes share the connection's cursor lock): + /// latest-wins state at a short timeout + a non-blocking shape poll per iteration. + /// Exits when the connection closes; events hop to main where all cursor state lives. + private func startCursorPump(_ connection: PunktfunkConnection) { + let thread = Thread { [weak self] in + while true { + do { + var newest: PunktfunkConnection.CursorStateEvent? + if let st = try connection.nextCursorState(timeoutMs: 100) { + newest = st + while let more = try connection.nextCursorState(timeoutMs: 0) { + newest = more // drain — latest wins + } + } + while let shape = try connection.nextCursorShape(timeoutMs: 0) { + DispatchQueue.main.async { self?.applyCursorShape(shape) } + } + if let st = newest { + DispatchQueue.main.async { self?.applyCursorState(st) } + } + } catch { + return // connection closed — the session is over + } + if self == nil { return } + } + } + thread.name = "pf-cursor-pump" + thread.start() + } + + private func applyCursorShape(_ ev: PunktfunkConnection.CursorShapeEvent) { + guard let cursor = Self.makeCursor(ev) else { + streamInputLog.warning("cursor shape rejected (\(ev.width)x\(ev.height)) — keeping the previous cursor") + return + } + if hostCursors.count >= 64 { hostCursors.removeAll() } // degenerate host: reset + hostCursors[ev.serial] = cursor + if cursorState?.serial == ev.serial { + window?.invalidateCursorRects(for: self) + } + } + + private func applyCursorState(_ ev: PunktfunkConnection.CursorStateEvent) { + let prev = cursorState + cursorState = ev + if prev?.visible != ev.visible || prev?.serial != ev.serial { + window?.invalidateCursorRects(for: self) + } + // M3 — host-driven mode flip, edge-triggered (a fresh host intent clears a manual + // override): hint ⇒ captured relative; clear ⇒ absolute, reappearing at the host's + // last pointer position. + if lastHint != ev.relativeHint { + lastHint = ev.relativeHint + hintOverride = false + } + if !hintOverride, captured, desktopMouse == ev.relativeHint { + setDesktopMouse(!ev.relativeHint, reappearAt: ev.relativeHint ? nil : (ev.x, ev.y)) + streamInputLog.info("host cursor hint: mouse model flipped to \(self.desktopMouse ? "desktop" : "capture", privacy: .public)") + } + } + + /// Build an `NSCursor` from a forwarded straight-alpha RGBA shape. + private static func makeCursor(_ ev: PunktfunkConnection.CursorShapeEvent) -> NSCursor? { + let (w, h) = (ev.width, ev.height) + guard w > 0, h > 0, ev.rgba.count >= w * h * 4, + let provider = CGDataProvider(data: ev.rgba as CFData), + let cg = CGImage( + width: w, height: h, bitsPerComponent: 8, bitsPerPixel: 32, + bytesPerRow: w * 4, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue), + provider: provider, decode: nil, shouldInterpolate: false, + intent: .defaultIntent) + else { return nil } + let image = NSImage(cgImage: cg, size: NSSize(width: w, height: h)) + return NSCursor( + image: image, + hotSpot: NSPoint(x: min(ev.hotX, w - 1), y: min(ev.hotY, h - 1))) + } + + /// Host video px → CG GLOBAL screen coordinates (top-left origin, the + /// `CGWarpMouseCursorPosition` convention `CursorCapture` established) through the + /// aspect-fit letterbox — the inverse direction of `hostPoint(from:)`. + private func cgScreenPoint(forHostX hx: Int32, _ hy: Int32) -> CGPoint? { + guard let connection, let window else { return nil } + let mode = connection.currentMode() + guard mode.width > 0, mode.height > 0 else { return nil } + let fit = AVMakeRect( + aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)), + insideRect: bounds) + guard fit.width > 0, fit.height > 0 else { return nil } + let u = (CGFloat(hx) / CGFloat(mode.width)).clamped(to: 0...1) + let v = (CGFloat(hy) / CGFloat(mode.height)).clamped(to: 0...1) + let videoMinYTop = bounds.height - fit.maxY + let pTop = CGPoint(x: fit.minX + u * fit.width, y: videoMinYTop + v * fit.height) + let inView = CGPoint(x: pTop.x, y: bounds.height - pTop.y) + let inWindow = convert(inView, to: nil) + let onScreen = window.convertPoint(toScreen: inWindow) + let primaryHeight = NSScreen.screens.first?.frame.height ?? 0 + return CGPoint(x: onScreen.x, y: primaryHeight - onScreen.y) + } + /// A single local monitor for motion + buttons, installed only while captured. A local /// monitor is more robust than view overrides for relative motion: it sidesteps the /// `window.acceptsMouseMovedEvents`/tracking-area/responder-chain requirements, and @@ -647,12 +781,10 @@ public final class StreamLayerView: NSView { streamInputLog.info("mouse-mode chord ignored: gamescope host is relative-only") return } - let wasCaptured = self.captured - if wasCaptured { self.releaseCapture() } - self.desktopMouse.toggle() - if wasCaptured { self.engageCapture(fromClick: false) } - self.window?.invalidateCursorRects(for: self) - streamInputLog.info("mouse mode: \(self.desktopMouse ? "desktop" : "capture", privacy: .public)") + // A manual flip outranks the standing host hint until the hint next CHANGES. + self.hintOverride = true + self.setDesktopMouse(!self.desktopMouse, reappearAt: nil) + streamInputLog.info("chord: mouse mode \(self.desktopMouse ? "desktop" : "capture", privacy: .public)") } // The cross-client combos (⌃⌥⇧Q/D/S — Ctrl+Alt+Shift on the other clients), delivered by // the monitor only while captured; the same key-window ownership rule as ⌘⎋ throughout. @@ -692,6 +824,13 @@ public final class StreamLayerView: NSView { if mode == .desktop && !absOK { streamInputLog.info("desktop mouse mode unavailable on a gamescope host (relative-only) — using capture") } + // Cursor channel (M2): the host stopped compositing the pointer — drain its shape/ + // state planes and draw the pointer as the real NSCursor (plus the M3 auto-flip). + if connection.hostSupportsCursor { + cursorChannelActive = true + streamInputLog.info("cursor channel negotiated — host cursor renders locally") + startCursorPump(connection) + } // Presenter choice + lifecycle live in SessionPresenter (shared with iOS/tvOS): stage-2 // (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 8cd685c1..8e6cd059 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -555,6 +555,9 @@ pub struct PunktfunkConnection { /// (a fetched payload, an offer's format list, or a fetch-request's MIME) — /// borrow-until-next-call, same contract as `last`. last_clip: std::sync::Mutex>>, + /// The last cursor shape handed out — `next_cursor_shape`'s `rgba` pointer borrows it + /// until the next cursor-shape call (the `last_audio` contract). + last_cursor_shape: std::sync::Mutex>, } /// Lazily-initialized in-core Opus decode state. A coupled-1-stream multistream decoder is @@ -1400,6 +1403,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex7( connect_ex_impl( host, port, + 0, // pre-v11 variant: no client caps width, height, refresh_hz, @@ -1459,6 +1463,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex8( connect_ex_impl( host, port, + 0, // pre-v11 variant: no client caps width, height, refresh_hz, @@ -1480,6 +1485,70 @@ pub unsafe extern "C" fn punktfunk_connect_ex8( } } +/// Like [`punktfunk_connect_ex8`], plus `client_caps` (ABI v11): a bitfield of +/// `PUNKTFUNK_CLIENT_CAP_CURSOR` (0x01). Setting the cursor bit asks the host to STOP +/// compositing the pointer into the video and forward it out-of-band instead — the embedder +/// MUST then drain [`punktfunk_connection_next_cursor_shape`] / +/// [`punktfunk_connection_next_cursor_state`] and draw the pointer itself, or the session has +/// no visible cursor at all. Pass 0 for the composited behavior of every earlier variant. +/// +/// # Safety +/// Same as [`punktfunk_connect_ex8`]. +#[cfg(feature = "quic")] +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn punktfunk_connect_ex9( + host: *const std::os::raw::c_char, + port: u16, + width: u32, + height: u32, + refresh_hz: u32, + compositor: u32, + gamepad: u32, + bitrate_kbps: u32, + video_caps: u8, + audio_channels: u8, + video_codecs: u8, + preferred_codec: u8, + client_caps: u8, + launch_id: *const std::os::raw::c_char, + pin_sha256: *const u8, + observed_sha256_out: *mut u8, + client_cert_pem: *const std::os::raw::c_char, + client_key_pem: *const std::os::raw::c_char, + timeout_ms: u32, + status_out: *mut i32, +) -> *mut PunktfunkConnection { + unsafe { + connect_ex_impl( + host, + port, + client_caps, + width, + height, + refresh_hz, + compositor, + gamepad, + bitrate_kbps, + video_caps, + audio_channels, + video_codecs, + preferred_codec, + launch_id, + pin_sha256, + observed_sha256_out, + client_cert_pem, + client_key_pem, + timeout_ms, + status_out, + ) + } +} + +/// [`punktfunk_connect_ex9`] `client_caps` bit: render the host cursor locally (the cursor +/// channel, `design/remote-desktop-sweep.md` M2). +pub const PUNKTFUNK_CLIENT_CAP_CURSOR: u8 = 0x01; + /// Shared body of [`punktfunk_connect_ex7`] / [`punktfunk_connect_ex8`]: `status_out` /// (nullable) is written on EVERY path — `Ok`, the mapped [`PunktfunkError`], /// `InvalidArg` for bad arguments, `Panic` if the connect panicked. @@ -1488,6 +1557,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex8( unsafe fn connect_ex_impl( host: *const std::os::raw::c_char, port: u16, + client_caps: u8, width: u32, height: u32, refresh_hz: u32, @@ -1574,10 +1644,10 @@ unsafe fn connect_ex_impl( // themselves (EDR / MediaCodec), so the host's EDID defaults are fine there. An `ex8` // variant can carry it if a passthrough embedder ever needs it. None, - // No client_caps in the C ABI yet either: cursor-channel opt-in for Apple/Android - // arrives with the ABI v11 cursor poll fns — until an embedder can RENDER the - // forwarded cursor it must not ask the host to stop compositing it. - 0, + // ABI v11 ([`punktfunk_connect_ex9`]): CLIENT_CAP_CURSOR here asks the host to STOP + // compositing the pointer — only an embedder that renders the cursor planes + // ([`punktfunk_connection_next_cursor_shape`]/`_state`) may set it. ex7/ex8 pass 0. + client_caps, launch, pin, identity, @@ -1597,6 +1667,7 @@ unsafe fn connect_ex_impl( last_audio: std::sync::Mutex::new(None), audio_pcm: std::sync::Mutex::new(AudioPcmState::default()), last_clip: std::sync::Mutex::new(None), + last_cursor_shape: std::sync::Mutex::new(None), })) } Err(e) => { @@ -2246,6 +2317,124 @@ pub unsafe extern "C" fn punktfunk_connection_next_hdr_meta( }) } +/// One forwarded host-cursor shape (ABI v11, the cursor channel): straight-alpha RGBA8, no +/// padding, `len == w * h * 4`, hotspot within `w`×`h`. `serial` is the identity +/// [`PunktfunkCursorState`] refers to — cache the built OS cursor by it. +#[repr(C)] +pub struct PunktfunkCursorShape { + pub serial: u32, + pub w: u16, + pub h: u16, + pub hot_x: u16, + pub hot_y: u16, + /// Borrows connection memory until the NEXT cursor-shape call (the audio contract). + pub rgba: *const u8, + pub len: usize, +} + +/// Per-frame host-cursor state (ABI v11): position (the pointer/hotspot point in the host +/// video's pixel space), visibility, and the host-driven relative-mode hint. `flags` bit 0 = +/// visible, bit 1 = relative hint (a host app grabbed/hid the pointer — run captured +/// relative; clear = return to absolute, reappearing at `x`/`y`). +#[repr(C)] +pub struct PunktfunkCursorState { + pub serial: u32, + pub flags: u8, + pub x: i32, + pub y: i32, +} + +/// Pull the next forwarded cursor SHAPE (sent on pointer-bitmap change over the reliable +/// control stream; only a session connected with `PUNKTFUNK_CLIENT_CAP_CURSOR` against a +/// capable host receives any). On `Ok`, `out->rgba` borrows connection memory until the next +/// cursor-shape call on this handle. Drain from a dedicated thread (one thread per plane). +/// +/// # Safety +/// `c` is a valid connection handle; `out` is writable. At most one thread pulls cursor +/// shapes; it may run concurrently with every other plane's puller. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_next_cursor_shape( + c: *mut PunktfunkConnection, + out: *mut PunktfunkCursorShape, + timeout_ms: u32, +) -> PunktfunkStatus { + guard(|| { + let c = match unsafe { c.as_ref() } { + Some(c) => c, + None => return PunktfunkStatus::NullPointer, + }; + if out.is_null() { + return PunktfunkStatus::NullPointer; + } + match c + .inner + .next_cursor_shape(std::time::Duration::from_millis(timeout_ms as u64)) + { + Ok(shape) => { + let mut slot = c.last_cursor_shape.lock().unwrap(); + *slot = Some(shape); + let sh = slot.as_ref().unwrap(); + unsafe { + *out = PunktfunkCursorShape { + serial: sh.serial, + w: sh.w, + h: sh.h, + hot_x: sh.hot_x, + hot_y: sh.hot_y, + rgba: sh.rgba.as_ptr(), + len: sh.rgba.len(), + }; + } + PunktfunkStatus::Ok + } + Err(e) => e.status(), + } + }) +} + +/// Pull the next cursor STATE (a `0xD0` datagram per host encode tick — latest-wins; drain +/// the queue and apply only the newest). Same negotiation gate as +/// [`punktfunk_connection_next_cursor_shape`]. +/// +/// # Safety +/// `c` is a valid connection handle; `out` is writable. At most one thread pulls cursor +/// state; it may run concurrently with every other plane's puller. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_next_cursor_state( + c: *mut PunktfunkConnection, + out: *mut PunktfunkCursorState, + timeout_ms: u32, +) -> PunktfunkStatus { + guard(|| { + let c = match unsafe { c.as_ref() } { + Some(c) => c, + None => return PunktfunkStatus::NullPointer, + }; + if out.is_null() { + return PunktfunkStatus::NullPointer; + } + match c + .inner + .next_cursor_state(std::time::Duration::from_millis(timeout_ms as u64)) + { + Ok(st) => { + unsafe { + *out = PunktfunkCursorState { + serial: st.serial, + flags: st.flags, + x: st.x, + y: st.y, + }; + } + PunktfunkStatus::Ok + } + Err(e) => e.status(), + } + }) +} + /// Pull the next per-AU host timing (0xCF) into `*out`: the host's capture→sent duration for one /// access unit, correlated to the AU by `pts_ns` (see [`PunktfunkHostTiming`]). /// [`PunktfunkStatus::NoFrame`] on timeout, [`PunktfunkStatus::Closed`] once the session ended. diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index 7131c6ff..8fa26123 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -102,7 +102,7 @@ pub use stats::Stats; /// v10: added `punktfunk_connection_clock_offset_now_ns` — the LIVE (mid-stream re-synced) /// clock offset ongoing latency math must use; the connect-time getter stays frozen by /// contract. Additive, client-local — no wire change, so [`WIRE_VERSION`] is unchanged. -pub const ABI_VERSION: u32 = 10; +pub const ABI_VERSION: u32 = 11; /// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. /// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 9f66f508..61827391 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -45,7 +45,7 @@ // v10: added `punktfunk_connection_clock_offset_now_ns` — the LIVE (mid-stream re-synced) // clock offset ongoing latency math must use; the connect-time getter stays frozen by // contract. Additive, client-local — no wire change, so [`WIRE_VERSION`] is unchanged. -#define ABI_VERSION 10 +#define ABI_VERSION 11 // The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. // Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** @@ -215,6 +215,10 @@ // clipboard, so a client may offer the toggle. (Mirrors `quic::HOST_CAP_CLIPBOARD`.) #define PUNKTFUNK_HOST_CAP_CLIPBOARD 2 +// [`punktfunk_connect_ex9`] `client_caps` bit: render the host cursor locally (the cursor +// channel, `design/remote-desktop-sweep.md` M2). +#define PUNKTFUNK_CLIENT_CAP_CURSOR 1 + // `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble // datagram — an old host that sent no self-termination lease. The client then falls back to its // own staleness heuristic for that update instead of a host-supplied deadline. @@ -1373,6 +1377,31 @@ typedef struct { } PunktfunkHdrMeta; #endif +// One forwarded host-cursor shape (ABI v11, the cursor channel): straight-alpha RGBA8, no +// padding, `len == w * h * 4`, hotspot within `w`×`h`. `serial` is the identity +// [`PunktfunkCursorState`] refers to — cache the built OS cursor by it. +typedef struct { + uint32_t serial; + uint16_t w; + uint16_t h; + uint16_t hot_x; + uint16_t hot_y; + // Borrows connection memory until the NEXT cursor-shape call (the audio contract). + const uint8_t *rgba; + uintptr_t len; +} PunktfunkCursorShape; + +// Per-frame host-cursor state (ABI v11): position (the pointer/hotspot point in the host +// video's pixel space), visibility, and the host-driven relative-mode hint. `flags` bit 0 = +// visible, bit 1 = relative hint (a host app grabbed/hid the pointer — run captured +// relative; clear = return to absolute, reappearing at `x`/`y`). +typedef struct { + uint32_t serial; + uint8_t flags; + int32_t x; + int32_t y; +} PunktfunkCursorState; + #if defined(PUNKTFUNK_FEATURE_QUIC) // One access unit's host-side processing time ([`punktfunk_connection_next_host_timing`]): // capture → fully sent, i.e. the whole host pipeline (capture read/convert, encode, FEC+seal, @@ -1859,6 +1888,38 @@ PunktfunkConnection *punktfunk_connect_ex8(const char *host, int32_t *status_out); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Like [`punktfunk_connect_ex8`], plus `client_caps` (ABI v11): a bitfield of +// `PUNKTFUNK_CLIENT_CAP_CURSOR` (0x01). Setting the cursor bit asks the host to STOP +// compositing the pointer into the video and forward it out-of-band instead — the embedder +// MUST then drain [`punktfunk_connection_next_cursor_shape`] / +// [`punktfunk_connection_next_cursor_state`] and draw the pointer itself, or the session has +// no visible cursor at all. Pass 0 for the composited behavior of every earlier variant. +// +// # Safety +// Same as [`punktfunk_connect_ex8`]. +PunktfunkConnection *punktfunk_connect_ex9(const char *host, + uint16_t port, + uint32_t width, + uint32_t height, + uint32_t refresh_hz, + uint32_t compositor, + uint32_t gamepad, + uint32_t bitrate_kbps, + uint8_t video_caps, + uint8_t audio_channels, + uint8_t video_codecs, + uint8_t preferred_codec, + uint8_t client_caps, + const char *launch_id, + const uint8_t *pin_sha256, + uint8_t *observed_sha256_out, + const char *client_cert_pem, + const char *client_key_pem, + uint32_t timeout_ms, + int32_t *status_out); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Generate a persistent client identity: a self-signed certificate + private key, both // PEM, NUL-terminated, written into the caller's buffers. Generate ONCE, store both @@ -2080,6 +2141,33 @@ PunktfunkStatus punktfunk_connection_next_hdr_meta(PunktfunkConnection *c, uint32_t timeout_ms); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Pull the next forwarded cursor SHAPE (sent on pointer-bitmap change over the reliable +// control stream; only a session connected with `PUNKTFUNK_CLIENT_CAP_CURSOR` against a +// capable host receives any). On `Ok`, `out->rgba` borrows connection memory until the next +// cursor-shape call on this handle. Drain from a dedicated thread (one thread per plane). +// +// # Safety +// `c` is a valid connection handle; `out` is writable. At most one thread pulls cursor +// shapes; it may run concurrently with every other plane's puller. +PunktfunkStatus punktfunk_connection_next_cursor_shape(PunktfunkConnection *c, + PunktfunkCursorShape *out, + uint32_t timeout_ms); +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Pull the next cursor STATE (a `0xD0` datagram per host encode tick — latest-wins; drain +// the queue and apply only the newest). Same negotiation gate as +// [`punktfunk_connection_next_cursor_shape`]. +// +// # Safety +// `c` is a valid connection handle; `out` is writable. At most one thread pulls cursor +// state; it may run concurrently with every other plane's puller. +PunktfunkStatus punktfunk_connection_next_cursor_state(PunktfunkConnection *c, + PunktfunkCursorState *out, + uint32_t timeout_ms); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Pull the next per-AU host timing (0xCF) into `*out`: the host's capture→sent duration for one // access unit, correlated to the AU by `pts_ns` (see [`PunktfunkHostTiming`]).