feat(apple/clipboard): macOS client half of the shared clipboard (Phase 1 §5)
ci / web (pull_request) Successful in 48s
ci / docs-site (pull_request) Successful in 53s
apple / swift (pull_request) Successful in 1m16s
apple / screenshots (pull_request) Has been skipped
android / android (pull_request) Has been cancelled
ci / rust (pull_request) Has been cancelled
ci / bench (pull_request) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (pull_request) Has been cancelled

The NSPasteboard bridge completing Phase 1 (design/clipboard-and-file-transfer.md
§5) — with the host backends on this branch, copy/paste now crosses the wire in
both directions on macOS. Lazy in both directions:

- PunktfunkConnection grows the clipboard plane: its own clipboardLock (close()
  joins it like the other pullers), hostCaps/hostSupportsClipboard from the
  Welcome, the typed ClipEvent vocabulary, and the six ABI wrappers
  (clipControl/clipOffer/clipFetch/clipServe/clipCancel/nextClipboard — borrowed
  event payloads copied out before the next poll).
- ClipboardSync (PunktfunkKit, macOS-only): one drain thread bridging
  NSPasteboard.general ↔ the QUIC clipboard plane. Local copies announce format
  lists via a 500 ms changeCount poll (+ immediate on app activation); bytes
  leave only on a host FetchRequest, answered from the live pasteboard and
  seq-guarded against staleness. Host copies install one NSPasteboardItem whose
  data provider fires only when a Mac app actually pastes, then blocks its
  provider thread (never main) on a 10 s-bounded fetch. Concealed/Transient
  pasteboards (password managers) are never announced; our own writes are
  changeCount-suppressed (§3.4). Text/RTF/HTML/PNG; files ride Phase 2.
- UI: per-host "Share clipboard with this host" toggle (StoredHost.clipboardSync,
  optional for saved-JSON forward-compat — wire-format tests extended), a
  mid-session Share/Stop Sharing Clipboard item in the Stream menu (⌃⌥⇧C,
  greyed without HOST_CAP_CLIPBOARD), SessionModel owning the lifecycle
  (start on streaming after the trust gate, drain joined off-main on teardown).

