Merge origin/main (shared clipboard Phase 1) into the local W6.2 + rumble-D line
Reconciles the two diverged mains: the local stack (W6.1/W6.2 crate decomposition, lid-closed fixes, ABR climb gating, rumble root-fix A-D incl. the W7 core module splits) x origin/main (clipboard wire ABI v8 + pf-clipboard + the macOS client). Textual conflicts were trivial (host Cargo.toml dep union; Cargo.lock + the cbindgen header regenerate). The real work was structural: the local line's W7 splits dissolved the two files the clipboard's Phase 0 landed in, so the merge left both layouts side by side. Resolved by re-homing the clipboard delta into the split layout and deleting the stale flat files: - quic/msgs.rs -> quic/clip.rs (the 0x40-0x44 messages, CLIP_* vocabulary, codecs) + HOST_CAP_CLIPBOARD into quic/caps.rs beside GAMEPAD_STATE; mod.rs declares clip and fixes the split-by-concern doc. - client.rs -> the client/ split: CtrlRequest clip variants + Negotiated.host_caps (control.rs), CLIP_EVENT_QUEUE (planes.rs), clip channel ends (worker.rs), NativeClient clip surface + channels (mod.rs), control-task encode/decode arms + the clipboard task spawn (pump.rs). Verified on macOS: core 174/174 (merged rumble + clipboard suites), pf-clipboard clippy -D warnings clean, fmt applied, header regenerated. Host-side compile rides the Linux/Windows legs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
// Shared clipboard, macOS client half (design/clipboard-and-file-transfer.md §5.2).
|
||||
//
|
||||
// Bridges NSPasteboard.general to the session's QUIC clipboard plane, both directions lazy:
|
||||
//
|
||||
// * **Local copy → host**: a changeCount poll announces the *format list* (`clipOffer`); the
|
||||
// bytes cross only when a host app pastes (a `.fetchRequest` event, answered from the live
|
||||
// pasteboard by `clipServe`).
|
||||
// * **Host copy → local**: a `.remoteOffer` writes one NSPasteboardItem whose
|
||||
// NSPasteboardItemDataProvider fires only when a Mac app actually pastes — the provider then
|
||||
// blocks (on its provider thread, never main) on a `clipFetch` round-trip.
|
||||
//
|
||||
// Password-manager respect: pasteboards marked `org.nspasteboard.ConcealedType` or
|
||||
// `org.nspasteboard.TransientType` are never announced, never fetchable. Echo suppression: the
|
||||
// changeCount of every write WE make is recorded so the announce poll skips it (§3.4).
|
||||
//
|
||||
// Phase 1 formats only (text / RTF / HTML / PNG). Files (NSFilePromiseProvider) ride Phase 2.
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// One live session's clipboard bridge. Created by the session model when streaming begins on a
|
||||
/// host that advertises `HOST_CAP_CLIPBOARD` and whose per-host toggle is on; `stop()` before the
|
||||
/// connection closes. All pasteboard traffic runs on one dedicated drain thread plus the
|
||||
/// AppKit-owned provider threads (paste fulfillment).
|
||||
public final class ClipboardSync: NSObject {
|
||||
/// Wire MIME ↔ NSPasteboard type for the Phase-1 vocabulary (§3.5), in announce order.
|
||||
private static let wireToPasteboard: [(wire: String, type: NSPasteboard.PasteboardType)] = [
|
||||
("text/plain;charset=utf-8", .string),
|
||||
("text/rtf", .rtf),
|
||||
("text/html", .html),
|
||||
("image/png", .png),
|
||||
]
|
||||
/// Pasteboard marker types that must never cross the wire (password managers mark secrets
|
||||
/// with these — see nspasteboard.org).
|
||||
private static let concealed = NSPasteboard.PasteboardType("org.nspasteboard.ConcealedType")
|
||||
private static let transient = NSPasteboard.PasteboardType("org.nspasteboard.TransientType")
|
||||
|
||||
/// How long a blocked paste waits for the host's bytes before providing nothing (§5.2).
|
||||
private static let fetchTimeout: TimeInterval = 10
|
||||
/// Serve chunk size for host-side pastes of our data (bounds the per-call ABI copy).
|
||||
private static let serveChunk = 4 << 20
|
||||
|
||||
private let connection: PunktfunkConnection
|
||||
/// `CLIP_FLAG_*` sent with the enable (`CLIP_FLAG_FILES` when the session permits files —
|
||||
/// always 0 in Phase 1).
|
||||
private let controlFlags: UInt8
|
||||
|
||||
/// Host `.state` updates, delivered on the main queue — drives the toggle/footnote UI.
|
||||
public var onState: ((_ enabled: Bool, _ policy: UInt8, _ reason: UInt8) -> Void)?
|
||||
|
||||
// Drain-thread state (touched only on the drain thread once started).
|
||||
private var offerSeq: UInt32 = 0
|
||||
private var lastSeenChangeCount = 0
|
||||
/// The changeCount of the last pasteboard write WE made (echo suppression + "do we still
|
||||
/// own the pasteboard" on teardown/clear).
|
||||
private var ownedChangeCount = -1
|
||||
/// The host offer currently installed on the local pasteboard (nil = none).
|
||||
private var installedRemoteSeq: UInt32?
|
||||
|
||||
/// Outbound fetches a blocked paste is waiting on. Guarded by `fetchLock` — appended by the
|
||||
/// drain thread (`.data` events), consumed by AppKit's provider threads.
|
||||
private struct PendingFetch {
|
||||
var buffer = Data()
|
||||
let done = DispatchSemaphore(value: 0)
|
||||
var failed = false
|
||||
}
|
||||
private let fetchLock = NSLock()
|
||||
private var pendingFetches: [UInt32: PendingFetch] = [:]
|
||||
|
||||
private final class StopFlag: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var stopped = false
|
||||
func stop() {
|
||||
lock.lock()
|
||||
stopped = true
|
||||
lock.unlock()
|
||||
}
|
||||
var isStopped: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return stopped
|
||||
}
|
||||
}
|
||||
private let flag = StopFlag()
|
||||
private let drainDone = DispatchSemaphore(value: 0)
|
||||
private var started = false
|
||||
/// Set by the app-activation observer, cleared by the drain loop: the user may have copied
|
||||
/// elsewhere and is coming back to paste — announce immediately instead of waiting out the
|
||||
/// poll interval.
|
||||
private final class OneShot: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var raised = false
|
||||
func raise() {
|
||||
lock.lock()
|
||||
raised = true
|
||||
lock.unlock()
|
||||
}
|
||||
func takeIfRaised() -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let was = raised
|
||||
raised = false
|
||||
return was
|
||||
}
|
||||
}
|
||||
private let checkNow = OneShot()
|
||||
private var activationObserver: NSObjectProtocol?
|
||||
|
||||
public init(connection: PunktfunkConnection, allowFiles: Bool = false) {
|
||||
self.connection = connection
|
||||
self.controlFlags = 0 // CLIP_FLAG_FILES rides Phase 2
|
||||
_ = allowFiles
|
||||
super.init()
|
||||
}
|
||||
|
||||
deinit { flag.stop() }
|
||||
|
||||
/// Enable sync with the host and start the drain thread. The host answers the enable with a
|
||||
/// `.state` event (surfaced via `onState`) — `BACKEND_UNAVAILABLE` et al. arrive there.
|
||||
public func start() {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
connection.clipControl(enabled: true, flags: controlFlags)
|
||||
// Baseline: whatever is on the pasteboard when sync starts is announced immediately —
|
||||
// the "copy first, then connect and paste" flow must work.
|
||||
lastSeenChangeCount = -1
|
||||
activationObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSApplication.didBecomeActiveNotification, object: nil, queue: nil
|
||||
) { [checkNow] _ in checkNow.raise() }
|
||||
let connection = self.connection
|
||||
let flag = self.flag
|
||||
let thread = Thread { [weak self] in
|
||||
var lastAnnounceCheck = Date.distantPast
|
||||
while !flag.isStopped {
|
||||
// Drain events (bounded burst so a chatty host can't starve the announce poll).
|
||||
var drained = 0
|
||||
while drained < 32, !flag.isStopped {
|
||||
let ev: PunktfunkConnection.ClipEvent?
|
||||
do {
|
||||
ev = try connection.nextClipboard(timeoutMs: drained == 0 ? 200 : 0)
|
||||
} catch {
|
||||
flag.stop() // session closed
|
||||
break
|
||||
}
|
||||
guard let ev else { break }
|
||||
drained += 1
|
||||
self?.handle(ev)
|
||||
}
|
||||
// Announce poll: every 500 ms, or immediately after app activation (§5.2).
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastAnnounceCheck) >= 0.5
|
||||
|| self?.checkNow.takeIfRaised() == true
|
||||
{
|
||||
lastAnnounceCheck = now
|
||||
self?.announceIfChanged()
|
||||
}
|
||||
}
|
||||
self?.drainDone.signal()
|
||||
}
|
||||
thread.name = "punktfunk-clipboard"
|
||||
thread.qualityOfService = .utility
|
||||
thread.start()
|
||||
}
|
||||
|
||||
/// Disable sync and join the drain thread. Called off-main before `connection.close()`
|
||||
/// (the same discipline as the audio/feedback drains). If the local pasteboard still holds
|
||||
/// our remote-offer items, they are cleared — their providers die with us.
|
||||
public func stop() {
|
||||
guard started else { return }
|
||||
started = false
|
||||
if let obs = activationObserver {
|
||||
NotificationCenter.default.removeObserver(obs)
|
||||
activationObserver = nil
|
||||
}
|
||||
connection.clipControl(enabled: false, flags: 0)
|
||||
flag.stop()
|
||||
drainDone.wait()
|
||||
// Fail every paste still blocked on us so no provider thread waits out its timeout.
|
||||
fetchLock.lock()
|
||||
for (_, pending) in pendingFetches {
|
||||
pending.done.signal()
|
||||
}
|
||||
pendingFetches.removeAll()
|
||||
fetchLock.unlock()
|
||||
let pb = NSPasteboard.general
|
||||
if installedRemoteSeq != nil, pb.changeCount == ownedChangeCount {
|
||||
pb.clearContents()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Local copy → host (announce)
|
||||
|
||||
/// Announce the local pasteboard's format list when it changed (skipping our own writes and
|
||||
/// concealed/transient pasteboards). Runs on the drain thread.
|
||||
private func announceIfChanged() {
|
||||
let pb = NSPasteboard.general
|
||||
let count = pb.changeCount
|
||||
guard count != lastSeenChangeCount else { return }
|
||||
lastSeenChangeCount = count
|
||||
if count == ownedChangeCount { return } // our own write (a remote offer) — never echo
|
||||
installedRemoteSeq = nil // a local copy replaced the host's offer
|
||||
let types = pb.types ?? []
|
||||
if types.contains(Self.concealed) || types.contains(Self.transient) { return }
|
||||
offerSeq &+= 1
|
||||
let kinds = Self.wireToPasteboard
|
||||
.filter { types.contains($0.type) }
|
||||
.map { PunktfunkConnection.ClipKind(mime: $0.wire) }
|
||||
// Empty = the pasteboard holds nothing we sync (or was cleared) — clears the host side.
|
||||
connection.clipOffer(seq: offerSeq, kinds: kinds)
|
||||
}
|
||||
|
||||
// MARK: - Event handling (drain thread)
|
||||
|
||||
private func handle(_ ev: PunktfunkConnection.ClipEvent) {
|
||||
switch ev {
|
||||
case let .state(enabled, policy, reason):
|
||||
if let onState {
|
||||
DispatchQueue.main.async { onState(enabled, policy, reason) }
|
||||
}
|
||||
case let .remoteOffer(seq, kinds):
|
||||
installRemoteOffer(seq: seq, kinds: kinds)
|
||||
case let .fetchRequest(reqId, seq, _, mime):
|
||||
serveFetch(reqId: reqId, seq: seq, mime: mime)
|
||||
case let .data(xferId, chunk, last):
|
||||
fetchLock.lock()
|
||||
if var pending = pendingFetches[xferId] {
|
||||
pending.buffer.append(chunk)
|
||||
pendingFetches[xferId] = pending
|
||||
if last {
|
||||
pendingFetches[xferId]?.done.signal()
|
||||
}
|
||||
}
|
||||
fetchLock.unlock()
|
||||
case let .cancelled(id), let .error(id, _):
|
||||
fetchLock.lock()
|
||||
if var pending = pendingFetches[id] {
|
||||
pending.failed = true
|
||||
pendingFetches[id] = pending
|
||||
pending.done.signal()
|
||||
}
|
||||
fetchLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Host copy → local (lazy install + blocked-paste fetch)
|
||||
|
||||
/// Write one NSPasteboardItem advertising the host's formats, each backed by a lazy data
|
||||
/// provider — bytes cross only when a Mac app pastes. Empty `kinds` = the host cleared its
|
||||
/// clipboard: drop our item if it's still current.
|
||||
private func installRemoteOffer(seq: UInt32, kinds: [PunktfunkConnection.ClipKind]) {
|
||||
let pb = NSPasteboard.general
|
||||
let types = kinds.compactMap { kind in
|
||||
Self.wireToPasteboard.first(where: { $0.wire == kind.mime })?.type
|
||||
}
|
||||
guard !types.isEmpty else {
|
||||
if installedRemoteSeq != nil, pb.changeCount == ownedChangeCount {
|
||||
pb.clearContents()
|
||||
ownedChangeCount = pb.changeCount
|
||||
lastSeenChangeCount = pb.changeCount
|
||||
}
|
||||
installedRemoteSeq = nil
|
||||
return
|
||||
}
|
||||
let item = NSPasteboardItem()
|
||||
item.setDataProvider(RemoteOfferProvider(sync: self, seq: seq), forTypes: types)
|
||||
pb.clearContents()
|
||||
pb.writeObjects([item])
|
||||
installedRemoteSeq = seq
|
||||
ownedChangeCount = pb.changeCount
|
||||
lastSeenChangeCount = pb.changeCount
|
||||
}
|
||||
|
||||
/// Blocked-paste fulfillment: fetch one wire format of host offer `seq` and wait (provider
|
||||
/// thread) for the drain thread to assemble the chunks. Nil on timeout/cancel/error — the
|
||||
/// paste then provides nothing rather than hanging (§3.4).
|
||||
///
|
||||
/// `fetchLock` is held ACROSS the `clipFetch` so the pending entry exists before the drain
|
||||
/// thread can process the first `.data` event (its `handle` takes `fetchLock` after
|
||||
/// releasing the connection's clipboard lock — no cycle).
|
||||
fileprivate func fetchBlocking(seq: UInt32, wireMime: String) -> Data? {
|
||||
fetchLock.lock()
|
||||
guard let xferId = connection.clipFetch(seq: seq, mime: wireMime) else {
|
||||
fetchLock.unlock()
|
||||
return nil
|
||||
}
|
||||
pendingFetches[xferId] = PendingFetch()
|
||||
let done = pendingFetches[xferId]!.done
|
||||
fetchLock.unlock()
|
||||
let outcome = done.wait(timeout: .now() + Self.fetchTimeout)
|
||||
fetchLock.lock()
|
||||
let pending = pendingFetches.removeValue(forKey: xferId)
|
||||
fetchLock.unlock()
|
||||
if outcome == .timedOut {
|
||||
connection.clipCancel(id: xferId)
|
||||
return nil
|
||||
}
|
||||
guard let pending, !pending.failed else { return nil }
|
||||
return pending.buffer
|
||||
}
|
||||
|
||||
// MARK: - Host paste of our data (serve)
|
||||
|
||||
/// Answer a host paste of our offered data from the live pasteboard. A stale `seq` (the
|
||||
/// local clipboard changed since that announce) is cancelled — never serve mismatched bytes.
|
||||
private func serveFetch(reqId: UInt32, seq: UInt32, mime: String) {
|
||||
let pb = NSPasteboard.general
|
||||
guard seq == offerSeq, pb.changeCount == lastSeenChangeCount,
|
||||
let type = Self.wireToPasteboard.first(where: { $0.wire == mime })?.type,
|
||||
let data = pb.data(forType: type)
|
||||
else {
|
||||
connection.clipCancel(id: reqId)
|
||||
return
|
||||
}
|
||||
var offset = 0
|
||||
while offset < data.count {
|
||||
let end = min(offset + Self.serveChunk, data.count)
|
||||
connection.clipServe(
|
||||
reqId: reqId, data: data.subdata(in: offset..<end), last: end == data.count)
|
||||
offset = end
|
||||
}
|
||||
if data.isEmpty {
|
||||
connection.clipServe(reqId: reqId, data: Data(), last: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The lazy paste hook: AppKit calls `provideDataForType` only when a Mac app actually pastes;
|
||||
/// the fetch then blocks this provider thread (never main) until the host's bytes arrive or the
|
||||
/// timeout provides nothing. One provider per installed remote offer — a dead sync (weak) or a
|
||||
/// superseded offer provides nothing.
|
||||
private final class RemoteOfferProvider: NSObject, NSPasteboardItemDataProvider {
|
||||
private weak var sync: ClipboardSync?
|
||||
private let seq: UInt32
|
||||
|
||||
init(sync: ClipboardSync, seq: UInt32) {
|
||||
self.sync = sync
|
||||
self.seq = seq
|
||||
}
|
||||
|
||||
func pasteboard(
|
||||
_ pasteboard: NSPasteboard?, item: NSPasteboardItem,
|
||||
provideDataForType type: NSPasteboard.PasteboardType
|
||||
) {
|
||||
guard let sync,
|
||||
let wire = wireMime(for: type),
|
||||
let data = sync.fetchBlocking(seq: seq, wireMime: wire)
|
||||
else { return }
|
||||
item.setData(data, forType: type)
|
||||
}
|
||||
|
||||
private func wireMime(for type: NSPasteboard.PasteboardType) -> String? {
|
||||
switch type {
|
||||
case .string: return "text/plain;charset=utf-8"
|
||||
case .rtf: return "text/rtf"
|
||||
case .html: return "text/html"
|
||||
case .png: return "image/png"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -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`.
|
||||
@@ -1015,10 +1032,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()
|
||||
@@ -1080,6 +1099,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).
|
||||
|
||||
Reference in New Issue
Block a user