The shared clipboard now works on iPhone and iPad #250
@@ -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
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user