The shared clipboard now works on iPhone and iPad #250

Merged
enricobuehler merged 1 commits from worktree-worktree-ios-clipboard into main 2026-08-15 16:15:15 +00:00
14 changed files with 935 additions and 317 deletions
@@ -30,10 +30,10 @@ struct AddHostSheet: View {
@State private var pinnedIDs: Set<String>
@ObservedObject private var profiles = ProfileStore.shared
#endif
#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.
#if !os(tvOS)
/// Share the clipboard with this host (design clipboard-and-file-transfer.md §5.3). Off by
/// default; honored only when the host advertises the capability at connect. Absent on tvOS,
/// which has no pasteboard to share.
@State private var clipboardSync: Bool
#endif
#if os(tvOS)
@@ -72,7 +72,7 @@ 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)
#if !os(tvOS)
_clipboardSync = State(initialValue: existing?.clipboardSync ?? false)
#endif
#if !os(tvOS)
@@ -144,7 +144,7 @@ struct AddHostSheet: View {
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
#if os(macOS)
#if !os(tvOS)
Toggle("Share clipboard with this host", isOn: $clipboardSync)
#endif
profileRows
@@ -200,11 +200,11 @@ struct AddHostSheet: View {
}
#if os(iOS)
/// Four fields + the action row a touch taller than the 3-field add sheet used to be. The
/// edit sheet's profile rows are the only thing that can outgrow it, and they say by how much;
/// a single fixed number is what clipped them.
/// Four fields, the clipboard toggle, and the action row. The edit sheet's profile rows are
/// the only thing that can outgrow it, and they say by how much; a single fixed number is what
/// clipped them.
private var sheetHeight: CGFloat {
var height: CGFloat = 392
var height: CGFloat = 392 + 44 // the fields and action row, plus the clipboard toggle
if showsProfileRows {
height += 116 // the Profile picker and its footnote
height += 96 + CGFloat(profiles.profiles.count) * 44 // the pins, their header + footer
@@ -282,7 +282,7 @@ struct AddHostSheet: View {
host.address = address.trimmingCharacters(in: .whitespaces)
host.port = UInt16(clamping: port)
host.macAddresses = Self.parseMacs(mac)
#if os(macOS)
#if !os(tvOS)
// 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
@@ -250,14 +250,14 @@ final class SessionModel: ObservableObject {
private var audio: SessionAudio?
private var gamepadCapture: GamepadCapture?
private var gamepadFeedback: GamepadFeedback?
#if os(macOS)
#if !os(tvOS)
/// 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.
/// item's title and the settings footnote. Always false on tvOS, which has no pasteboard.
@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.
@@ -768,7 +768,7 @@ final class SessionModel: ObservableObject {
#endif
let feedback = gamepadFeedback
gamepadFeedback = nil
#if os(macOS)
#if !os(tvOS)
let clipboard = clipboardSync
clipboardSync = nil
#endif
@@ -781,8 +781,11 @@ 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
#if !os(tvOS)
// Disables sync on the wire while the connection is still up and on iOS pulls a
// host offer the user has not pasted yet down to real bytes, which needs that
// connection, so it must stay ahead of the close below.
clipboard?.stop()
#endif
// Deliberate user quit tell the host to skip the keep-alive linger (must precede close).
if deliberate { conn.disconnectQuit() }
@@ -792,7 +795,7 @@ final class SessionModel: ObservableObject {
Task.detached {
audio?.stop()
feedback?.stop()
#if os(macOS)
#if !os(tvOS)
clipboard?.stop()
#endif
}
@@ -940,7 +943,7 @@ final class SessionModel: ObservableObject {
let feedback = GamepadFeedback(connection: conn, manager: .shared)
feedback.start()
gamepadFeedback = feedback
#if os(macOS)
#if !os(tvOS)
// Shared clipboard: opt-in per host AND host-advertised (older hosts / operator-disabled
// hosts never see a ClipControl) AND granted to this device (per-client access §5
// without the bit the host would refuse with CLIP_REASON_NOT_PERMITTED anyway; not
@@ -958,7 +961,7 @@ final class SessionModel: ObservableObject {
#endif
}
#if os(macOS)
#if !os(tvOS)
/// 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).
@@ -977,9 +980,9 @@ final class SessionModel: ObservableObject {
/// 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.
/// tells the host, which drops any selection we own there. No-op on tvOS or while idle.
func toggleClipboardSync() {
#if os(macOS)
#if !os(tvOS)
guard let conn = connection, phase == .streaming else { return }
if let sync = clipboardSync {
clipboardSync = nil
@@ -22,8 +22,7 @@ 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).
/// The connected host advertises `HOST_CAP_CLIPBOARD` (gates the Share Clipboard item).
var clipboardAvailable: Bool
/// Clipboard sync is live (host-acked) drives the item's Stop/Share title.
var clipboardOn: Bool
@@ -78,14 +77,15 @@ struct StreamCommands: Commands {
}
.keyboardShortcut("a", modifiers: [.control, .option, .shift])
.disabled(session?.isStreaming != true || session?.micAvailable != 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).
// when the host doesn't advertise the cap (older host / operator policy off). On iPad
// there is no menu bar to show it in, but a hardware keyboard still reaches it.
Button(session?.clipboardOn == true ? "Stop Sharing Clipboard" : "Share Clipboard") {
session?.toggleClipboard()
}
.keyboardShortcut("c", modifiers: [.control, .option, .shift])
.disabled(session?.isStreaming != true || session?.clipboardAvailable != true)
#if os(macOS)
// Toggle the window's fullscreen. F is the macOS-standard fullscreen combo; here it's
// explicit so it's discoverable AND survives capture while streaming the stream view
// swallows keys, so InputCapture's monitor detects the same combo and posts the same
@@ -0,0 +1,76 @@
// The shared clipboard's format vocabulary (design/clipboard-and-file-transfer.md §3.5), stated
// once for AppKit and UIKit alike.
//
// Every Apple pasteboard type in the table IS a uniform type identifier, and both frameworks name
// them with the same strings `NSPasteboard.PasteboardType.png` and the UIPasteboard type
// `"public.png"` are the same bytes. Keeping the table as plain strings is therefore not a
// lowest-common-denominator compromise; it is the actual shared spelling, and it keeps the two
// platform adapters from drifting apart in what they announce.
#if !os(tvOS)
import Foundation
enum ClipboardFormats {
/// Wire MIME uniform type identifier, in announce order. Files
/// (`application/x-punktfunk-files`) ride Phase 2 and are absent here.
///
/// Original image formats sit beside the mandatory `image/png` floor rather than replacing it:
/// a copied JPEG never balloons into PNG and a GIF keeps its animation, while a peer that can
/// only place PNG still has something to take.
static let table: [(wire: String, uti: String)] = [
("text/plain;charset=utf-8", "public.utf8-plain-text"),
("text/rtf", "public.rtf"),
("text/html", "public.html"),
("image/png", "public.png"),
("image/jpeg", "public.jpeg"),
("image/gif", "com.compuserve.gif"),
]
/// Pasteboard marker types that must never cross the wire password managers mark secrets
/// with these (see nspasteboard.org). A Mac convention that costs nothing to honour on iOS:
/// the cross-platform managers set them there too, and a pasteboard that carries neither is
/// unaffected.
static let concealed = "org.nspasteboard.ConcealedType"
static let transient = "org.nspasteboard.TransientType"
/// Image types we do not announce verbatim but CAN serve `image/png` from by transcoding at
/// fetch time screenshots and Preview leave TIFF, the camera roll leaves HEIC.
static let pngSources = ["public.tiff", "public.heic"]
static func uti(forWire wire: String) -> String? {
table.first { $0.wire == wire }?.uti
}
static func wire(forUti uti: String) -> String? {
table.first { $0.uti == uti }?.wire
}
/// True when the pasteboard is carrying a secret and must be ignored entirely.
static func isConcealed(_ types: [String]) -> Bool {
types.contains(concealed) || types.contains(transient)
}
/// The format list to announce for a pasteboard holding `types` the lazy offer's whole
/// payload (§3.2). Empty means "nothing we sync", which legitimately clears the peer's side.
static func offerKinds(forTypes types: [String]) -> [PunktfunkConnection.ClipKind] {
var kinds = table
.filter { types.contains($0.uti) }
.map { PunktfunkConnection.ClipKind(mime: $0.wire) }
// PNG floor: announce the portable `image/png` whenever ANY convertible image is present
// native PNG, TIFF/HEIC, or a JPEG/GIF original already being offered verbatim above. The
// adapters convert at fetch time, so the fallback costs nothing unless a peer pastes it.
if !kinds.contains(where: { $0.mime == "image/png" }),
types.contains(where: { pngSources.contains($0) })
|| kinds.contains(where: { $0.mime.hasPrefix("image/") })
{
kinds.append(PunktfunkConnection.ClipKind(mime: "image/png"))
}
return kinds
}
/// The uniform types to place for a remote offer, in the table's order and skipping kinds this
/// client has no mapping for (files, and whatever a future host learns to offer).
static func placeableUtis(for kinds: [PunktfunkConnection.ClipKind]) -> [String] {
kinds.compactMap { uti(forWire: $0.mime) }
}
}
#endif
@@ -0,0 +1,115 @@
// The macOS half of the clipboard seam: `NSPasteboard.general`.
//
// AppKit's lazy-paste contract is a blocking one `provideDataForType` is called on a provider
// thread the moment a Mac app pastes, and whatever the item holds when that call returns is what
// the app gets. So this adapter is the one place that turns the asynchronous fetch into a wait.
#if os(macOS)
import AppKit
import Foundation
typealias SystemPasteboard = AppKitPasteboard
final class AppKitPasteboard: ClipboardPasteboard {
private let pb = NSPasteboard.general
private var activationObserver: NSObjectProtocol?
/// The provider backing the offer currently on the pasteboard. AppKit's own reference to it is
/// not something to rely on: nothing crosses if it is collected before the user pastes.
private var provider: BlockingOfferProvider?
var changeCount: Int { pb.changeCount }
var typeIdentifiers: [String] { (pb.types ?? []).map(\.rawValue) }
/// Read one wire format, converting where macOS stores a different native type: `image/png` is
/// served from a real `.png` entry when present, else converted from whatever image
/// representation the pasteboard holds (TIFF from screenshots and Preview, WebP/AVIF/GIF from
/// browsers `NSImage` decodes them all) into PNG at fetch time.
func read(wire: String) -> Data? {
guard wire == "image/png" else {
guard let uti = ClipboardFormats.uti(forWire: wire) else { return nil }
return pb.data(forType: NSPasteboard.PasteboardType(uti))
}
if let png = pb.data(forType: .png) {
return png
}
guard let img = NSImage(pasteboard: pb),
let tiff = img.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff)
else {
return nil
}
return rep.representation(using: .png, properties: [:])
}
func installLazy(utis: [String], fetch: @escaping ClipboardFetch) -> Int {
let provider = BlockingOfferProvider(fetch: fetch)
let item = NSPasteboardItem()
item.setDataProvider(provider, forTypes: utis.map { NSPasteboard.PasteboardType($0) })
pb.clearContents()
pb.writeObjects([item])
self.provider = provider
return pb.changeCount
}
/// Unused on macOS a promise here outlives any paste that might come, so there is never
/// cause to resolve one early. Implemented anyway so the seam has no platform-shaped hole.
func installResolved(_ items: [(uti: String, data: Data)]) -> Int {
let item = NSPasteboardItem()
for (uti, data) in items {
item.setData(data, forType: NSPasteboard.PasteboardType(uti))
}
pb.clearContents()
pb.writeObjects([item])
provider = nil
return pb.changeCount
}
func clear() -> Int {
pb.clearContents()
provider = nil
return pb.changeCount
}
/// A Mac keeps running after a session ends, but the promise dies with the sync regardless, so
/// there is nothing to be gained by spending a round-trip on it at teardown clearing leaves
/// the user exactly where they were.
let resolvesPendingOfferOnTeardown = false
func startObserving(onActivate: @escaping () -> Void) {
activationObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didBecomeActiveNotification, object: nil, queue: nil
) { _ in onActivate() }
}
func stopObserving() {
if let activationObserver {
NotificationCenter.default.removeObserver(activationObserver)
self.activationObserver = nil
}
}
}
/// 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. On timeout
/// or a dead session it provides nothing, so the paste inserts nothing rather than hanging.
private final class BlockingOfferProvider: NSObject, NSPasteboardItemDataProvider {
private let fetch: ClipboardFetch
init(fetch: @escaping ClipboardFetch) {
self.fetch = fetch
}
func pasteboard(
_ pasteboard: NSPasteboard?, item: NSPasteboardItem,
provideDataForType type: NSPasteboard.PasteboardType
) {
guard let wire = ClipboardFormats.wire(forUti: type.rawValue) else { return }
let box = ClipboardResultBox()
fetch(wire) { box.settle($0) }
// The fetch enforces its own deadline and always completes; this is only a backstop
// against a lost completion wedging an AppKit thread forever.
guard let data = box.wait(timeout: ClipboardSync.fetchTimeout + 2) else { return }
item.setData(data, forType: type)
}
}
#endif
@@ -0,0 +1,121 @@
// The iOS/iPadOS half of the clipboard seam: `UIPasteboard.general`.
//
// Two things differ from AppKit in ways that shape the code here.
//
// **Laziness is asynchronous.** UIKit promises data with `NSItemProvider`, whose load handler is
// handed a completion rather than a return value, so this adapter passes the fetch straight
// through no thread is blocked waiting for a paste to resolve.
//
// **Reading the pasteboard is a privacy event.** Since iOS 14 the system tells the user when an
// app reads pasteboard *contents*, and since iOS 16 it asks first when the content came from
// another app. Reading *metadata* the change count, the list of type identifiers does not.
// That maps exactly onto the lazy design: the announce poll only ever looks at metadata, so it is
// silent no matter how long a session runs, and the one moment a read really happens is when
// someone on the host pastes, which is a deliberate act the user is present for.
#if !os(tvOS) && !os(macOS) && canImport(UIKit)
import Foundation
import UIKit
import UniformTypeIdentifiers
typealias SystemPasteboard = UIKitPasteboard
final class UIKitPasteboard: ClipboardPasteboard {
private let pb = UIPasteboard.general
private var activationObserver: NSObjectProtocol?
var changeCount: Int { pb.changeCount }
/// `types` reports the identifiers present without touching a single byte of content, so the
/// poll costs the user nothing and raises no banner.
var typeIdentifiers: [String] { pb.types }
/// Read one wire format, converting where iOS stores a different native type: `image/png` is
/// served from a real PNG entry when present, else re-encoded from whatever image the
/// pasteboard holds a photo copied out of Photos is HEIC, a screenshot may arrive as TIFF,
/// and neither is something a host can be expected to place.
///
/// This is the one call that reads contents, and on iOS 16+ it can put a permission alert in
/// front of the user and wait for their answer. `ClipboardSync` calls it off the drain thread
/// for exactly that reason.
func read(wire: String) -> Data? {
guard wire == "image/png" else {
guard let uti = ClipboardFormats.uti(forWire: wire) else { return nil }
if let data = pb.data(forPasteboardType: uti) {
return data
}
// UIPasteboard stores plain text as a string rather than a data representation often
// enough that the typed read comes back empty on content we can plainly see.
guard uti == UTType.utf8PlainText.identifier else { return nil }
return pb.string?.data(using: .utf8)
}
if let png = pb.data(forPasteboardType: UTType.png.identifier) {
return png
}
return pb.image?.pngData()
}
func installLazy(utis: [String], fetch: @escaping ClipboardFetch) -> Int {
let provider = NSItemProvider()
for uti in utis {
guard let type = UTType(uti), let wire = ClipboardFormats.wire(forUti: uti) else {
continue
}
provider.registerDataRepresentation(for: type, visibility: .all) { completion in
fetch(wire) { data in
if let data {
completion(data, nil)
} else {
completion(nil, ClipboardOfferError.unavailable)
}
}
return nil
}
}
// `localOnly`: these bytes do not exist on this device yet they are a promise against a
// session that is about to end. Handing that to Universal Clipboard would either force an
// eager pull of everything the host ever copies or strand another device with a promise
// nothing can answer.
pb.setItemProviders([provider], localOnly: true, expirationDate: nil)
return pb.changeCount
}
func installResolved(_ items: [(uti: String, data: Data)]) -> Int {
var representations: [String: Any] = [:]
for (uti, data) in items {
representations[uti] = data
}
pb.setItems([representations], options: [.localOnly: true])
return pb.changeCount
}
func clear() -> Int {
pb.items = []
return pb.changeCount
}
/// Backgrounding the app ends the session (see `ContentView`'s scenePhase driver), and with it
/// any hope of answering a promise so an offer the user has not pasted yet is pulled down to
/// real bytes while the connection is still open.
let resolvesPendingOfferOnTeardown = true
func startObserving(onActivate: @escaping () -> Void) {
activationObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil
) { _ in onActivate() }
}
func stopObserving() {
if let activationObserver {
NotificationCenter.default.removeObserver(activationObserver)
self.activationObserver = nil
}
}
}
/// What a lazy representation reports when the host cannot supply it a stale offer, a timed-out
/// fetch, or a session that ended. UIKit shows the paste as producing nothing, which is the same
/// outcome AppKit gets by providing no data.
enum ClipboardOfferError: Error {
case unavailable
}
#endif
@@ -0,0 +1,110 @@
// The platform seam under `ClipboardSync`: everything that actually touches NSPasteboard or
// UIPasteboard, and nothing else.
//
// The sync logic above this protocol the drain thread, the offer sequence numbers, the pending
// fetches a blocked paste waits on, echo suppression is identical on macOS and iOS and is worth
// having exactly one copy of. What genuinely differs is small and lives in the two adapters:
// AppKit fulfils a paste by BLOCKING a provider thread, UIKit by answering an asynchronous load
// handler; AppKit transcodes images through NSImage, UIKit through UIImage; and only UIKit has to
// worry about the process being suspended out from under an offer it promised to serve.
#if !os(tvOS)
import Foundation
/// Pulls the bytes of one lazily-offered wire format from the host. Called on whichever thread the
/// OS fulfils a paste on never the drain thread, which has to stay free to deliver the very
/// chunks this fetch is waiting for and answers asynchronously.
typealias ClipboardFetch = (_ wire: String, _ completion: @escaping (Data?) -> Void) -> Void
/// The system pasteboard, as much of it as the shared clipboard needs.
///
/// Calls arrive from the drain thread, the serve queue, and the thread tearing the sync down;
/// `ClipboardSync` serializes them with its own lock, so an adapter need not be internally
/// synchronized. It must not, however, block on the **main** queue: that lock is also taken from
/// main, and a `main.sync` under it would deadlock.
protocol ClipboardPasteboard: AnyObject {
/// Monotonic per pasteboard write, by anyone. Reading it must never count as reading the
/// pasteboard's *contents* on iOS that distinction is the difference between a silent poll
/// and a system paste banner on every tick.
var changeCount: Int { get }
/// The uniform type identifiers currently on the pasteboard. Must be answerable WITHOUT
/// reading contents, for the same reason.
var typeIdentifiers: [String] { get }
/// Bytes for one wire format, read from the live pasteboard and transcoded where the system
/// stores a different native type (`image/png` from a TIFF screenshot). Nil when the format
/// is not really there. This one DOES read contents.
func read(wire: String) -> Data?
/// Replace the pasteboard with a single item advertising `utis`, each backed by `fetch` the
/// bytes cross only if something actually pastes. Returns the resulting `changeCount`, which
/// the caller records so it can tell its own write apart from the user's next copy.
func installLazy(utis: [String], fetch: @escaping ClipboardFetch) -> Int
/// Replace the pasteboard with concrete bytes. Only iOS needs this (see
/// `ClipboardSync.resolvePendingOffer`); on macOS a lazy promise outlives any paste that
/// might come, so the adapter there never has cause to call it.
func installResolved(_ items: [(uti: String, data: Data)]) -> Int
/// Empty the pasteboard. Returns the resulting `changeCount`.
func clear() -> Int
/// Whether a host offer still sitting unresolved on the pasteboard should be pulled down to
/// concrete bytes as the sync is torn down, instead of being dropped.
///
/// This is the difference between the two platforms' idea of how long a promise lives. A Mac
/// keeps running long after a session ends, but the promise dies with the sync either way, so
/// AppKit clears it and the user loses nothing they had before. On iOS the teardown IS the
/// user leaving backgrounding ends the session and "copy on the host, then paste into
/// Safari" is the whole point of the feature on a tablet, so those bytes have to be made real
/// while the connection that can still supply them is open.
var resolvesPendingOfferOnTeardown: Bool { get }
/// Start watching for the user coming back to the app. The case that matters is "copied
/// elsewhere, now focusing the stream to paste" the offer must reach the host before their
/// V lands, which is sooner than the announce poll would get there on its own.
func startObserving(onActivate: @escaping () -> Void)
func stopObserving()
}
/// A fetch result handed between threads: the OS fulfils a paste on one thread and the drain
/// thread produces the bytes on another.
final class ClipboardResultBox: @unchecked Sendable {
private let ready = DispatchSemaphore(value: 0)
private let lock = NSLock()
private var value: Data?
private var settled = false
func settle(_ data: Data?) {
lock.lock()
guard !settled else {
lock.unlock()
return
}
settled = true
value = data
lock.unlock()
ready.signal()
}
/// Blocks until the bytes arrive, or gives up. Never call this from the drain thread it is
/// the drain thread that delivers what is being waited for.
func wait(timeout: TimeInterval) -> Data? {
guard ready.wait(timeout: .now() + timeout) == .success else { return nil }
lock.lock()
defer { lock.unlock() }
return value
}
}
extension ClipboardPasteboard {
/// The wire formats this pasteboard is currently carrying, honouring the concealed/transient
/// markers. Nil when the pasteboard holds a secret distinct from "holds nothing we sync",
/// which is an empty list and legitimately clears the peer.
var offerKinds: [PunktfunkConnection.ClipKind]? {
let types = typeIdentifiers
guard !ClipboardFormats.isConcealed(types) else { return nil }
return ClipboardFormats.offerKinds(forTypes: types)
}
}
#endif
@@ -1,51 +1,45 @@
// Shared clipboard, macOS client half (design/clipboard-and-file-transfer.md §5.2).
// Shared clipboard, client half (design/clipboard-and-file-transfer.md §5.2). One implementation
// for macOS and iOS/iPadOS; everything that touches an actual pasteboard sits behind
// `ClipboardPasteboard`.
//
// Bridges NSPasteboard.general to the session's QUIC clipboard plane, both directions lazy:
// Both directions are 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.
// * **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` places a pasteboard item whose data provider fires only
// when a local app actually pastes the provider then pulls the bytes over a `clipFetch`.
//
// 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
// Phase 1 formats only (text / RTF / HTML / PNG / JPEG / GIF). Files ride Phase 2.
#if !os(tvOS)
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).
/// connection closes. All wire traffic runs on one dedicated drain thread, plus the OS-owned
/// threads that fulfil a paste.
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),
// Original image formats pass through VERBATIM beside the PNG floor a copied JPEG
// never balloons into PNG, a GIF keeps its animation; the destination picks the richest
// kind it can place.
("image/jpeg", NSPasteboard.PasteboardType("public.jpeg")),
("image/gif", NSPasteboard.PasteboardType("com.compuserve.gif")),
]
/// 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
/// How long a paste waits for the host's bytes before giving up and providing nothing (§5.2).
/// Enforced here, so an adapter that has to block a thread can treat it as a guarantee.
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
/// Announce poll interval how stale a local copy may be before the host hears about it.
private static let announceInterval: TimeInterval = 0.5
/// Ceiling on what `resolvePendingOffer` will pull. Text and modest images are worth having on
/// the chance the user pastes them after the session ends; a 200 MB screenshot is not.
private static let resolveBudget = 8 << 20
/// And how long that may hold up teardown. Short on purpose it sits between the user
/// leaving and the connection closing, and a LAN round-trip for a few KB of text is
/// milliseconds. An offer that cannot be had in this long is one the user does without.
private static let resolveTimeout: TimeInterval = 3
private let connection: PunktfunkConnection
private let pasteboard: any ClipboardPasteboard
/// `CLIP_FLAG_*` sent with the enable (`CLIP_FLAG_FILES` when the session permits files
/// always 0 in Phase 1).
private let controlFlags: UInt8
@@ -53,72 +47,91 @@ public final class ClipboardSync: NSObject {
/// 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).
// MARK: Offer bookkeeping
//
// Read by the drain thread, and written by the thread tearing the sync down too, so it is all
// under one lock. Nothing here is held across a fetch or a pasteboard read.
private let stateLock = NSLock()
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).
/// The changeCount of the last pasteboard write WE made (echo suppression, and "do we still
/// own the pasteboard" on teardown).
private var ownedChangeCount = -1
/// The host offer currently installed on the local pasteboard (nil = none).
private var installedRemoteSeq: UInt32?
/// The host offer currently placed on the local pasteboard (nil = none).
private var installedRemote: (seq: UInt32, kinds: [PunktfunkConnection.ClipKind])?
/// The offer already pulled down to concrete bytes, so teardown neither re-fetches it nor
/// takes it back off the pasteboard.
private var resolvedSeq: 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 {
// MARK: Outbound fetches
//
// Appended by whichever thread starts a fetch, completed by the drain thread as `.data`
// arrives. Guarded by `fetchLock`, which is never held while a completion runs.
private final class PendingFetch {
var buffer = Data()
let done = DispatchSemaphore(value: 0)
var failed = false
let completion: (Data?) -> Void
init(completion: @escaping (Data?) -> Void) { self.completion = completion }
}
private let fetchLock = NSLock()
private var pendingFetches: [UInt32: PendingFetch] = [:]
/// Fires the deadline that keeps a blocked paste from waiting forever on a host that went
/// quiet mid-transfer.
private let deadlines = DispatchQueue(label: "io.unom.punktfunk.clipboard.deadline")
/// Serves host pastes off the drain thread where a pasteboard read can await a user decision
/// (iOS's paste permission alert) the drain thread must keep running, it is the one that
/// would deliver the host's cancel.
private let serves = DispatchQueue(label: "io.unom.punktfunk.clipboard.serve")
private final class StopFlag: @unchecked Sendable {
private final class Flag: @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
private var value = false
func raise() {
lock.lock()
raised = true
value = true
lock.unlock()
}
func takeIfRaised() -> Bool {
var isRaised: Bool {
lock.lock()
defer { lock.unlock() }
let was = raised
raised = false
return value
}
/// Read-and-clear, for the one-shot "check the pasteboard now" nudge.
func take() -> Bool {
lock.lock()
defer { lock.unlock() }
let was = value
value = false
return was
}
}
private let checkNow = OneShot()
private var activationObserver: NSObjectProtocol?
private let stopped = Flag()
/// Raised by the activation observer, taken by the drain loop: the user may have copied
/// elsewhere and is coming back to paste announce now rather than waiting out the poll.
private let checkNow = Flag()
private let drainDone = DispatchSemaphore(value: 0)
private var started = false
public init(connection: PunktfunkConnection, allowFiles: Bool = false) {
/// - Parameter allowFiles: reserved for Phase 2; `CLIP_FLAG_FILES` is never set yet.
public convenience init(connection: PunktfunkConnection, allowFiles: Bool = false) {
self.init(connection: connection, pasteboard: SystemPasteboard(), allowFiles: allowFiles)
}
/// Designated init, taking the pasteboard so tests can drive the whole state machine against a
/// stub without an AppKit/UIKit pasteboard in the way.
init(
connection: PunktfunkConnection, pasteboard: any ClipboardPasteboard,
allowFiles: Bool = false
) {
self.connection = connection
self.pasteboard = pasteboard
self.controlFlags = 0 // CLIP_FLAG_FILES rides Phase 2
_ = allowFiles
super.init()
}
deinit { flag.stop() }
deinit { stopped.raise() }
// MARK: - Lifecycle
/// 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.
@@ -126,103 +139,105 @@ public final class ClipboardSync: NSObject {
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.
// Baseline: whatever is on the pasteboard when sync starts is announced immediately the
// "copy first, then connect and paste" flow must work.
stateLock.lock()
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()
}
stateLock.unlock()
pasteboard.startObserving(onActivate: { [checkNow] in checkNow.raise() })
let thread = Thread { [weak self] in self?.drain() }
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.
/// Disable sync and join the drain thread. Called off-main before `connection.close()` (the
/// same discipline as the audio/feedback drains).
///
/// A host offer still sitting on the local pasteboard as a promise has to be dealt with here,
/// because after this returns nothing can answer it: either it is pulled down to real bytes
/// (iOS, where this teardown is the user walking away with something they copied) or it is
/// cleared, so a later paste comes up empty rather than silently doing nothing.
public func stop() {
guard started else { return }
started = false
if let obs = activationObserver {
NotificationCenter.default.removeObserver(obs)
activationObserver = nil
pasteboard.stopObserving()
// Before anything is torn down the drain thread has to still be running to deliver the
// chunks, and the connection still open to carry the fetch.
if pasteboard.resolvesPendingOfferOnTeardown {
resolvePendingOffer()
}
connection.clipControl(enabled: false, flags: 0)
flag.stop()
stopped.raise()
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()
// Fail every paste still blocked on us so nothing waits out its timeout against a dead
// session.
settleAll(nil)
stateLock.lock()
let ownsUnresolvedOffer =
installedRemote != nil && resolvedSeq != installedRemote?.seq
&& pasteboard.changeCount == ownedChangeCount
installedRemote = nil
stateLock.unlock()
if ownsUnresolvedOffer {
_ = pasteboard.clear()
}
pendingFetches.removeAll()
fetchLock.unlock()
let pb = NSPasteboard.general
if installedRemoteSeq != nil, pb.changeCount == ownedChangeCount {
pb.clearContents()
}
private func drain() {
var lastAnnounceCheck = Date.distantPast
while !stopped.isRaised {
// Drain events (bounded burst so a chatty host can't starve the announce poll).
var drained = 0
while drained < 32, !stopped.isRaised {
let ev: PunktfunkConnection.ClipEvent?
do {
ev = try connection.nextClipboard(timeoutMs: drained == 0 ? 200 : 0)
} catch {
stopped.raise() // session closed
break
}
guard let ev else { break }
drained += 1
handle(ev)
}
let now = Date()
if now.timeIntervalSince(lastAnnounceCheck) >= Self.announceInterval
|| checkNow.take()
{
lastAnnounceCheck = now
announceIfChanged()
}
}
drainDone.signal()
}
// 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.
/// 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
var kinds = Self.wireToPasteboard
.filter { types.contains($0.type) }
.map { PunktfunkConnection.ClipKind(mime: $0.wire) }
// PNG floor: announce the portable `image/png` whenever ANY convertible image is present
// native PNG, TIFF/HEIC (screenshots, Preview), or a JPEG/GIF original already being
// offered verbatim above. `readWireData` converts at fetch time (lazy, §3.5), so the
// fallback costs nothing unless a peer actually pastes it.
if !kinds.contains(where: { $0.mime == "image/png" }),
types.contains(.tiff)
|| types.contains(NSPasteboard.PasteboardType("public.heic"))
|| kinds.contains(where: { $0.mime.hasPrefix("image/") })
{
kinds.append(PunktfunkConnection.ClipKind(mime: "image/png"))
let count = pasteboard.changeCount
stateLock.lock()
guard count != lastSeenChangeCount else {
stateLock.unlock()
return
}
lastSeenChangeCount = count
guard count != ownedChangeCount else {
stateLock.unlock() // our own write (a remote offer) never echo
return
}
installedRemote = nil // a local copy replaced the host's offer
stateLock.unlock()
guard let kinds = pasteboard.offerKinds else { return } // concealed never announced
stateLock.lock()
offerSeq &+= 1
let seq = offerSeq
stateLock.unlock()
// Empty = the pasteboard holds nothing we sync (or was cleared) clears the host side.
connection.clipOffer(seq: offerSeq, kinds: kinds)
connection.clipOffer(seq: seq, kinds: kinds)
}
// MARK: - Event handling (drain thread)
@@ -236,93 +251,166 @@ public final class ClipboardSync: NSObject {
case let .remoteOffer(seq, kinds):
installRemoteOffer(seq: seq, kinds: kinds)
case let .fetchRequest(reqId, seq, _, mime):
serveFetch(reqId: reqId, seq: seq, mime: mime)
serves.async { [weak self] in self?.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()
}
}
let pending = pendingFetches[xferId]
pending?.buffer.append(chunk)
let finished = last ? pendingFetches.removeValue(forKey: xferId) : nil
fetchLock.unlock()
// Outside the lock: a completion may start the next fetch (or wake a thread that will).
if let finished {
finished.completion(finished.buffer)
}
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()
settle(id, nil)
}
}
// MARK: - Host copy local (lazy install + blocked-paste fetch)
// MARK: - Host copy local (lazy placement + paste-time 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.
/// Place a pasteboard item advertising the host's formats, each backed by a lazy provider
/// bytes cross only when a local 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
let utis = ClipboardFormats.placeableUtis(for: kinds)
guard !utis.isEmpty else {
stateLock.lock()
let owned = installedRemote != nil && pasteboard.changeCount == ownedChangeCount
installedRemote = nil
resolvedSeq = nil
if owned {
let after = pasteboard.clear()
ownedChangeCount = after
lastSeenChangeCount = after
}
installedRemoteSeq = nil
stateLock.unlock()
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
let fetch: ClipboardFetch = { [weak self] wire, done in
guard let self else {
done(nil)
return
}
self.fetch(seq: seq, wire: wire, completion: done)
}
let before = pasteboard.changeCount
let after = pasteboard.installLazy(utis: utis, fetch: fetch)
stateLock.lock()
installedRemote = (seq, kinds)
resolvedSeq = nil
// Only claim the pasteboard if the write actually landed. Recording a change count we did
// not cause is the one bookkeeping mistake with no recovery: the announce poll would read
// the user's own next copy as our echo and never tell the host about it again.
if after != before {
ownedChangeCount = after
lastSeenChangeCount = after
}
stateLock.unlock()
}
/// 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? {
/// Start pulling one wire format of host offer `seq`. `completion` runs exactly once with
/// the bytes, or with nil on a stale offer, a timeout, a cancel, or a closing session.
private func fetch(seq: UInt32, wire: String, completion: @escaping (Data?) -> Void) {
fetchLock.lock()
guard let xferId = connection.clipFetch(seq: seq, mime: wireMime) else {
guard !stopped.isRaised, let xferId = connection.clipFetch(seq: seq, mime: wire) else {
fetchLock.unlock()
return nil
completion(nil)
return
}
pendingFetches[xferId] = PendingFetch()
let done = pendingFetches[xferId]!.done
pendingFetches[xferId] = PendingFetch(completion: completion)
fetchLock.unlock()
let outcome = done.wait(timeout: .now() + Self.fetchTimeout)
deadlines.asyncAfter(deadline: .now() + Self.fetchTimeout) { [weak self] in
guard let self, self.settle(xferId, nil) else { return }
self.connection.clipCancel(id: xferId)
}
}
/// Complete one pending fetch if it hasn't been already. Returns whether this call is the one
/// that settled it, so a deadline knows whether it still has to cancel the transfer.
@discardableResult
private func settle(_ xferId: UInt32, _ data: Data?) -> Bool {
fetchLock.lock()
let pending = pendingFetches.removeValue(forKey: xferId)
fetchLock.unlock()
if outcome == .timedOut {
connection.clipCancel(id: xferId)
return nil
pending?.completion(data)
return pending != nil
}
private func settleAll(_ data: Data?) {
fetchLock.lock()
let all = pendingFetches
pendingFetches.removeAll()
fetchLock.unlock()
for (_, pending) in all {
pending.completion(data)
}
guard let pending, !pending.failed else { return nil }
return pending.buffer
}
// MARK: - Resolving a promise before it dies (iOS)
/// Pull the host's offer down to concrete bytes, replacing the promise on the pasteboard.
///
/// A lazy promise is only as good as the ability to answer it, and on iOS that ability ends
/// with the session: backgrounding the app disconnects it. Without this, "copy on the host,
/// then paste into Safari on the iPad" would hand Safari an empty promise. Everything else
/// about the design stays lazy a user who copies on the host, pastes nothing, and stays in
/// the app moves no clipboard bytes at all; this runs once, at the end, for content that is
/// still on the pasteboard and still unclaimed.
///
/// Runs on the thread calling `stop()` off-main by contract, and never the drain thread,
/// which has to keep running to deliver what this waits for.
private func resolvePendingOffer() {
stateLock.lock()
let offer = installedRemote
let unresolved = resolvedSeq != installedRemote?.seq
let stillOurs = pasteboard.changeCount == ownedChangeCount
stateLock.unlock()
guard let offer, unresolved, stillOurs, !stopped.isRaised else { return }
let deadline = Date().addingTimeInterval(Self.resolveTimeout)
var items: [(uti: String, data: Data)] = []
var budget = Self.resolveBudget
for kind in offer.kinds {
guard let uti = ClipboardFormats.uti(forWire: kind.mime) else { continue }
// A size hint of 0 means "unknown" try it, and let the byte count enforce the cap.
guard kind.sizeHint <= UInt64(budget), !stopped.isRaised else { continue }
let left = deadline.timeIntervalSinceNow
guard left > 0 else { break }
let box = ClipboardResultBox()
fetch(seq: offer.seq, wire: kind.mime) { box.settle($0) }
guard let data = box.wait(timeout: left), data.count <= budget else { continue }
budget -= data.count
items.append((uti, data))
}
guard !items.isEmpty else { return }
// Re-check: the host may have copied again, or the user may have copied locally, while we
// were pulling either way these bytes are no longer what belongs on the pasteboard.
stateLock.lock()
defer { stateLock.unlock() }
let before = pasteboard.changeCount
guard installedRemote?.seq == offer.seq, before == ownedChangeCount else { return }
let after = pasteboard.installResolved(items)
// As in `installRemoteOffer`: a write that did not land leaves the promise in place, so
// teardown should still take it back rather than believing these bytes are on the board.
guard after != before else { return }
resolvedSeq = offer.seq
ownedChangeCount = after
lastSeenChangeCount = after
}
// 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.
/// 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.
///
/// Runs on `serves`, not the drain thread: reading the pasteboard can block on a user decision
/// (iOS's paste permission alert), and the drain thread has to stay live throughout.
private func serveFetch(reqId: UInt32, seq: UInt32, mime: String) {
let pb = NSPasteboard.general
guard seq == offerSeq, pb.changeCount == lastSeenChangeCount,
let data = Self.readWireData(pb, mime)
else {
stateLock.lock()
let fresh = seq == offerSeq && pasteboard.changeCount == lastSeenChangeCount
stateLock.unlock()
guard fresh, !stopped.isRaised, let data = pasteboard.read(wire: mime) else {
connection.clipCancel(id: reqId)
return
}
@@ -337,66 +425,5 @@ public final class ClipboardSync: NSObject {
connection.clipServe(reqId: reqId, data: Data(), last: true)
}
}
/// Read one wire format from the pasteboard, converting where macOS stores a different
/// native type: `image/png` is served from a real `.png` entry when present, else converted
/// from whatever image representation the pasteboard holds (TIFF from screenshots/Preview,
/// WebP/AVIF/GIF from browsers `NSImage` decodes them all) into PNG at fetch time.
private static func readWireData(_ pb: NSPasteboard, _ mime: String) -> Data? {
guard mime == "image/png" else {
guard let type = wireToPasteboard.first(where: { $0.wire == mime })?.type else {
return nil
}
return pb.data(forType: type)
}
if let png = pb.data(forType: .png) {
return png
}
// No native PNG: decode whatever image the pasteboard carries and re-encode.
guard let img = NSImage(pasteboard: pb),
let tiff = img.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff)
else {
return nil
}
return rep.representation(using: .png, properties: [:])
}
}
/// 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"
case NSPasteboard.PasteboardType("public.jpeg"): return "image/jpeg"
case NSPasteboard.PasteboardType("com.compuserve.gif"): return "image/gif"
default: return nil
}
}
}
#endif
@@ -34,10 +34,10 @@ public struct StoredHost: Identifiable, Codable, Hashable, Sendable {
/// 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`.
/// Share the clipboard with this host (macOS and iOS sessions; tvOS has no pasteboard see
/// 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?
/// This host's default settings profile (`StreamProfile.id`) what a plain click/tap uses.
/// nil, or an id whose profile was deleted, resolves as "Default settings", i.e. exactly
@@ -0,0 +1,154 @@
// The shared clipboard's format decisions what a given pasteboard gets announced as, and what a
// host offer gets placed as. Pure functions, and the half of the sync that macOS and iOS now
// genuinely share, so a change that suits one platform and breaks the other fails here.
import XCTest
@testable import PunktfunkKit
#if !os(tvOS)
final class ClipboardFormatsTests: XCTestCase {
// MARK: - Announcing what is on the local pasteboard
func testPlainTextAnnouncesOnlyText() {
let kinds = ClipboardFormats.offerKinds(forTypes: ["public.utf8-plain-text"])
XCTAssertEqual(kinds.map(\.mime), ["text/plain;charset=utf-8"])
}
func testRichTextAnnouncesEveryRepresentationInTableOrder() {
// A copy out of a word processor leaves all three; the host picks the richest it can place,
// so the order the table declares is the order the offer carries.
let kinds = ClipboardFormats.offerKinds(forTypes: [
"public.html", "public.utf8-plain-text", "public.rtf",
])
XCTAssertEqual(kinds.map(\.mime), ["text/plain;charset=utf-8", "text/rtf", "text/html"])
}
func testUnknownTypesAreNotAnnounced() {
// A pasteboard holding only things we have no wire vocabulary for announces nothing, which
// legitimately clears the host's side rather than offering something unfetchable.
let kinds = ClipboardFormats.offerKinds(forTypes: ["com.apple.mail.PasteboardTypeMessage"])
XCTAssertTrue(kinds.isEmpty)
}
// MARK: - The PNG floor
func testScreenshotTiffAnnouncesPngEvenThoughTiffIsNotOnTheWire() {
// Screenshots and Preview leave TIFF, which no host can place. The adapters transcode at
// fetch time, so announcing the PNG floor costs nothing until someone actually pastes.
let kinds = ClipboardFormats.offerKinds(forTypes: ["public.tiff"])
XCTAssertEqual(kinds.map(\.mime), ["image/png"])
}
func testPhotoHeicAnnouncesPng() {
// The iOS case: a photo copied out of Photos is HEIC.
let kinds = ClipboardFormats.offerKinds(forTypes: ["public.heic"])
XCTAssertEqual(kinds.map(\.mime), ["image/png"])
}
func testJpegCrossesVerbatimAndStillCarriesThePngFloor() {
// The original rides beside the floor rather than replacing it a copied JPEG must not
// balloon into a lossless PNG for peers that can take JPEG.
let kinds = ClipboardFormats.offerKinds(forTypes: ["public.jpeg"])
XCTAssertEqual(kinds.map(\.mime), ["image/jpeg", "image/png"])
}
func testGifCrossesVerbatimSoAnimationSurvives() {
let kinds = ClipboardFormats.offerKinds(forTypes: ["com.compuserve.gif"])
XCTAssertEqual(kinds.map(\.mime), ["image/gif", "image/png"])
}
func testNativePngIsNotAnnouncedTwice() {
let kinds = ClipboardFormats.offerKinds(forTypes: ["public.png", "public.tiff"])
XCTAssertEqual(kinds.map(\.mime), ["image/png"])
}
// MARK: - Secrets
func testConcealedAndTransientPasteboardsAreRecognized() {
// Password managers mark secrets with these; nothing so marked is ever announced.
XCTAssertTrue(
ClipboardFormats.isConcealed([
"public.utf8-plain-text", "org.nspasteboard.ConcealedType",
]))
XCTAssertTrue(
ClipboardFormats.isConcealed([
"public.utf8-plain-text", "org.nspasteboard.TransientType",
]))
XCTAssertFalse(ClipboardFormats.isConcealed(["public.utf8-plain-text"]))
}
func testAConcealedPasteboardOffersNothingRatherThanAnEmptyOffer() {
// The distinction matters: nil means "say nothing at all", where an empty list would tell
// the host to drop what it has.
let secret = StubPasteboard(types: ["public.utf8-plain-text", "org.nspasteboard.ConcealedType"])
XCTAssertNil(secret.offerKinds)
let ordinary = StubPasteboard(types: ["public.utf8-plain-text"])
XCTAssertEqual(ordinary.offerKinds?.map(\.mime), ["text/plain;charset=utf-8"])
let empty = StubPasteboard(types: [])
XCTAssertEqual(empty.offerKinds?.isEmpty, true)
}
// MARK: - Placing what the host offered
func testHostOfferMapsToUniformTypesAndSkipsWhatWeCannotPlace() {
// Files ride Phase 2 an offer carrying them places the rest and ignores that kind rather
// than failing the whole paste.
let kinds = [
PunktfunkConnection.ClipKind(mime: "text/plain;charset=utf-8"),
PunktfunkConnection.ClipKind(mime: "application/x-punktfunk-files"),
PunktfunkConnection.ClipKind(mime: "image/png"),
]
XCTAssertEqual(
ClipboardFormats.placeableUtis(for: kinds), ["public.utf8-plain-text", "public.png"])
}
func testWireAndUniformTypeMapBothWays() {
for (wire, uti) in ClipboardFormats.table {
XCTAssertEqual(ClipboardFormats.uti(forWire: wire), uti)
XCTAssertEqual(ClipboardFormats.wire(forUti: uti), wire)
}
XCTAssertNil(ClipboardFormats.uti(forWire: "application/x-punktfunk-files"))
XCTAssertNil(ClipboardFormats.wire(forUti: "public.tiff"))
}
// MARK: - Handing bytes between threads
func testResultBoxDeliversBytesToAWaiter() {
let box = ClipboardResultBox()
DispatchQueue.global().async { box.settle(Data("hello".utf8)) }
XCTAssertEqual(box.wait(timeout: 5), Data("hello".utf8))
}
func testResultBoxTimesOutRatherThanWaitingForever() {
// What a paste does when the host goes quiet mid-transfer: give up, insert nothing.
XCTAssertNil(ClipboardResultBox().wait(timeout: 0.05))
}
func testResultBoxKeepsTheFirstAnswer() {
// The fetch deadline and a late arrival can both fire; whichever settles first wins, and
// the loser must not overwrite it or signal a second time.
let box = ClipboardResultBox()
box.settle(nil)
box.settle(Data("late".utf8))
XCTAssertNil(box.wait(timeout: 1))
}
}
/// A pasteboard that holds nothing but a type list enough to exercise the announce decision
/// without an AppKit or UIKit pasteboard in the test process.
private final class StubPasteboard: ClipboardPasteboard {
private let types: [String]
init(types: [String]) { self.types = types }
var changeCount = 0
var typeIdentifiers: [String] { types }
func read(wire: String) -> Data? { nil }
func installLazy(utis: [String], fetch: @escaping ClipboardFetch) -> Int { 0 }
func installResolved(_ items: [(uti: String, data: Data)]) -> Int { 0 }
func clear() -> Int { 0 }
let resolvesPendingOfferOnTeardown = false
func startObserving(onActivate: @escaping () -> Void) {}
func stopObserving() {}
}
#endif
+3 -3
View File
@@ -208,9 +208,9 @@ reach, which app honours which of them, the two mouse modes, the three touch mod
are all on [Mouse, touch and pen](/docs/input#getting-your-input-back).
Copying between the two machines is a separate opt-in: the host operator allows it in `host.env`
and you turn it on for that one host in your client. Content crosses today from the macOS, Windows
and Android apps — the Linux client has the switch but no bridge behind it yet, and iOS, iPadOS and
tvOS have neither. See [Shared clipboard](/docs/clipboard).
and you turn it on for that one host in your client. Content crosses today from the macOS, iOS,
iPadOS, Windows and Android apps — the Linux client has the switch but no bridge behind it yet, and
tvOS has no pasteboard to share. See [Shared clipboard](/docs/clipboard).
## Which should I use?
+19 -9
View File
@@ -64,6 +64,7 @@ about *that* machine. You set it in the host's edit sheet, and it is deliberatel
| Client | Where the switch is | Label | Default |
|---|---|---|---|
| macOS | Host card menu → **Edit…** | **Share clipboard with this host** | Off |
| iOS, iPadOS | Host card menu → **Edit…** | **Share clipboard with this host** | Off |
| Windows | Host tile menu → **Edit…** | **Share clipboard with this host** | Off |
| Linux (GTK) | Host card menu → **Edit…** | **Share clipboard** | Off |
| Android (touch) | Host card menu → **Edit…** | **Shared clipboard** | **On** |
@@ -75,10 +76,12 @@ no clipboard row, so there is nowhere to change it there. It stays on, which is
The setting is read when a session starts, so if you change it while streaming, reconnect.
macOS can also flip it mid-session: **Stream ▸ Share Clipboard** (⌃⌥⇧C), which becomes **Stop
Sharing Clipboard** once the host has acknowledged it.
Sharing Clipboard** once the host has acknowledged it. On an iPad with a hardware keyboard the same
combo works, though there is no menu bar to show it in — and only while the pointer is released, as
a captured session sends the keys to the host instead.
iOS, iPadOS, tvOS and a Steam Deck in Gaming Mode have no clipboard switch — neither the Decky
panel nor the client's console home has a host edit sheet — see
tvOS and a Steam Deck in Gaming Mode have no clipboard switch — the Apple TV has no pasteboard to
share at all, and neither the Decky panel nor the client's console home has a host edit sheet — see
[what each client does](#which-hosts-and-clients-support-it) below.
## Nothing crosses until something pastes
@@ -91,16 +94,22 @@ That holds for everything you copy on your own machine, and for both directions
does **not** hold for a host copy arriving at a Windows or Android client: those two fetch the
content straight away and put it on your local clipboard, whether or not you ever paste. On Windows
that is because the lazy path needs Windows delayed rendering, which the client doesn't implement
yet; on Android there is no way to satisfy a paste from the network at all. The macOS client is
lazy in both directions.
yet; on Android there is no way to satisfy a paste from the network at all. The macOS and iOS
clients are lazy in both directions.
On iOS there is one deliberate exception. Backgrounding the app ends the session, and a promise
nobody can answer is worse than no promise at all — so if the host copied something and you have not
pasted it yet, those bytes are pulled across as the session ends, up to 8 MiB. That is what makes
"copy on the host, switch to Safari, paste" work on an iPad. Nothing is fetched if you never leave
the app, or if you already pasted.
A single transfer is capped at 64 MiB. Nothing else limits size, so a very large host-side copy can
cross to a Windows or Android client for a paste that never happens.
What you copy on the **macOS or Windows client** is filtered for secrets: content marked
`org.nspasteboard.ConcealedType` or `org.nspasteboard.TransientType` on macOS, or
What you copy on the **macOS, iOS or Windows client** is filtered for secrets: content marked
`org.nspasteboard.ConcealedType` or `org.nspasteboard.TransientType` on the Apple clients, or
`ExcludeClipboardContentFromMonitorProcessing` on Windows — what password managers set — is never
announced and never served. That check exists only in those two clients. The Android client has no
announced and never served. That check exists only in those clients. The Android client has no
equivalent, and neither does the host, so a password copied **on the host** is announced to your
client like anything else.
@@ -128,10 +137,11 @@ when a host application pastes.
| Client | What crosses |
|---|---|
| macOS | Plain text, rich text (RTF), HTML, and PNG, JPEG and GIF images |
| iOS, iPadOS | Plain text, rich text (RTF), HTML, and PNG, JPEG and GIF images |
| Windows | Plain text, and PNG images |
| Android, Android TV | **Plain text only** |
| Linux (GTK), Steam Deck | Nothing yet — see below |
| iOS, iPadOS, tvOS | Not implemented |
| tvOS | Not implemented — tvOS has no pasteboard |
The **Linux client has the switch but no working clipboard bridge**: it enables the plane and then
has no code to read or write the desktop's own clipboard, so nothing is announced and nothing is
+2 -2
View File
@@ -38,8 +38,8 @@ than de-escalating and staying degraded. The next structural lever is **sub-fram
overlapping encode and transmit inside a single frame via a direct slice path — which matters most
at high resolutions.
**Finishing the clipboard.** Text crosses today from a Windows, macOS or Android client to a host
whose operator turned the feature on, with images on the first two. Two pieces are genuinely
**Finishing the clipboard.** Text crosses today from a Windows, macOS, iOS, iPadOS or Android
client to a host whose operator turned the feature on, with images on all but Android. Two pieces are genuinely
unfinished: the **Linux client's** side of the bridge is a stub, so a Linux client offers and
applies nothing; and **file transfer** has a wire format and a host-side policy but no client that
offers files, so a copied file never crosses. See [Clipboard](/docs/clipboard).
+7 -5
View File
@@ -504,7 +504,7 @@ text from an IME), are covered in [Input](/docs/input).
| Linux desktop | ✅ ¹ | ⚠️ ¹³ | ✅ ² | ✅ | ❌ ³ | ✅ |
| Windows desktop | ✅ ¹ | ⚠️ ¹³ | ✅ ² | ✅ | ⚠️ ⁴ | ✅ |
| macOS | ⚠️ ⁵ | ⚠️ ⁶ | ✅ ² | ✅ | ✅ ⁷ | ✅ |
| iPhone · iPad | ⚠️ ⁵ | ⚠️ ⁶ | ✅ ² | ✅ | ❌ ⁸ | ✅ |
| iPhone · iPad | ⚠️ ⁵ | ⚠️ ⁶ | ✅ ² | ✅ | ✅ ⁷ | ✅ |
| Apple TV | ⚠️ ⁵ | ⚠️ ⁶ | ✅ ² | ❌ ⁹ | ❌ ⁸ | ✅ |
| Android · Android TV | ⚠️ ⁵ | ❌ | ✅ ² | ✅ | ⚠️ ¹⁰ | ✅ |
| Moonlight | ⚠️ ¹¹ | ❌ | ✅ ² | ❌ | ❌ | ❓ ¹² |
@@ -523,8 +523,10 @@ text from an IME), are covered in [Input](/docs/input).
full-chroma colour conversion, so 4:4:4 needs no encoder probe and resolves on any vendor. See
[Client settings](/docs/client-settings).
7. The richest implementation: text, RTF, HTML and images, fetched lazily in both directions.
Concealed and transient pasteboard items are skipped.
8. The clipboard bridge is macOS-only within the Apple app.
Concealed and transient pasteboard items are skipped. On iOS one thing is not lazy: because
backgrounding the app ends the session, an unpasted host offer is pulled across (up to 8 MiB)
as the session ends, so it survives to be pasted in another app.
8. tvOS has no pasteboard, so there is nothing to share.
9. tvOS gives apps no microphone input at all.
10. Text only, by design — and unlike every other client, Android's per-host **Shared clipboard**
switch starts **on** (the desktop clients and the Apple app default it off). Nothing crosses
@@ -550,7 +552,7 @@ These are negotiated, and either side can be the reason it did not happen:
host without the clipboard protocol (a gamescope session, for instance). The per-host "Share
clipboard" switch is edited before you connect, so it is never greyed out — on Linux, Windows,
Android and the Apple add-host sheet it stays settable against a host that will refuse, and just
does nothing. Only macOS reflects the refusal live: the Stream ▸ Share Clipboard menu item is
does nothing. Only the Apple app reflects the refusal live: the Stream ▸ Share Clipboard item is
disabled when the connected host has not advertised the capability.
- **Pen input** — the host advertises it only if it can really inject: a usable `/dev/uinput` on
Linux, or synthetic pointer devices on Windows 10 1809+. Without it, clients fold pen into touch.
@@ -574,7 +576,7 @@ capability.
| **Windows host** | Newest large surface, shipping as an installer with its own virtual-display driver — both signed with Punktfunk's own certificates rather than a publicly trusted one, so Windows warns on install (see [Windows host](/docs/windows-host#about-the-signatures)). NVENC is well trodden; the AMD (AMF) path was validated on real hardware in mid-2026 (Ryzen 7000 iGPU, 1080p120 HDR P010) and the Intel (QSV) path on Arc, but both see far less field time than NVENC — several of QSV's newer arms are still marked unvalidated in the code. One structural constraint that catches people: it must run in the interactive console session, not session 0. |
| **GameStream / Moonlight plane** | Works, and whether it is on depends on how you installed. Every Linux package (deb, RPM, Arch, the Bazzite sysext) and the SteamOS installer ship the unit as `serve --gamestream`, so GameStream is **on** there; NixOS defaults it on too. The Windows installer's checkbox is unticked, so it is **off** unless you asked for it, and a bare `punktfunk-host serve` is off. It pairs over plain HTTP with weaker legacy encryption — trusted LAN only, and worth turning off if you don't use Moonlight (see [Security](/docs/security#gamestream--moonlight-compatibility-is-the-weak-crypto-path)). It is a compatibility surface, so Punktfunk-only features (profiles, links, clipboard, microphone) are not on it. |
| **Linux and Windows desktop clients** | Packaged and current. They are one codebase: the same session binary streams for both, and for the Decky plugin and the `punktfunk` CLI. |
| **Apple client** (macOS · iOS · iPadOS · tvOS) | One universal build, distributed as a **TestFlight beta**; the Mac also has a notarized DMG. Feature-complete apart from the platform gaps named above (no microphone on tvOS, clipboard on macOS only). |
| **Apple client** (macOS · iOS · iPadOS · tvOS) | One universal build, distributed as a **TestFlight beta**; the Mac also has a notarized DMG. Feature-complete apart from the platform gaps named above (no microphone or clipboard on tvOS). |
| **Android client** (phone · TV) | Published on **Google Play** as a public listing for releases, with an invite-only Internal testing track for canary, plus a sideloadable APK. The same app in leanback mode is the TV client. |
| **Decky plugin** (Steam Deck) | Ships through install-from-URL rather than the Decky store, and keeps itself and the client it launches up to date. It is a launcher, not a second client: it starts the Linux client rather than streaming itself, and holds no settings, no library and no host editor of its own — its **Open Punktfunk** button hands all of that to the client's console home. |
| **Web console** | The full management surface — dashboard and sessions, pairing, library, displays, plugins and the plugin store, logs, stats, settings, and host updates. It cannot yet run a speed test or set a bitrate; the client apps can. |