5d0e23d6a5
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>
362 lines
15 KiB
Swift
362 lines
15 KiB
Swift
// 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
|