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