From 5d0e23d6a5a7332a1695f408ade5c3918b3cec5d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 17 Jul 2026 13:40:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(apple/clipboard):=20macOS=20client=20half?= =?UTF-8?q?=20of=20the=20shared=20clipboard=20(Phase=201=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Sources/PunktfunkClient/ContentView.swift | 3 + .../PunktfunkClient/Home/AddHostSheet.swift | 17 + .../Session/SessionModel.swift | 66 ++++ .../Session/StreamCommands.swift | 15 + .../Clipboard/ClipboardSync.swift | 361 ++++++++++++++++++ .../Connection/PunktfunkConnection.swift | 176 +++++++++ .../Sources/PunktfunkShared/StoredHost.swift | 8 +- .../SharedFoundationTests.swift | 3 +- 8 files changed, 647 insertions(+), 2 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardSync.swift diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index a672880d..3b5e23e9 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -223,6 +223,9 @@ struct ContentView: View { #if !os(tvOS) .focusedSceneValue(\.sessionFocus, SessionFocus( isStreaming: model.connection != nil, + clipboardAvailable: model.connection?.hostSupportsClipboard == true, + clipboardOn: model.clipboardEnabled, + toggleClipboard: { model.toggleClipboardSync() }, disconnect: { model.disconnect() })) #endif #if os(macOS) diff --git a/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift b/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift index 6d75c509..b719b236 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift @@ -20,6 +20,12 @@ struct AddHostSheet: View { @State private var address: String @State private var port: Int @State private var mac: String + #if os(macOS) + /// Share the clipboard with this host (macOS sessions only; design + /// clipboard-and-file-transfer.md §5.3). Off by default; honored only when the host + /// advertises the capability at connect. + @State private var clipboardSync: Bool + #endif #if os(tvOS) private enum EditField: String, Identifiable { case name, address, port, mac @@ -41,6 +47,9 @@ struct AddHostSheet: View { _port = State(initialValue: Int(existing?.port ?? 9777)) let stored = existing?.macAddresses ?? [] _mac = State(initialValue: (stored.isEmpty ? suggestedMacs : stored).joined(separator: ", ")) + #if os(macOS) + _clipboardSync = State(initialValue: existing?.clipboardSync ?? false) + #endif } var body: some View { @@ -96,6 +105,9 @@ struct AddHostSheet: View { #if os(iOS) .textInputAutocapitalization(.never) #endif + #if os(macOS) + Toggle("Share clipboard with this host", isOn: $clipboardSync) + #endif } #if !os(tvOS) .formStyle(.grouped) @@ -147,6 +159,11 @@ struct AddHostSheet: View { host.address = address.trimmingCharacters(in: .whitespaces) host.port = UInt16(clamping: port) host.macAddresses = Self.parseMacs(mac) + #if os(macOS) + // nil when off: the key stays absent from the saved JSON (forward-compat, and "never + // opted in" and "opted out" read the same — off). + host.clipboardSync = clipboardSync ? true : nil + #endif onSave(host) dismiss() } diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 3181f2e6..12db3065 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -139,6 +139,18 @@ final class SessionModel: ObservableObject { private var audio: SessionAudio? private var gamepadCapture: GamepadCapture? private var gamepadFeedback: GamepadFeedback? + #if os(macOS) + /// The live session's clipboard bridge (design/clipboard-and-file-transfer.md §5) — created + /// by `beginStreaming` when the per-host toggle is on and the host advertises + /// `HOST_CAP_CLIPBOARD`; stopped (off-main, drain joined) in `disconnect`. + private var clipboardSync: ClipboardSync? + #endif + /// Whether clipboard sync is live (host-acked `ClipState.enabled`) — drives the Stream menu + /// item's title and the settings footnote. Always false off-macOS. + @Published private(set) var clipboardEnabled = false + /// The host's last `ClipState.reason` (`CLIP_REASON_*`) — why an enable was refused + /// (backend unavailable / policy disabled / …); 0 = OK. + @Published private(set) var clipboardReason: UInt8 = 0 #if os(tvOS) /// Siri Remote → host pointer while streaming (touch surface moves, press = left click, /// Play/Pause = right click) + the remote's deliberate exit (hold Back ≥ 1 s). See @@ -418,6 +430,12 @@ final class SessionModel: ObservableObject { #endif let feedback = gamepadFeedback gamepadFeedback = nil + #if os(macOS) + let clipboard = clipboardSync + clipboardSync = nil + #endif + clipboardEnabled = false + clipboardReason = 0 if let conn = connection { // Drain-thread teardown waits the pullers out and close() waits out in-flight // polls + joins the Rust worker threads — keep all of it off the main actor, @@ -425,6 +443,9 @@ final class SessionModel: ObservableObject { Task.detached { audio?.stop() feedback?.stop() + #if os(macOS) + clipboard?.stop() // disables sync on the wire while the connection is still up + #endif // Deliberate user quit → tell the host to skip the keep-alive linger (must precede close). if deliberate { conn.disconnectQuit() } conn.close() @@ -433,6 +454,9 @@ final class SessionModel: ObservableObject { Task.detached { audio?.stop() feedback?.stop() + #if os(macOS) + clipboard?.stop() + #endif } } connection = nil @@ -507,6 +531,14 @@ final class SessionModel: ObservableObject { let feedback = GamepadFeedback(connection: conn, manager: .shared) feedback.start() gamepadFeedback = feedback + #if os(macOS) + // Shared clipboard: opt-in per host AND host-advertised (older hosts / operator-disabled + // hosts never see a ClipControl). Same trust gate as audio — nothing is announced + // during the trust prompt. + if activeHost?.clipboardSync == true, conn.hostSupportsClipboard { + startClipboardSync(conn) + } + #endif #if os(tvOS) let pointer = SiriRemotePointer(connection: conn) pointer.onDisconnectRequest = { [weak self] in self?.disconnect() } @@ -515,6 +547,40 @@ final class SessionModel: ObservableObject { #endif } + #if os(macOS) + /// Create + start the session's clipboard bridge and route its host acks into the published + /// UI state. `ClipboardSync.start()` sends the enable; the host's `.state` answer flips + /// `clipboardEnabled` (or leaves it false with a `clipboardReason` the UI can explain). + private func startClipboardSync(_ conn: PunktfunkConnection) { + let sync = ClipboardSync(connection: conn) + sync.onState = { [weak self] enabled, _, reason in + Task { @MainActor in + self?.clipboardEnabled = enabled + self?.clipboardReason = reason + } + } + sync.start() + clipboardSync = sync + } + #endif + + /// Flip clipboard sync mid-session (the Stream menu). Off → on requires the host cap; on → + /// off tears the bridge down (off-main — the drain join must not block the main actor) and + /// tells the host, which drops any selection we own there. No-op off-macOS or while idle. + func toggleClipboardSync() { + #if os(macOS) + guard let conn = connection, phase == .streaming else { return } + if let sync = clipboardSync { + clipboardSync = nil + clipboardEnabled = false + clipboardReason = 0 + Task.detached { sync.stop() } + } else if conn.hostSupportsClipboard { + startClipboardSync(conn) + } + #endif + } + private func startStatsTimer() { lastFramesDropped = 0 // a fresh connection's cumulative drop counter starts at 0 latencySplit.reset() // no stale receipts/samples from a previous session diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift index 18a41660..bf55b8d1 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift @@ -21,6 +21,12 @@ import SwiftUI /// `.focusedSceneValue` so the Scene-level commands can drive it. struct SessionFocus { var isStreaming: Bool + /// The connected host advertises `HOST_CAP_CLIPBOARD` (gates the Share Clipboard item — + /// macOS-only UI, but the fact is platform-neutral). + var clipboardAvailable: Bool + /// Clipboard sync is live (host-acked) — drives the item's Stop/Share title. + var clipboardOn: Bool + var toggleClipboard: () -> Void var disconnect: () -> Void } @@ -58,6 +64,15 @@ struct StreamCommands: Commands { } .keyboardShortcut("q", modifiers: [.control, .option, .shift]) .disabled(session?.isStreaming != true) + #if os(macOS) + // Mid-session clipboard flip (design/clipboard-and-file-transfer.md §5.3). Greyed + // when the host doesn't advertise the cap (older host / operator policy off). + Button(session?.clipboardOn == true ? "Stop Sharing Clipboard" : "Share Clipboard") { + session?.toggleClipboard() + } + .keyboardShortcut("c", modifiers: [.control, .option, .shift]) + .disabled(session?.isStreaming != true || session?.clipboardAvailable != true) + #endif Divider() Button("Disconnect") { session?.disconnect() } .keyboardShortcut("d", modifiers: [.control, .option, .shift]) diff --git a/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardSync.swift b/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardSync.swift new file mode 100644 index 00000000..5f336569 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardSync.swift @@ -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.. 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 diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index e3adc481..60dff76c 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -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). diff --git a/clients/apple/Sources/PunktfunkShared/StoredHost.swift b/clients/apple/Sources/PunktfunkShared/StoredHost.swift index 91bf9c9d..a99932f8 100644 --- a/clients/apple/Sources/PunktfunkShared/StoredHost.swift +++ b/clients/apple/Sources/PunktfunkShared/StoredHost.swift @@ -34,11 +34,16 @@ public struct StoredHost: Identifiable, Codable, Hashable { /// client can send a magic packet to wake the host later (when it's asleep and no longer /// advertising). Optional (same forward-compat reason as `mgmtPort`); nil until first learned. public var macAddresses: [String]? + /// Share the clipboard with this host (macOS sessions; design/clipboard-and-file-transfer.md + /// §5.3). Opt-in per host: nil/false = off (nil also keeps older saved JSON decoding — same + /// forward-compat reason as `mgmtPort`). Honored only when the host advertises + /// `HOST_CAP_CLIPBOARD`. + public var clipboardSync: Bool? public init( id: UUID = UUID(), name: String, address: String, port: UInt16 = 9777, pinnedSHA256: Data? = nil, lastConnected: Date? = nil, mgmtPort: UInt16? = nil, - macAddresses: [String]? = nil + macAddresses: [String]? = nil, clipboardSync: Bool? = nil ) { self.id = id self.name = name @@ -48,6 +53,7 @@ public struct StoredHost: Identifiable, Codable, Hashable { self.lastConnected = lastConnected self.mgmtPort = mgmtPort self.macAddresses = macAddresses + self.clipboardSync = clipboardSync } public var displayName: String { name.isEmpty ? address : name } diff --git a/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift b/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift index a6ef0ceb..8fcd2f1c 100644 --- a/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift @@ -18,7 +18,7 @@ final class SharedFoundationTests: XCTestCase { name: "Tower", address: "192.168.1.173", port: 9777, pinnedSHA256: Data([0xDE, 0xAD, 0xBE, 0xEF]), lastConnected: Date(timeIntervalSince1970: 1_700_000_000), - mgmtPort: 47990, macAddresses: ["aa:bb:cc:dd:ee:ff"]) + mgmtPort: 47990, macAddresses: ["aa:bb:cc:dd:ee:ff"], clipboardSync: true) let data = try JSONEncoder().encode(host) let decoded = try JSONDecoder().decode(StoredHost.self, from: data) @@ -39,6 +39,7 @@ final class SharedFoundationTests: XCTestCase { XCTAssertNil(decoded.macAddresses) XCTAssertNil(decoded.pinnedSHA256) XCTAssertNil(decoded.lastConnected) + XCTAssertNil(decoded.clipboardSync) // Resolvers fall back cleanly. XCTAssertEqual(decoded.effectiveMgmtPort, punktfunkDefaultMgmtPort) XCTAssertEqual(decoded.wakeMacs, [])