diff --git a/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift b/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift index 89215e93..0eaa83ba 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift @@ -30,10 +30,10 @@ struct AddHostSheet: View { @State private var pinnedIDs: Set @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 diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index b435ac1e..8d9fbffa 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift index 86f50069..5ab827b7 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardFormats.swift b/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardFormats.swift new file mode 100644 index 00000000..59462071 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardFormats.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardPasteboard+AppKit.swift b/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardPasteboard+AppKit.swift new file mode 100644 index 00000000..43ea9cfb --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardPasteboard+AppKit.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardPasteboard+UIKit.swift b/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardPasteboard+UIKit.swift new file mode 100644 index 00000000..f6a63628 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardPasteboard+UIKit.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardPasteboard.swift b/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardPasteboard.swift new file mode 100644 index 00000000..7a4443d1 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardPasteboard.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardSync.swift b/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardSync.swift index 5083b8b1..5044366b 100644 --- a/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardSync.swift +++ b/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardSync.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkShared/StoredHost.swift b/clients/apple/Sources/PunktfunkShared/StoredHost.swift index 35ef7907..f0ac26c1 100644 --- a/clients/apple/Sources/PunktfunkShared/StoredHost.swift +++ b/clients/apple/Sources/PunktfunkShared/StoredHost.swift @@ -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 diff --git a/clients/apple/Tests/PunktfunkKitTests/ClipboardFormatsTests.swift b/clients/apple/Tests/PunktfunkKitTests/ClipboardFormatsTests.swift new file mode 100644 index 00000000..1b4a33b2 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/ClipboardFormatsTests.swift @@ -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 diff --git a/docs-site/content/docs/clients.md b/docs-site/content/docs/clients.md index 631a462f..082a2977 100644 --- a/docs-site/content/docs/clients.md +++ b/docs-site/content/docs/clients.md @@ -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? diff --git a/docs-site/content/docs/clipboard.md b/docs-site/content/docs/clipboard.md index af27dbca..ec9e3d90 100644 --- a/docs-site/content/docs/clipboard.md +++ b/docs-site/content/docs/clipboard.md @@ -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 diff --git a/docs-site/content/docs/roadmap.md b/docs-site/content/docs/roadmap.md index d094fbf2..3e458468 100644 --- a/docs-site/content/docs/roadmap.md +++ b/docs-site/content/docs/roadmap.md @@ -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). diff --git a/docs-site/content/docs/support-matrix.md b/docs-site/content/docs/support-matrix.md index 2f7c4358..d9d85c09 100644 --- a/docs-site/content/docs/support-matrix.md +++ b/docs-site/content/docs/support-matrix.md @@ -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. |