swift build + swift test green (macOS). Requires the ABI v8 xcframework
(scripts/build-xcframework.sh).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 13:40:47 +02:00
parent da0578771e
commit 5d0e23d6a5
8 changed files with 647 additions and 2 deletions
@@ -196,6 +196,10 @@ public final class PunktfunkConnection {
/// Same role for the host-timing (0xCF) puller its own plane in the core, drained
/// non-blockingly by the app's 1 s stats tick (never contends with the blocking pullers).
private let statsLock = NSLock()
/// Same role for the shared-clipboard drain thread (`nextClipboard` its own plane in the
/// 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()
/// Negotiated session mode (host-confirmed).
public private(set) var width: UInt32 = 0
@@ -346,6 +350,16 @@ public final class PunktfunkConnection {
/// parse-window size for `USER_FLAG_CHUNK_ALIGNED` PyroWave AUs (plan §4.4). Other codecs
/// never need it.
public private(set) var shardPayload: UInt32 = 1408
/// The host capability bitfield (`Welcome.host_caps`): `PUNKTFUNK_HOST_CAP_GAMEPAD_STATE` /
/// `PUNKTFUNK_HOST_CAP_CLIPBOARD`. `0` for an older host that didn't say.
public private(set) var hostCaps: UInt8 = 0
/// Whether this host advertises the shared clipboard (`HOST_CAP_CLIPBOARD`) the gate for
/// offering the clipboard toggle. Absent on an older host, or one whose operator policy
/// (`PUNKTFUNK_CLIPBOARD=off`) keeps the feature dark.
public var hostSupportsClipboard: Bool {
hostCaps & UInt8(PUNKTFUNK_HOST_CAP_CLIPBOARD) != 0
}
/// 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) }
@@ -461,6 +475,9 @@ public final class PunktfunkConnection {
var shard: UInt32 = 1408
_ = punktfunk_connection_shard_payload(handle, &shard)
shardPayload = shard
var caps: UInt8 = 0
_ = punktfunk_connection_host_caps(handle, &caps)
hostCaps = caps
}
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
@@ -987,10 +1004,12 @@ public final class PunktfunkConnection {
audioLock.lock()
feedbackLock.lock()
statsLock.lock()
clipboardLock.lock()
abiLock.lock()
let h = handle
handle = nil
abiLock.unlock()
clipboardLock.unlock()
statsLock.unlock()
feedbackLock.unlock()
audioLock.unlock()
@@ -1052,6 +1071,163 @@ public final class PunktfunkConnection {
_ = punktfunk_connection_send_rich_input(h, &rich)
}
// MARK: - Shared clipboard (design/clipboard-and-file-transfer.md §5)
/// One advertised clipboard format in a lazy offer the format list crosses the wire,
/// the bytes only on a fetch.
public struct ClipKind: Sendable, Equatable {
public let mime: String
/// Best-effort size in bytes; `0` = unknown.
public let sizeHint: UInt64
public init(mime: String, sizeHint: UInt64 = 0) {
self.mime = mime
self.sizeHint = sizeHint
}
}
/// A shared-clipboard event from `nextClipboard`. The drain thread turns these into
/// NSPasteboard operations (`ClipboardSync`).
public enum ClipEvent: Sendable, Equatable {
/// The host copied: its lazy format list (empty = the host clipboard was cleared).
/// Fetch a format with `clipFetch(seq:mime:)` when a local app pastes.
case remoteOffer(seq: UInt32, kinds: [ClipKind])
/// Host ack / policy / backend update for `clipControl` (`CLIP_REASON_*`).
case state(enabled: Bool, policy: UInt8, reason: UInt8)
/// The host is pasting OUR offered data answer with `clipServe(reqId:...)`.
case fetchRequest(reqId: UInt32, seq: UInt32, fileIndex: UInt32, mime: String)
/// Bytes for a fetch we started (`last` = final chunk).
case data(xferId: UInt32, chunk: Data, last: Bool)
/// A transfer was cancelled (either side).
case cancelled(id: UInt32)
/// A transfer failed (`status` = a PunktfunkStatus code).
case error(id: UInt32, status: Int32)
}
/// Enable/disable the shared clipboard for this session. Opt-in: nothing is announced or
/// served until enabled. The host answers with a `.state` event carrying the resolved
/// outcome (its operator policy is authoritative). Best-effort a dropped call on a
/// closing session is fine.
public func clipControl(enabled: Bool, flags: UInt8 = 0) {
clipboardLock.lock()
defer { clipboardLock.unlock() }
guard let h = liveHandle() else { return }
_ = punktfunk_connection_clipboard_control(h, enabled, flags)
}
/// Announce that the local pasteboard changed the lazy format-list offer (`seq` monotonic,
/// newest wins; empty `kinds` clears the host side). The bytes cross only if the host fetches.
public func clipOffer(seq: UInt32, kinds: [ClipKind]) {
clipboardLock.lock()
defer { clipboardLock.unlock() }
guard let h = liveHandle() else { return }
guard !kinds.isEmpty else {
_ = punktfunk_connection_clipboard_offer(h, seq, nil, 0)
return
}
// The C array borrows NUL-terminated strings for the duration of the call only.
let cStrings = kinds.map { strdup($0.mime) }
defer { cStrings.forEach { free($0) } }
let arr = zip(cStrings, kinds).map {
PunktfunkClipKind(mime: $0.map { UnsafePointer($0) }, size_hint: $1.sizeHint)
}
_ = arr.withUnsafeBufferPointer {
punktfunk_connection_clipboard_offer(h, seq, $0.baseAddress, UInt(arr.count))
}
}
/// Start pulling one format of the host's offer `seq` (a local app is pasting). Returns the
/// transfer id echoed on the resulting `.data`/`.error`/`.cancelled` events, or nil when the
/// session is closing.
public func clipFetch(seq: UInt32, mime: String, fileIndex: UInt32 = UInt32.max) -> UInt32? {
clipboardLock.lock()
defer { clipboardLock.unlock() }
guard let h = liveHandle() else { return nil }
var xfer: UInt32 = 0
let rc = mime.withCString {
punktfunk_connection_clipboard_fetch(h, seq, $0, fileIndex, &xfer)
}
return rc == statusOK ? xfer : nil
}
/// Provide bytes answering a `.fetchRequest` (the host is pasting our offered data). Call
/// repeatedly to stream; `last = true` completes the transfer. An empty final chunk is fine.
public func clipServe(reqId: UInt32, data: Data, last: Bool) {
clipboardLock.lock()
defer { clipboardLock.unlock() }
guard let h = liveHandle() else { return }
if data.isEmpty {
_ = punktfunk_connection_clipboard_serve(h, reqId, nil, 0, last)
} else {
data.withUnsafeBytes { p in
_ = punktfunk_connection_clipboard_serve(
h, reqId, p.bindMemory(to: UInt8.self).baseAddress, UInt(data.count), last)
}
}
}
/// Cancel a clipboard transfer by id an outbound fetch's `xferId` or an inbound
/// `.fetchRequest`'s `reqId`.
public func clipCancel(id: UInt32) {
clipboardLock.lock()
defer { clipboardLock.unlock() }
guard let h = liveHandle() else { return }
_ = punktfunk_connection_clipboard_cancel(h, id)
}
/// Pull the next shared-clipboard event; nil on timeout, throws `.closed` once the session
/// ended. Drain from a single dedicated thread (`ClipboardSync`) the event's borrowed
/// payload is copied into the returned `ClipEvent` before the next poll can overwrite it.
public func nextClipboard(timeoutMs: UInt32) throws -> ClipEvent? {
clipboardLock.lock()
defer { clipboardLock.unlock() }
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
var ev = PunktfunkClipEvent()
let rc = punktfunk_connection_next_clipboard(h, &ev, timeoutMs)
switch rc {
case statusOK:
return Self.decodeClipEvent(ev)
case statusNoFrame:
return nil
case statusClosed:
throw PunktfunkClientError.closed
default:
throw PunktfunkClientError.status(rc)
}
}
/// Copy a raw C clip event (whose `data` borrows a per-connection slot) into an owned Swift
/// value. Unknown kinds (a newer core) decode to nil and are skipped by the drain.
private static func decodeClipEvent(_ ev: PunktfunkClipEvent) -> ClipEvent? {
let payload = ev.data.map { Data(bytes: $0, count: Int(ev.len)) } ?? Data()
switch Int32(ev.kind) {
case PUNKTFUNK_CLIP_REMOTE_OFFER:
// One `mime\tsize_hint\n` line per advertised format.
let kinds = String(decoding: payload, as: UTF8.self)
.split(separator: "\n")
.compactMap { line -> ClipKind? in
let parts = line.split(separator: "\t", maxSplits: 1)
guard let mime = parts.first, !mime.isEmpty else { return nil }
let hint = parts.count > 1 ? UInt64(parts[1]) ?? 0 : 0
return ClipKind(mime: String(mime), sizeHint: hint)
}
return .remoteOffer(seq: ev.transfer_id, kinds: kinds)
case PUNKTFUNK_CLIP_STATE:
return .state(enabled: ev.enabled != 0, policy: ev.policy, reason: ev.reason)
case PUNKTFUNK_CLIP_FETCH_REQUEST:
return .fetchRequest(
reqId: ev.transfer_id, seq: ev.seq, fileIndex: ev.file_index,
mime: String(decoding: payload, as: UTF8.self))
case PUNKTFUNK_CLIP_DATA:
return .data(xferId: ev.transfer_id, chunk: payload, last: ev.last != 0)
case PUNKTFUNK_CLIP_CANCELLED:
return .cancelled(id: ev.transfer_id)
case PUNKTFUNK_CLIP_ERROR:
return .error(id: ev.transfer_id, status: ev.status)
default:
return nil
}
}
deinit { close() }
/// Snapshot the handle unless close is pending (callers hold their plane lock).