diff --git a/Cargo.lock b/Cargo.lock index e0938a63..e0af4b0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2798,6 +2798,23 @@ dependencies = [ "windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", ] +[[package]] +name = "pf-clipboard" +version = "0.12.0" +dependencies = [ + "anyhow", + "ashpd", + "futures-util", + "libc", + "punktfunk-core", + "quinn", + "tokio", + "tracing", + "wayland-client", + "wayland-protocols", + "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "pf-console-ui" version = "0.12.0" @@ -3282,6 +3299,7 @@ dependencies = [ "opus", "parking_lot", "pf-capture", + "pf-clipboard", "pf-driver-proto", "pf-encode", "pf-frame", diff --git a/Cargo.toml b/Cargo.toml index c8e6363f..edeb5176 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/punktfunk-host/vendor/usbip-sim", "crates/punktfunk-tray", "crates/pf-client-core", + "crates/pf-clipboard", "crates/pf-presenter", "crates/pf-console-ui", "crates/pf-ffvk", diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 4d3aace3..5c0f27e7 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -224,6 +224,9 @@ struct ContentView: View { #if !os(tvOS) .focusedSceneValue(\.sessionFocus, SessionFocus( isStreaming: model.connection != nil, + clipboardAvailable: model.connection?.hostSupportsClipboard == true, + clipboardOn: model.clipboardEnabled, + toggleClipboard: { model.toggleClipboardSync() }, disconnect: { model.disconnect() })) #endif #if os(macOS) diff --git a/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift b/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift index 6d75c509..b719b236 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift @@ -20,6 +20,12 @@ struct AddHostSheet: View { @State private var address: String @State private var port: Int @State private var mac: String + #if os(macOS) + /// Share the clipboard with this host (macOS sessions only; design + /// clipboard-and-file-transfer.md §5.3). Off by default; honored only when the host + /// advertises the capability at connect. + @State private var clipboardSync: Bool + #endif #if os(tvOS) private enum EditField: String, Identifiable { case name, address, port, mac @@ -41,6 +47,9 @@ struct AddHostSheet: View { _port = State(initialValue: Int(existing?.port ?? 9777)) let stored = existing?.macAddresses ?? [] _mac = State(initialValue: (stored.isEmpty ? suggestedMacs : stored).joined(separator: ", ")) + #if os(macOS) + _clipboardSync = State(initialValue: existing?.clipboardSync ?? false) + #endif } var body: some View { @@ -96,6 +105,9 @@ struct AddHostSheet: View { #if os(iOS) .textInputAutocapitalization(.never) #endif + #if os(macOS) + Toggle("Share clipboard with this host", isOn: $clipboardSync) + #endif } #if !os(tvOS) .formStyle(.grouped) @@ -147,6 +159,11 @@ struct AddHostSheet: View { host.address = address.trimmingCharacters(in: .whitespaces) host.port = UInt16(clamping: port) host.macAddresses = Self.parseMacs(mac) + #if os(macOS) + // nil when off: the key stays absent from the saved JSON (forward-compat, and "never + // opted in" and "opted out" read the same — off). + host.clipboardSync = clipboardSync ? true : nil + #endif onSave(host) dismiss() } diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 3181f2e6..12db3065 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -139,6 +139,18 @@ final class SessionModel: ObservableObject { private var audio: SessionAudio? private var gamepadCapture: GamepadCapture? private var gamepadFeedback: GamepadFeedback? + #if os(macOS) + /// The live session's clipboard bridge (design/clipboard-and-file-transfer.md §5) — created + /// by `beginStreaming` when the per-host toggle is on and the host advertises + /// `HOST_CAP_CLIPBOARD`; stopped (off-main, drain joined) in `disconnect`. + private var clipboardSync: ClipboardSync? + #endif + /// Whether clipboard sync is live (host-acked `ClipState.enabled`) — drives the Stream menu + /// item's title and the settings footnote. Always false off-macOS. + @Published private(set) var clipboardEnabled = false + /// The host's last `ClipState.reason` (`CLIP_REASON_*`) — why an enable was refused + /// (backend unavailable / policy disabled / …); 0 = OK. + @Published private(set) var clipboardReason: UInt8 = 0 #if os(tvOS) /// Siri Remote → host pointer while streaming (touch surface moves, press = left click, /// Play/Pause = right click) + the remote's deliberate exit (hold Back ≥ 1 s). See @@ -418,6 +430,12 @@ final class SessionModel: ObservableObject { #endif let feedback = gamepadFeedback gamepadFeedback = nil + #if os(macOS) + let clipboard = clipboardSync + clipboardSync = nil + #endif + clipboardEnabled = false + clipboardReason = 0 if let conn = connection { // Drain-thread teardown waits the pullers out and close() waits out in-flight // polls + joins the Rust worker threads — keep all of it off the main actor, @@ -425,6 +443,9 @@ final class SessionModel: ObservableObject { Task.detached { audio?.stop() feedback?.stop() + #if os(macOS) + clipboard?.stop() // disables sync on the wire while the connection is still up + #endif // Deliberate user quit → tell the host to skip the keep-alive linger (must precede close). if deliberate { conn.disconnectQuit() } conn.close() @@ -433,6 +454,9 @@ final class SessionModel: ObservableObject { Task.detached { audio?.stop() feedback?.stop() + #if os(macOS) + clipboard?.stop() + #endif } } connection = nil @@ -507,6 +531,14 @@ final class SessionModel: ObservableObject { let feedback = GamepadFeedback(connection: conn, manager: .shared) feedback.start() gamepadFeedback = feedback + #if os(macOS) + // Shared clipboard: opt-in per host AND host-advertised (older hosts / operator-disabled + // hosts never see a ClipControl). Same trust gate as audio — nothing is announced + // during the trust prompt. + if activeHost?.clipboardSync == true, conn.hostSupportsClipboard { + startClipboardSync(conn) + } + #endif #if os(tvOS) let pointer = SiriRemotePointer(connection: conn) pointer.onDisconnectRequest = { [weak self] in self?.disconnect() } @@ -515,6 +547,40 @@ final class SessionModel: ObservableObject { #endif } + #if os(macOS) + /// Create + start the session's clipboard bridge and route its host acks into the published + /// UI state. `ClipboardSync.start()` sends the enable; the host's `.state` answer flips + /// `clipboardEnabled` (or leaves it false with a `clipboardReason` the UI can explain). + private func startClipboardSync(_ conn: PunktfunkConnection) { + let sync = ClipboardSync(connection: conn) + sync.onState = { [weak self] enabled, _, reason in + Task { @MainActor in + self?.clipboardEnabled = enabled + self?.clipboardReason = reason + } + } + sync.start() + clipboardSync = sync + } + #endif + + /// Flip clipboard sync mid-session (the Stream menu). Off → on requires the host cap; on → + /// off tears the bridge down (off-main — the drain join must not block the main actor) and + /// tells the host, which drops any selection we own there. No-op off-macOS or while idle. + func toggleClipboardSync() { + #if os(macOS) + guard let conn = connection, phase == .streaming else { return } + if let sync = clipboardSync { + clipboardSync = nil + clipboardEnabled = false + clipboardReason = 0 + Task.detached { sync.stop() } + } else if conn.hostSupportsClipboard { + startClipboardSync(conn) + } + #endif + } + private func startStatsTimer() { lastFramesDropped = 0 // a fresh connection's cumulative drop counter starts at 0 latencySplit.reset() // no stale receipts/samples from a previous session diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift index 18a41660..bf55b8d1 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift @@ -21,6 +21,12 @@ import SwiftUI /// `.focusedSceneValue` so the Scene-level commands can drive it. struct SessionFocus { var isStreaming: Bool + /// The connected host advertises `HOST_CAP_CLIPBOARD` (gates the Share Clipboard item — + /// macOS-only UI, but the fact is platform-neutral). + var clipboardAvailable: Bool + /// Clipboard sync is live (host-acked) — drives the item's Stop/Share title. + var clipboardOn: Bool + var toggleClipboard: () -> Void var disconnect: () -> Void } @@ -58,6 +64,15 @@ struct StreamCommands: Commands { } .keyboardShortcut("q", modifiers: [.control, .option, .shift]) .disabled(session?.isStreaming != true) + #if os(macOS) + // Mid-session clipboard flip (design/clipboard-and-file-transfer.md §5.3). Greyed + // when the host doesn't advertise the cap (older host / operator policy off). + Button(session?.clipboardOn == true ? "Stop Sharing Clipboard" : "Share Clipboard") { + session?.toggleClipboard() + } + .keyboardShortcut("c", modifiers: [.control, .option, .shift]) + .disabled(session?.isStreaming != true || session?.clipboardAvailable != true) + #endif Divider() Button("Disconnect") { session?.disconnect() } .keyboardShortcut("d", modifiers: [.control, .option, .shift]) diff --git a/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardSync.swift b/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardSync.swift new file mode 100644 index 00000000..5f336569 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Clipboard/ClipboardSync.swift @@ -0,0 +1,361 @@ +// Shared clipboard, macOS client half (design/clipboard-and-file-transfer.md §5.2). +// +// Bridges NSPasteboard.general to the session's QUIC clipboard plane, both directions lazy: +// +// * **Local copy → host**: a changeCount poll announces the *format list* (`clipOffer`); the +// bytes cross only when a host app pastes (a `.fetchRequest` event, answered from the live +// pasteboard by `clipServe`). +// * **Host copy → local**: a `.remoteOffer` writes one NSPasteboardItem whose +// NSPasteboardItemDataProvider fires only when a Mac app actually pastes — the provider then +// blocks (on its provider thread, never main) on a `clipFetch` round-trip. +// +// Password-manager respect: pasteboards marked `org.nspasteboard.ConcealedType` or +// `org.nspasteboard.TransientType` are never announced, never fetchable. Echo suppression: the +// changeCount of every write WE make is recorded so the announce poll skips it (§3.4). +// +// Phase 1 formats only (text / RTF / HTML / PNG). Files (NSFilePromiseProvider) ride Phase 2. +#if os(macOS) +import AppKit +import Foundation + +/// One live session's clipboard bridge. Created by the session model when streaming begins on a +/// host that advertises `HOST_CAP_CLIPBOARD` and whose per-host toggle is on; `stop()` before the +/// connection closes. All pasteboard traffic runs on one dedicated drain thread plus the +/// AppKit-owned provider threads (paste fulfillment). +public final class ClipboardSync: NSObject { + /// Wire MIME ↔ NSPasteboard type for the Phase-1 vocabulary (§3.5), in announce order. + private static let wireToPasteboard: [(wire: String, type: NSPasteboard.PasteboardType)] = [ + ("text/plain;charset=utf-8", .string), + ("text/rtf", .rtf), + ("text/html", .html), + ("image/png", .png), + ] + /// Pasteboard marker types that must never cross the wire (password managers mark secrets + /// with these — see nspasteboard.org). + private static let concealed = NSPasteboard.PasteboardType("org.nspasteboard.ConcealedType") + private static let transient = NSPasteboard.PasteboardType("org.nspasteboard.TransientType") + + /// How long a blocked paste waits for the host's bytes before providing nothing (§5.2). + private static let fetchTimeout: TimeInterval = 10 + /// Serve chunk size for host-side pastes of our data (bounds the per-call ABI copy). + private static let serveChunk = 4 << 20 + + private let connection: PunktfunkConnection + /// `CLIP_FLAG_*` sent with the enable (`CLIP_FLAG_FILES` when the session permits files — + /// always 0 in Phase 1). + private let controlFlags: UInt8 + + /// Host `.state` updates, delivered on the main queue — drives the toggle/footnote UI. + public var onState: ((_ enabled: Bool, _ policy: UInt8, _ reason: UInt8) -> Void)? + + // Drain-thread state (touched only on the drain thread once started). + private var offerSeq: UInt32 = 0 + private var lastSeenChangeCount = 0 + /// The changeCount of the last pasteboard write WE made (echo suppression + "do we still + /// own the pasteboard" on teardown/clear). + private var ownedChangeCount = -1 + /// The host offer currently installed on the local pasteboard (nil = none). + private var installedRemoteSeq: UInt32? + + /// Outbound fetches a blocked paste is waiting on. Guarded by `fetchLock` — appended by the + /// drain thread (`.data` events), consumed by AppKit's provider threads. + private struct PendingFetch { + var buffer = Data() + let done = DispatchSemaphore(value: 0) + var failed = false + } + private let fetchLock = NSLock() + private var pendingFetches: [UInt32: PendingFetch] = [:] + + private final class StopFlag: @unchecked Sendable { + private let lock = NSLock() + private var stopped = false + func stop() { + lock.lock() + stopped = true + lock.unlock() + } + var isStopped: Bool { + lock.lock() + defer { lock.unlock() } + return stopped + } + } + private let flag = StopFlag() + private let drainDone = DispatchSemaphore(value: 0) + private var started = false + /// Set by the app-activation observer, cleared by the drain loop: the user may have copied + /// elsewhere and is coming back to paste — announce immediately instead of waiting out the + /// poll interval. + private final class OneShot: @unchecked Sendable { + private let lock = NSLock() + private var raised = false + func raise() { + lock.lock() + raised = true + lock.unlock() + } + func takeIfRaised() -> Bool { + lock.lock() + defer { lock.unlock() } + let was = raised + raised = false + return was + } + } + private let checkNow = OneShot() + private var activationObserver: NSObjectProtocol? + + public init(connection: PunktfunkConnection, allowFiles: Bool = false) { + self.connection = connection + self.controlFlags = 0 // CLIP_FLAG_FILES rides Phase 2 + _ = allowFiles + super.init() + } + + deinit { flag.stop() } + + /// Enable sync with the host and start the drain thread. The host answers the enable with a + /// `.state` event (surfaced via `onState`) — `BACKEND_UNAVAILABLE` et al. arrive there. + public func start() { + guard !started else { return } + started = true + connection.clipControl(enabled: true, flags: controlFlags) + // Baseline: whatever is on the pasteboard when sync starts is announced immediately — + // the "copy first, then connect and paste" flow must work. + lastSeenChangeCount = -1 + activationObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, object: nil, queue: nil + ) { [checkNow] _ in checkNow.raise() } + let connection = self.connection + let flag = self.flag + let thread = Thread { [weak self] in + var lastAnnounceCheck = Date.distantPast + while !flag.isStopped { + // Drain events (bounded burst so a chatty host can't starve the announce poll). + var drained = 0 + while drained < 32, !flag.isStopped { + let ev: PunktfunkConnection.ClipEvent? + do { + ev = try connection.nextClipboard(timeoutMs: drained == 0 ? 200 : 0) + } catch { + flag.stop() // session closed + break + } + guard let ev else { break } + drained += 1 + self?.handle(ev) + } + // Announce poll: every 500 ms, or immediately after app activation (§5.2). + let now = Date() + if now.timeIntervalSince(lastAnnounceCheck) >= 0.5 + || self?.checkNow.takeIfRaised() == true + { + lastAnnounceCheck = now + self?.announceIfChanged() + } + } + self?.drainDone.signal() + } + thread.name = "punktfunk-clipboard" + thread.qualityOfService = .utility + thread.start() + } + + /// Disable sync and join the drain thread. Called off-main before `connection.close()` + /// (the same discipline as the audio/feedback drains). If the local pasteboard still holds + /// our remote-offer items, they are cleared — their providers die with us. + public func stop() { + guard started else { return } + started = false + if let obs = activationObserver { + NotificationCenter.default.removeObserver(obs) + activationObserver = nil + } + connection.clipControl(enabled: false, flags: 0) + flag.stop() + drainDone.wait() + // Fail every paste still blocked on us so no provider thread waits out its timeout. + fetchLock.lock() + for (_, pending) in pendingFetches { + pending.done.signal() + } + pendingFetches.removeAll() + fetchLock.unlock() + let pb = NSPasteboard.general + if installedRemoteSeq != nil, pb.changeCount == ownedChangeCount { + pb.clearContents() + } + } + + // MARK: - Local copy → host (announce) + + /// Announce the local pasteboard's format list when it changed (skipping our own writes and + /// concealed/transient pasteboards). Runs on the drain thread. + private func announceIfChanged() { + let pb = NSPasteboard.general + let count = pb.changeCount + guard count != lastSeenChangeCount else { return } + lastSeenChangeCount = count + if count == ownedChangeCount { return } // our own write (a remote offer) — never echo + installedRemoteSeq = nil // a local copy replaced the host's offer + let types = pb.types ?? [] + if types.contains(Self.concealed) || types.contains(Self.transient) { return } + offerSeq &+= 1 + let kinds = Self.wireToPasteboard + .filter { types.contains($0.type) } + .map { PunktfunkConnection.ClipKind(mime: $0.wire) } + // Empty = the pasteboard holds nothing we sync (or was cleared) — clears the host side. + connection.clipOffer(seq: offerSeq, kinds: kinds) + } + + // MARK: - Event handling (drain thread) + + private func handle(_ ev: PunktfunkConnection.ClipEvent) { + switch ev { + case let .state(enabled, policy, reason): + if let onState { + DispatchQueue.main.async { onState(enabled, policy, reason) } + } + case let .remoteOffer(seq, kinds): + installRemoteOffer(seq: seq, kinds: kinds) + case let .fetchRequest(reqId, seq, _, mime): + serveFetch(reqId: reqId, seq: seq, mime: mime) + case let .data(xferId, chunk, last): + fetchLock.lock() + if var pending = pendingFetches[xferId] { + pending.buffer.append(chunk) + pendingFetches[xferId] = pending + if last { + pendingFetches[xferId]?.done.signal() + } + } + fetchLock.unlock() + case let .cancelled(id), let .error(id, _): + fetchLock.lock() + if var pending = pendingFetches[id] { + pending.failed = true + pendingFetches[id] = pending + pending.done.signal() + } + fetchLock.unlock() + } + } + + // MARK: - Host copy → local (lazy install + blocked-paste fetch) + + /// Write one NSPasteboardItem advertising the host's formats, each backed by a lazy data + /// provider — bytes cross only when a Mac app pastes. Empty `kinds` = the host cleared its + /// clipboard: drop our item if it's still current. + private func installRemoteOffer(seq: UInt32, kinds: [PunktfunkConnection.ClipKind]) { + let pb = NSPasteboard.general + let types = kinds.compactMap { kind in + Self.wireToPasteboard.first(where: { $0.wire == kind.mime })?.type + } + guard !types.isEmpty else { + if installedRemoteSeq != nil, pb.changeCount == ownedChangeCount { + pb.clearContents() + ownedChangeCount = pb.changeCount + lastSeenChangeCount = pb.changeCount + } + installedRemoteSeq = nil + return + } + let item = NSPasteboardItem() + item.setDataProvider(RemoteOfferProvider(sync: self, seq: seq), forTypes: types) + pb.clearContents() + pb.writeObjects([item]) + installedRemoteSeq = seq + ownedChangeCount = pb.changeCount + lastSeenChangeCount = pb.changeCount + } + + /// Blocked-paste fulfillment: fetch one wire format of host offer `seq` and wait (provider + /// thread) for the drain thread to assemble the chunks. Nil on timeout/cancel/error — the + /// paste then provides nothing rather than hanging (§3.4). + /// + /// `fetchLock` is held ACROSS the `clipFetch` so the pending entry exists before the drain + /// thread can process the first `.data` event (its `handle` takes `fetchLock` after + /// releasing the connection's clipboard lock — no cycle). + fileprivate func fetchBlocking(seq: UInt32, wireMime: String) -> Data? { + fetchLock.lock() + guard let xferId = connection.clipFetch(seq: seq, mime: wireMime) else { + fetchLock.unlock() + return nil + } + pendingFetches[xferId] = PendingFetch() + let done = pendingFetches[xferId]!.done + fetchLock.unlock() + let outcome = done.wait(timeout: .now() + Self.fetchTimeout) + fetchLock.lock() + let pending = pendingFetches.removeValue(forKey: xferId) + fetchLock.unlock() + if outcome == .timedOut { + connection.clipCancel(id: xferId) + return nil + } + guard let pending, !pending.failed else { return nil } + return pending.buffer + } + + // MARK: - Host paste of our data (serve) + + /// Answer a host paste of our offered data from the live pasteboard. A stale `seq` (the + /// local clipboard changed since that announce) is cancelled — never serve mismatched bytes. + private func serveFetch(reqId: UInt32, seq: UInt32, mime: String) { + let pb = NSPasteboard.general + guard seq == offerSeq, pb.changeCount == lastSeenChangeCount, + let type = Self.wireToPasteboard.first(where: { $0.wire == mime })?.type, + let data = pb.data(forType: type) + else { + connection.clipCancel(id: reqId) + return + } + var offset = 0 + while offset < data.count { + let end = min(offset + Self.serveChunk, data.count) + connection.clipServe( + reqId: reqId, data: data.subdata(in: offset.. String? { + switch type { + case .string: return "text/plain;charset=utf-8" + case .rtf: return "text/rtf" + case .html: return "text/html" + case .png: return "image/png" + default: return nil + } + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 067a1a4c..f3cf9e5e 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -196,6 +196,10 @@ public final class PunktfunkConnection { /// Same role for the host-timing (0xCF) puller — its own plane in the core, drained /// non-blockingly by the app's 1 s stats tick (never contends with the blocking pullers). private let statsLock = NSLock() + /// Same role for the shared-clipboard drain thread (`nextClipboard` — its own plane in the + /// core). The clip *sends* (`clipControl`/`clipOffer`/`clipServe`…) share this lock too: + /// they're quick non-blocking enqueues, and a single lock keeps close() ordering simple. + private let clipboardLock = NSLock() /// Negotiated session mode (host-confirmed). public private(set) var width: UInt32 = 0 @@ -346,6 +350,16 @@ public final class PunktfunkConnection { /// parse-window size for `USER_FLAG_CHUNK_ALIGNED` PyroWave AUs (plan §4.4). Other codecs /// never need it. public private(set) var shardPayload: UInt32 = 1408 + + /// The host capability bitfield (`Welcome.host_caps`): `PUNKTFUNK_HOST_CAP_GAMEPAD_STATE` / + /// `PUNKTFUNK_HOST_CAP_CLIPBOARD`. `0` for an older host that didn't say. + public private(set) var hostCaps: UInt8 = 0 + /// Whether this host advertises the shared clipboard (`HOST_CAP_CLIPBOARD`) — the gate for + /// offering the clipboard toggle. Absent on an older host, or one whose operator policy + /// (`PUNKTFUNK_CLIPBOARD=off`) keeps the feature dark. + public var hostSupportsClipboard: Bool { + hostCaps & UInt8(PUNKTFUNK_HOST_CAP_CLIPBOARD) != 0 + } /// The resolved codec as a `VideoCodec` (H.264 / HEVC / AV1) — drives the bitstream framing /// (Annex-B NAL parsing vs the AV1 OBU repack). public var videoCodec: VideoCodec { VideoCodec(wire: resolvedCodec) } @@ -461,6 +475,9 @@ public final class PunktfunkConnection { var shard: UInt32 = 1408 _ = punktfunk_connection_shard_payload(handle, &shard) shardPayload = shard + var caps: UInt8 = 0 + _ = punktfunk_connection_host_caps(handle, &caps) + hostCaps = caps } /// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`. @@ -1015,10 +1032,12 @@ public final class PunktfunkConnection { audioLock.lock() feedbackLock.lock() statsLock.lock() + clipboardLock.lock() abiLock.lock() let h = handle handle = nil abiLock.unlock() + clipboardLock.unlock() statsLock.unlock() feedbackLock.unlock() audioLock.unlock() @@ -1080,6 +1099,163 @@ public final class PunktfunkConnection { _ = punktfunk_connection_send_rich_input(h, &rich) } + // MARK: - Shared clipboard (design/clipboard-and-file-transfer.md §5) + + /// One advertised clipboard format in a lazy offer — the format list crosses the wire, + /// the bytes only on a fetch. + public struct ClipKind: Sendable, Equatable { + public let mime: String + /// Best-effort size in bytes; `0` = unknown. + public let sizeHint: UInt64 + public init(mime: String, sizeHint: UInt64 = 0) { + self.mime = mime + self.sizeHint = sizeHint + } + } + + /// A shared-clipboard event from `nextClipboard`. The drain thread turns these into + /// NSPasteboard operations (`ClipboardSync`). + public enum ClipEvent: Sendable, Equatable { + /// The host copied: its lazy format list (empty = the host clipboard was cleared). + /// Fetch a format with `clipFetch(seq:mime:)` when a local app pastes. + case remoteOffer(seq: UInt32, kinds: [ClipKind]) + /// Host ack / policy / backend update for `clipControl` (`CLIP_REASON_*`). + case state(enabled: Bool, policy: UInt8, reason: UInt8) + /// The host is pasting OUR offered data — answer with `clipServe(reqId:...)`. + case fetchRequest(reqId: UInt32, seq: UInt32, fileIndex: UInt32, mime: String) + /// Bytes for a fetch we started (`last` = final chunk). + case data(xferId: UInt32, chunk: Data, last: Bool) + /// A transfer was cancelled (either side). + case cancelled(id: UInt32) + /// A transfer failed (`status` = a PunktfunkStatus code). + case error(id: UInt32, status: Int32) + } + + /// Enable/disable the shared clipboard for this session. Opt-in: nothing is announced or + /// served until enabled. The host answers with a `.state` event carrying the resolved + /// outcome (its operator policy is authoritative). Best-effort — a dropped call on a + /// closing session is fine. + public func clipControl(enabled: Bool, flags: UInt8 = 0) { + clipboardLock.lock() + defer { clipboardLock.unlock() } + guard let h = liveHandle() else { return } + _ = punktfunk_connection_clipboard_control(h, enabled, flags) + } + + /// Announce that the local pasteboard changed — the lazy format-list offer (`seq` monotonic, + /// newest wins; empty `kinds` clears the host side). The bytes cross only if the host fetches. + public func clipOffer(seq: UInt32, kinds: [ClipKind]) { + clipboardLock.lock() + defer { clipboardLock.unlock() } + guard let h = liveHandle() else { return } + guard !kinds.isEmpty else { + _ = punktfunk_connection_clipboard_offer(h, seq, nil, 0) + return + } + // The C array borrows NUL-terminated strings for the duration of the call only. + let cStrings = kinds.map { strdup($0.mime) } + defer { cStrings.forEach { free($0) } } + let arr = zip(cStrings, kinds).map { + PunktfunkClipKind(mime: $0.map { UnsafePointer($0) }, size_hint: $1.sizeHint) + } + _ = arr.withUnsafeBufferPointer { + punktfunk_connection_clipboard_offer(h, seq, $0.baseAddress, UInt(arr.count)) + } + } + + /// Start pulling one format of the host's offer `seq` (a local app is pasting). Returns the + /// transfer id echoed on the resulting `.data`/`.error`/`.cancelled` events, or nil when the + /// session is closing. + public func clipFetch(seq: UInt32, mime: String, fileIndex: UInt32 = UInt32.max) -> UInt32? { + clipboardLock.lock() + defer { clipboardLock.unlock() } + guard let h = liveHandle() else { return nil } + var xfer: UInt32 = 0 + let rc = mime.withCString { + punktfunk_connection_clipboard_fetch(h, seq, $0, fileIndex, &xfer) + } + return rc == statusOK ? xfer : nil + } + + /// Provide bytes answering a `.fetchRequest` (the host is pasting our offered data). Call + /// repeatedly to stream; `last = true` completes the transfer. An empty final chunk is fine. + public func clipServe(reqId: UInt32, data: Data, last: Bool) { + clipboardLock.lock() + defer { clipboardLock.unlock() } + guard let h = liveHandle() else { return } + if data.isEmpty { + _ = punktfunk_connection_clipboard_serve(h, reqId, nil, 0, last) + } else { + data.withUnsafeBytes { p in + _ = punktfunk_connection_clipboard_serve( + h, reqId, p.bindMemory(to: UInt8.self).baseAddress, UInt(data.count), last) + } + } + } + + /// Cancel a clipboard transfer by id — an outbound fetch's `xferId` or an inbound + /// `.fetchRequest`'s `reqId`. + public func clipCancel(id: UInt32) { + clipboardLock.lock() + defer { clipboardLock.unlock() } + guard let h = liveHandle() else { return } + _ = punktfunk_connection_clipboard_cancel(h, id) + } + + /// Pull the next shared-clipboard event; nil on timeout, throws `.closed` once the session + /// ended. Drain from a single dedicated thread (`ClipboardSync`) — the event's borrowed + /// payload is copied into the returned `ClipEvent` before the next poll can overwrite it. + public func nextClipboard(timeoutMs: UInt32) throws -> ClipEvent? { + clipboardLock.lock() + defer { clipboardLock.unlock() } + guard let h = liveHandle() else { throw PunktfunkClientError.closed } + var ev = PunktfunkClipEvent() + let rc = punktfunk_connection_next_clipboard(h, &ev, timeoutMs) + switch rc { + case statusOK: + return Self.decodeClipEvent(ev) + case statusNoFrame: + return nil + case statusClosed: + throw PunktfunkClientError.closed + default: + throw PunktfunkClientError.status(rc) + } + } + + /// Copy a raw C clip event (whose `data` borrows a per-connection slot) into an owned Swift + /// value. Unknown kinds (a newer core) decode to nil and are skipped by the drain. + private static func decodeClipEvent(_ ev: PunktfunkClipEvent) -> ClipEvent? { + let payload = ev.data.map { Data(bytes: $0, count: Int(ev.len)) } ?? Data() + switch Int32(ev.kind) { + case PUNKTFUNK_CLIP_REMOTE_OFFER: + // One `mime\tsize_hint\n` line per advertised format. + let kinds = String(decoding: payload, as: UTF8.self) + .split(separator: "\n") + .compactMap { line -> ClipKind? in + let parts = line.split(separator: "\t", maxSplits: 1) + guard let mime = parts.first, !mime.isEmpty else { return nil } + let hint = parts.count > 1 ? UInt64(parts[1]) ?? 0 : 0 + return ClipKind(mime: String(mime), sizeHint: hint) + } + return .remoteOffer(seq: ev.transfer_id, kinds: kinds) + case PUNKTFUNK_CLIP_STATE: + return .state(enabled: ev.enabled != 0, policy: ev.policy, reason: ev.reason) + case PUNKTFUNK_CLIP_FETCH_REQUEST: + return .fetchRequest( + reqId: ev.transfer_id, seq: ev.seq, fileIndex: ev.file_index, + mime: String(decoding: payload, as: UTF8.self)) + case PUNKTFUNK_CLIP_DATA: + return .data(xferId: ev.transfer_id, chunk: payload, last: ev.last != 0) + case PUNKTFUNK_CLIP_CANCELLED: + return .cancelled(id: ev.transfer_id) + case PUNKTFUNK_CLIP_ERROR: + return .error(id: ev.transfer_id, status: ev.status) + default: + return nil + } + } + deinit { close() } /// Snapshot the handle unless close is pending (callers hold their plane lock). diff --git a/clients/apple/Sources/PunktfunkShared/StoredHost.swift b/clients/apple/Sources/PunktfunkShared/StoredHost.swift index 91bf9c9d..a99932f8 100644 --- a/clients/apple/Sources/PunktfunkShared/StoredHost.swift +++ b/clients/apple/Sources/PunktfunkShared/StoredHost.swift @@ -34,11 +34,16 @@ public struct StoredHost: Identifiable, Codable, Hashable { /// client can send a magic packet to wake the host later (when it's asleep and no longer /// advertising). Optional (same forward-compat reason as `mgmtPort`); nil until first learned. public var macAddresses: [String]? + /// Share the clipboard with this host (macOS sessions; design/clipboard-and-file-transfer.md + /// §5.3). Opt-in per host: nil/false = off (nil also keeps older saved JSON decoding — same + /// forward-compat reason as `mgmtPort`). Honored only when the host advertises + /// `HOST_CAP_CLIPBOARD`. + public var clipboardSync: Bool? public init( id: UUID = UUID(), name: String, address: String, port: UInt16 = 9777, pinnedSHA256: Data? = nil, lastConnected: Date? = nil, mgmtPort: UInt16? = nil, - macAddresses: [String]? = nil + macAddresses: [String]? = nil, clipboardSync: Bool? = nil ) { self.id = id self.name = name @@ -48,6 +53,7 @@ public struct StoredHost: Identifiable, Codable, Hashable { self.lastConnected = lastConnected self.mgmtPort = mgmtPort self.macAddresses = macAddresses + self.clipboardSync = clipboardSync } public var displayName: String { name.isEmpty ? address : name } diff --git a/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift b/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift index a6ef0ceb..8fcd2f1c 100644 --- a/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/SharedFoundationTests.swift @@ -18,7 +18,7 @@ final class SharedFoundationTests: XCTestCase { name: "Tower", address: "192.168.1.173", port: 9777, pinnedSHA256: Data([0xDE, 0xAD, 0xBE, 0xEF]), lastConnected: Date(timeIntervalSince1970: 1_700_000_000), - mgmtPort: 47990, macAddresses: ["aa:bb:cc:dd:ee:ff"]) + mgmtPort: 47990, macAddresses: ["aa:bb:cc:dd:ee:ff"], clipboardSync: true) let data = try JSONEncoder().encode(host) let decoded = try JSONDecoder().decode(StoredHost.self, from: data) @@ -39,6 +39,7 @@ final class SharedFoundationTests: XCTestCase { XCTAssertNil(decoded.macAddresses) XCTAssertNil(decoded.pinnedSHA256) XCTAssertNil(decoded.lastConnected) + XCTAssertNil(decoded.clipboardSync) // Resolvers fall back cleanly. XCTAssertEqual(decoded.effectiveMgmtPort, punktfunkDefaultMgmtPort) XCTAssertEqual(decoded.wakeMacs, []) diff --git a/crates/pf-clipboard/Cargo.toml b/crates/pf-clipboard/Cargo.toml new file mode 100644 index 00000000..9b2970e4 --- /dev/null +++ b/crates/pf-clipboard/Cargo.toml @@ -0,0 +1,47 @@ +# Shared clipboard (plan §W6 shape, design/clipboard-and-file-transfer.md §4): the host-side +# session-clipboard backends — `ext-data-control-v1` (KWin/wlroots/Sway/Hyprland) and Mutter's +# *direct* `org.gnome.Mutter.RemoteDesktop.Session` clipboard on Linux, the Win32 clipboard +# (delayed rendering) on Windows — behind one `HostClipboard`, plus the backend-agnostic session +# coordinator bridging it to the QUIC clipboard plane. The wire protocol and the client half live +# in `punktfunk-core`; the orchestrator consumes only the portable facade (policy / `ClipCoordCmd` / +# `start`), so it stays free of platform cfg. +[package] +name = "pf-clipboard" +version = "0.12.0" +edition = "2021" +rust-version.workspace = true +license = "MIT OR Apache-2.0" +description = "punktfunk host shared clipboard: per-OS session-clipboard backends behind one HostClipboard + the QUIC clipboard-plane coordinator." +publish = false + +[dependencies] +punktfunk-core = { path = "../punktfunk-core", features = ["quic"] } +anyhow = "1" +tracing = "0.1" +quinn = "0.11" +tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] } + +[target.'cfg(target_os = "linux")'.dependencies] +# Mutter's direct RemoteDesktop clipboard is raw D-Bus via `ashpd::zbus` — NOT the xdg +# `org.freedesktop.portal.Clipboard`, which needs an interactive grant a headless host can't +# answer. Reusing ashpd's zbus re-export keeps one zbus version across the workspace. +ashpd = "0.13" +futures-util = "0.3" +# Raw fd plumbing on the paste pipes: `pipe2(O_CLOEXEC)` + `poll` on the data-control receive +# side, `fcntl` un-nonblocking on Mutter's transfer fd. +libc = "0.2" +wayland-client = "0.31" +# `staging`: `ext_data_control_v1` (the session-clipboard protocol) ships in the staging set. +wayland-protocols = { version = "0.32", features = ["client", "staging"] } + +[target.'cfg(target_os = "windows")'.dependencies] +# The Win32 clipboard on a hidden message-loop window: `WM_CLIPBOARDUPDATE` listener + OLE +# delayed rendering (`WM_RENDERFORMAT`) for text / CF_HTML / RTF / PNG. +windows = { version = "0.62", features = [ + "Win32_Foundation", + "Win32_System_DataExchange", + "Win32_System_LibraryLoader", + "Win32_System_Memory", + "Win32_System_Ole", + "Win32_UI_WindowsAndMessaging", +] } diff --git a/crates/pf-clipboard/src/host.rs b/crates/pf-clipboard/src/host.rs new file mode 100644 index 00000000..87f40649 --- /dev/null +++ b/crates/pf-clipboard/src/host.rs @@ -0,0 +1,409 @@ +//! Host-side shared-clipboard backend. +//! +//! The wire protocol and the client half live in `punktfunk-core` +//! (`punktfunk_core::quic` + `punktfunk_core::clipboard`); this module drives the **host's** real +//! session clipboard so it can offer what a host app copied and paste what the remote client +//! offered (`design/clipboard-and-file-transfer.md` §4). +//! +//! Concrete backends, selected at session start ([`HostClipboard::open`]) and presented as one +//! [`HostClipboard`] to the [`session`] coordinator: +//! * [`wayland`] (Linux) — `ext-data-control-v1` (KWin, wlroots / Sway, Hyprland). Preferred when present. +//! * [`mutter`] (Linux) — GNOME. Mutter implements **no** wlr/ext data-control, but its *direct* +//! `org.gnome.Mutter.RemoteDesktop.Session` D-Bus API carries the same clipboard operations (the +//! xdg `org.freedesktop.portal.Clipboard` would need an interactive grant a headless host can't +//! answer — so we skip it and talk to Mutter directly, as the input injector already does). +//! * [`windows`] — the Win32 clipboard: a hidden message-only window watches `WM_CLIPBOARDUPDATE` +//! and serves client content via OLE delayed rendering (`WM_RENDERFORMAT`). +//! +//! The `zwlr-data-control-unstable-v1` fallback (older wlroots/KWin) is a follow-up. The module +//! compiles on Linux and Windows; the [`session`] coordinator is backend-agnostic. + +#[cfg(target_os = "linux")] +mod mutter; +#[cfg(target_os = "linux")] +mod wayland; +#[cfg(target_os = "windows")] +mod windows; +/// Pure Win32-clipboard ↔ wire byte conversions (CF_HTML offset math, UTF-16 text, RTF NUL +/// trimming). Free of any Win32 dependency, so it compiles — and its unit tests run — on any host +/// (`cfg(test)`); the Windows backend is the only production consumer. +#[cfg(any(target_os = "windows", test))] +mod winfmt; + +pub mod session; + +#[cfg(target_os = "linux")] +use std::io::Write as _; +#[cfg(target_os = "linux")] +use std::os::fd::OwnedFd; +use std::sync::Arc; + +/// A clipboard event surfaced by a host backend to the [`session`] coordinator. Both the +/// data-control and Mutter backends emit this identical shape. +pub enum ClipEvent { + /// The host selection changed (a host app copied). `mimes` are the **wire** MIMEs offered (empty + /// = the clipboard was cleared). The coordinator forwards these as a `ClipOffer` to the client; + /// bytes cross only if the client later fetches. + Selection { mimes: Vec }, + /// A host app is pasting content the client offered. The coordinator fetches the wire-`mime` + /// bytes from the client and hands them to `responder`. + Paste { + mime: String, + responder: PasteResponder, + }, + /// The backend ended (compositor / session gone). + Closed, +} + +/// How a backend receives the bytes answering a [`ClipEvent::Paste`]. The two host clipboard +/// mechanisms complete a paste differently, so the coordinator stays agnostic by handing bytes to +/// whichever responder the backend attached. +pub enum PasteResponder { + /// data-control: the compositor handed us the destination pipe on the `send` event — write the + /// bytes and close it (EOF completes the paste). + #[cfg(target_os = "linux")] + Fd(OwnedFd), + /// Mutter: hand the bytes back to the backend actor, which owns the `SelectionWrite` fd and the + /// trailing `SelectionWriteDone` call that Mutter's transfer requires. + #[cfg(target_os = "linux")] + Channel(tokio::sync::oneshot::Sender>), + /// Windows: hand the bytes to the `WM_RENDERFORMAT` handler blocking the clipboard message-loop + /// thread, which then `SetClipboardData`s them for the pasting app (`std::sync::mpsc`, since that + /// thread waits synchronously — see [`windows`]). + #[cfg(target_os = "windows")] + Sync(std::sync::mpsc::Sender>), +} + +impl PasteResponder { + /// Deliver the fetched bytes (empty on a failed fetch → an empty paste, never a hang). + pub async fn respond(self, bytes: Vec) { + match self { + #[cfg(target_os = "linux")] + PasteResponder::Fd(fd) => { + let _ = tokio::task::spawn_blocking(move || fulfill_paste(fd, &bytes)).await; + } + #[cfg(target_os = "linux")] + PasteResponder::Channel(tx) => { + let _ = tx.send(bytes); + } + #[cfg(target_os = "windows")] + PasteResponder::Sync(tx) => { + let _ = tx.send(bytes); + } + } + } +} + +/// Write `bytes` into a paste pipe `fd` and close it (EOF signals the reader). Blocking — run off the +/// reactor for large payloads. +#[cfg(target_os = "linux")] +fn fulfill_paste(fd: OwnedFd, bytes: &[u8]) -> std::io::Result<()> { + let mut file = std::fs::File::from(fd); + file.write_all(bytes)?; + Ok(()) +} + +/// The active host clipboard backend, chosen per session: `ext-data-control` +/// (KWin/wlroots/Hyprland/Sway) or Mutter's direct RemoteDesktop clipboard (GNOME) on Linux, or the +/// Win32 clipboard on Windows. Presented as one type so the [`session`] coordinator is +/// backend-agnostic. +pub enum HostClipboard { + #[cfg(target_os = "linux")] + DataControl(wayland::ClipboardBackend), + #[cfg(target_os = "linux")] + Mutter(mutter::MutterClipboard), + #[cfg(target_os = "windows")] + Windows(windows::WindowsClipboard), +} + +impl HostClipboard { + /// Open whichever backend this session supports. Linux tries data-control first + /// (KWin/wlroots/Hyprland/Sway) then Mutter's direct clipboard (GNOME); Windows opens the Win32 + /// clipboard. Errors when none is available (gamescope, no live compositor) — the caller then + /// reports `BACKEND_UNAVAILABLE`. + pub async fn open() -> anyhow::Result<( + HostClipboard, + tokio::sync::mpsc::UnboundedReceiver, + )> { + #[cfg(target_os = "linux")] + { + // data-control's bind does blocking Wayland roundtrips — keep them off the reactor. + let dc = tokio::task::spawn_blocking(wayland::ClipboardBackend::open) + .await + .map_err(|e| anyhow::anyhow!("data-control open join: {e}"))?; + match dc { + Ok((b, rx)) => return Ok((HostClipboard::DataControl(b), rx)), + Err(e) => tracing::debug!( + error = format!("{e:#}"), + "no ext-data-control — trying Mutter direct clipboard" + ), + } + let (m, rx) = mutter::MutterClipboard::open().await.map_err(|e| { + e.context("no clipboard backend (neither ext-data-control nor Mutter)") + })?; + Ok((HostClipboard::Mutter(m), rx)) + } + #[cfg(target_os = "windows")] + { + let (b, rx) = windows::WindowsClipboard::open().await?; + Ok((HostClipboard::Windows(b), rx)) + } + } + + /// The current host selection's wire MIMEs (empty = nothing to offer). + pub fn current_wire_mimes(&self) -> Vec { + match self { + #[cfg(target_os = "linux")] + HostClipboard::DataControl(b) => b.current_wire_mimes(), + #[cfg(target_os = "linux")] + HostClipboard::Mutter(m) => m.current_wire_mimes(), + #[cfg(target_os = "windows")] + HostClipboard::Windows(w) => w.current_wire_mimes(), + } + } + + /// Install a client's offered formats as the host selection. + pub fn set_offer(&self, wire_mimes: &[String]) -> anyhow::Result<()> { + match self { + #[cfg(target_os = "linux")] + HostClipboard::DataControl(b) => b.set_offer(wire_mimes), + #[cfg(target_os = "linux")] + HostClipboard::Mutter(m) => { + m.set_offer(wire_mimes); + Ok(()) + } + #[cfg(target_os = "windows")] + HostClipboard::Windows(w) => { + w.set_offer(wire_mimes); + Ok(()) + } + } + } + + /// Drop the host selection we own. + pub fn clear_offer(&self) -> anyhow::Result<()> { + match self { + #[cfg(target_os = "linux")] + HostClipboard::DataControl(b) => b.clear_offer(), + #[cfg(target_os = "linux")] + HostClipboard::Mutter(m) => { + m.clear_offer(); + Ok(()) + } + #[cfg(target_os = "windows")] + HostClipboard::Windows(w) => { + w.clear_offer(); + Ok(()) + } + } + } + + /// Read one wire format of the current host selection (a client's fetch). Async: data-control + /// blocks on a pipe (offloaded), Mutter round-trips D-Bus + reads a pipe, Windows reads the + /// clipboard on a blocking thread. + pub async fn read_current(self: &Arc, wire_mime: &str) -> anyhow::Result> { + match &**self { + #[cfg(target_os = "linux")] + HostClipboard::DataControl(_) => { + let me = Arc::clone(self); + let wire = wire_mime.to_string(); + tokio::task::spawn_blocking(move || match &*me { + HostClipboard::DataControl(b) => b.read_current(&wire), + _ => unreachable!("variant checked above"), + }) + .await + .map_err(|e| anyhow::anyhow!("data-control read join: {e}"))? + } + #[cfg(target_os = "linux")] + HostClipboard::Mutter(m) => m.read_current(wire_mime).await, + #[cfg(target_os = "windows")] + HostClipboard::Windows(w) => w.read_current(wire_mime).await, + } + } +} + +// ---- Format normalization (design/clipboard-and-file-transfer.md §3.5) ------------------------ +// +// One portable vocabulary crosses the wire; each end maps to platform types at fetch time. Phase 1 +// covers text / RTF / HTML / PNG (files are Phase 2). The wire MIMEs match the core's table. + +/// Wire MIME for UTF-8 plain text. +pub const WIRE_TEXT: &str = "text/plain;charset=utf-8"; +/// Wire MIME for HTML. +pub const WIRE_HTML: &str = "text/html"; +/// Wire MIME for rich text. +pub const WIRE_RTF: &str = "text/rtf"; +/// Wire MIME for a PNG image. +pub const WIRE_PNG: &str = "image/png"; + +/// Map a Wayland selection MIME to its canonical wire MIME, or `None` to drop it (internal targets +/// like `TARGETS`/`TIMESTAMP`/`SAVE_TARGETS`, and formats we don't sync in Phase 1). Aliases +/// collapse onto one canonical wire name so the offered list dedups cleanly. +#[cfg(target_os = "linux")] +pub fn wayland_to_wire(wl: &str) -> Option<&'static str> { + // Strip any parameter noise for the plain-text aliases (some apps send `text/plain;charset=...` + // with odd charsets, or bare `text/plain`). + let base = wl.split(';').next().unwrap_or(wl).trim(); + match wl { + "text/html" => Some(WIRE_HTML), + "text/rtf" | "application/rtf" | "text/richtext" => Some(WIRE_RTF), + "image/png" => Some(WIRE_PNG), + _ => match base { + "text/plain" | "UTF8_STRING" | "STRING" | "TEXT" => Some(WIRE_TEXT), + _ => None, + }, + } +} + +/// The Wayland MIME candidates to request, in preference order, when a client fetches `wire` from +/// the host clipboard. The first one present in the current offer is used. +#[cfg(target_os = "linux")] +pub fn wayland_candidates(wire: &str) -> &'static [&'static str] { + match wire { + WIRE_TEXT => &[ + "text/plain;charset=utf-8", + "text/plain", + "UTF8_STRING", + "STRING", + "TEXT", + ], + WIRE_HTML => &["text/html"], + WIRE_RTF => &["text/rtf", "application/rtf", "text/richtext"], + WIRE_PNG => &["image/png"], + _ => &[], + } +} + +/// Pick the Wayland MIME to `receive()` for a wire fetch: the first [`wayland_candidates`] entry the +/// current selection actually advertises. +#[cfg(target_os = "linux")] +pub fn pick_wayland_mime(wire: &str, available: &[String]) -> Option { + wayland_candidates(wire) + .iter() + .find(|c| available.iter().any(|a| a == *c)) + .map(|c| c.to_string()) +} + +/// Normalize a raw Wayland offer's MIME list into the deduplicated wire MIME list announced to the +/// client (drops internal targets; collapses aliases; preserves a stable order). +#[cfg(target_os = "linux")] +pub fn offer_wire_mimes(raw: &[String]) -> Vec<&'static str> { + let mut out: Vec<&'static str> = Vec::new(); + for m in raw { + if let Some(wire) = wayland_to_wire(m) { + if !out.contains(&wire) { + out.push(wire); + } + } + } + out +} + +/// The Wayland MIMEs to advertise when installing a source for a client's offer. Each wire MIME +/// expands to its canonical Wayland name(s); a rich-text-only offer also advertises `text/plain` +/// so plain-text targets always paste (§3.5 synthesis — destination-side, one direction only). +#[cfg(target_os = "linux")] +pub fn wayland_offers_for(wire_mimes: &[String]) -> Vec { + let mut out: Vec = Vec::new(); + let mut push = |s: &str| { + if !out.iter().any(|o| o == s) { + out.push(s.to_string()); + } + }; + let mut has_plain = false; + let mut has_rich = false; + for w in wire_mimes { + match w.as_str() { + WIRE_TEXT => { + has_plain = true; + push("text/plain;charset=utf-8"); + push("text/plain"); + push("UTF8_STRING"); + push("STRING"); + } + WIRE_HTML => { + has_rich = true; + push("text/html"); + } + WIRE_RTF => { + has_rich = true; + push("text/rtf"); + } + WIRE_PNG => push("image/png"), + other => push(other), + } + } + // Synthesis: rich text without plain text → also advertise plain (the source derives it lazily). + if has_rich && !has_plain { + push("text/plain;charset=utf-8"); + push("text/plain"); + push("UTF8_STRING"); + push("STRING"); + } + out +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + + #[test] + fn wayland_to_wire_canonicalizes_and_drops_targets() { + assert_eq!(wayland_to_wire("text/plain"), Some(WIRE_TEXT)); + assert_eq!(wayland_to_wire("UTF8_STRING"), Some(WIRE_TEXT)); + assert_eq!(wayland_to_wire("text/plain;charset=utf-8"), Some(WIRE_TEXT)); + assert_eq!(wayland_to_wire("text/html"), Some(WIRE_HTML)); + assert_eq!(wayland_to_wire("application/rtf"), Some(WIRE_RTF)); + assert_eq!(wayland_to_wire("image/png"), Some(WIRE_PNG)); + // Internal targets and unsupported formats are dropped. + assert_eq!(wayland_to_wire("TARGETS"), None); + assert_eq!(wayland_to_wire("TIMESTAMP"), None); + assert_eq!(wayland_to_wire("image/jpeg"), None); + } + + #[test] + fn offer_wire_mimes_dedups_aliases() { + let raw = vec![ + "TARGETS".to_string(), + "UTF8_STRING".to_string(), + "text/plain;charset=utf-8".to_string(), + "text/plain".to_string(), + "text/html".to_string(), + ]; + // text aliases collapse to one WIRE_TEXT; TARGETS dropped; html kept. + assert_eq!(offer_wire_mimes(&raw), vec![WIRE_TEXT, WIRE_HTML]); + } + + #[test] + fn pick_wayland_mime_prefers_canonical() { + let avail = vec!["text/plain".to_string(), "UTF8_STRING".to_string()]; + // Canonical charset form isn't present, so it falls to the next candidate. + assert_eq!( + pick_wayland_mime(WIRE_TEXT, &avail), + Some("text/plain".to_string()) + ); + let avail2 = vec![ + "text/plain;charset=utf-8".to_string(), + "text/plain".to_string(), + ]; + assert_eq!( + pick_wayland_mime(WIRE_TEXT, &avail2), + Some("text/plain;charset=utf-8".to_string()) + ); + assert_eq!(pick_wayland_mime(WIRE_PNG, &avail2), None); + } + + #[test] + fn wayland_offers_synthesizes_plain_for_rich_only() { + let offers = wayland_offers_for(&[WIRE_HTML.to_string()]); + assert!(offers.iter().any(|m| m == "text/html")); + assert!( + offers.iter().any(|m| m == "text/plain;charset=utf-8"), + "rich-only offer must synthesize plain text: {offers:?}" + ); + // Plain already present → no duplicate synthesis, and text aliases included. + let offers2 = wayland_offers_for(&[WIRE_TEXT.to_string()]); + assert!(offers2.iter().any(|m| m == "UTF8_STRING")); + assert_eq!(offers2.iter().filter(|m| *m == "text/plain").count(), 1); + } +} diff --git a/crates/pf-clipboard/src/host/mutter.rs b/crates/pf-clipboard/src/host/mutter.rs new file mode 100644 index 00000000..bc3143d8 --- /dev/null +++ b/crates/pf-clipboard/src/host/mutter.rs @@ -0,0 +1,524 @@ +//! GNOME clipboard backend via Mutter's **direct** `org.gnome.Mutter.RemoteDesktop.Session` D-Bus +//! API (`design/clipboard-and-file-transfer.md` §4.1). +//! +//! Mutter implements no wlr/ext `data-control` (a deliberate privacy stance), so [`super::wayland`] +//! can't bind on GNOME. But Mutter's own RemoteDesktop session — the same interface the input +//! injector drives directly to dodge the xdg-portal approval dialog (`inject/linux/libei.rs` +//! `connect_mutter`) — carries the full clipboard surface: `EnableClipboard`, `SetSelection`, +//! `SelectionRead`/`SelectionWrite`/`SelectionWriteDone`, and the `SelectionOwnerChanged` / +//! `SelectionTransfer` signals. We open our **own** standalone session for it (it coexists with the +//! injector's input session; validated on GNOME 50), so this backend is self-contained just like the +//! data-control one. +//! +//! One actor task owns the zbus connection + session and multiplexes the two signals with a command +//! channel; the fds Mutter hands out are **non-blocking**, so reads/writes flip them to blocking and +//! run on a blocking thread. Option/signal dict keys are hyphenated: `mime-types`, `session-is-owner`. + +use std::collections::HashMap; +use std::io::{Read as _, Write as _}; +use std::os::fd::{AsRawFd, OwnedFd}; +use std::sync::{Arc, Mutex}; + +use anyhow::{anyhow, Context, Result}; +use ashpd::zbus::{ + self, + zvariant::{OwnedObjectPath, OwnedValue, Value}, +}; +use futures_util::StreamExt; +use tokio::sync::{mpsc, oneshot}; + +use super::{ClipEvent, PasteResponder}; + +const RD_BUS: &str = "org.gnome.Mutter.RemoteDesktop"; +const RD_PATH: &str = "/org/gnome/Mutter/RemoteDesktop"; +const RD_IFACE: &str = "org.gnome.Mutter.RemoteDesktop"; +const SESSION_IFACE: &str = "org.gnome.Mutter.RemoteDesktop.Session"; + +/// Upper bound on one `SelectionRead` (matches the wire clipboard cap, §7). +const CLIP_READ_CAP: u64 = 64 << 20; + +/// Handle to the Mutter clipboard actor, held (inside a [`super::HostClipboard`]) by the session +/// coordinator. +pub struct MutterClipboard { + cmd_tx: mpsc::UnboundedSender, + /// Raw MIMEs the current host selection advertises (empty when we own it, or nothing is copied). + /// Written by the actor on `SelectionOwnerChanged`; read for `current_wire_mimes` / fetches. + current_raw: Arc>>, +} + +enum Cmd { + SetOffer(Vec), + ClearOffer, + ReadCurrent { + wire: String, + reply: oneshot::Sender>>, + }, +} + +impl MutterClipboard { + /// Create a standalone Mutter RemoteDesktop session, `Start` + `EnableClipboard` it, and spawn + /// the actor. Errors when Mutter isn't running (not GNOME) — the caller falls through to + /// `BACKEND_UNAVAILABLE`. + pub async fn open() -> Result<(MutterClipboard, mpsc::UnboundedReceiver)> { + let conn = zbus::Connection::session() + .await + .context("session D-Bus (Mutter clipboard)")?; + let rd = zbus::Proxy::new(&conn, RD_BUS, RD_PATH, RD_IFACE) + .await + .context("Mutter RemoteDesktop proxy (is gnome-shell running?)")?; + let session_path: OwnedObjectPath = rd + .call("CreateSession", &()) + .await + .context("Mutter RemoteDesktop.CreateSession (clipboard)")?; + let session = zbus::Proxy::new(&conn, RD_BUS, session_path, SESSION_IFACE) + .await + .context("Mutter RemoteDesktop.Session proxy")?; + session + .call_method("Start", &()) + .await + .context("Mutter RemoteDesktop.Session.Start (clipboard)")?; + let empty: HashMap<&str, Value> = HashMap::new(); + session + .call_method("EnableClipboard", &(empty,)) + .await + .context("Mutter EnableClipboard")?; + + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); + let current_raw = Arc::new(Mutex::new(Vec::new())); + + tokio::spawn(actor(conn, session, cmd_rx, event_tx, current_raw.clone())); + tracing::info!("clipboard backend bound (Mutter RemoteDesktop direct)"); + Ok(( + MutterClipboard { + cmd_tx, + current_raw, + }, + event_rx, + )) + } + + /// Install a client's offered formats as the host selection (fire-and-forget on the actor). + pub fn set_offer(&self, wire_mimes: &[String]) { + let _ = self.cmd_tx.send(Cmd::SetOffer(wire_mimes.to_vec())); + } + + /// Relinquish the selection we own. + pub fn clear_offer(&self) { + let _ = self.cmd_tx.send(Cmd::ClearOffer); + } + + /// The current host selection's wire MIMEs (empty = nothing / we own it). + pub fn current_wire_mimes(&self) -> Vec { + super::offer_wire_mimes(&self.current_raw.lock().unwrap()) + .into_iter() + .map(str::to_string) + .collect() + } + + /// Read one wire format of the current host selection (a client's fetch). Round-trips the actor + /// (SelectionRead + a blocking fd read). + pub async fn read_current(&self, wire: &str) -> Result> { + let (reply, rx) = oneshot::channel(); + self.cmd_tx + .send(Cmd::ReadCurrent { + wire: wire.to_string(), + reply, + }) + .map_err(|_| anyhow!("Mutter clipboard actor gone"))?; + rx.await + .map_err(|_| anyhow!("Mutter clipboard read dropped"))? + } +} + +/// The actor: owns the connection + session, subscribes to the two clipboard signals, and serves +/// commands. Exits when the command channel closes (session ending) or a signal stream ends. +async fn actor( + conn: zbus::Connection, + session: zbus::Proxy<'static>, + mut cmd_rx: mpsc::UnboundedReceiver, + event_tx: mpsc::UnboundedSender, + current_raw: Arc>>, +) { + let (mut owner, mut transfer) = match ( + session.receive_signal("SelectionOwnerChanged").await, + session.receive_signal("SelectionTransfer").await, + ) { + (Ok(o), Ok(t)) => (o, t), + _ => { + tracing::warn!("Mutter clipboard: could not subscribe to selection signals"); + let _ = event_tx.send(ClipEvent::Closed); + return; + } + }; + + loop { + tokio::select! { + sig = owner.next() => { + let Some(msg) = sig else { break }; + let Ok((opts,)) = msg.body().deserialize::<(HashMap,)>() else { + continue; + }; + let is_owner = dict_bool(&opts, "session-is-owner").unwrap_or(false); + let raw = dict_mimes(&opts, "mime-types"); + if is_owner { + // Our own offer (the client's content) — not host clipboard; don't announce it, + // and clear `current_raw` so a fetch never reads our own source back. + current_raw.lock().unwrap().clear(); + } else { + *current_raw.lock().unwrap() = raw.clone(); + let wire = super::offer_wire_mimes(&raw) + .into_iter() + .map(str::to_string) + .collect(); + if event_tx.send(ClipEvent::Selection { mimes: wire }).is_err() { + break; + } + } + } + sig = transfer.next() => { + let Some(msg) = sig else { break }; + let Ok((mime, serial)) = msg.body().deserialize::<(String, u32)>() else { + continue; + }; + match super::wayland_to_wire(&mime) { + Some(wire) => { + // A host app pastes our offer: hand the fetch to the coordinator, then serve + // the returned bytes into the SelectionWrite fd off-task. NB Mutter issues + // *two* transfers per read (a size probe + the real read), so the coordinator + // fetches from the client twice per paste — correct, just not deduplicated. + let (tx, rx) = oneshot::channel(); + if event_tx + .send(ClipEvent::Paste { + mime: wire.to_string(), + responder: PasteResponder::Channel(tx), + }) + .is_err() + { + break; + } + let session = session.clone(); + tokio::spawn(async move { + let bytes = rx.await.unwrap_or_default(); + serve_write(&session, serial, bytes).await; + }); + } + // Format we don't sync — fail the transfer cleanly. + None => serve_write(&session, serial, Vec::new()).await, + } + } + cmd = cmd_rx.recv() => { + let Some(cmd) = cmd else { break }; // coordinator gone → session ending + match cmd { + Cmd::SetOffer(wire) => { + let wl = super::wayland_offers_for(&wire); + if let Err(e) = set_selection(&session, &wl).await { + tracing::debug!(error = %e, "Mutter SetSelection failed"); + } + } + Cmd::ClearOffer => { + if let Err(e) = set_selection(&session, &[]).await { + tracing::debug!(error = %e, "Mutter clear selection failed"); + } + } + Cmd::ReadCurrent { wire, reply } => { + let raw = current_raw.lock().unwrap().clone(); + let _ = reply.send(read_selection(&session, &wire, &raw).await); + } + } + } + } + } + // Keep the connection owned for the actor's whole life (Mutter ties the session to it). + drop(conn); + let _ = event_tx.send(ClipEvent::Closed); +} + +/// Offer `wl_mimes` as the host selection; an empty list relinquishes ownership. +async fn set_selection(session: &zbus::Proxy<'_>, wl_mimes: &[String]) -> Result<()> { + let mut opts: HashMap<&str, Value> = HashMap::new(); + if !wl_mimes.is_empty() { + let refs: Vec<&str> = wl_mimes.iter().map(String::as_str).collect(); + opts.insert("mime-types", Value::from(refs)); + } + session + .call_method("SetSelection", &(opts,)) + .await + .context("Mutter SetSelection")?; + Ok(()) +} + +/// Read the current selection's `wire` format: pick a concrete offered MIME, `SelectionRead` it, and +/// read the (non-blocking) fd to EOF on a blocking thread. +async fn read_selection(session: &zbus::Proxy<'_>, wire: &str, raw: &[String]) -> Result> { + let mime = + super::pick_wayland_mime(wire, raw).context("format not offered by the host clipboard")?; + let fd: zbus::zvariant::OwnedFd = session + .call("SelectionRead", &(mime.as_str(),)) + .await + .context("Mutter SelectionRead")?; + let fd = OwnedFd::from(fd); + tokio::task::spawn_blocking(move || read_fd_to_end(fd)) + .await + .map_err(|e| anyhow!("SelectionRead join: {e}"))? +} + +/// Serve one `SelectionTransfer`: `SelectionWrite` → write `bytes` → `SelectionWriteDone`. Any write +/// failure still reports done (success=false) so Mutter completes the transfer. +async fn serve_write(session: &zbus::Proxy<'_>, serial: u32, bytes: Vec) { + let ok = match write_selection(session, serial, bytes).await { + Ok(()) => true, + Err(e) => { + tracing::debug!(error = %e, "Mutter SelectionWrite failed"); + false + } + }; + let _ = session + .call_method("SelectionWriteDone", &(serial, ok)) + .await; +} + +async fn write_selection(session: &zbus::Proxy<'_>, serial: u32, bytes: Vec) -> Result<()> { + let fd: zbus::zvariant::OwnedFd = session + .call("SelectionWrite", &(serial,)) + .await + .context("Mutter SelectionWrite")?; + let fd = OwnedFd::from(fd); + tokio::task::spawn_blocking(move || write_fd(fd, &bytes)) + .await + .map_err(|e| anyhow!("SelectionWrite join: {e}"))? +} + +/// Read a Mutter clipboard fd to EOF (capped). The fd is `O_NONBLOCK`; flip it to blocking first. +fn read_fd_to_end(fd: OwnedFd) -> Result> { + set_blocking(&fd)?; + let file = std::fs::File::from(fd); + let mut buf = Vec::new(); + file.take(CLIP_READ_CAP) + .read_to_end(&mut buf) + .context("read SelectionRead fd")?; + Ok(buf) +} + +/// Write `bytes` into a Mutter clipboard fd and close it (EOF). Flip the `O_NONBLOCK` fd to blocking. +fn write_fd(fd: OwnedFd, bytes: &[u8]) -> Result<()> { + set_blocking(&fd)?; + let mut file = std::fs::File::from(fd); + file.write_all(bytes).context("write SelectionWrite fd")?; + Ok(()) +} + +/// Peel any `Value::Value` (variant) wrappers to the concrete value. The `a{sv}` dict values Mutter +/// sends arrive as variants, so a plain `TryFrom` (which matches the concrete type) never +/// sees through them — this strips the layer first. +fn peel<'a>(v: &'a Value<'a>) -> &'a Value<'a> { + let mut cur = v; + while let Value::Value(inner) = cur { + cur = inner; + } + cur +} + +/// Extract a boolean dict entry (e.g. `session-is-owner`), unwrapping the variant. +fn dict_bool(opts: &HashMap, key: &str) -> Option { + match peel(opts.get(key)?) { + Value::Bool(b) => Some(*b), + _ => None, + } +} + +/// Extract a string-array dict entry (e.g. `mime-types`), unwrapping the variant. Mutter wraps the +/// array in a single-field struct (`(as)`, seen on `SelectionOwnerChanged`), so unwrap that too. +fn dict_mimes(opts: &HashMap, key: &str) -> Vec { + let Some(v) = opts.get(key) else { + return Vec::new(); + }; + let mut val = peel(v); + if let Value::Structure(s) = val { + match s.fields().first() { + Some(first) => val = peel(first), + None => return Vec::new(), + } + } + let Value::Array(arr) = val else { + return Vec::new(); + }; + arr.inner() + .iter() + .filter_map(|e| match peel(e) { + Value::Str(s) => Some(s.to_string()), + _ => None, + }) + .collect() +} + +/// Clear `O_NONBLOCK` on a Mutter clipboard fd so a blocking `spawn_blocking` read/write works. +fn set_blocking(fd: &OwnedFd) -> Result<()> { + let raw = fd.as_raw_fd(); + // SAFETY: `raw` is a valid fd owned by `fd` for the duration of these fcntl calls. + let flags = unsafe { libc::fcntl(raw, libc::F_GETFL) }; + if flags < 0 { + return Err(anyhow!( + "fcntl F_GETFL: {}", + std::io::Error::last_os_error() + )); + } + // SAFETY: as above; clearing O_NONBLOCK on our own fd. + let rc = unsafe { libc::fcntl(raw, libc::F_SETFL, flags & !libc::O_NONBLOCK) }; + if rc < 0 { + return Err(anyhow!( + "fcntl F_SETFL: {}", + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +/// On-glass tests against a **live GNOME/Mutter** session (`WAYLAND_DISPLAY=wayland-0`). `#[ignore]`d +/// — run explicitly under GNOME (Mutter has no `wl-clipboard`, so a second Mutter session plays the +/// "host app"): +/// +/// ```text +/// cargo test -p punktfunk-host --bin punktfunk-host -- --ignored --nocapture clipboard::mutter::live +/// ``` +/// +/// Skips (does not fail) when Mutter isn't running, so `--ignored` off-GNOME is a clean no-op. +#[cfg(test)] +mod live { + use super::*; + use std::time::Duration; + + /// A second Mutter session standing in for a host clipboard app. + struct Helper { + session: zbus::Proxy<'static>, + _conn: zbus::Connection, + } + + impl Helper { + async fn open() -> Result { + let conn = zbus::Connection::session().await?; + let rd = zbus::Proxy::new(&conn, RD_BUS, RD_PATH, RD_IFACE).await?; + let path: OwnedObjectPath = rd.call("CreateSession", &()).await?; + let session = zbus::Proxy::new(&conn, RD_BUS, path, SESSION_IFACE).await?; + session.call_method("Start", &()).await?; + let empty: HashMap<&str, Value> = HashMap::new(); + session.call_method("EnableClipboard", &(empty,)).await?; + Ok(Helper { + session, + _conn: conn, + }) + } + + /// Own the selection offering plain text, serving `payload` on every transfer request. + async fn offer_text(&self, payload: &'static [u8]) { + let mut transfer = self + .session + .receive_signal("SelectionTransfer") + .await + .unwrap(); + let session = self.session.clone(); + tokio::spawn(async move { + while let Some(msg) = transfer.next().await { + if let Ok((_mime, serial)) = msg.body().deserialize::<(String, u32)>() { + serve_write(&session, serial, payload.to_vec()).await; + } + } + }); + set_selection( + &self.session, + &[ + "text/plain;charset=utf-8".to_string(), + "text/plain".to_string(), + ], + ) + .await + .unwrap(); + } + + /// Paste the current selection's plain text. + async fn read_text(&self) -> Vec { + let fd: zbus::zvariant::OwnedFd = self + .session + .call("SelectionRead", &("text/plain;charset=utf-8",)) + .await + .unwrap(); + let fd = OwnedFd::from(fd); + tokio::task::spawn_blocking(move || read_fd_to_end(fd)) + .await + .unwrap() + .unwrap() + } + } + + async fn next_selection( + rx: &mut mpsc::UnboundedReceiver, + timeout: Duration, + ) -> Option> { + tokio::time::timeout(timeout, async { + loop { + match rx.recv().await { + Some(ClipEvent::Selection { mimes }) if !mimes.is_empty() => { + return Some(mimes) + } + Some(_) => continue, + None => return None, + } + } + }) + .await + .ok() + .flatten() + } + + #[test] + #[ignore = "needs a live GNOME/Mutter session (WAYLAND_DISPLAY=wayland-0)"] + fn live_mutter_roundtrip() { + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let (backend, mut rx) = match MutterClipboard::open().await { + Ok(v) => v, + Err(e) => { + eprintln!("SKIP: no Mutter clipboard (not GNOME?): {e:#}"); + return; + } + }; + let helper = Helper::open().await.expect("helper Mutter session"); + + // --- host copy → backend observes Selection + read_current returns the bytes --- + helper.offer_text(b"gnome-host-copied").await; + let mimes = next_selection(&mut rx, Duration::from_secs(3)) + .await + .expect("Selection after the helper offered text"); + assert!( + mimes.iter().any(|m| m == super::super::WIRE_TEXT), + "offer carries wire text: {mimes:?}" + ); + let got = backend + .read_current(super::super::WIRE_TEXT) + .await + .expect("read_current text"); + assert_eq!(got, b"gnome-host-copied"); + + // --- backend offers client content → the host app (helper) pastes it --- + backend.set_offer(&[super::super::WIRE_TEXT.to_string()]); + tokio::time::sleep(Duration::from_millis(500)).await; // let SetSelection take effect + // Answer every Paste request the host app (helper) triggers, until the read completes. + let paste_side = async { + while let Some(ev) = rx.recv().await { + if let ClipEvent::Paste { responder, .. } = ev { + responder.respond(b"punktfunk-served".to_vec()).await; + } + } + }; + let read = tokio::select! { + r = helper.read_text() => r, + _ = paste_side => Vec::new(), + }; + assert_eq!(read, b"punktfunk-served"); + }); + } +} diff --git a/crates/pf-clipboard/src/host/session.rs b/crates/pf-clipboard/src/host/session.rs new file mode 100644 index 00000000..72bf33da --- /dev/null +++ b/crates/pf-clipboard/src/host/session.rs @@ -0,0 +1,254 @@ +//! Host clipboard coordinator (`design/clipboard-and-file-transfer.md` §4.2). +//! +//! One async task per streaming session that bridges the real session clipboard (whichever +//! [`super::HostClipboard`] backend this platform opened) to the QUIC clipboard plane. It owns all +//! four data paths: +//! +//! * **host copy → client** — a backend [`ClipEvent::Selection`] becomes a [`ClipOffer`] pushed to the +//! control loop (`offer_tx`), which forwards it to the client. +//! * **client fetch of the host clipboard** — the fetch-stream `accept_bi` loop lives here; each +//! stream is answered by reading the current host selection ([`ClipboardBackend::read_current`]). +//! * **client copy → host** — a [`ClipCoordCmd::RemoteOffer`] installs the client's formats as a lazy +//! host selection ([`HostClipboard::set_offer`]). +//! * **host paste of client content** — a backend [`ClipEvent::Paste`] triggers an *outbound* fetch to +//! the client, whose bytes are handed to the backend's [`PasteResponder`]. +//! +//! The coordinator is backend-agnostic (Linux data-control / Mutter, Windows Win32); the control loop +//! reaches it through the portable [`ClipCoordCmd`] channel so the host's native control loop +//! compiles on every host platform. + +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; + +use punktfunk_core::clipboard::CLIP_FETCH_CAP; +use punktfunk_core::quic::{ + clipstream, ClipFetch, ClipFetchHdr, ClipKind, ClipOffer, CLIP_FETCH_OK, CLIP_FETCH_STALE, + CLIP_FETCH_UNAVAILABLE, CLIP_FILE_INDEX_NONE, +}; + +use super::{ClipEvent, HostClipboard, PasteResponder}; +use crate::ClipCoordCmd; + +/// Upper bound on one outbound fetch (host pasting client content). A client that never answers must +/// not hang the pasting host app's pipe read (§3.4) — the paste falls back to empty instead. +const FETCH_TIMEOUT: Duration = Duration::from_secs(60); + +/// Open whichever host clipboard backend this session supports (data-control, else Mutter direct) and +/// spawn the coordinator. Returns `true` when a live backend was bound (the caller's control loop +/// then serves real clipboard data); `false` when none is available (gamescope, no live compositor), +/// in which case the channels are dropped so the control loop reports `CLIP_REASON_BACKEND_UNAVAILABLE` +/// and declines fetches defensively. +pub async fn start( + conn: quinn::Connection, + clip_enabled: Arc, + cmd_rx: UnboundedReceiver, + offer_tx: UnboundedSender, +) -> bool { + match HostClipboard::open().await { + Ok((backend, clip_rx)) => { + tokio::spawn(run( + conn, + Arc::new(backend), + clip_rx, + clip_enabled, + cmd_rx, + offer_tx, + )); + true + } + Err(e) => { + tracing::info!(error = %format!("{e:#}"), "clipboard backend unavailable — fetches will be declined"); + false + } + } +} + +/// The coordinator loop. Multiplexes control-loop commands, backend clipboard events, and inbound +/// fetch streams; exits when any of the three peers goes away (session ending). +async fn run( + conn: quinn::Connection, + backend: Arc, + mut clip_rx: UnboundedReceiver, + clip_enabled: Arc, + mut cmd_rx: UnboundedReceiver, + offer_tx: UnboundedSender, +) { + // Seq of the offer the host most recently announced; a client fetch naming a different seq is + // stale (the host clipboard moved on) and is declined. + let host_seq = Arc::new(AtomicU32::new(0)); + let mut next_seq: u32 = 1; + // Seq of the client's most recent offer, echoed on the outbound fetch we open when a host app + // pastes client content (informational for the client's serve side). + let mut client_seq: u32 = 0; + + loop { + tokio::select! { + cmd = cmd_rx.recv() => { + let Some(cmd) = cmd else { break }; // control loop gone → session ending + match cmd { + ClipCoordCmd::SetEnabled(true) => { + // A just-enabled client should see whatever the host already has copied. + let mimes = backend.current_wire_mimes(); + if !mimes.is_empty() { + let _ = offer_tx.send(build_offer(&mut next_seq, &host_seq, mimes)); + } + } + ClipCoordCmd::SetEnabled(false) => { + if let Err(e) = backend.clear_offer() { + tracing::debug!(error = %e, "clipboard clear_offer failed"); + } + } + ClipCoordCmd::RemoteOffer { seq, mimes } => { + client_seq = seq; + let res = if mimes.is_empty() { + backend.clear_offer() + } else { + backend.set_offer(&mimes) + }; + if let Err(e) = res { + tracing::debug!(error = %e, "clipboard apply remote offer failed"); + } + } + } + } + ev = clip_rx.recv() => { + let Some(ev) = ev else { break }; // backend dispatch thread ended + match ev { + ClipEvent::Selection { mimes } => { + // Forward host copies (empty `mimes` = the clipboard was cleared) only while + // the client has sync on — the offer is metadata; bytes still cross lazily. + if clip_enabled.load(Ordering::SeqCst) { + let _ = offer_tx.send(build_offer(&mut next_seq, &host_seq, mimes)); + } + } + ClipEvent::Paste { mime, responder } => { + // A host app is pasting the client's offered content: pull that format from + // the client and hand it to the backend's responder. Off-task so the loop + // keeps serving. + tokio::spawn(fetch_into_pipe(conn.clone(), client_seq, mime, responder)); + } + ClipEvent::Closed => break, + } + } + accepted = conn.accept_bi() => { + let Ok((send, recv)) = accepted else { break }; // connection gone + // The control stream is already accepted at the handshake, so every stream here is a + // clipboard fetch. Serve it off-task (the read blocks on the source app's pipe). + tokio::spawn(serve_fetch( + send, + recv, + Arc::clone(&backend), + Arc::clone(&host_seq), + clip_enabled.load(Ordering::SeqCst), + )); + } + } + } + // Session ending: don't leave our lazy source as the compositor's active selection. + let _ = backend.clear_offer(); +} + +/// Mint a [`ClipOffer`] for `mimes`, advancing the host offer seq (skipping 0, the "never offered" +/// sentinel) and publishing it as the current one for staleness checks. +fn build_offer(next_seq: &mut u32, host_seq: &AtomicU32, mimes: Vec) -> ClipOffer { + let seq = *next_seq; + *next_seq = next_seq.wrapping_add(1); + if *next_seq == 0 { + *next_seq = 1; + } + host_seq.store(seq, Ordering::SeqCst); + let kinds = mimes + .into_iter() + .map(|mime| ClipKind { mime, size_hint: 0 }) + .collect(); + ClipOffer { seq, kinds } +} + +/// Serve one inbound fetch stream (a client pulling the host clipboard): validate the header + +/// request, then answer with the current host selection's bytes for the requested wire MIME. +async fn serve_fetch( + mut send: quinn::SendStream, + mut recv: quinn::RecvStream, + backend: Arc, + host_seq: Arc, + enabled: bool, +) { + let _ = send.set_priority(-1); + match clipstream::read_stream_header(&mut recv).await { + Ok(k) if k == clipstream::CLIP_STREAM_KIND_FETCH => {} + _ => { + let _ = send.reset(clipstream::cancelled_code()); + return; + } + } + let req = match clipstream::read_fetch(&mut recv).await { + Ok(r) => r, + Err(_) => return, + }; + + let decline = |status: u8| ClipFetchHdr { + status, + total_size: 0, + }; + if !enabled { + let _ = clipstream::write_fetch_hdr(&mut send, &decline(CLIP_FETCH_UNAVAILABLE)).await; + return; + } + if req.seq != host_seq.load(Ordering::SeqCst) { + let _ = clipstream::write_fetch_hdr(&mut send, &decline(CLIP_FETCH_STALE)).await; + return; + } + + // `read_current` reads the host selection (a blocking pipe read, offloaded by the backend). + match backend.read_current(&req.mime).await { + Ok(data) => { + let hdr = ClipFetchHdr { + status: CLIP_FETCH_OK, + total_size: data.len() as u64, + }; + if clipstream::write_fetch_hdr(&mut send, &hdr).await.is_ok() { + let _ = clipstream::write_data(&mut send, &data).await; + } + } + // The format vanished (clipboard changed mid-fetch) or the read failed → nothing to send. + Err(_) => { + let _ = clipstream::write_fetch_hdr(&mut send, &decline(CLIP_FETCH_UNAVAILABLE)).await; + } + } +} + +/// Pull `mime` of the client's current offer (`seq`) over an outbound fetch stream and hand the bytes +/// to the backend's paste `responder`. Any failure (timeout, decline, I/O) responds with empty bytes +/// so the pasting host app gets an empty paste instead of hanging. +async fn fetch_into_pipe( + conn: quinn::Connection, + seq: u32, + mime: String, + responder: PasteResponder, +) { + let req = ClipFetch { + seq, + file_index: CLIP_FILE_INDEX_NONE, + mime, + }; + let fetched = tokio::time::timeout(FETCH_TIMEOUT, async { + let (send, mut recv) = clipstream::open_fetch(&conn, &req).await.ok()?; + let hdr = clipstream::read_fetch_hdr(&mut recv).await.ok()?; + if hdr.status != CLIP_FETCH_OK { + return None; + } + let data = clipstream::read_data(&mut recv, CLIP_FETCH_CAP) + .await + .ok()?; + drop(send); // clean close of our half + Some(data) + }) + .await + .ok() + .flatten(); + + responder.respond(fetched.unwrap_or_default()).await; +} diff --git a/crates/pf-clipboard/src/host/wayland.rs b/crates/pf-clipboard/src/host/wayland.rs new file mode 100644 index 00000000..4e53e366 --- /dev/null +++ b/crates/pf-clipboard/src/host/wayland.rs @@ -0,0 +1,599 @@ +//! `ext-data-control-v1` clipboard backend (`design/clipboard-and-file-transfer.md` §4.1). +//! +//! A dedicated thread owns the `wayland-client` [`EventQueue`] and runs a poll loop that dispatches +//! selection + paste events, emitting them over a channel. Everything else — installing a lazy +//! source (a client's offer) and `receive()`-ing the host selection (a client's fetch) — is issued +//! from the session thread on the shared, `Send + Sync` proxy handles; only *dispatch* is +//! single-threaded (per the wayland-client contract). Templated on `inject/linux/wlr.rs`. +//! +//! The `zwlr-data-control-unstable-v1` fallback for older wlroots/KWin is a mechanical parallel of +//! this file (the protocols are 1:1) — a follow-up. + +use std::collections::HashMap; +use std::io::Read; +use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; + +use anyhow::{anyhow, Context, Result}; +use wayland_client::backend::ObjectId; +use wayland_client::protocol::wl_registry; +use wayland_client::protocol::wl_seat::WlSeat; +use wayland_client::{event_created_child, Connection, Dispatch, Proxy, QueueHandle}; +use wayland_protocols::ext::data_control::v1::client::{ + ext_data_control_device_v1::{self, ExtDataControlDeviceV1}, + ext_data_control_manager_v1::ExtDataControlManagerV1, + ext_data_control_offer_v1::{self, ExtDataControlOfferV1}, + ext_data_control_source_v1::{self, ExtDataControlSourceV1}, +}; + +use super::{ClipEvent, PasteResponder}; + +/// Upper bound on bytes read from one `receive()` transfer (matches the wire clipboard cap, §7) so a +/// hostile host app can't stream unboundedly into our buffer. +const CLIP_READ_CAP: u64 = 64 << 20; + +/// The current host selection, shared between the dispatch thread (writer) and the session thread +/// (reader, for `receive()`). +struct CurrentSelection { + offer: ExtDataControlOfferV1, + /// Raw Wayland MIMEs the offer advertises (what `receive()` accepts). + mimes: Vec, +} + +/// Dispatch-thread state. Also collects the manager + seat during the bind roundtrip. +struct State { + mgr: Option, + seat: Option, + /// Offers accumulating their MIME list before the `selection` event promotes one. + pending: HashMap>, + current: Arc>>, + /// Pending count of our own `set_selection`s whose `selection` echo must be dropped rather than + /// announced back to the client (loop prevention, §3.4). Bumped by the session before each set; + /// each of our sets produces exactly one echo on wlroots/KWin, so one decrement per echo pairs + /// them up — a counter (not a bool) keeps rapid back-to-back offers from leaking a self-echo. + suppress_echoes: Arc, + tx: tokio::sync::mpsc::UnboundedSender, +} + +impl Dispatch for State { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + if let wl_registry::Event::Global { + name, + interface, + version, + } = event + { + match interface.as_str() { + "ext_data_control_manager_v1" => { + state.mgr = Some(registry.bind(name, version.min(1), qh, ())); + } + "wl_seat" => { + state.seat = Some(registry.bind(name, version.min(7), qh, ())); + } + _ => {} + } + } + } +} + +// Manager + seat emit nothing we consume. +impl Dispatch for State { + fn event( + _: &mut Self, + _: &ExtDataControlManagerV1, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} +impl Dispatch for State { + fn event( + _: &mut Self, + _: &WlSeat, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _dev: &ExtDataControlDeviceV1, + event: ext_data_control_device_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + use ext_data_control_device_v1::Event; + match event { + // A new offer is being introduced; its `offer` events follow before `selection`. + Event::DataOffer { id } => { + state.pending.insert(id.id(), Vec::new()); + } + // The active selection changed. `Some` = a new clipboard; `None` = cleared. + Event::Selection { id } => { + // Consume one pending self-echo if any (atomic vs. the session thread's bumps; the + // dispatch thread is the only decrementer). `Ok` = there was one → suppress. + let suppressed = state + .suppress_echoes + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |c| c.checked_sub(1)) + .is_ok(); + match id { + Some(offer) => { + let mimes = state.pending.remove(&offer.id()).unwrap_or_default(); + if suppressed { + // Our own source's echo — don't store it as the host clipboard and + // don't announce it back to the client. + return; + } + let wire = super::offer_wire_mimes(&mimes) + .into_iter() + .map(str::to_string) + .collect::>(); + *state.current.lock().unwrap() = Some(CurrentSelection { offer, mimes }); + let _ = state.tx.send(ClipEvent::Selection { mimes: wire }); + } + None => { + *state.current.lock().unwrap() = None; + if !suppressed { + let _ = state.tx.send(ClipEvent::Selection { mimes: Vec::new() }); + } + } + } + } + Event::Finished => { + let _ = state.tx.send(ClipEvent::Closed); + } + // Primary selection is out of scope for the shared clipboard. + _ => {} + } + } + + event_created_child!(State, ExtDataControlDeviceV1, [ + ext_data_control_device_v1::EVT_DATA_OFFER_OPCODE => (ExtDataControlOfferV1, ()), + ]); +} + +impl Dispatch for State { + fn event( + state: &mut Self, + offer: &ExtDataControlOfferV1, + event: ext_data_control_offer_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let ext_data_control_offer_v1::Event::Offer { mime_type } = event { + if let Some(list) = state.pending.get_mut(&offer.id()) { + list.push(mime_type); + } + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _src: &ExtDataControlSourceV1, + event: ext_data_control_source_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + use ext_data_control_source_v1::Event; + match event { + // A host app pasted our (the client's) offered data. + Event::Send { mime_type, fd } => match super::wayland_to_wire(&mime_type) { + Some(wire) => { + let _ = state.tx.send(ClipEvent::Paste { + mime: wire.to_string(), + responder: PasteResponder::Fd(fd), + }); + } + // We can't satisfy this format — closing the fd yields an empty paste. + None => drop(fd), + }, + // Our source was superseded (a host app or another client set a new selection). + Event::Cancelled => {} + _ => {} + } + } +} + +/// The host clipboard backend handle used by the session thread. +pub struct ClipboardBackend { + conn: Connection, + mgr: ExtDataControlManagerV1, + device: ExtDataControlDeviceV1, + qh: QueueHandle, + current: Arc>>, + suppress_echoes: Arc, + active_source: Mutex>, + stop: Arc, + thread: Option>, +} + +impl ClipboardBackend { + /// Connect to the active session's Wayland display (env already applied by + /// `vdisplay::apply_session_env`), bind `ext_data_control`, and start the dispatch thread. + /// Returns the handle plus the event stream. Errors if the compositor lacks the protocol + /// (caller reports `BackendUnavailable`). + pub fn open() -> Result<( + ClipboardBackend, + tokio::sync::mpsc::UnboundedReceiver, + )> { + let conn = Connection::connect_to_env() + .context("connect to Wayland for clipboard (WAYLAND_DISPLAY/XDG_RUNTIME_DIR set?)")?; + let mut queue = conn.new_event_queue(); + let qh = queue.handle(); + let _registry = conn.display().get_registry(&qh, ()); + + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let current = Arc::new(Mutex::new(None)); + let suppress_echoes = Arc::new(AtomicU32::new(0)); + let mut state = State { + mgr: None, + seat: None, + pending: HashMap::new(), + current: current.clone(), + suppress_echoes: suppress_echoes.clone(), + tx, + }; + queue + .roundtrip(&mut state) + .context("Wayland registry roundtrip")?; + + let mgr = state + .mgr + .clone() + .context("compositor lacks ext_data_control_manager_v1")?; + let seat = state + .seat + .clone() + .context("compositor advertised no wl_seat")?; + let device = mgr.get_data_device(&seat, &qh, ()); + // Second roundtrip: the compositor sends the initial selection for the freshly-bound device + // (the current host clipboard), which the session announces to the client. + queue + .roundtrip(&mut state) + .context("Wayland get_data_device roundtrip")?; + + let stop = Arc::new(AtomicBool::new(false)); + let thread = { + let conn = conn.clone(); + let stop = stop.clone(); + std::thread::Builder::new() + .name("punktfunk-clipboard".into()) + .spawn(move || dispatch_loop(conn, queue, state, stop)) + .context("spawn clipboard dispatch thread")? + }; + + Ok(( + ClipboardBackend { + conn, + mgr, + device, + qh, + current, + suppress_echoes, + active_source: Mutex::new(None), + stop, + thread: Some(thread), + }, + rx, + )) + } + + /// Install a lazy source advertising a client's offered formats (wire MIMEs) as the host + /// selection. A later host-app paste fires a [`ClipEvent::Paste`]. Replaces any previous offer. + pub fn set_offer(&self, wire_mimes: &[String]) -> Result<()> { + let wl_mimes = super::wayland_offers_for(wire_mimes); + if wl_mimes.is_empty() { + return self.clear_offer(); + } + let src = self.mgr.create_data_source(&self.qh, ()); + for m in &wl_mimes { + src.offer(m.clone()); + } + // Suppress the selection echo our own set triggers (loop prevention). + self.suppress_echoes.fetch_add(1, Ordering::SeqCst); + self.device.set_selection(Some(&src)); + self.conn.flush().context("flush set_selection")?; + let mut slot = self.active_source.lock().unwrap(); + if let Some(old) = slot.take() { + old.destroy(); + } + *slot = Some(src); + Ok(()) + } + + /// Drop the host selection we own (client disabled sync / offered nothing). + pub fn clear_offer(&self) -> Result<()> { + let mut slot = self.active_source.lock().unwrap(); + if let Some(old) = slot.take() { + self.suppress_echoes.fetch_add(1, Ordering::SeqCst); + self.device.set_selection(None); + old.destroy(); + self.conn.flush().context("flush clear selection")?; + } + Ok(()) + } + + /// The current host selection's wire MIMEs (what a client offer announcement would carry), or + /// empty if the clipboard is empty. Used to answer an immediate query. + pub fn current_wire_mimes(&self) -> Vec { + match self.current.lock().unwrap().as_ref() { + Some(sel) => super::offer_wire_mimes(&sel.mimes) + .into_iter() + .map(str::to_string) + .collect(), + None => Vec::new(), + } + } + + /// Read one format (`wire_mime`) of the current host selection into a byte vector — a client's + /// lazy fetch. BLOCKS on the pipe until the source app finishes, so call from a blocking + /// context (e.g. `spawn_blocking`). Errors if there is no selection or the format isn't offered. + pub fn read_current(&self, wire_mime: &str) -> Result> { + let (offer, wl_mime) = { + let cur = self.current.lock().unwrap(); + let sel = cur.as_ref().context("no current host selection")?; + let wl = super::pick_wayland_mime(wire_mime, &sel.mimes) + .context("format not offered by the host clipboard")?; + (sel.offer.clone(), wl) + }; + let (read_fd, write_fd) = make_pipe()?; + offer.receive(wl_mime, write_fd.as_fd()); + self.conn.flush().context("flush receive")?; + // Close our write end so the pipe reaches EOF once the source app closes its dup. + drop(write_fd); + let mut buf = Vec::new(); + // `read_fd` is a fresh, uniquely-owned pipe read end; `File` takes sole ownership and closes + // it on drop. + let file = std::fs::File::from(read_fd); + file.take(CLIP_READ_CAP) + .read_to_end(&mut buf) + .context("read clipboard transfer")?; + Ok(buf) + } +} + +impl Drop for ClipboardBackend { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + } +} + +/// The dispatch thread: poll the Wayland socket with a short timeout so `stop` is honored promptly, +/// dispatching selection/paste events into `state`. +fn dispatch_loop( + conn: Connection, + mut queue: wayland_client::EventQueue, + mut state: State, + stop: Arc, +) { + while !stop.load(Ordering::SeqCst) { + if queue.dispatch_pending(&mut state).is_err() { + break; + } + if conn.flush().is_err() { + break; + } + let Some(guard) = conn.prepare_read() else { + // Events are already queued; loop to dispatch them. + continue; + }; + let raw_fd = guard.connection_fd().as_raw_fd(); + let mut pfd = libc::pollfd { + fd: raw_fd, + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: `pfd` is a single valid pollfd; `poll` reads/writes exactly it for 200 ms. + let rc = unsafe { libc::poll(&mut pfd, 1, 200) }; + if rc < 0 { + let err = std::io::Error::last_os_error(); + drop(guard); + if err.kind() == std::io::ErrorKind::Interrupted { + continue; // EINTR — recheck stop, retry + } + break; + } + if rc == 0 { + drop(guard); // timeout — recheck stop + continue; + } + if pfd.revents & libc::POLLIN != 0 { + if guard.read().is_err() { + break; + } + } else { + drop(guard); // POLLHUP / POLLERR — connection gone + break; + } + } + let _ = state.tx.send(ClipEvent::Closed); +} + +/// Create a `pipe2(O_CLOEXEC)`, returning `(read_end, write_end)` as owned fds. +fn make_pipe() -> Result<(OwnedFd, OwnedFd)> { + let mut fds = [0 as libc::c_int; 2]; + // SAFETY: `pipe2` fully initializes the 2-element `fds` on success (returns 0); on failure (-1) + // we bail before reading it. Each returned fd is fresh and owned by exactly one `OwnedFd`. + let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) }; + if rc < 0 { + return Err(anyhow!("pipe2 failed: {}", std::io::Error::last_os_error())); + } + // SAFETY: `fds[0]`/`fds[1]` are the fresh, uniquely-owned pipe ends from the checked `pipe2`. + let read_fd = unsafe { OwnedFd::from_raw_fd(fds[0]) }; + // SAFETY: as above for the write end. + let write_fd = unsafe { OwnedFd::from_raw_fd(fds[1]) }; + Ok((read_fd, write_fd)) +} + +/// On-glass tests against a **live** `data-control` compositor (Hyprland / Sway / KWin). `#[ignore]`d +/// — run explicitly under such a session with `wl-clipboard` present: +/// +/// ```text +/// WAYLAND_DISPLAY=wayland-1 cargo test -p punktfunk-host --bin punktfunk-host \ +/// -- --ignored --nocapture clipboard::wayland::live +/// ``` +/// +/// Each test skips (does not fail) when `open()` finds no backend — so `--ignored` on GNOME (no +/// data-control) or a headless CI runner is a clean no-op instead of a false failure. +#[cfg(test)] +mod live { + use super::*; + use std::io::Write as _; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + /// Poll the event channel (sync `try_recv`, no runtime) until `pred` matches or `timeout`. + fn wait_event( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + timeout: Duration, + mut pred: impl FnMut(&ClipEvent) -> bool, + ) -> Option { + let deadline = Instant::now() + timeout; + loop { + match rx.try_recv() { + Ok(ev) if pred(&ev) => return Some(ev), + Ok(_) => {} + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => { + if Instant::now() >= deadline { + return None; + } + std::thread::sleep(Duration::from_millis(20)); + } + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => return None, + } + } + } + + /// Set the compositor selection from a "host app" (`wl-copy`, which forks a server that holds it). + fn wl_copy(bytes: &[u8], mime: &str) { + let mut child = Command::new("wl-copy") + .arg("--type") + .arg(mime) + .stdin(Stdio::piped()) + .spawn() + .expect("spawn wl-copy"); + child + .stdin + .take() + .unwrap() + .write_all(bytes) + .expect("write to wl-copy"); + let _ = child.wait(); // foreground exits; the fork keeps serving + std::thread::sleep(Duration::from_millis(150)); + } + + fn open_or_skip() -> Option<( + ClipboardBackend, + tokio::sync::mpsc::UnboundedReceiver, + )> { + if Command::new("wl-copy").arg("--version").output().is_err() { + eprintln!("SKIP: wl-clipboard not installed"); + return None; + } + match ClipboardBackend::open() { + Ok(v) => Some(v), + Err(e) => { + eprintln!("SKIP: no data-control backend on this compositor: {e:#}"); + None + } + } + } + + /// Host copy → we observe a `Selection` and can `read_current` the exact bytes back — both text + /// and PNG (§3.5 format normalization end to end). + #[test] + #[ignore = "needs a live data-control compositor (WAYLAND_DISPLAY)"] + fn live_host_copy_is_readable() { + let Some((backend, mut rx)) = open_or_skip() else { + return; + }; + + // Text. + wl_copy(b"hello-from-host-app", "text/plain;charset=utf-8"); + let ev = wait_event(&mut rx, Duration::from_secs(3), |e| { + matches!(e, ClipEvent::Selection { mimes } if mimes.iter().any(|m| m == super::super::WIRE_TEXT)) + }) + .expect("Selection event carrying text after wl-copy"); + assert!(matches!(ev, ClipEvent::Selection { .. })); + assert_eq!( + backend.read_current(super::super::WIRE_TEXT).unwrap(), + b"hello-from-host-app" + ); + + // PNG (arbitrary bytes tagged image/png — data-control is format-agnostic). + let png = b"\x89PNG\r\n\x1a\n-fake-but-tagged-image/png"; + wl_copy(png, "image/png"); + wait_event(&mut rx, Duration::from_secs(3), |e| { + matches!(e, ClipEvent::Selection { mimes } if mimes.iter().any(|m| m == super::super::WIRE_PNG)) + }) + .expect("Selection event carrying image/png"); + assert_eq!(backend.read_current(super::super::WIRE_PNG).unwrap(), png); + } + + /// We install a client's offer as the host selection; a host app (`wl-paste`) pasting it fires a + /// `Paste` event that we fulfill with bytes, and the host app receives exactly those bytes. + #[test] + #[ignore = "needs a live data-control compositor (WAYLAND_DISPLAY)"] + fn live_set_offer_is_pasteable() { + let Some((backend, mut rx)) = open_or_skip() else { + return; + }; + + backend + .set_offer(&[super::super::WIRE_TEXT.to_string()]) + .expect("install offer"); + + // A host app pastes our offered selection. + let child = Command::new("wl-paste") + .arg("-n") + .stdout(Stdio::piped()) + .spawn() + .expect("spawn wl-paste"); + + let paste = wait_event(&mut rx, Duration::from_secs(3), |e| { + matches!(e, ClipEvent::Paste { .. }) + }) + .expect("Paste event after wl-paste reads our offer"); + match paste { + ClipEvent::Paste { mime, responder } => { + assert_eq!( + mime, + super::super::WIRE_TEXT, + "paste requested the text format" + ); + match responder { + PasteResponder::Fd(fd) => { + super::super::fulfill_paste(fd, b"served-by-punktfunk").expect("fulfill"); + } + PasteResponder::Channel(_) => panic!("data-control paste must carry an fd"), + } + } + _ => unreachable!(), + } + + let out = child.wait_with_output().expect("wl-paste output"); + assert_eq!(out.stdout, b"served-by-punktfunk"); + } +} diff --git a/crates/pf-clipboard/src/host/windows.rs b/crates/pf-clipboard/src/host/windows.rs new file mode 100644 index 00000000..503b53fc --- /dev/null +++ b/crates/pf-clipboard/src/host/windows.rs @@ -0,0 +1,664 @@ +//! Host-side shared-clipboard backend for Windows (`design/clipboard-and-file-transfer.md` §4, Phase +//! 3). The Win32 clipboard is thread-affine and message-driven, so the whole backend lives on one +//! dedicated **message-loop thread** owning a hidden message-only window: +//! +//! * **host copy → client** — `AddClipboardFormatListener` delivers `WM_CLIPBOARDUPDATE`; we map the +//! available formats to wire MIMEs, cache them, and emit [`ClipEvent::Selection`]. Our own offers +//! are suppressed by the owner-check (we never forward what we ourselves put on the clipboard). +//! * **client fetch of the host clipboard** — a `Cmd::Read` reads the requested format's HGLOBAL and +//! converts it to wire bytes ([`super::winfmt`]). +//! * **client copy → host** — a `Cmd::SetOffer` installs the client's formats via OLE **delayed +//! rendering**: `SetClipboardData(fmt, NULL)`, so no bytes cross until a host app actually pastes. +//! * **host paste of client content** — the paste triggers `WM_RENDERFORMAT`; the message-loop thread +//! blocks (bounded) while the coordinator fetches the bytes from the client, then `SetClipboardData`s +//! them for the pasting app. +//! +//! The async coordinator drives the thread through a [`Cmd`] channel woken by `PostMessage(WM_APP_CMD)` +//! (`PostMessage` is the documented thread-safe way to poke a message loop). Per-window state hangs +//! off `GWLP_USERDATA`, so multiple concurrent sessions each get their own window + state. + +// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). +#![deny(clippy::undocumented_unsafe_blocks)] + +use std::cell::RefCell; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use anyhow::Context as _; + +use ::windows::core::{w, PCWSTR}; +use ::windows::Win32::Foundation::{ + GetLastError, GlobalFree, HANDLE, HGLOBAL, HINSTANCE, HWND, LPARAM, LRESULT, WPARAM, +}; +use ::windows::Win32::System::DataExchange::{ + AddClipboardFormatListener, CloseClipboard, EmptyClipboard, GetClipboardData, + GetClipboardOwner, IsClipboardFormatAvailable, OpenClipboard, RegisterClipboardFormatW, + SetClipboardData, +}; +use ::windows::Win32::System::LibraryLoader::GetModuleHandleW; +use ::windows::Win32::System::Memory::{ + GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE, GMEM_ZEROINIT, +}; +use ::windows::Win32::System::Ole::CF_UNICODETEXT; +use ::windows::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetMessageW, + GetWindowLongPtrW, PostMessageW, PostQuitMessage, RegisterClassW, SetWindowLongPtrW, + TranslateMessage, GWLP_USERDATA, HWND_MESSAGE, MSG, WINDOW_EX_STYLE, WINDOW_STYLE, WM_APP, + WM_CLIPBOARDUPDATE, WM_DESTROY, WM_RENDERFORMAT, WNDCLASSW, +}; + +use super::winfmt; +use super::{ClipEvent, PasteResponder, WIRE_HTML, WIRE_PNG, WIRE_RTF, WIRE_TEXT}; + +/// Custom app message that wakes the pump to drain the [`Cmd`] channel. +const WM_APP_CMD: u32 = WM_APP + 1; +/// Upper bound the message-loop thread waits for the client's bytes during a `WM_RENDERFORMAT` paste. +/// The pasting app is frozen until we answer, so this caps how long a paste can hang; on expiry the +/// format is left unrendered (an empty paste) rather than blocking indefinitely. +const RENDER_TIMEOUT: Duration = Duration::from_secs(10); +/// `OpenClipboard` fails while another process transiently holds the clipboard (clipboard managers do +/// this constantly); retry briefly before giving up. +const OPEN_RETRIES: u32 = 20; +const OPEN_RETRY_DELAY: Duration = Duration::from_millis(5); + +/// `RegisterClassW` returns this when the (process-global) class already exists — expected on the 2nd+ +/// concurrent session, and not an error (we never unregister the class). +const ERROR_CLASS_ALREADY_EXISTS: u32 = 1410; + +type ClipTx = tokio::sync::mpsc::UnboundedSender; + +/// A command from the async coordinator into the message-loop thread. Delivered over a tokio channel +/// and drained on `WM_APP_CMD`. +enum Cmd { + /// Install the client's wire MIMEs as a delayed-render host selection (empty ⇒ clear). + SetOffer(Vec), + /// Drop the selection we own. + Clear, + /// Read one wire format of the current host selection for a client fetch. + Read { + wire: String, + resp: tokio::sync::oneshot::Sender>>, + }, + /// Tear the window + thread down. + Shutdown, +} + +/// Message-loop-thread-owned state, reached from the `WndProc` via `GWLP_USERDATA`. Only the message +/// thread ever dereferences it, so the `RefCell`s are sound (no cross-thread sharing); the fields the +/// async handle also touches (`current_wire`) are behind their own `Arc`. +struct WinClip { + /// Backend → coordinator events. + clip_tx: ClipTx, + /// The current host selection's wire MIMEs, shared with the [`WindowsClipboard`] handle. + current_wire: Arc>>, + /// Coordinator → backend commands, drained on `WM_APP_CMD`. + cmd_rx: RefCell>, + /// Clipboard format ids we currently promise via delayed rendering (for `WM_RENDERFORMAT`). + offered: RefCell>, + fmt_html: u32, + fmt_rtf: u32, + fmt_png: u32, + /// Our own message window — used for the owner-check and clipboard opens. + own_hwnd: HWND, +} + +impl WinClip { + /// `WM_CLIPBOARDUPDATE`: a host app copied (or the clipboard was cleared). Suppress our own + /// delayed-render echoes via the owner-check, else announce the new wire MIMEs. + fn on_clipboard_update(&self, hwnd: HWND) { + // SAFETY: GetClipboardOwner has no preconditions and needs no open clipboard. + let owner = unsafe { GetClipboardOwner() }.unwrap_or_default(); + if owner.0 == hwnd.0 { + // Our own offer's echo (we own the clipboard) — not a host copy. + return; + } + let mimes = self.available_wire_mimes(); + *self.current_wire.lock().unwrap() = mimes.clone(); + let _ = self.clip_tx.send(ClipEvent::Selection { mimes }); + } + + /// The wire MIMEs the current clipboard advertises, in a stable order. + fn available_wire_mimes(&self) -> Vec { + // SAFETY: IsClipboardFormatAvailable has no preconditions and needs no open clipboard. + let avail = |fmt: u32| unsafe { IsClipboardFormatAvailable(fmt) }.is_ok(); + let mut out = Vec::new(); + if avail(CF_UNICODETEXT.0 as u32) { + out.push(WIRE_TEXT.to_string()); + } + if avail(self.fmt_html) { + out.push(WIRE_HTML.to_string()); + } + if avail(self.fmt_rtf) { + out.push(WIRE_RTF.to_string()); + } + if avail(self.fmt_png) { + out.push(WIRE_PNG.to_string()); + } + out + } + + /// `WM_APP_CMD`: run every queued coordinator command on this thread. Drained into a `Vec` first so + /// the `cmd_rx` borrow is released before any command runs (defensive against re-entry). + fn drain_commands(&self, hwnd: HWND) { + let mut cmds = Vec::new(); + { + let mut rx = self.cmd_rx.borrow_mut(); + while let Ok(c) = rx.try_recv() { + cmds.push(c); + } + } + for c in cmds { + match c { + Cmd::SetOffer(wire) => self.apply_offer(hwnd, &wire), + Cmd::Clear => self.clear(hwnd), + Cmd::Read { wire, resp } => { + let _ = resp.send(self.read(&wire)); + } + Cmd::Shutdown => { + // Drop our offer first so no WM_RENDERALLFORMATS fires as the window dies (we do + // NOT want the client's content to outlive the session on the host clipboard). + self.clear(hwnd); + // SAFETY: our own live window; triggers WM_DESTROY → PostQuitMessage → pump exit. + unsafe { + let _ = DestroyWindow(hwnd); + } + return; + } + } + } + } + + /// Install the client's offer as a delayed-render host selection. + fn apply_offer(&self, hwnd: HWND, wire: &[String]) { + let fmts = self.formats_for_offer(wire); + if fmts.is_empty() { + self.clear(hwnd); + return; + } + if open_clipboard_retry(hwnd).is_err() { + tracing::debug!("clipboard: OpenClipboard for set_offer failed"); + return; + } + let _guard = ClipboardGuard; + // SAFETY: the clipboard is open (ClipboardGuard closes it); EmptyClipboard makes us the owner, + // then each SetClipboardData(_, None) registers a delayed-render promise for that format. + unsafe { + let _ = EmptyClipboard(); + for &f in &fmts { + let _ = SetClipboardData(f, None); + } + } + *self.offered.borrow_mut() = fmts; + } + + /// Drop the selection we own (empty the clipboard iff we're still its owner). + fn clear(&self, hwnd: HWND) { + let had = { + let mut o = self.offered.borrow_mut(); + let was = !o.is_empty(); + o.clear(); + was + }; + if !had { + return; + } + // SAFETY: GetClipboardOwner has no preconditions. + let owner = unsafe { GetClipboardOwner() }.unwrap_or_default(); + if owner.0 != hwnd.0 { + return; // someone else took the clipboard already + } + if open_clipboard_retry(hwnd).is_err() { + return; + } + let _guard = ClipboardGuard; + // SAFETY: the clipboard is open (ClipboardGuard closes it); empty it to drop our promises. + unsafe { + let _ = EmptyClipboard(); + } + } + + /// Read one wire format of the current host selection (a client fetch). + fn read(&self, wire: &str) -> anyhow::Result> { + let fmt = self + .format_for_wire(wire) + .context("unsupported wire MIME")?; + // If we own the clipboard, its content is our own delayed-render offer (the client's copy), + // not a host selection — declining avoids GetClipboardData re-entering our own WM_RENDERFORMAT. + // SAFETY: GetClipboardOwner has no preconditions. + if unsafe { GetClipboardOwner() }.unwrap_or_default().0 == self.own_hwnd.0 { + anyhow::bail!("clipboard currently held by our own offer"); + } + open_clipboard_retry(self.own_hwnd)?; + let _guard = ClipboardGuard; + // SAFETY: the clipboard is open (ClipboardGuard closes it). GetClipboardData hands back a + // clipboard-owned HGLOBAL (we must NOT free it); GlobalLock/Size/Unlock are balanced and we + // copy exactly GlobalSize bytes out before the lock is released. + let raw = unsafe { + let handle = GetClipboardData(fmt).context("GetClipboardData")?; + let hg = HGLOBAL(handle.0); + let p = GlobalLock(hg); + if p.is_null() { + anyhow::bail!("GlobalLock failed"); + } + let n = GlobalSize(hg); + let mut buf = vec![0u8; n]; + std::ptr::copy_nonoverlapping(p as *const u8, buf.as_mut_ptr(), n); + let _ = GlobalUnlock(hg); + buf + }; + Ok(convert_from_win(wire, &raw)) + } + + /// `WM_RENDERFORMAT`: a host app is pasting a format we promised. Fetch the bytes from the client + /// (blocking this thread, bounded) and `SetClipboardData` them for the paster. + fn on_render_format(&self, fmt: u32) { + let Some(wire) = self.wire_for_format(fmt) else { + return; + }; + let (tx, rx) = std::sync::mpsc::channel::>(); + let ev = ClipEvent::Paste { + mime: wire.to_string(), + responder: PasteResponder::Sync(tx), + }; + if self.clip_tx.send(ev).is_err() { + return; // coordinator gone + } + let bytes = match rx.recv_timeout(RENDER_TIMEOUT) { + Ok(b) => b, + Err(_) => return, // timeout / dropped → leave the format unrendered (empty paste) + }; + let win_bytes = convert_to_win(wire, &bytes); + let Ok(hg) = alloc_hglobal(&win_bytes) else { + return; + }; + // Do NOT OpenClipboard here — the pasting app already holds it open across WM_RENDERFORMAT. + // SAFETY: `hg` is a freshly-filled moveable HGLOBAL. On success the clipboard takes ownership + // (we must not free it); on failure ownership stays with us, so we free it. + unsafe { + if SetClipboardData(fmt, Some(HANDLE(hg.0))).is_err() { + let _ = GlobalFree(Some(hg)); + } + } + } + + /// The Win32 clipboard format id for a wire MIME (`None` = unsupported). + fn format_for_wire(&self, wire: &str) -> Option { + match wire { + WIRE_TEXT => Some(CF_UNICODETEXT.0 as u32), + WIRE_HTML => Some(self.fmt_html), + WIRE_RTF => Some(self.fmt_rtf), + WIRE_PNG => Some(self.fmt_png), + _ => None, + } + } + + /// The wire MIME for a Win32 clipboard format id (`None` = one we don't offer). + fn wire_for_format(&self, fmt: u32) -> Option<&'static str> { + if fmt == CF_UNICODETEXT.0 as u32 { + Some(WIRE_TEXT) + } else if fmt == self.fmt_html { + Some(WIRE_HTML) + } else if fmt == self.fmt_rtf { + Some(WIRE_RTF) + } else if fmt == self.fmt_png { + Some(WIRE_PNG) + } else { + None + } + } + + /// The clipboard format ids to promise for a client offer (dedup, 1:1 with the wire MIMEs — the OS + /// auto-synthesizes CF_TEXT/CF_OEMTEXT from CF_UNICODETEXT, so no manual text fan-out is needed). + fn formats_for_offer(&self, wire: &[String]) -> Vec { + let mut out = Vec::new(); + for w in wire { + if let Some(f) = self.format_for_wire(w) { + if !out.contains(&f) { + out.push(f); + } + } + } + out + } +} + +/// The active Windows clipboard backend handle held by [`super::HostClipboard`]. All Win32 work runs +/// on the message-loop thread; this is just the async-side control surface. +pub struct WindowsClipboard { + cmd_tx: tokio::sync::mpsc::UnboundedSender, + /// The message window's `HWND` as an `isize` (so the handle stays `Send`/`Sync`); rebuilt for the + /// `PostMessage` wakeups, which are documented thread-safe. + hwnd: isize, + current_wire: Arc>>, + join: Option>, +} + +impl WindowsClipboard { + /// Spin up the message-loop thread + hidden window and return once it has bound (or failed). + pub async fn open() -> anyhow::Result<( + WindowsClipboard, + tokio::sync::mpsc::UnboundedReceiver, + )> { + let (clip_tx, clip_rx) = tokio::sync::mpsc::unbounded_channel::(); + let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::(); + let current_wire = Arc::new(Mutex::new(Vec::new())); + + // Register the three custom formats up front — process-global and thread-agnostic, so this is + // fine off the message thread and lets bring-up fail cleanly if the atoms can't be created. + let fmt_html = register_format(w!("HTML Format"))?; + let fmt_rtf = register_format(w!("Rich Text Format"))?; + let fmt_png = register_format(w!("PNG"))?; + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::>(); + let cw = Arc::clone(¤t_wire); + let join = std::thread::Builder::new() + .name("punktfunk-clipboard-win".into()) + .spawn(move || pump_thread(clip_tx, cmd_rx, cw, fmt_html, fmt_rtf, fmt_png, ready_tx)) + .context("spawn windows clipboard thread")?; + + let hwnd = match tokio::time::timeout(Duration::from_secs(3), ready_rx).await { + Ok(Ok(Ok(h))) => h, + Ok(Ok(Err(e))) => return Err(e), + Ok(Err(_)) => anyhow::bail!("windows clipboard thread exited during bring-up"), + Err(_) => anyhow::bail!("windows clipboard bring-up timed out"), + }; + + Ok(( + WindowsClipboard { + cmd_tx, + hwnd, + current_wire, + join: Some(join), + }, + clip_rx, + )) + } + + /// The current host selection's wire MIMEs (empty = nothing to offer). + pub fn current_wire_mimes(&self) -> Vec { + self.current_wire.lock().unwrap().clone() + } + + /// Install a client's offered formats as the host selection (fire-and-forget onto the thread). + pub fn set_offer(&self, wire_mimes: &[String]) { + let _ = self.cmd_tx.send(Cmd::SetOffer(wire_mimes.to_vec())); + self.wake(); + } + + /// Drop the host selection we own (fire-and-forget onto the thread). + pub fn clear_offer(&self) { + let _ = self.cmd_tx.send(Cmd::Clear); + self.wake(); + } + + /// Read one wire format of the current host selection (a client's fetch). + pub async fn read_current(&self, wire_mime: &str) -> anyhow::Result> { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.cmd_tx + .send(Cmd::Read { + wire: wire_mime.to_string(), + resp: tx, + }) + .map_err(|_| anyhow::anyhow!("clipboard thread gone"))?; + self.wake(); + rx.await + .map_err(|_| anyhow::anyhow!("clipboard read dropped"))? + } + + /// Poke the message loop so it drains the command channel. + fn wake(&self) { + // SAFETY: PostMessageW is documented thread-safe; `hwnd` is our message window (or already + // destroyed, in which case the post harmlessly fails and is ignored). + let _ = unsafe { + PostMessageW( + Some(HWND(self.hwnd as *mut core::ffi::c_void)), + WM_APP_CMD, + WPARAM(0), + LPARAM(0), + ) + }; + } +} + +impl Drop for WindowsClipboard { + fn drop(&mut self) { + let _ = self.cmd_tx.send(Cmd::Shutdown); + self.wake(); + if let Some(j) = self.join.take() { + let _ = j.join(); + } + } +} + +/// RAII `CloseClipboard` guard — pairs with a successful `open_clipboard_retry`, closing on scope exit +/// (including early `?`/`bail!` returns). +struct ClipboardGuard; + +impl Drop for ClipboardGuard { + fn drop(&mut self) { + // SAFETY: constructed only after a successful OpenClipboard on this thread. + unsafe { + let _ = CloseClipboard(); + } + } +} + +/// Register (or resolve the existing id of) a custom clipboard format. +fn register_format(name: PCWSTR) -> anyhow::Result { + // SAFETY: RegisterClipboardFormatW is thread-agnostic and process-global; `name` is a static + // NUL-terminated wide literal. + let id = unsafe { RegisterClipboardFormatW(name) }; + if id == 0 { + anyhow::bail!("RegisterClipboardFormatW failed"); + } + Ok(id) +} + +/// Allocate a moveable HGLOBAL holding `bytes` (zero-init so an empty payload is still a valid, locked +/// buffer). Ownership is transferred to the clipboard by a following `SetClipboardData`. +fn alloc_hglobal(bytes: &[u8]) -> anyhow::Result { + // SAFETY: allocate at least one byte (GlobalLock of a 0-size block is unreliable), lock it, copy + // the payload in, unlock. Alloc/lock/unlock are balanced; on lock failure we free before erroring. + unsafe { + let hg = GlobalAlloc(GMEM_MOVEABLE | GMEM_ZEROINIT, bytes.len().max(1)) + .context("GlobalAlloc")?; + let p = GlobalLock(hg); + if p.is_null() { + let _ = GlobalFree(Some(hg)); + anyhow::bail!("GlobalLock failed"); + } + std::ptr::copy_nonoverlapping(bytes.as_ptr(), p as *mut u8, bytes.len()); + let _ = GlobalUnlock(hg); + Ok(hg) + } +} + +/// `OpenClipboard(hwnd)` with a brief retry loop (another process often holds it transiently). +fn open_clipboard_retry(hwnd: HWND) -> anyhow::Result<()> { + for _ in 0..OPEN_RETRIES { + // SAFETY: OpenClipboard with our window as owner; balanced by ClipboardGuard/CloseClipboard. + if unsafe { OpenClipboard(Some(hwnd)) }.is_ok() { + return Ok(()); + } + std::thread::sleep(OPEN_RETRY_DELAY); + } + anyhow::bail!("OpenClipboard failed after retries") +} + +/// Convert a Win32 clipboard payload to wire bytes. +fn convert_from_win(wire: &str, raw: &[u8]) -> Vec { + match wire { + WIRE_TEXT => winfmt::text_from_utf16(raw), + WIRE_HTML => winfmt::html_from_cf(raw), + WIRE_RTF => winfmt::rtf_from_cf(raw), + _ => raw.to_vec(), // PNG + anything else: verbatim + } +} + +/// Convert wire bytes to a Win32 clipboard payload. +fn convert_to_win(wire: &str, wire_bytes: &[u8]) -> Vec { + match wire { + WIRE_TEXT => winfmt::text_to_utf16(wire_bytes), + WIRE_HTML => winfmt::html_to_cf(wire_bytes), + _ => wire_bytes.to_vec(), // RTF + PNG + anything else: verbatim + } +} + +/// Create the hidden message-only window (registering the class once, process-wide). +fn create_window() -> anyhow::Result { + // SAFETY: standard window-class registration + message-only window creation; every argument is a + // valid handle / static literal, and `wndproc` matches the WNDPROC ABI. + unsafe { + let hinstance: HINSTANCE = GetModuleHandleW(PCWSTR::null()) + .context("GetModuleHandleW")? + .into(); + let class_name = w!("PunktfunkClipboardWindow"); + let wc = WNDCLASSW { + lpfnWndProc: Some(wndproc), + hInstance: hinstance, + lpszClassName: class_name, + ..Default::default() + }; + if RegisterClassW(&wc) == 0 { + let code = GetLastError(); + if code.0 != ERROR_CLASS_ALREADY_EXISTS { + anyhow::bail!("RegisterClassW failed: {code:?}"); + } + } + let hwnd = CreateWindowExW( + WINDOW_EX_STYLE(0), + class_name, + w!(""), + WINDOW_STYLE(0), + 0, + 0, + 0, + 0, + Some(HWND_MESSAGE), + None, + Some(hinstance), + None, + ) + .context("CreateWindowExW")?; + Ok(hwnd) + } +} + +/// The message-loop thread body: build the window, wire up state, then pump until `WM_QUIT`. +fn pump_thread( + clip_tx: ClipTx, + cmd_rx: tokio::sync::mpsc::UnboundedReceiver, + current_wire: Arc>>, + fmt_html: u32, + fmt_rtf: u32, + fmt_png: u32, + ready_tx: tokio::sync::oneshot::Sender>, +) { + let hwnd = match create_window() { + Ok(h) => h, + Err(e) => { + let _ = ready_tx.send(Err(e)); + return; + } + }; + + // A clone that outlives the boxed state, so we can announce Closed after the pump ends. + let closed_tx = clip_tx.clone(); + + let state = Box::new(WinClip { + clip_tx, + current_wire, + cmd_rx: RefCell::new(cmd_rx), + offered: RefCell::new(Vec::new()), + fmt_html, + fmt_rtf, + fmt_png, + own_hwnd: hwnd, + }); + let ptr = Box::into_raw(state); + // SAFETY: stash the state pointer for the WndProc; the window was created on this thread and the + // pointer stays valid until we reclaim the Box after the pump exits. + unsafe { + SetWindowLongPtrW(hwnd, GWLP_USERDATA, ptr as isize); + } + + // Snapshot whatever is already on the host clipboard, so the first client `enable` announces it + // (AddClipboardFormatListener only delivers *subsequent* changes). + { + // SAFETY: `ptr` is the live state we just stored; only this thread dereferences it. + let st = unsafe { &*ptr }; + *st.current_wire.lock().unwrap() = st.available_wire_mimes(); + } + + // SAFETY: `hwnd` is our live window; start receiving WM_CLIPBOARDUPDATE. + if let Err(e) = unsafe { AddClipboardFormatListener(hwnd) } { + // SAFETY: tear down the half-built window and reclaim the leaked state box. + unsafe { + let _ = DestroyWindow(hwnd); + drop(Box::from_raw(ptr)); + } + let _ = ready_tx.send(Err( + anyhow::Error::new(e).context("AddClipboardFormatListener") + )); + return; + } + + let _ = ready_tx.send(Ok(hwnd.0 as isize)); + + // SAFETY: the standard Win32 message pump. GetMessageW returns >0 for a message, 0 for WM_QUIT, + // and -1 on error — `.0 > 0` exits on both 0 and -1. + unsafe { + let mut msg = MSG::default(); + while GetMessageW(&mut msg, None, 0, 0).0 > 0 { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + + // Pump exited (window destroyed): reclaim the leaked state box. No WndProc runs after this point. + // SAFETY: `ptr` came from Box::into_raw above, is dereferenced only on this thread, and the + // message loop has ended so no further access occurs. + unsafe { + drop(Box::from_raw(ptr)); + } + let _ = closed_tx.send(ClipEvent::Closed); +} + +/// The window procedure. Reaches per-window state through `GWLP_USERDATA`; runs only on the message +/// thread. Registered as the class `WNDPROC` (a safe fn coerces to the `unsafe extern "system"` ABI). +extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + // SAFETY: GWLP_USERDATA holds the `*const WinClip` stored right after window creation (0/null for + // the WM_(NC)CREATE messages that fire before that — handled by the null check below). + let ptr = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) } as *const WinClip; + if ptr.is_null() { + // SAFETY: default processing before our state pointer is attached. + return unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }; + } + // SAFETY: `ptr` is the live Box leaked in pump_thread, owned by this (the only) message + // thread and freed only after the pump exits; the WndProc is not re-entered for this window, so + // `&*ptr` is a valid shared borrow. + let st = unsafe { &*ptr }; + match msg { + WM_CLIPBOARDUPDATE => { + st.on_clipboard_update(hwnd); + LRESULT(0) + } + WM_RENDERFORMAT => { + st.on_render_format(wparam.0 as u32); + LRESULT(0) + } + WM_APP_CMD => { + st.drain_commands(hwnd); + LRESULT(0) + } + WM_DESTROY => { + // SAFETY: ends the GetMessageW pump by posting WM_QUIT to this thread's queue. + unsafe { + PostQuitMessage(0); + } + LRESULT(0) + } + // SAFETY: default handling for every other message. + _ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }, + } +} diff --git a/crates/pf-clipboard/src/host/winfmt.rs b/crates/pf-clipboard/src/host/winfmt.rs new file mode 100644 index 00000000..af78c2f2 --- /dev/null +++ b/crates/pf-clipboard/src/host/winfmt.rs @@ -0,0 +1,257 @@ +//! Pure byte conversions between the Win32 clipboard formats and the portable wire MIMEs +//! (`design/clipboard-and-file-transfer.md` §3.5). Kept free of any `windows`-crate dependency so it +//! compiles on every host and its unit tests exercise the fiddly bits (CF_HTML offset math, UTF-16 +//! (de)serialization) without a Windows box. The [`super::windows`] backend is the only production +//! consumer; it wraps these with the actual `GetClipboardData`/`SetClipboardData` calls. +//! +//! Format map (Win32 ↔ wire): +//! * `CF_UNICODETEXT` (UTF-16LE + NUL) ↔ `text/plain;charset=utf-8` +//! * `"HTML Format"` (CF_HTML, UTF-8 + ASCII header) ↔ `text/html` +//! * `"Rich Text Format"` (raw RTF) ↔ `text/rtf` +//! * `"PNG"` (raw PNG) ↔ `image/png` — identity, handled inline by the backend. + +// ---- CF_UNICODETEXT ↔ text/plain;charset=utf-8 ----------------------------------------------- + +/// `CF_UNICODETEXT` HGLOBAL bytes → UTF-8 wire bytes. `raw` is the exact `GlobalSize`-length buffer; +/// it holds little-endian UTF-16 code units terminated by a single `0x0000`. +pub fn text_from_utf16(raw: &[u8]) -> Vec { + // Reinterpret each LE 2-byte pair as a UTF-16 code unit; a stray odd trailing byte (never present + // in valid data) is dropped by `chunks_exact`. + let mut units: Vec = raw + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + // Strip exactly one trailing NUL terminator if present (guard against eating a real code unit). + if units.last() == Some(&0) { + units.pop(); + } + String::from_utf16_lossy(&units).into_bytes() +} + +/// UTF-8 wire bytes → `CF_UNICODETEXT` HGLOBAL bytes (UTF-16LE + a required `0x0000` terminator). +pub fn text_to_utf16(wire: &[u8]) -> Vec { + let s = String::from_utf8_lossy(wire); + let mut out = Vec::with_capacity(wire.len() * 2 + 2); + for u in s.encode_utf16() { + out.extend_from_slice(&u.to_le_bytes()); + } + out.extend_from_slice(&0u16.to_le_bytes()); // REQUIRED NUL terminator for CF_UNICODETEXT + out +} + +// ---- "HTML Format" (CF_HTML) ↔ text/html ----------------------------------------------------- +// +// CF_HTML is UTF-8: an ASCII `Key:Value\r\n` header carrying byte offsets, then the HTML with +// ``/`` markers. Offsets are byte counts from buffer start; +// the offsets live *inside* the header, so their digit-width feeds back into the header length. The +// spec-blessed fix (Chromium/Firefox/LibreOffice) is fixed-width 10-digit zero-padded offsets, which +// makes the header a compile-time constant and every offset a one-pass computation. + +const CF_HTML_HEADER: &str = "Version:0.9\r\n\ + StartHTML:0000000000\r\n\ + EndHTML:0000000000\r\n\ + StartFragment:0000000000\r\n\ + EndFragment:0000000000\r\n"; +const CF_HTML_PREFIX: &str = "\r\n"; +const CF_HTML_SUFFIX: &str = "\r\n"; + +/// UTF-8 HTML fragment (wire bytes) → a `CF_HTML` buffer, NUL-terminated. The trailing NUL is the +/// conventional CF_HTML expectation (§4); `EndHTML` still points at content end, before the NUL. +pub fn html_to_cf(wire: &[u8]) -> Vec { + let fragment = String::from_utf8_lossy(wire); + let start_html = CF_HTML_HEADER.len(); // 105 + let start_fragment = start_html + CF_HTML_PREFIX.len(); // 139 + let end_fragment = start_fragment + fragment.len(); // byte length — fragment may be multibyte + let end_html = end_fragment + CF_HTML_SUFFIX.len(); + + let mut buf = Vec::with_capacity(end_html + 1); + buf.extend_from_slice(CF_HTML_HEADER.as_bytes()); + buf.extend_from_slice(CF_HTML_PREFIX.as_bytes()); + buf.extend_from_slice(fragment.as_bytes()); + buf.extend_from_slice(CF_HTML_SUFFIX.as_bytes()); + + // Overwrite the four zero-padded fields in place, restricting the search to the header region so a + // fragment that happens to contain "StartHTML:" can't fool the patcher. + patch_offset(&mut buf[..start_html], b"StartHTML:", start_html); + patch_offset(&mut buf[..start_html], b"EndHTML:", end_html); + patch_offset(&mut buf[..start_html], b"StartFragment:", start_fragment); + patch_offset(&mut buf[..start_html], b"EndFragment:", end_fragment); + + buf.push(0); // conventional NUL terminator + buf +} + +/// A `CF_HTML` buffer → the UTF-8 HTML fragment (wire bytes). Uses the `StartFragment`/`EndFragment` +/// offsets; falls back to `StartHTML`/`EndHTML`, then to the whole buffer, if the markers are absent. +pub fn html_from_cf(raw: &[u8]) -> Vec { + let range = header_range(raw, b"StartFragment:", b"EndFragment:") + .or_else(|| header_range(raw, b"StartHTML:", b"EndHTML:")); + match range { + // Content is UTF-8 per spec; return the exact slice (drop any trailing NUL for cleanliness). + Some((start, end)) => { + let slice = &raw[start..end]; + strip_trailing_nul(slice).to_vec() + } + None => strip_trailing_nul(raw).to_vec(), + } +} + +/// Resolve `[start_label .. end_label]` into a validated byte range within `raw`. +fn header_range(raw: &[u8], start_label: &[u8], end_label: &[u8]) -> Option<(usize, usize)> { + let start = read_header_offset(raw, start_label)?; + let end = read_header_offset(raw, end_label)?; + if start <= end && end <= raw.len() { + Some((start, end)) + } else { + None + } +} + +/// Overwrite the 10 ASCII digits following `label` in `header` with `value`, zero-padded. +fn patch_offset(header: &mut [u8], label: &[u8], value: usize) { + if let Some(pos) = find(header, label) { + let at = pos + label.len(); + if at + 10 <= header.len() { + let digits = format!("{value:010}"); + header[at..at + 10].copy_from_slice(digits.as_bytes()); + } + } +} + +/// Read the decimal integer following `label:` in the ASCII header. The colon-suffixed labels only +/// match in the header, never the marker comments (``) or fragment text. +fn read_header_offset(raw: &[u8], label: &[u8]) -> Option { + let mut at = find(raw, label)? + label.len(); + let mut n: usize = 0; + let mut any = false; + while let Some(&b) = raw.get(at) { + if b.is_ascii_digit() { + n = n.checked_mul(10)?.checked_add((b - b'0') as usize)?; + any = true; + at += 1; + } else { + break; // stops at '\r' + } + } + any.then_some(n) +} + +fn find(hay: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() || needle.len() > hay.len() { + return None; + } + hay.windows(needle.len()).position(|w| w == needle) +} + +// ---- "Rich Text Format" ↔ text/rtf ----------------------------------------------------------- + +/// `"Rich Text Format"` HGLOBAL bytes → RTF wire bytes. RTF is `{ }`-delimited; some producers append +/// a NUL past the final `}`, so strip a single trailing NUL to keep the wire payload byte-clean. +pub fn rtf_from_cf(raw: &[u8]) -> Vec { + strip_trailing_nul(raw).to_vec() +} + +fn strip_trailing_nul(b: &[u8]) -> &[u8] { + match b.last() { + Some(0) => &b[..b.len() - 1], + _ => b, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_round_trips_and_handles_terminator() { + // UTF-8 → UTF-16LE+NUL → UTF-8. + let wire = "héllo 🌍".as_bytes(); + let cf = text_to_utf16(wire); + // Ends with a single 0x0000 terminator. + assert_eq!(&cf[cf.len() - 2..], &[0, 0]); + assert_eq!(text_from_utf16(&cf), wire); + + // A CF buffer *without* a terminator still decodes (no code unit eaten). + let no_term: Vec = "hi".encode_utf16().flat_map(u16::to_le_bytes).collect(); + assert_eq!(text_from_utf16(&no_term), b"hi"); + + // Empty text → just the terminator → empty wire. + assert_eq!(text_to_utf16(b""), vec![0, 0]); + assert_eq!(text_from_utf16(&[0, 0]), b""); + } + + #[test] + fn cf_html_matches_the_spec_offsets() { + // The worked example from the format reference: fragment "Hello". + let cf = html_to_cf(b"Hello"); + let s = String::from_utf8(cf.clone()).unwrap(); + assert!(s.contains("StartHTML:0000000105"), "{s}"); + assert!(s.contains("EndHTML:0000000178"), "{s}"); + assert!(s.contains("StartFragment:0000000139"), "{s}"); + assert!(s.contains("EndFragment:0000000144"), "{s}"); + // The declared fragment range must slice back to exactly "Hello". + let start = read_header_offset(&cf, b"StartFragment:").unwrap(); + let end = read_header_offset(&cf, b"EndFragment:").unwrap(); + assert_eq!(&cf[start..end], b"Hello"); + // Trailing NUL present, and EndHTML points *before* it. + assert_eq!(*cf.last().unwrap(), 0); + assert_eq!(read_header_offset(&cf, b"EndHTML:").unwrap(), cf.len() - 1); + } + + #[test] + fn cf_html_round_trips_including_multibyte() { + for frag in [ + "Hello", + "bold & ital", + "café ☕ x", + "", + ] { + let cf = html_to_cf(frag.as_bytes()); + assert_eq!(html_from_cf(&cf), frag.as_bytes(), "fragment {frag:?}"); + } + } + + #[test] + fn cf_html_extract_tolerates_foreign_producers() { + // A producer that adds SourceURL and uses Version 1.0 — offsets must still drive extraction, + // never a hardcoded 105-byte header. + let fragment = "picked"; + let prefix = ""; + let header_body = format!( + "Version:1.0\r\nStartHTML:{sh:010}\r\nEndHTML:{eh:010}\r\n\ + StartFragment:{sf:010}\r\nEndFragment:{ef:010}\r\nSourceURL:https://x/\r\n", + sh = 0, + eh = 0, + sf = 0, + ef = 0, + ); + // Compute real offsets against this ad-hoc layout. + let start_html = header_body.len(); + let start_fragment = start_html + prefix.len(); + let end_fragment = start_fragment + fragment.len(); + let end_html = end_fragment + "".len(); + let full = format!( + "Version:1.0\r\nStartHTML:{start_html:010}\r\nEndHTML:{end_html:010}\r\n\ + StartFragment:{start_fragment:010}\r\nEndFragment:{end_fragment:010}\r\nSourceURL:https://x/\r\n\ + {prefix}{fragment}" + ); + assert_eq!(html_from_cf(full.as_bytes()), fragment.as_bytes()); + } + + #[test] + fn cf_html_extract_falls_back_without_markers() { + // No fragment markers at all → whole buffer (minus any NUL). + let mut b = b"

no markers

".to_vec(); + assert_eq!(html_from_cf(&b), b"

no markers

"); + b.push(0); + assert_eq!(html_from_cf(&b), b"

no markers

"); + } + + #[test] + fn rtf_strips_one_trailing_nul() { + assert_eq!(rtf_from_cf(br"{\rtf1 hi}"), br"{\rtf1 hi}"); + assert_eq!(rtf_from_cf(b"{\\rtf1 hi}\0"), br"{\rtf1 hi}"); + // Only one NUL is stripped. + assert_eq!(rtf_from_cf(b"x\0\0"), b"x\0"); + } +} diff --git a/crates/pf-clipboard/src/lib.rs b/crates/pf-clipboard/src/lib.rs new file mode 100644 index 00000000..d035fca4 --- /dev/null +++ b/crates/pf-clipboard/src/lib.rs @@ -0,0 +1,147 @@ +//! Shared clipboard, host side (plan §W6 shape; `design/clipboard-and-file-transfer.md` §4). +//! +//! The wire protocol and the client half live in `punktfunk-core` (`punktfunk_core::quic` + +//! `punktfunk_core::clipboard`); this crate drives the **host's** real session clipboard through +//! the per-OS backends in [`host`] and bridges it to the QUIC clipboard plane through the +//! [`host::session`] coordinator. +//! +//! The orchestrator consumes only this portable facade — [`policy`] / [`enabled`] / +//! [`cap_advertised`], the [`ClipCoordCmd`] channel vocabulary, [`start`], and +//! [`spawn_decline_loop`] — so its control loop compiles unchanged on every host platform; the +//! platform split lives entirely behind [`start`]. + +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use punktfunk_core::quic::ClipOffer; + +/// The per-OS backends (`ext-data-control-v1` / Mutter direct / Win32) behind one +/// `HostClipboard`, plus the backend-agnostic [`host::session`] coordinator. +#[cfg(any(target_os = "linux", target_os = "windows"))] +pub mod host; + +/// Operator clipboard policy from `PUNKTFUNK_CLIPBOARD` (`design/clipboard-and-file-transfer.md` +/// §4.2): `off` (default — the whole feature is dark), `on` / `1` (text + files), `text-only` / +/// `no-files` (text/RTF/HTML/image only). Returns `None` when clipboard is off (the host neither +/// advertises the cap nor accepts fetch streams); otherwise the permitted-format +/// [`punktfunk_core::quic::CLIP_POLICY_TEXT`] / `CLIP_POLICY_FILES` bitfield. +/// +/// The policy gates the advertised capability and whether the [`host::session`] coordinator +/// starts. `off` keeps the whole feature dark. +pub fn policy() -> Option { + use punktfunk_core::quic::{CLIP_POLICY_FILES, CLIP_POLICY_TEXT}; + match std::env::var("PUNKTFUNK_CLIPBOARD") + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str() + { + "" | "0" | "off" | "false" => None, + "text-only" | "no-files" | "text" => Some(CLIP_POLICY_TEXT), + _ => Some(CLIP_POLICY_TEXT | CLIP_POLICY_FILES), // "on" / "1" / anything truthy + } +} + +/// Whether the shared clipboard is enabled at all for this host (policy not `off`). +pub fn enabled() -> bool { + policy().is_some() +} + +/// Whether the host should advertise `HOST_CAP_CLIPBOARD` in the `Welcome`: the operator policy +/// enables it AND this platform has a backend (Linux data-control / Mutter, or the Win32 +/// clipboard) — the client greys the toggle out otherwise. A Linux host whose compositor lacks +/// data-control still advertises it and answers a later enable with `BACKEND_UNAVAILABLE`, so the +/// client can surface *why* it's unavailable. +pub fn cap_advertised() -> bool { + enabled() && cfg!(any(target_os = "linux", target_os = "windows")) +} + +/// A command from the session control loop into the host clipboard coordinator +/// ([`host::session`]). Defined here — portable — so the control loop compiles on every host +/// platform; the coordinator that consumes it exists only where a backend does. +pub enum ClipCoordCmd { + /// The client toggled sync. When enabled, the coordinator (re)announces the current host + /// clipboard; when disabled, it drops any selection it owns and stops forwarding host copies. + SetEnabled(bool), + /// The client copied: install its offered wire MIMEs as a lazy host selection (empty = clear). + RemoteOffer { seq: u32, mimes: Vec }, +} + +/// Handle to the host clipboard coordinator, held by the session control loop. +pub struct ClipCoord { + /// Whether a real backend is live. `false` on gamescope / older GNOME / an unsupported + /// platform; the control loop then answers an enable request with + /// `CLIP_REASON_BACKEND_UNAVAILABLE` and [`spawn_decline_loop`] handles any stray fetch stream. + pub available: bool, + pub cmd_tx: tokio::sync::mpsc::UnboundedSender, + /// Host-copy announcements from the coordinator → control loop → client. + pub offer_rx: tokio::sync::mpsc::UnboundedReceiver, +} + +/// Open the host clipboard backend (when the operator policy allows it, this session mirrors a +/// real compositor, and the platform has a backend) and spawn its coordinator, returning a handle. +/// Otherwise the handle is inert (`available = false`, channels dropped) so the caller's control +/// loop stays platform-agnostic. `has_compositor` is false for the synthetic protocol-test source, +/// which has no display/clipboard to share — keeping it out of the real session clipboard. +pub async fn start( + conn: quinn::Connection, + clip_enabled: Arc, + has_compositor: bool, +) -> ClipCoord { + let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel(); + let (offer_tx, offer_rx) = tokio::sync::mpsc::unbounded_channel(); + #[cfg(any(target_os = "linux", target_os = "windows"))] + let available = if has_compositor && enabled() { + host::session::start(conn, clip_enabled, cmd_rx, offer_tx).await + } else { + drop((conn, clip_enabled, cmd_rx, offer_tx)); + false + }; + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + let available = { + let _ = (conn, clip_enabled, cmd_rx, offer_tx, has_compositor); + false + }; + ClipCoord { + available, + cmd_tx, + offer_rx, + } +} + +/// Clipboard fetch-stream accept loop, fallback flavor (`design/clipboard-and-file-transfer.md` +/// §3.3, §4.2). When a backend is live the coordinator (spawned by [`start`]) owns `accept_bi` and +/// serves real host clipboard bytes. This is for the other case: the operator allowed the cap but +/// no backend bound (gamescope / older GNOME / a not-yet-implemented platform), so a stray or +/// hostile fetch stream is answered `CLIP_FETCH_UNAVAILABLE` instead of hanging. Exactly one +/// `accept_bi` consumer runs (this OR the coordinator). The control stream is the FIRST bi-stream +/// (already accepted at the handshake), so this loop only ever sees clipboard fetch streams; it +/// dies with the connection. +pub fn spawn_decline_loop(conn: quinn::Connection) { + tokio::spawn(async move { + use punktfunk_core::quic::{clipstream, ClipFetchHdr, CLIP_FETCH_UNAVAILABLE}; + while let Ok((mut send, mut recv)) = conn.accept_bi().await { + tokio::spawn(async move { + // Validate the stream header + request; a malformed/unknown stream is dropped. + match clipstream::read_stream_header(&mut recv).await { + Ok(k) if k == clipstream::CLIP_STREAM_KIND_FETCH => {} + _ => { + let _ = send.reset(clipstream::cancelled_code()); + return; + } + } + if clipstream::read_fetch(&mut recv).await.is_err() { + return; + } + let _ = clipstream::write_fetch_hdr( + &mut send, + &ClipFetchHdr { + status: CLIP_FETCH_UNAVAILABLE, + total_size: 0, + }, + ) + .await; + }); + } + }); +} diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index f6df680b..a19fccb9 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -527,6 +527,10 @@ pub struct PunktfunkConnection { /// `inner.audio_channels`; `pcm` is a fixed-capacity reusable buffer the returned pointer /// borrows until the next PCM call (same contract as `last_audio`). audio_pcm: std::sync::Mutex, + /// Backs the `data`/`len` pointer of the last `punktfunk_connection_next_clipboard` event + /// (a fetched payload, an offer's format list, or a fetch-request's MIME) — + /// borrow-until-next-call, same contract as `last`. + last_clip: std::sync::Mutex>>, } /// Lazily-initialized in-core Opus decode state. A coupled-1-stream multistream decoder is @@ -948,6 +952,14 @@ pub const PUNKTFUNK_CODEC_AV1: u8 = 0x04; /// (design/pyrowave-codec-plan.md §3). (Mirrors `quic::CODEC_PYROWAVE`.) pub const PUNKTFUNK_CODEC_PYROWAVE: u8 = 0x08; +/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host applies gamepad-state +/// snapshots (a capable client sends full-state snapshots instead of per-transition events). +/// (Mirrors `quic::HOST_CAP_GAMEPAD_STATE`.) +pub const PUNKTFUNK_HOST_CAP_GAMEPAD_STATE: u8 = 0x01; +/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host supports the shared +/// clipboard, so a client may offer the toggle. (Mirrors `quic::HOST_CAP_CLIPBOARD`.) +pub const PUNKTFUNK_HOST_CAP_CLIPBOARD: u8 = 0x02; + // Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift). #[cfg(feature = "quic")] const _: () = { @@ -958,6 +970,8 @@ const _: () = { assert!(PUNKTFUNK_CODEC_HEVC == crate::quic::CODEC_HEVC); assert!(PUNKTFUNK_CODEC_AV1 == crate::quic::CODEC_AV1); assert!(PUNKTFUNK_CODEC_PYROWAVE == crate::quic::CODEC_PYROWAVE); + assert!(PUNKTFUNK_HOST_CAP_GAMEPAD_STATE == crate::quic::HOST_CAP_GAMEPAD_STATE); + assert!(PUNKTFUNK_HOST_CAP_CLIPBOARD == crate::quic::HOST_CAP_CLIPBOARD); }; // Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift). @@ -1552,6 +1566,7 @@ unsafe fn connect_ex_impl( last: std::sync::Mutex::new(None), last_audio: std::sync::Mutex::new(None), audio_pcm: std::sync::Mutex::new(AudioPcmState::default()), + last_clip: std::sync::Mutex::new(None), })) } Err(e) => { @@ -2555,6 +2570,397 @@ pub unsafe extern "C" fn punktfunk_connection_gamepad( }) } +// ============================================================================================ +// Shared clipboard (design/clipboard-and-file-transfer.md §5.1). Additive, ABI v6. All poll/serve +// bytes ride the mTLS-pinned QUIC session; nothing here opens a new listener or port. +// ============================================================================================ + +/// [`PunktfunkClipEvent::kind`] — the host announced clipboard content is available +/// (`transfer_id` = the offer `seq`; `data`/`len` = a `\n`-separated `"\t"` +/// format list). Fetch it lazily (only on a local paste) via +/// [`punktfunk_connection_clipboard_fetch`]. +pub const PUNKTFUNK_CLIP_REMOTE_OFFER: u8 = 1; +/// [`PunktfunkClipEvent::kind`] — host ack / policy / backend update (`enabled`/`policy`/`reason` +/// valid). Reflect it in the toggle UI. +pub const PUNKTFUNK_CLIP_STATE: u8 = 2; +/// [`PunktfunkClipEvent::kind`] — the host is pasting our offered data: answer with +/// [`punktfunk_connection_clipboard_serve`] (`transfer_id` = `req_id`; `seq`/`file_index` valid; +/// `data`/`len` = the requested MIME). +pub const PUNKTFUNK_CLIP_FETCH_REQUEST: u8 = 3; +/// [`PunktfunkClipEvent::kind`] — bytes for a fetch we started (`transfer_id` = `xfer_id`; +/// `data`/`len` = the payload, borrowed until the next `next_clipboard`; `last` = final chunk). +pub const PUNKTFUNK_CLIP_DATA: u8 = 4; +/// [`PunktfunkClipEvent::kind`] — a transfer was cancelled (`transfer_id` = the id). +pub const PUNKTFUNK_CLIP_CANCELLED: u8 = 5; +/// [`PunktfunkClipEvent::kind`] — a transfer failed (`transfer_id` = the id; `status` = a +/// `PunktfunkStatus` code). +pub const PUNKTFUNK_CLIP_ERROR: u8 = 6; + +/// One advertised clipboard format passed to [`punktfunk_connection_clipboard_offer`]. +#[cfg(feature = "quic")] +#[repr(C)] +pub struct PunktfunkClipKind { + /// NUL-terminated UTF-8 wire MIME (e.g. `text/plain;charset=utf-8`). ≤ 128 bytes on the wire. + pub mime: *const std::os::raw::c_char, + /// Best-effort size in bytes; `0` = unknown. + pub size_hint: u64, +} + +/// A shared-clipboard event, filled by [`punktfunk_connection_next_clipboard`]. A flat tagged +/// struct (like `PunktfunkHidOutput`): read the fields named in the `kind`'s doc; the rest are 0. +#[cfg(feature = "quic")] +#[repr(C)] +#[derive(Clone, Copy)] +pub struct PunktfunkClipEvent { + /// One of `PUNKTFUNK_CLIP_*`. + pub kind: u8, + /// `State`: 1 = enabled, 0 = disabled. + pub enabled: u8, + /// `State`: bitfield of `quic::CLIP_POLICY_*` — what the host currently permits. + pub policy: u8, + /// `State`: one of `quic::CLIP_REASON_*`. + pub reason: u8, + /// `Data`: 1 = final chunk of this transfer. + pub last: u8, + /// Per-transfer id: the offer `seq` (RemoteOffer), the `req_id` (FetchRequest), or the + /// `xfer_id` (Data/Cancelled/Error). + pub transfer_id: u32, + /// `FetchRequest`: the offer `seq` the request is against. + pub seq: u32, + /// `FetchRequest`: file index, or `quic::CLIP_FILE_INDEX_NONE`. + pub file_index: u32, + /// `Error`: a `PunktfunkStatus` code (negative); 0 otherwise. + pub status: i32, + /// RemoteOffer/FetchRequest/Data: a pointer into a per-connection slot, valid until the next + /// `next_clipboard` call; NULL for the other kinds. + pub data: *const u8, + /// Byte length of `data` (0 when `data` is NULL). + pub len: usize, +} + +/// Fill a [`PunktfunkClipEvent`] from a core event, parking any variable-length bytes in `slot` +/// (borrow-until-next-call) and pointing `data`/`len` at them. +#[cfg(feature = "quic")] +fn build_clip_event( + ev: crate::clipboard::ClipEventCore, + slot: &mut Option>, +) -> PunktfunkClipEvent { + use crate::clipboard::ClipEventCore as E; + let mut out = PunktfunkClipEvent { + kind: 0, + enabled: 0, + policy: 0, + reason: 0, + last: 0, + transfer_id: 0, + seq: 0, + file_index: 0, + status: 0, + data: std::ptr::null(), + len: 0, + }; + *slot = None; + match ev { + E::RemoteOffer { seq, kinds } => { + out.kind = PUNKTFUNK_CLIP_REMOTE_OFFER; + out.transfer_id = seq; + let mut blob = String::new(); + for k in &kinds { + blob.push_str(&k.mime); + blob.push('\t'); + blob.push_str(&k.size_hint.to_string()); + blob.push('\n'); + } + *slot = Some(blob.into_bytes()); + } + E::State { + enabled, + policy, + reason, + } => { + out.kind = PUNKTFUNK_CLIP_STATE; + out.enabled = enabled as u8; + out.policy = policy; + out.reason = reason; + } + E::FetchRequest { + req_id, + seq, + file_index, + mime, + } => { + out.kind = PUNKTFUNK_CLIP_FETCH_REQUEST; + out.transfer_id = req_id; + out.seq = seq; + out.file_index = file_index; + *slot = Some(mime.into_bytes()); + } + E::Data { + xfer_id, + bytes, + last, + } => { + out.kind = PUNKTFUNK_CLIP_DATA; + out.transfer_id = xfer_id; + out.last = last as u8; + *slot = Some(bytes); + } + E::Cancelled { id } => { + out.kind = PUNKTFUNK_CLIP_CANCELLED; + out.transfer_id = id; + } + E::Error { id, code } => { + out.kind = PUNKTFUNK_CLIP_ERROR; + out.transfer_id = id; + out.status = code; + } + } + if let Some(v) = slot.as_ref() { + out.data = v.as_ptr(); + out.len = v.len(); + } + out +} + +/// The host capability bitfield the session's `Welcome` carried — a bitfield of +/// `PUNKTFUNK_HOST_CAP_GAMEPAD_STATE` / `PUNKTFUNK_HOST_CAP_CLIPBOARD`. A client tests +/// `caps & PUNKTFUNK_HOST_CAP_CLIPBOARD` to decide whether to offer the shared-clipboard toggle. +/// Safe any time after connect. +/// +/// # Safety +/// `c` is a valid connection handle; `caps` is writable (NULL is skipped). +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_host_caps( + c: *const PunktfunkConnection, + caps: *mut u8, +) -> PunktfunkStatus { + guard(|| { + let c = match unsafe { c.as_ref() } { + Some(c) => c, + None => return PunktfunkStatus::NullPointer, + }; + unsafe { + if !caps.is_null() { + *caps = c.inner.host_caps(); + } + } + PunktfunkStatus::Ok + }) +} + +/// Enable or disable the shared clipboard for this session (`design` §3.1). Opt-in: nothing is +/// announced or served until this is called with `enabled = true`. `flags` carries +/// `quic::CLIP_FLAG_FILES` (allow file transfer). The host replies with a `State` event. +/// +/// # Safety +/// `c` is a valid connection handle. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_clipboard_control( + c: *const PunktfunkConnection, + enabled: bool, + flags: u8, +) -> PunktfunkStatus { + guard(|| { + let c = match unsafe { c.as_ref() } { + Some(c) => c, + None => return PunktfunkStatus::NullPointer, + }; + match c.inner.clip_control(enabled, flags) { + Ok(()) => PunktfunkStatus::Ok, + Err(e) => e.status(), + } + }) +} + +/// Announce that the local clipboard changed — the lazy format-list offer. `seq` is a monotonic +/// per-sender counter (newest wins); `kinds`/`n` is the advertised formats (≤ 16). The bytes cross +/// only if the host later fetches. +/// +/// # Safety +/// `c` is a valid connection handle; `kinds` points to `n` `PunktfunkClipKind`s (NULL allowed only +/// when `n == 0`), each `mime` a valid NUL-terminated UTF-8 string. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_clipboard_offer( + c: *const PunktfunkConnection, + seq: u32, + kinds: *const PunktfunkClipKind, + n: usize, +) -> PunktfunkStatus { + guard(|| { + let c = match unsafe { c.as_ref() } { + Some(c) => c, + None => return PunktfunkStatus::NullPointer, + }; + if kinds.is_null() && n != 0 { + return PunktfunkStatus::NullPointer; + } + let mut out = Vec::with_capacity(n); + if n != 0 { + let slice = unsafe { std::slice::from_raw_parts(kinds, n) }; + for k in slice { + let mime = if k.mime.is_null() { + String::new() + } else { + match unsafe { std::ffi::CStr::from_ptr(k.mime) }.to_str() { + Ok(s) => s.to_string(), + Err(_) => return PunktfunkStatus::InvalidArg, + } + }; + out.push(crate::quic::ClipKind { + mime, + size_hint: k.size_hint, + }); + } + } + match c.inner.clip_offer(seq, out) { + Ok(()) => PunktfunkStatus::Ok, + Err(e) => e.status(), + } + }) +} + +/// Start pulling one format (`mime`) of the host's current offer `seq` — lazily, on a local paste. +/// `file_index` selects a file for a file transfer, or `quic::CLIP_FILE_INDEX_NONE` for a non-file +/// format. Writes the transfer id (echoed on the resulting `Data`/`Error`/`Cancelled` event) to +/// `xfer_id_out`. +/// +/// # Safety +/// `c` is a valid connection handle; `mime` is a valid NUL-terminated UTF-8 string; `xfer_id_out` +/// is writable (NULL is skipped). +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_clipboard_fetch( + c: *const PunktfunkConnection, + seq: u32, + mime: *const std::os::raw::c_char, + file_index: u32, + xfer_id_out: *mut u32, +) -> PunktfunkStatus { + guard(|| { + let c = match unsafe { c.as_ref() } { + Some(c) => c, + None => return PunktfunkStatus::NullPointer, + }; + if mime.is_null() { + return PunktfunkStatus::NullPointer; + } + let mime = match unsafe { std::ffi::CStr::from_ptr(mime) }.to_str() { + Ok(s) => s.to_string(), + Err(_) => return PunktfunkStatus::InvalidArg, + }; + match c.inner.clip_fetch(seq, mime, file_index) { + Ok(xfer_id) => { + unsafe { + if !xfer_id_out.is_null() { + *xfer_id_out = xfer_id; + } + } + PunktfunkStatus::Ok + } + Err(e) => e.status(), + } + }) +} + +/// Provide bytes answering a `FetchRequest` event (the host is pasting our offered data). Call +/// repeatedly to stream a large payload; `last = true` completes it. `data` may be NULL only when +/// `len == 0` (e.g. a final empty chunk). `punktfunk_connection_clipboard_cancel(req_id)` aborts. +/// +/// # Safety +/// `c` is a valid connection handle; `data` points to `len` bytes (NULL allowed only when +/// `len == 0`). +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_clipboard_serve( + c: *const PunktfunkConnection, + req_id: u32, + data: *const u8, + len: usize, + last: bool, +) -> PunktfunkStatus { + guard(|| { + let c = match unsafe { c.as_ref() } { + Some(c) => c, + None => return PunktfunkStatus::NullPointer, + }; + if data.is_null() && len != 0 { + return PunktfunkStatus::NullPointer; + } + let bytes = if len == 0 { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(data, len) }.to_vec() + }; + match c.inner.clip_serve(req_id, bytes, last) { + Ok(()) => PunktfunkStatus::Ok, + Err(e) => e.status(), + } + }) +} + +/// Cancel a clipboard transfer by id — either an outbound fetch (`xfer_id` from +/// [`punktfunk_connection_clipboard_fetch`]) or an inbound serve (`req_id` from a `FetchRequest`). +/// +/// # Safety +/// `c` is a valid connection handle. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_clipboard_cancel( + c: *const PunktfunkConnection, + id: u32, +) -> PunktfunkStatus { + guard(|| { + let c = match unsafe { c.as_ref() } { + Some(c) => c, + None => return PunktfunkStatus::NullPointer, + }; + match c.inner.clip_cancel(id) { + Ok(()) => PunktfunkStatus::Ok, + Err(e) => e.status(), + } + }) +} + +/// Pull the next shared-clipboard event into `*out`. [`PunktfunkStatus::NoFrame`] on timeout, +/// [`PunktfunkStatus::Closed`] once the session ended. A native client drains this on its own +/// thread and drives the OS pasteboard from it. The `data`/`len` pointer (when non-NULL) borrows a +/// per-connection buffer valid until the next `next_clipboard` call on this handle. +/// +/// # Safety +/// `c` is a valid connection handle; `out` is writable for one `PunktfunkClipEvent`. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_next_clipboard( + c: *mut PunktfunkConnection, + out: *mut PunktfunkClipEvent, + timeout_ms: u32, +) -> PunktfunkStatus { + guard(|| { + let c = match unsafe { c.as_ref() } { + Some(c) => c, + None => return PunktfunkStatus::NullPointer, + }; + if out.is_null() { + return PunktfunkStatus::NullPointer; + } + match c + .inner + .next_clip(std::time::Duration::from_millis(timeout_ms as u64)) + { + Ok(ev) => { + let mut slot = c.last_clip.lock().unwrap(); + let out_ev = build_clip_event(ev, &mut slot); + unsafe { *out = out_ev }; + PunktfunkStatus::Ok + } + Err(e) => e.status(), + } + }) +} + /// The compositor backend the host actually resolved for this session (one of the /// `PUNKTFUNK_COMPOSITOR_*` values; the `Welcome`'s echo of the [`punktfunk_connect_ex`] /// preference). `PUNKTFUNK_COMPOSITOR_AUTO` = an older host that didn't say. Clients use it for diff --git a/crates/punktfunk-core/src/client/control.rs b/crates/punktfunk-core/src/client/control.rs index 7a3db36d..92d77853 100644 --- a/crates/punktfunk-core/src/client/control.rs +++ b/crates/punktfunk-core/src/client/control.rs @@ -1,7 +1,7 @@ //! `CtrlRequest` (the embedder's control-stream requests) and `Negotiated` (the handshake result). use crate::config::{CompositorPref, GamepadPref, Mode}; -use crate::quic::{ColorInfo, LossReport, ProbeRequest, RfiRequest}; +use crate::quic::{ClipControl, ClipOffer, ColorInfo, LossReport, ProbeRequest, RfiRequest}; /// A control-stream request the embedder makes on the open handshake stream: a mode switch or a /// speed test. One outbound channel carries both so the worker's `select!` has a single writer @@ -22,6 +22,12 @@ pub(crate) enum CtrlRequest { /// its report tick after the first no-op clock flush — the "the clock stepped under me" /// signal; the control task also self-triggers one every [`CLOCK_RESYNC_INTERVAL`]. ClockResync, + /// Shared-clipboard enable/disable for this session (`design/clipboard-and-file-transfer.md` + /// §3.1). Idempotent; carries the file-permission flag. + ClipControl(ClipControl), + /// Announce that the local clipboard changed — the lazy format-list offer (bytes cross later on + /// a fetch stream). Symmetric message; the host may send one too. + ClipOffer(ClipOffer), } /// What the worker reports to [`NativeClient::connect`] once the handshake lands: the @@ -55,4 +61,8 @@ pub(crate) struct Negotiated { pub(crate) audio_channels: u8, /// The single codec the host will emit (`quic::CODEC_*`). pub(crate) codec: u8, + /// The host capability bitfield ([`crate::quic::Welcome::host_caps`]): + /// [`crate::quic::HOST_CAP_GAMEPAD_STATE`], [`crate::quic::HOST_CAP_CLIPBOARD`]. Exposed to the + /// embedder via [`NativeClient::host_caps`] so a native client greys out unsupported toggles. + pub(crate) host_caps: u8, } diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index 1cf0a394..5056701c 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -11,12 +11,16 @@ //! invariant) plus a blocking data-plane pump; frames cross to the embedder over a bounded //! channel. All methods are safe to call from any single embedder thread. +use crate::clipboard::{ClipCommand, ClipEventCore}; use crate::config::{CompositorPref, GamepadPref, Mode}; use crate::error::{PunktfunkError, Result}; use crate::input::InputEvent; -use crate::quic::{endpoint, ColorInfo, HdrMeta, HidOutput, ProbeRequest, RfiRequest, RichInput}; +use crate::quic::{ + endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, ProbeRequest, + RfiRequest, RichInput, +}; use crate::session::Frame; -use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; use std::sync::mpsc::{Receiver, RecvTimeoutError}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -38,7 +42,8 @@ pub use self::rumble::{ActuatorQuirks, RumbleCommand}; use self::control::{CtrlRequest, Negotiated}; use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop}; use self::planes::{ - RumbleUpdate, AUDIO_QUEUE, HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, RUMBLE_QUEUE, + RumbleUpdate, AUDIO_QUEUE, CLIP_EVENT_QUEUE, HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, + RUMBLE_QUEUE, }; use self::probe::ProbeState; use self::pump::run_pump; @@ -99,6 +104,20 @@ pub struct NativeClient { /// Bounded ([`CTRL_QUEUE`]) — the requests are sparse; a full queue means the control task /// is wedged/dead, and callers treat it like a closed session. ctrl_tx: tokio::sync::mpsc::Sender, + /// Inbound shared-clipboard events (remote offers, host acks, fetch-requests, fetched + /// payloads), drained by [`NativeClient::next_clip`] → the C ABI poll. Fed by the control task + /// (metadata) and the clipboard task (fetch data). + clip: Mutex>, + /// Outbound clipboard fetch/serve/cancel commands → the worker's clipboard task + /// ([`crate::clipboard::run`]). Unbounded like `input_tx`; the commands are sparse and each + /// carries at most one paste's bytes. + clip_cmd_tx: tokio::sync::mpsc::UnboundedSender, + /// Monotonic id for outbound fetches ([`NativeClient::clip_fetch`]); stays below + /// [`crate::clipboard::INBOUND_REQ_FLAG`] so it never collides with an inbound serve `req_id`. + next_xfer_id: AtomicU32, + /// The host capability bitfield ([`crate::quic::Welcome::host_caps`]) — see + /// [`NativeClient::host_caps`]. + pub host_caps: u8, /// Speed-test accumulator, shared with the data-plane pump + control task. probe: Arc>, shutdown: Arc, @@ -315,6 +334,9 @@ impl NativeClient { let (mic_tx, mic_rx) = tokio::sync::mpsc::channel::<(u32, u64, Vec)>(MIC_QUEUE); let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::(); let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::channel::(CTRL_QUEUE); + let (clip_event_tx, clip_event_rx) = + std::sync::mpsc::sync_channel::(CLIP_EVENT_QUEUE); + let (clip_cmd_tx, clip_cmd_rx) = tokio::sync::mpsc::unbounded_channel::(); let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); let shutdown = Arc::new(AtomicBool::new(false)); let quit = Arc::new(AtomicBool::new(false)); @@ -383,6 +405,8 @@ impl NativeClient { rich_input_rx, ctrl_rx, ctrl_tx: ctrl_tx_pump, + clip_event_tx, + clip_cmd_rx, ready_tx, shutdown: shutdown_w, quit: quit_w, @@ -418,6 +442,10 @@ impl NativeClient { mic_tx, rich_input_tx, ctrl_tx, + clip: Mutex::new(clip_event_rx), + clip_cmd_tx, + next_xfer_id: AtomicU32::new(1), + host_caps: negotiated.host_caps, probe, shutdown, quit, @@ -860,6 +888,84 @@ impl NativeClient { self.input_tx.send(*ev).map_err(|_| PunktfunkError::Closed) } + /// The host capability bitfield the [`crate::quic::Welcome`] carried + /// ([`crate::quic::HOST_CAP_GAMEPAD_STATE`], [`crate::quic::HOST_CAP_CLIPBOARD`]). A native + /// client tests `host_caps() & HOST_CAP_CLIPBOARD` to decide whether to offer the + /// shared-clipboard toggle. + pub fn host_caps(&self) -> u8 { + self.host_caps + } + + /// Enable or disable the shared clipboard for this session (`design/clipboard-and-file-transfer.md` + /// §3.1). Opt-in: nothing is announced or served until this crosses with `enabled = true`. + /// `flags` carries [`crate::quic::CLIP_FLAG_FILES`]. Non-blocking; the host replies with a + /// `State` event ([`NativeClient::next_clip`]). + pub fn clip_control(&self, enabled: bool, flags: u8) -> Result<()> { + self.ctrl_tx + .try_send(CtrlRequest::ClipControl(ClipControl { enabled, flags })) + .map_err(|_| PunktfunkError::Closed) + } + + /// Announce that the local clipboard changed — the lazy format-list offer. `seq` is a + /// monotonic per-sender counter (newest wins); `kinds` is the advertised formats (≤ + /// [`crate::quic::CLIP_MAX_KINDS`]). The bytes cross only if the host later fetches. + pub fn clip_offer(&self, seq: u32, kinds: Vec) -> Result<()> { + self.ctrl_tx + .try_send(CtrlRequest::ClipOffer(ClipOffer { seq, kinds })) + .map_err(|_| PunktfunkError::Closed) + } + + /// Start pulling one format (`mime`) of the host's current offer `seq` — lazily, when a local + /// app pastes. `file_index` selects a file for a file transfer, or + /// [`crate::quic::CLIP_FILE_INDEX_NONE`] for a non-file format. Returns the `xfer_id` echoed on + /// the resulting `Data` / `Error` / `Cancelled` event. + pub fn clip_fetch(&self, seq: u32, mime: String, file_index: u32) -> Result { + let xfer_id = self.next_xfer_id.fetch_add(1, Ordering::Relaxed); + // Stay in the low id space (inbound serve ids carry the high bit); wrap defensively. + let xfer_id = xfer_id & !crate::clipboard::INBOUND_REQ_FLAG; + self.clip_cmd_tx + .send(ClipCommand::Fetch { + xfer_id, + seq, + file_index, + mime, + }) + .map_err(|_| PunktfunkError::Closed)?; + Ok(xfer_id) + } + + /// Provide bytes answering a `FetchRequest` event (the host is pasting our offered data). Call + /// repeatedly to stream a large payload; `last = true` completes it. `clip_cancel(req_id)` + /// aborts instead. + pub fn clip_serve(&self, req_id: u32, bytes: Vec, last: bool) -> Result<()> { + self.clip_cmd_tx + .send(ClipCommand::Serve { + req_id, + bytes, + last, + }) + .map_err(|_| PunktfunkError::Closed) + } + + /// Cancel a clipboard transfer by id — either an outbound fetch (`xfer_id` from + /// [`NativeClient::clip_fetch`]) or an inbound serve (`req_id` from a `FetchRequest` event). + pub fn clip_cancel(&self, id: u32) -> Result<()> { + self.clip_cmd_tx + .send(ClipCommand::Cancel { id }) + .map_err(|_| PunktfunkError::Closed) + } + + /// Pull the next shared-clipboard event (remote offer, host ack/state, fetch-request, fetched + /// data, cancel, error); same timeout/closed semantics as [`NativeClient::next_hidout`]. A + /// native client drains this on its own thread and drives the OS pasteboard from it. + pub fn next_clip(&self, timeout: Duration) -> Result { + match self.clip.lock().unwrap().recv_timeout(timeout) { + Ok(e) => Ok(e), + Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame), + Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed), + } + } + /// Queue one Opus mic frame for delivery as a 0xCB uplink datagram (the inverse of /// [`next_audio`](Self::next_audio)). `seq`/`pts_ns` are the caller's own counters (the host /// uses them only for diagnostics). The host decodes it into a virtual microphone source. diff --git a/crates/punktfunk-core/src/client/planes.rs b/crates/punktfunk-core/src/client/planes.rs index e568504f..7c0b6e6e 100644 --- a/crates/punktfunk-core/src/client/planes.rs +++ b/crates/punktfunk-core/src/client/planes.rs @@ -29,6 +29,12 @@ pub(crate) const HDR_META_QUEUE: usize = 8; /// harmless, it's per-frame observability, not state. pub(crate) const HOST_TIMING_QUEUE: usize = 512; +/// Clipboard event plane depth (offers, host acks, fetch-requests, fetched payloads). Clipboard +/// activity is human-paced and sparse; a small ring is ample. Overflow drops the newest event +/// (try_send), same discipline as the other planes — a dropped offer heals on the next copy, and +/// a dropped fetch-request makes the serving stream time out and reset cleanly. +pub(crate) const CLIP_EVENT_QUEUE: usize = 32; + /// One Opus packet from the host's audio datagram stream (48 kHz stereo, 5 ms frames). #[derive(Clone, Debug)] pub struct AudioPacket { diff --git a/crates/punktfunk-core/src/client/pump.rs b/crates/punktfunk-core/src/client/pump.rs index 704b4cb7..3c528ec4 100644 --- a/crates/punktfunk-core/src/client/pump.rs +++ b/crates/punktfunk-core/src/client/pump.rs @@ -11,9 +11,9 @@ use crate::config::{CompositorPref, GamepadPref, Role}; use crate::error::PunktfunkError; use crate::packet::FLAG_PROBE; use crate::quic::{ - accept_resync, endpoint, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClockEcho, - ClockResync, Hello, HidOutput, LossReport, ProbeRequest, ProbeResult, Reconfigure, - Reconfigured, RequestKeyframe, ResyncStep, SetBitrate, Start, Welcome, + accept_resync, endpoint, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipOffer, + ClipState, ClockEcho, ClockResync, Hello, HidOutput, LossReport, ProbeRequest, ProbeResult, + Reconfigure, Reconfigured, RequestKeyframe, ResyncStep, SetBitrate, Start, Welcome, }; use crate::session::Session; use crate::transport::UdpTransport; @@ -49,6 +49,8 @@ pub(super) async fn run_pump(args: WorkerArgs) { mut rich_input_rx, mut ctrl_rx, ctrl_tx, + clip_event_tx, + clip_cmd_rx, ready_tx, shutdown, quit, @@ -213,6 +215,7 @@ pub(super) async fn run_pump(args: WorkerArgs) { audio_channels: welcome.audio_channels, codec: welcome.codec, shard_payload: welcome.shard_payload, + host_caps: welcome.host_caps, }, welcome.host_caps, )) @@ -413,6 +416,9 @@ pub(super) async fn run_pump(args: WorkerArgs) { let bitrate_ack = bitrate_ack.clone(); let clock_offset = clock_offset.clone(); let clock_gen = clock_gen.clone(); + // The control task feeds clipboard metadata events (ClipState/ClipOffer) onto the same event + // plane the clipboard task uses for fetch data; the original tx goes to that task below. + let clip_event_tx = clip_event_tx.clone(); tokio::spawn(async move { // Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every // CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after @@ -442,6 +448,8 @@ pub(super) async fn run_pump(args: WorkerArgs) { } resync.begin(wall_clock_ns()).encode() } + CtrlRequest::ClipControl(c) => c.encode(), + CtrlRequest::ClipOffer(o) => o.encode(), }; if io::write_msg(&mut ctrl_send, &bytes).await.is_err() { break; @@ -523,6 +531,21 @@ pub(super) async fn run_pump(args: WorkerArgs) { } ResyncStep::Idle => {} } + } else if let Ok(state) = ClipState::decode(&msg) { + // Host ack / policy / backend update for the toggle UI (try_send: a + // lagging embedder drops the newest — a stale toggle heals on the next). + let _ = clip_event_tx.try_send(ClipEventCore::State { + enabled: state.enabled, + policy: state.policy, + reason: state.reason, + }); + } else if let Ok(offer) = ClipOffer::decode(&msg) { + // The host copied something: surface the lazy format list; the embedder + // fetches only if a local app pastes. + let _ = clip_event_tx.try_send(ClipEventCore::RemoteOffer { + seq: offer.seq, + kinds: offer.kinds, + }); } else { tracing::warn!( tag = ?msg.first(), @@ -606,6 +629,17 @@ pub(super) async fn run_pump(args: WorkerArgs) { } }); + // Clipboard task: the fetch-stream accept loop (host pulls what we offered) + outbound fetches + // (we pull what the host offered). Metadata (enable/offer/state) rides the control task above; + // only bulk bytes flow here. Dies with the connection (accept_bi errors) or when the embedder + // drops the command sender. Always spawned — a host without HOST_CAP_CLIPBOARD simply never + // opens a clip stream, and our control-plane offers hit its "unknown message" arm harmlessly. + tokio::spawn(crate::clipboard::run( + conn.clone(), + clip_event_tx, + clip_cmd_rx, + )); + // Watch for connection close → stop the pump. { let shutdown = shutdown.clone(); diff --git a/crates/punktfunk-core/src/client/worker.rs b/crates/punktfunk-core/src/client/worker.rs index b407acd2..25915bb1 100644 --- a/crates/punktfunk-core/src/client/worker.rs +++ b/crates/punktfunk-core/src/client/worker.rs @@ -1,6 +1,7 @@ //! `WorkerArgs` (the pump's constructor payload) and `reject_from_close`. use super::*; +use crate::clipboard::{ClipCommand, ClipEventCore}; use crate::config::{CompositorPref, GamepadPref, Mode}; use crate::error::Result; use crate::input::InputEvent; @@ -38,6 +39,11 @@ pub(crate) struct WorkerArgs { pub(crate) rich_input_rx: tokio::sync::mpsc::UnboundedReceiver, pub(crate) ctrl_rx: tokio::sync::mpsc::Receiver, pub(crate) ctrl_tx: tokio::sync::mpsc::Sender, + /// Inbound clipboard event plane feed — the control task pushes ClipState/ClipOffer, the + /// clipboard task pushes fetch data; drained by [`NativeClient::next_clip`]. + pub(crate) clip_event_tx: SyncSender, + /// Outbound clipboard fetch/serve/cancel commands from the embedder → [`crate::clipboard::run`]. + pub(crate) clip_cmd_rx: tokio::sync::mpsc::UnboundedReceiver, pub(crate) ready_tx: std::sync::mpsc::Sender>, pub(crate) shutdown: Arc, /// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set. diff --git a/crates/punktfunk-core/src/clipboard.rs b/crates/punktfunk-core/src/clipboard.rs new file mode 100644 index 00000000..4ee08a43 --- /dev/null +++ b/crates/punktfunk-core/src/clipboard.rs @@ -0,0 +1,304 @@ +//! Client-side shared-clipboard transport (`design/clipboard-and-file-transfer.md` §5.1). +//! +//! This is the client-core half of Phase 0: the per-session async task that runs the fetch-stream +//! **accept loop** (so the host can pull data the client offered) and drives **outbound fetches** +//! (so the client can pull what the host offered), surfacing everything to the embedder as +//! poll-style [`ClipEventCore`] events. The metadata plane — enabling sync and announcing offers — +//! rides the control stream as ordinary [`ClipControl`]/[`ClipOffer`] control messages +//! ([`crate::client`] routes those); only the bulk bytes flow through here, over the +//! [`crate::quic::clipstream`] fetch bi-streams. +//! +//! There is intentionally **no OS pasteboard code here** — that lives in the native client (macOS +//! first). The event/command seam is what the C ABI ([`crate::abi`]) exposes so a native client +//! polls offers/fetch-requests and answers with bytes. + +use std::collections::HashMap; +use std::sync::mpsc::SyncSender; +use std::sync::{Arc, Mutex}; + +use tokio::sync::mpsc::UnboundedReceiver; +use tokio::sync::oneshot; + +use crate::error::PunktfunkStatus; +use crate::quic::{ + clipstream, ClipFetch, ClipFetchHdr, ClipKind, CLIP_FETCH_DENIED, CLIP_FETCH_OK, + CLIP_FETCH_STALE, CLIP_FETCH_UNAVAILABLE, +}; + +/// Per-fetch requester-side size cap (bytes). A holder that streams more than this is treated as a +/// cap breach and the fetch fails rather than buffering unboundedly (§7). Phase 0 uses one fixed +/// value; a future host-policy `PUNKTFUNK_CLIP_MAX_MB` tightens it per session. +pub const CLIP_FETCH_CAP: usize = 64 << 20; + +/// Inbound-serve `req_id`s carry this high bit so they never collide with the client-assigned +/// outbound-fetch `xfer_id`s (which count up from 1). A single [`ClipCommand::Cancel`] `id` can +/// then be routed to the right table. +pub const INBOUND_REQ_FLAG: u32 = 0x8000_0000; + +/// Overall stall bound for one outbound fetch (`design/clipboard-and-file-transfer.md` §3.4): a +/// holder that never answers fails the transfer instead of hanging it. +const FETCH_STALL_SECS: u64 = 60; + +/// A clipboard event surfaced to the embedder via `NativeClient::next_clip` (→ the C ABI poll +/// `punktfunk_connection_next_clipboard`). +#[derive(Clone, Debug)] +pub enum ClipEventCore { + /// The host announced new clipboard content (the host copied). The embedder decides whether to + /// fetch it — lazily, only when a local app actually pastes. + RemoteOffer { seq: u32, kinds: Vec }, + /// Host ack / unsolicited policy or backend update, for the toggle UI. + State { + enabled: bool, + policy: u8, + reason: u8, + }, + /// The host is pasting content the client offered: it opened a fetch stream for + /// `(mime, file_index)`. The embedder must answer with `clip_serve(req_id, …)` (or + /// `clip_cancel(req_id)`). + FetchRequest { + req_id: u32, + seq: u32, + file_index: u32, + mime: String, + }, + /// Bytes for a fetch the embedder started (`xfer_id` from `clip_fetch`). Phase 0 delivers the + /// whole payload in one event (`last = true`). + Data { + xfer_id: u32, + bytes: Vec, + last: bool, + }, + /// A transfer was cancelled (by either side). + Cancelled { id: u32 }, + /// A transfer failed; `code` is a [`PunktfunkStatus`] value (negative). + Error { id: u32, code: i32 }, +} + +/// A command from the embedder (via the C ABI) into the clipboard task. `ClipControl`/`ClipOffer` +/// are *not* here — they ride the control stream as ordinary control messages. +pub enum ClipCommand { + /// Open a fetch of the remote's offered content; `xfer_id` is client-assigned and echoed back + /// on the resulting [`ClipEventCore::Data`]/`Error`/`Cancelled`. + Fetch { + xfer_id: u32, + seq: u32, + file_index: u32, + mime: String, + }, + /// Provide bytes answering an inbound [`ClipEventCore::FetchRequest`] (`req_id`). Chunks + /// accumulate; `last` completes the transfer. + Serve { + req_id: u32, + bytes: Vec, + last: bool, + }, + /// Cancel a transfer by id — either an outbound fetch (`xfer_id`) or an inbound serve + /// (`req_id`, high bit set). + Cancel { id: u32 }, +} + +type ServeWaiters = Arc>>>>>; + +/// Map a non-OK [`ClipFetchHdr::status`] to the [`PunktfunkStatus`] surfaced on an +/// [`ClipEventCore::Error`]. +fn fetch_status_to_code(status: u8) -> i32 { + let s = match status { + CLIP_FETCH_STALE => PunktfunkStatus::NoFrame, // stale offer → "nothing to insert" + CLIP_FETCH_UNAVAILABLE => PunktfunkStatus::Unsupported, + CLIP_FETCH_DENIED => PunktfunkStatus::InvalidArg, + _ => PunktfunkStatus::BadPacket, + }; + s as i32 +} + +/// The per-session clipboard task. Runs until the connection closes or the embedder drops the +/// command sender. It owns no clipboard *content* — the embedder supplies bytes on demand. +pub async fn run( + conn: quinn::Connection, + events: SyncSender, + mut cmd_rx: UnboundedReceiver, +) { + // Inbound fetch-serve waiters: req_id -> a oneshot the serving stream task parks on until the + // embedder answers with bytes (`Some`) or denies/cancels (`None`). + let serve_waiters: ServeWaiters = Arc::new(Mutex::new(HashMap::new())); + // Accumulation buffers for chunked `clip_serve` (req_id -> bytes so far). + let mut serve_bufs: HashMap> = HashMap::new(); + // Outbound fetch cancel triggers: xfer_id -> a oneshot that aborts the fetch task. + let mut fetch_cancels: HashMap> = HashMap::new(); + let mut next_req_id: u32 = 1; + + loop { + tokio::select! { + // The host opened a fetch bi-stream toward us (it is pasting our offered data). + accepted = conn.accept_bi() => { + let Ok((send, recv)) = accepted else { break }; // connection gone + let req_id = INBOUND_REQ_FLAG | next_req_id; + next_req_id = next_req_id.wrapping_add(1); + if next_req_id == 0 { + next_req_id = 1; + } + let events = events.clone(); + let waiters = serve_waiters.clone(); + tokio::spawn(serve_inbound(send, recv, req_id, events, waiters)); + } + cmd = cmd_rx.recv() => { + let Some(cmd) = cmd else { break }; // NativeClient dropped + match cmd { + ClipCommand::Fetch { xfer_id, seq, file_index, mime } => { + let (cancel_tx, cancel_rx) = oneshot::channel(); + fetch_cancels.insert(xfer_id, cancel_tx); + let conn = conn.clone(); + let events = events.clone(); + let req = ClipFetch { seq, file_index, mime }; + tokio::spawn(run_outbound_fetch(conn, xfer_id, req, events, cancel_rx)); + } + ClipCommand::Serve { req_id, bytes, last } => { + serve_bufs.entry(req_id).or_default().extend_from_slice(&bytes); + if last { + let full = serve_bufs.remove(&req_id).unwrap_or_default(); + if let Some(tx) = serve_waiters.lock().unwrap().remove(&req_id) { + let _ = tx.send(Some(full)); + } + } + } + ClipCommand::Cancel { id } => { + // Route to whichever table owns the id (they are disjoint by the high bit). + if let Some(tx) = fetch_cancels.remove(&id) { + let _ = tx.send(()); + } + serve_bufs.remove(&id); + if let Some(tx) = serve_waiters.lock().unwrap().remove(&id) { + let _ = tx.send(None); // deny — the serving task writes UNAVAILABLE + } + } + } + } + } + } +} + +/// Serve one inbound fetch stream: validate the header + request, emit a [`ClipEventCore::FetchRequest`], +/// then park until the embedder supplies bytes (or denies), and stream them back. +async fn serve_inbound( + mut send: quinn::SendStream, + mut recv: quinn::RecvStream, + req_id: u32, + events: SyncSender, + waiters: ServeWaiters, +) { + let _ = send.set_priority(-1); + let kind = match clipstream::read_stream_header(&mut recv).await { + Ok(k) => k, + Err(_) => return, + }; + if kind != clipstream::CLIP_STREAM_KIND_FETCH { + let _ = send.reset(clipstream::cancelled_code()); + return; + } + let req = match clipstream::read_fetch(&mut recv).await { + Ok(r) => r, + Err(_) => return, + }; + + // Register the waiter before emitting the event, so an immediate `clip_serve` can't race ahead + // of the insert. + let (tx, rx) = oneshot::channel(); + waiters.lock().unwrap().insert(req_id, tx); + let ev = ClipEventCore::FetchRequest { + req_id, + seq: req.seq, + file_index: req.file_index, + mime: req.mime, + }; + if events.try_send(ev).is_err() { + // The embedder isn't draining events (or the session is ending): refuse cleanly. + waiters.lock().unwrap().remove(&req_id); + let _ = clipstream::write_fetch_hdr( + &mut send, + &ClipFetchHdr { + status: CLIP_FETCH_UNAVAILABLE, + total_size: 0, + }, + ) + .await; + return; + } + + match rx.await { + Ok(Some(bytes)) => { + if clipstream::write_fetch_hdr( + &mut send, + &ClipFetchHdr { + status: CLIP_FETCH_OK, + total_size: bytes.len() as u64, + }, + ) + .await + .is_ok() + { + let _ = clipstream::write_data(&mut send, &bytes).await; + } + } + // Denied, cancelled, or the waiter was dropped (task ending): tell the peer it's gone. + _ => { + let _ = clipstream::write_fetch_hdr( + &mut send, + &ClipFetchHdr { + status: CLIP_FETCH_UNAVAILABLE, + total_size: 0, + }, + ) + .await; + } + } +} + +/// Drive one outbound fetch: open the stream, read the header + data (bounded), and emit a +/// [`ClipEventCore::Data`] / `Error` / `Cancelled`. +async fn run_outbound_fetch( + conn: quinn::Connection, + xfer_id: u32, + req: ClipFetch, + events: SyncSender, + cancel_rx: oneshot::Receiver<()>, +) { + let transfer = async { + let (send, mut recv) = clipstream::open_fetch(&conn, &req) + .await + .map_err(|_| PunktfunkStatus::Io as i32)?; + let hdr = clipstream::read_fetch_hdr(&mut recv) + .await + .map_err(|_| PunktfunkStatus::Io as i32)?; + if hdr.status != CLIP_FETCH_OK { + return Err(fetch_status_to_code(hdr.status)); + } + let data = clipstream::read_data(&mut recv, CLIP_FETCH_CAP) + .await + .map_err(|_| PunktfunkStatus::Io as i32)?; + drop(send); // done — dropping the send half is a clean FIN-less close on our side + Ok(data) + }; + + tokio::select! { + r = transfer => match r { + Ok(data) => { + let _ = events.try_send(ClipEventCore::Data { xfer_id, bytes: data, last: true }); + } + Err(code) => { + let _ = events.try_send(ClipEventCore::Error { id: xfer_id, code }); + } + }, + _ = cancel_rx => { + // The `transfer` future is dropped here; its streams reset on drop. + let _ = events.try_send(ClipEventCore::Cancelled { id: xfer_id }); + } + // Overall stall bound (design/clipboard-and-file-transfer.md §3.4): a holder that never + // answers must not hang the transfer. Dropping `transfer` resets the streams. + _ = tokio::time::sleep(std::time::Duration::from_secs(FETCH_STALL_SECS)) => { + let _ = events.try_send(ClipEventCore::Error { + id: xfer_id, + code: PunktfunkStatus::Timeout as i32, + }); + } + } +} diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index f3aabfd5..d5a0dc50 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -30,6 +30,11 @@ mod abr; pub mod audio; #[cfg(feature = "quic")] pub mod client; +/// Client-side shared-clipboard transport: the per-session task that runs the fetch-stream accept +/// loop, drives outbound fetches, and serves inbound ones — surfaced to the embedder as poll +/// events. Wire codecs live in [`quic`]; the OS pasteboard integration lives in the native client. +#[cfg(feature = "quic")] +pub mod clipboard; pub mod config; pub mod crypto; pub mod error; @@ -73,7 +78,11 @@ pub use stats::Stats; /// application close) and the `PunktfunkStatus` −20 block itself. Additive — the close codes are /// new application-close vocabulary an old peer simply never sends/reads, so [`WIRE_VERSION`] is /// unchanged. -pub const ABI_VERSION: u32 = 7; +/// v8: added the shared-clipboard client surface — `punktfunk_connection_host_caps` and +/// `punktfunk_connection_clipboard_{control,offer,fetch,serve,cancel}` + +/// `punktfunk_connection_next_clipboard`. Additive; the wire grows only backward-compatible control +/// messages (0x40-0x44) and a new `Welcome::host_caps` bit, so [`WIRE_VERSION`] is unchanged. +pub const ABI_VERSION: u32 = 8; /// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. /// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** diff --git a/crates/punktfunk-core/src/quic/caps.rs b/crates/punktfunk-core/src/quic/caps.rs index 9181b625..f33cff98 100644 --- a/crates/punktfunk-core/src/quic/caps.rs +++ b/crates/punktfunk-core/src/quic/caps.rs @@ -40,6 +40,14 @@ pub const VIDEO_CAP_PROBE_SEQ: u8 = 0x10; /// button/axis events; toward a host that doesn't set the bit it keeps the legacy events. pub const HOST_CAP_GAMEPAD_STATE: u8 = 0x01; +/// [`Welcome::host_caps`] bit: the host has a shared-clipboard service (a working OS backend) +/// **and** its operator policy does not hard-disable it, so the client may offer the clipboard +/// toggle. Absent (an older host, or `PUNKTFUNK_CLIPBOARD` off) ⇒ the client greys the toggle +/// out. Purely additive: nothing clipboard-related happens until a [`ClipControl`]`{ enabled: +/// true }` crosses (see `design/clipboard-and-file-transfer.md` §3.1). Packs into the existing +/// trailing `host_caps` byte — no wire-layout change. +pub const HOST_CAP_CLIPBOARD: u8 = 0x02; + /// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** /// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST /// advertise this. diff --git a/crates/punktfunk-core/src/quic/clipstream.rs b/crates/punktfunk-core/src/quic/clipstream.rs new file mode 100644 index 00000000..b89d80eb --- /dev/null +++ b/crates/punktfunk-core/src/quic/clipstream.rs @@ -0,0 +1,117 @@ +//! Per-transfer clipboard fetch streams (`design/clipboard-and-file-transfer.md` §3.3). +//! +//! Bulk clipboard / file bytes never ride the control stream (u16-capped) or datagrams (lossy, +//! single-packet). The **requester opens a fresh QUIC bi-stream** toward the data holder, writes a +//! small stream header + a [`ClipFetch`]; the holder replies with a [`ClipFetchHdr`] then raw data +//! chunks until FIN. One transfer per stream ⇒ natural flow control, clean cancelation +//! (`RESET_STREAM`), and no head-of-line blocking against control or other transfers. +//! +//! These helpers are the transport half only; they hold no clipboard state, so the host and the +//! client-core reuse the exact same open/accept/serve wire dance (the accept-loop that dispatches +//! by stream kind lives on each side, since the two sides own their connections differently). + +use super::{io, ClipFetch, ClipFetchHdr}; + +/// First bytes an opener writes on a freshly-opened clipboard bi-stream: a magic keeping this +/// stream namespace disjoint from any future stream kind, plus a 1-byte kind discriminator. A +/// distinct magic means a stream opened for some other future purpose can never be misrouted here. +pub const STREAM_MAGIC: &[u8; 4] = b"PKFs"; + +/// Stream-kind byte: a clipboard fetch (request/response of one format). Future stream kinds +/// (e.g. a bulk file-content push) mux under the same [`STREAM_MAGIC`] with a different byte. +pub const CLIP_STREAM_KIND_FETCH: u8 = 0x01; + +/// QUIC application error code used to `reset`/`stop` a clipboard fetch stream on cancel — sync +/// disabled mid-transfer, paste timed out, size cap exceeded, teardown. Distinct from the +/// connection close codes ([`super::QUIT_CLOSE_CODE`] `0x51` / [`super::APP_EXITED_CLOSE_CODE`] +/// `0x52`), the connection reject code `0x42`, and the pairing-rejection close block +/// `0x60`–`0x67` — stream reset codes and connection close codes are separate QUIC namespaces, +/// but the vocabularies stay disjoint on purpose so a captured code is unambiguous. +pub const CLIP_CANCELLED_CODE: u32 = 0x70; + +/// Chunk size for streaming fetch data (64 KiB writes — matches the control-frame bound). +pub const CLIP_CHUNK: usize = 64 * 1024; + +/// The `VarInt` form of [`CLIP_CANCELLED_CODE`], for `SendStream::reset` / `RecvStream::stop`. +pub fn cancelled_code() -> quinn::VarInt { + quinn::VarInt::from_u32(CLIP_CANCELLED_CODE) +} + +/// Requester side: open a fresh bi-stream toward the holder, deprioritize it under the control +/// stream, write the stream header + the [`ClipFetch`], and hand back both halves. The send half +/// is returned so the caller can `reset`/`finish` for cancelation; the recv half is positioned to +/// read the [`ClipFetchHdr`] next (see [`read_fetch_hdr`]). +pub async fn open_fetch( + conn: &quinn::Connection, + req: &ClipFetch, +) -> std::io::Result<(quinn::SendStream, quinn::RecvStream)> { + let (mut send, recv) = conn.open_bi().await.map_err(std::io::Error::other)?; + // Yield to the control stream (default priority 0) so a large paste never head-of-line-blocks + // the input/audio/control traffic sharing this connection. + let _ = send.set_priority(-1); + // The opener MUST write before the peer's `accept_bi()` can return (quinn contract), so send + // the whole request eagerly. + let mut hdr = Vec::with_capacity(5); + hdr.extend_from_slice(STREAM_MAGIC); + hdr.push(CLIP_STREAM_KIND_FETCH); + send.write_all(&hdr).await.map_err(std::io::Error::other)?; + io::write_msg(&mut send, &req.encode()).await?; + Ok((send, recv)) +} + +/// Holder side, step 1: after `accept_bi()`, read and validate the 5-byte stream header. Returns +/// the kind byte (e.g. [`CLIP_STREAM_KIND_FETCH`]); an unknown magic is an error and the caller +/// should `stop` the stream. +pub async fn read_stream_header(recv: &mut quinn::RecvStream) -> std::io::Result { + let mut hdr = [0u8; 5]; + recv.read_exact(&mut hdr) + .await + .map_err(std::io::Error::other)?; + if &hdr[0..4] != STREAM_MAGIC { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "bad clip stream magic", + )); + } + Ok(hdr[4]) +} + +/// Holder side, step 2: read the [`ClipFetch`] request that follows the header. +pub async fn read_fetch(recv: &mut quinn::RecvStream) -> std::io::Result { + let raw = io::read_msg(recv).await?; + ClipFetch::decode(&raw) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "bad ClipFetch")) +} + +/// Holder side, step 3: send the response header (before any data chunks). +pub async fn write_fetch_hdr( + send: &mut quinn::SendStream, + hdr: &ClipFetchHdr, +) -> std::io::Result<()> { + io::write_msg(send, &hdr.encode()).await +} + +/// Holder side, step 4 (only when the header was [`super::CLIP_FETCH_OK`]): stream `data` as +/// 64 KiB chunks then FIN so the requester's [`read_data`] terminates. +pub async fn write_data(send: &mut quinn::SendStream, data: &[u8]) -> std::io::Result<()> { + for chunk in data.chunks(CLIP_CHUNK) { + send.write_all(chunk).await.map_err(std::io::Error::other)?; + } + send.finish().map_err(std::io::Error::other)?; + Ok(()) +} + +/// Requester side: read the [`ClipFetchHdr`] the holder sends before any data chunks. +pub async fn read_fetch_hdr(recv: &mut quinn::RecvStream) -> std::io::Result { + let raw = io::read_msg(recv).await?; + ClipFetchHdr::decode(&raw) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "bad ClipFetchHdr")) +} + +/// Requester side: after an OK [`ClipFetchHdr`], drain the data chunks to a `Vec`, bounded by +/// `max_bytes` (the requester's size cap — a breach errors, and the caller resets the stream). +pub async fn read_data(recv: &mut quinn::RecvStream, max_bytes: usize) -> std::io::Result> { + recv.read_to_end(max_bytes) + .await + .map_err(std::io::Error::other) +} diff --git a/crates/punktfunk-core/src/quic/control.rs b/crates/punktfunk-core/src/quic/control.rs index 6b2a837d..8ea6c15f 100644 --- a/crates/punktfunk-core/src/quic/control.rs +++ b/crates/punktfunk-core/src/quic/control.rs @@ -468,3 +468,317 @@ pub fn frame(payload: &[u8]) -> Vec { b.extend_from_slice(payload); b } + +// --------------------------------------------------------------------------------------------- +// Shared clipboard & file transfer — wire codecs (ported from the pre-W7 quic/msgs.rs on the +// clipboard-feature merge; the control-stream metadata messages live beside the clock codecs). +// --------------------------------------------------------------------------------------------- + +// --------------------------------------------------------------------------------------------- +// Shared clipboard & file transfer (design/clipboard-and-file-transfer.md §3). The small +// metadata messages ride the control stream (0x40-0x42); the two fetch-stream messages +// (0x43-0x44) travel on a per-transfer bi-stream (see the [`super::clipstream`] helpers), never +// the control stream, so they are never dispatched by the control loops. All are typed +// (`CTL_MAGIC` + type byte), so an older peer hits its "unknown control message" arm and drops +// any it doesn't know — the whole feature is forward-safe. +// --------------------------------------------------------------------------------------------- + +/// Type byte of [`ClipControl`] (client → host): enable/disable the shared clipboard for this +/// session. Idempotent; opt-in is enforced here, not just in UI. +pub const MSG_CLIP_CONTROL: u8 = 0x40; +/// Type byte of [`ClipState`] (host → client): ack + unsolicited policy/backend updates. +pub const MSG_CLIP_STATE: u8 = 0x41; +/// Type byte of [`ClipOffer`] (symmetric): the lazy announcement — format list only, no bytes. +pub const MSG_CLIP_OFFER: u8 = 0x42; +/// Type byte of [`ClipFetch`] (requester → holder, **fetch stream only**): pull one format of the +/// current offer. +pub const MSG_CLIP_FETCH: u8 = 0x43; +/// Type byte of [`ClipFetchHdr`] (holder → requester, **fetch stream only**): the fetch response +/// header that precedes the data chunks. +pub const MSG_CLIP_FETCH_HDR: u8 = 0x44; + +/// [`ClipControl::flags`] bit: the client permits file kinds to be offered/fetched this session. +/// Absent ⇒ files are filtered out of offers in both directions (text/rich/image only). +pub const CLIP_FLAG_FILES: u8 = 0x01; + +/// [`ClipState::policy`] bit: the host permits non-file formats (text/RTF/HTML/image). Always set +/// while enabled unless a future direction limit clears it. +pub const CLIP_POLICY_TEXT: u8 = 0x01; +/// [`ClipState::policy`] bit: the host permits file formats. Cleared by the operator `no-files` +/// / `text-only` policy so the client can grey out "Include files". +pub const CLIP_POLICY_FILES: u8 = 0x02; + +/// [`ClipState::reason`]: normal ack, nothing exceptional. +pub const CLIP_REASON_OK: u8 = 0; +/// [`ClipState::reason`]: this session type has no working clipboard backend (e.g. a gamescope +/// session with no data-control global) — the client shows "not supported in this session type". +pub const CLIP_REASON_BACKEND_UNAVAILABLE: u8 = 1; +/// [`ClipState::reason`]: another client took over the single per-desktop clipboard binding; this +/// one was disabled (last `ClipControl{enabled}` wins). +pub const CLIP_REASON_TAKEN_OVER: u8 = 2; +/// [`ClipState::reason`]: the host operator policy (`PUNKTFUNK_CLIPBOARD=off`) disables clipboard. +pub const CLIP_REASON_POLICY_DISABLED: u8 = 3; +/// [`ClipState::reason`]: enabled, but the host policy forbids file transfer (`no-files` / +/// `text-only`) — surfaced so the client greys "Include files" with a footnote. +pub const CLIP_REASON_NO_FILES: u8 = 4; + +/// [`ClipFetchHdr::status`]: the requested format is being served; data chunks follow until FIN. +pub const CLIP_FETCH_OK: u8 = 0; +/// [`ClipFetchHdr::status`]: the fetch named a `seq` that is no longer the holder's current offer; +/// the requester degrades the paste to "nothing inserted" rather than wrong data. No chunks follow. +pub const CLIP_FETCH_STALE: u8 = 1; +/// [`ClipFetchHdr::status`]: the format/index is not available (no backend, or it vanished). No +/// chunks follow. +pub const CLIP_FETCH_UNAVAILABLE: u8 = 2; +/// [`ClipFetchHdr::status`]: policy/cap denies this fetch (e.g. a file fetch under `no-files`). No +/// chunks follow. +pub const CLIP_FETCH_DENIED: u8 = 3; + +/// Maximum number of [`ClipKind`] entries in one [`ClipOffer`] (resource cap, §7). +pub const CLIP_MAX_KINDS: usize = 16; +/// Maximum length in bytes of a [`ClipKind::mime`] string (resource cap, §7). +pub const CLIP_MAX_MIME: usize = 128; +/// [`ClipFetch::file_index`] sentinel meaning "not a file fetch" (a whole non-file format, or the +/// file *manifest* itself). Real file fetches use `0..n`. +pub const CLIP_FILE_INDEX_NONE: u32 = u32::MAX; + +/// One advertised clipboard format inside a [`ClipOffer`] — a portable MIME name plus a size hint. +/// The bytes never ride here; they cross lazily on a fetch stream only when the destination pastes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClipKind { + /// Portable wire MIME, e.g. `text/plain;charset=utf-8`, `text/html`, `image/png`, + /// `application/x-punktfunk-files`. Each end maps it to a platform type at fetch time. ≤ + /// [`CLIP_MAX_MIME`] bytes; a longer one is rejected on decode. + pub mime: String, + /// Best-effort total size of this format in bytes; `0` = unknown (a streaming provider). + pub size_hint: u64, +} + +/// `client → host` ([`MSG_CLIP_CONTROL`]): flip the shared clipboard on/off for this session. +/// Sent when the user toggles the per-host pref and once at session start if it is on. **Nothing +/// clipboard-related happens on either side until an `enabled: true` arrives** — opt-in at the +/// protocol layer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ClipControl { + pub enabled: bool, + /// Bitfield of [`CLIP_FLAG_FILES`] (+ reserved bits for future direction limits). + pub flags: u8, +} + +/// `host → client` ([`MSG_CLIP_STATE`]): acknowledge a [`ClipControl`] and push unsolicited +/// updates (policy changed, backend lost). The client surfaces `reason`/`policy` in the toggle UI +/// instead of failing silently. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ClipState { + pub enabled: bool, + /// Bitfield of [`CLIP_POLICY_TEXT`] / [`CLIP_POLICY_FILES`] — what the host currently permits. + pub policy: u8, + /// One of the `CLIP_REASON_*` values explaining `enabled`/`policy`. + pub reason: u8, +} + +/// Symmetric ([`MSG_CLIP_OFFER`], either direction): the lazy announcement. Sent when the local +/// clipboard changes; carries the **format list only** (comfortably inside the 64 KiB control +/// frame). A new offer replaces the sender's previous one; `seq` lets the holder reject stale +/// fetches (§3.4). Files are announced as one `application/x-punktfunk-files` kind — the file +/// list itself is fetched lazily, never inlined here. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClipOffer { + /// Monotonic per sender; newest wins. + pub seq: u32, + /// ≤ [`CLIP_MAX_KINDS`] entries. + pub kinds: Vec, +} + +/// `requester → holder` ([`MSG_CLIP_FETCH`], **fetch stream only**): the first message on a +/// per-transfer bi-stream, naming which format (and, for files, which entry) of `seq` to pull. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClipFetch { + /// The offer `seq` this fetch is against; the holder answers [`CLIP_FETCH_STALE`] if it is no + /// longer current. + pub seq: u32, + /// File index for a file transfer, or [`CLIP_FILE_INDEX_NONE`] for a non-file format / the + /// file manifest. + pub file_index: u32, + /// The requested wire MIME (≤ [`CLIP_MAX_MIME`] bytes). + pub mime: String, +} + +/// `holder → requester` ([`MSG_CLIP_FETCH_HDR`], **fetch stream only**): the response header that +/// precedes the raw data chunks (which run until the stream's FIN). When `status` is anything +/// other than [`CLIP_FETCH_OK`] no chunks follow. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ClipFetchHdr { + /// One of the `CLIP_FETCH_*` values. + pub status: u8, + /// Total byte count that will follow; `0` = unknown (a streaming provider — FIN ends it). + pub total_size: u64, +} + +/// Append one [`ClipKind`] to `b`: `mime_len u8 || mime bytes || size_hint u64 LE`. +fn put_clip_kind(b: &mut Vec, k: &ClipKind) { + let mime = k.mime.as_bytes(); + let n = mime.len().min(CLIP_MAX_MIME); + b.push(n as u8); + b.extend_from_slice(&mime[..n]); + b.extend_from_slice(&k.size_hint.to_le_bytes()); +} + +/// Read one [`ClipKind`] at `off`, returning it and the next offset. +fn get_clip_kind(b: &[u8], off: usize) -> Result<(ClipKind, usize)> { + if off >= b.len() { + return Err(PunktfunkError::InvalidArg("truncated ClipKind")); + } + let n = b[off] as usize; + if n > CLIP_MAX_MIME { + return Err(PunktfunkError::InvalidArg("ClipKind mime too long")); + } + let mime_start = off + 1; + let size_start = mime_start + n; + if size_start + 8 > b.len() { + return Err(PunktfunkError::InvalidArg("ClipKind overruns message")); + } + let mime = String::from_utf8_lossy(&b[mime_start..size_start]).into_owned(); + let size_hint = u64::from_le_bytes(b[size_start..size_start + 8].try_into().unwrap()); + Ok((ClipKind { mime, size_hint }, size_start + 8)) +} + +impl ClipControl { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] enabled[5] flags[6] + let mut b = Vec::with_capacity(7); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_CLIP_CONTROL); + b.push(self.enabled as u8); + b.push(self.flags); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() != 7 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CLIP_CONTROL { + return Err(PunktfunkError::InvalidArg("bad ClipControl")); + } + Ok(ClipControl { + enabled: b[5] != 0, + flags: b[6], + }) + } +} + +impl ClipState { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] enabled[5] policy[6] reason[7] + let mut b = Vec::with_capacity(8); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_CLIP_STATE); + b.push(self.enabled as u8); + b.push(self.policy); + b.push(self.reason); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() != 8 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CLIP_STATE { + return Err(PunktfunkError::InvalidArg("bad ClipState")); + } + Ok(ClipState { + enabled: b[5] != 0, + policy: b[6], + reason: b[7], + }) + } +} + +impl ClipOffer { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] seq[5..9] count[9] then `count` ClipKinds + let mut b = Vec::with_capacity(10 + self.kinds.len() * 16); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_CLIP_OFFER); + b.extend_from_slice(&self.seq.to_le_bytes()); + let count = self.kinds.len().min(CLIP_MAX_KINDS); + b.push(count as u8); + for k in &self.kinds[..count] { + put_clip_kind(&mut b, k); + } + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() < 10 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CLIP_OFFER { + return Err(PunktfunkError::InvalidArg("bad ClipOffer")); + } + let seq = u32::from_le_bytes(b[5..9].try_into().unwrap()); + let count = b[9] as usize; + if count > CLIP_MAX_KINDS { + return Err(PunktfunkError::InvalidArg("ClipOffer too many kinds")); + } + let mut kinds = Vec::with_capacity(count); + let mut off = 10; + for _ in 0..count { + let (k, next) = get_clip_kind(b, off)?; + kinds.push(k); + off = next; + } + if off != b.len() { + return Err(PunktfunkError::InvalidArg("trailing bytes")); + } + Ok(ClipOffer { seq, kinds }) + } +} + +impl ClipFetch { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] seq[5..9] file_index[9..13] mime(len u8 || bytes)[13..] + let mime = self.mime.as_bytes(); + let n = mime.len().min(CLIP_MAX_MIME); + let mut b = Vec::with_capacity(14 + n); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_CLIP_FETCH); + b.extend_from_slice(&self.seq.to_le_bytes()); + b.extend_from_slice(&self.file_index.to_le_bytes()); + b.push(n as u8); + b.extend_from_slice(&mime[..n]); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() < 14 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CLIP_FETCH { + return Err(PunktfunkError::InvalidArg("bad ClipFetch")); + } + let seq = u32::from_le_bytes(b[5..9].try_into().unwrap()); + let file_index = u32::from_le_bytes(b[9..13].try_into().unwrap()); + let n = b[13] as usize; + if n > CLIP_MAX_MIME || b.len() != 14 + n { + return Err(PunktfunkError::InvalidArg("bad ClipFetch mime")); + } + let mime = String::from_utf8_lossy(&b[14..14 + n]).into_owned(); + Ok(ClipFetch { + seq, + file_index, + mime, + }) + } +} + +impl ClipFetchHdr { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] status[5] total_size[6..14] + let mut b = Vec::with_capacity(14); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_CLIP_FETCH_HDR); + b.push(self.status); + b.extend_from_slice(&self.total_size.to_le_bytes()); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() != 14 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CLIP_FETCH_HDR { + return Err(PunktfunkError::InvalidArg("bad ClipFetchHdr")); + } + Ok(ClipFetchHdr { + status: b[5], + total_size: u64::from_le_bytes(b[6..14].try_into().unwrap()), + }) + } +} diff --git a/crates/punktfunk-core/src/quic/mod.rs b/crates/punktfunk-core/src/quic/mod.rs index 8cf231aa..70f6114d 100644 --- a/crates/punktfunk-core/src/quic/mod.rs +++ b/crates/punktfunk-core/src/quic/mod.rs @@ -52,6 +52,10 @@ pub mod endpoint; /// Async framed-message IO over a quinn stream (`u16 LE length || payload`). pub mod io; +/// Per-transfer clipboard fetch bi-streams (`PKFs` magic + kind byte, then request/response). The +/// transport half of the shared clipboard; wire codecs are in [`control`], state lives per side. +pub mod clipstream; + /// SPAKE2 over Ed25519 for the pairing ceremony. The two roles use the asymmetric flow so /// the identities are ordered; each side binds **both** certificate fingerprints as the /// SPAKE2 identities, so the derived key only matches when client and host agree on the PIN diff --git a/crates/punktfunk-core/src/quic/tests.rs b/crates/punktfunk-core/src/quic/tests.rs index af08942b..da3c74e9 100644 --- a/crates/punktfunk-core/src/quic/tests.rs +++ b/crates/punktfunk-core/src/quic/tests.rs @@ -1316,3 +1316,390 @@ fn fingerprint_is_sha256_of_der() { assert_eq!(a, endpoint::cert_fingerprint(b"cert-a")); assert_ne!(a, endpoint::cert_fingerprint(b"cert-b")); } + +// ---- Shared clipboard control + fetch-stream message codecs (0x40-0x44) ----------------------- + +#[test] +fn clip_control_roundtrip() { + for (enabled, flags) in [ + (true, 0u8), + (false, 0), + (true, CLIP_FLAG_FILES), + (false, 0xFF), + ] { + let m = ClipControl { enabled, flags }; + assert_eq!(ClipControl::decode(&m.encode()).unwrap(), m); + } + // Disjoint from its host→client sibling (type byte + length) and exact length. + assert!(ClipControl::decode( + &ClipState { + enabled: true, + policy: 0, + reason: 0 + } + .encode() + ) + .is_err()); + let bytes = ClipControl { + enabled: true, + flags: 0, + } + .encode(); + assert!(ClipControl::decode(&[bytes.as_slice(), &[0]].concat()).is_err()); + assert!(ClipControl::decode(&bytes[..bytes.len() - 1]).is_err()); +} + +#[test] +fn clip_state_roundtrip() { + let cases = [ + ClipState { + enabled: true, + policy: CLIP_POLICY_TEXT | CLIP_POLICY_FILES, + reason: CLIP_REASON_OK, + }, + ClipState { + enabled: false, + policy: 0, + reason: CLIP_REASON_BACKEND_UNAVAILABLE, + }, + ClipState { + enabled: true, + policy: CLIP_POLICY_TEXT, + reason: CLIP_REASON_NO_FILES, + }, + ]; + for m in cases { + assert_eq!(ClipState::decode(&m.encode()).unwrap(), m); + } + // A ClipControl must not decode as a ClipState (type byte). + assert!(ClipState::decode( + &ClipControl { + enabled: true, + flags: 0 + } + .encode() + ) + .is_err()); + let bytes = cases[0].encode(); + assert!(ClipState::decode(&bytes[..bytes.len() - 1]).is_err()); +} + +#[test] +fn clip_offer_roundtrip() { + // Empty offer, one kind, and a full multi-format offer (text/rich/image/files). + let cases = [ + ClipOffer { + seq: 0, + kinds: vec![], + }, + ClipOffer { + seq: 1, + kinds: vec![ClipKind { + mime: "text/plain;charset=utf-8".into(), + size_hint: 12, + }], + }, + ClipOffer { + seq: u32::MAX, + kinds: vec![ + ClipKind { + mime: "text/plain;charset=utf-8".into(), + size_hint: 0, + }, + ClipKind { + mime: "text/html".into(), + size_hint: 4096, + }, + ClipKind { + mime: "image/png".into(), + size_hint: 1 << 30, + }, + ClipKind { + mime: "application/x-punktfunk-files".into(), + size_hint: 5_000_000_000, + }, + ], + }, + ]; + for m in &cases { + assert_eq!(&ClipOffer::decode(&m.encode()).unwrap(), m); + } + // Trailing bytes are rejected (get_clip_kind consumes exactly to the end). + let mut padded = cases[1].encode(); + padded.push(0); + assert!(ClipOffer::decode(&padded).is_err()); + // A count byte over the cap is rejected before allocating. + let mut over = cases[0].encode(); + over[9] = (CLIP_MAX_KINDS + 1) as u8; + assert!(ClipOffer::decode(&over).is_err()); + // Disjoint from a same-family control message. + assert!(ClipOffer::decode( + &ClipControl { + enabled: true, + flags: 0 + } + .encode() + ) + .is_err()); +} + +#[test] +fn clip_fetch_roundtrip() { + let cases = [ + ClipFetch { + seq: 1, + file_index: CLIP_FILE_INDEX_NONE, + mime: "text/plain;charset=utf-8".into(), + }, + ClipFetch { + seq: 7, + file_index: 0, + mime: "application/x-punktfunk-files".into(), + }, + ClipFetch { + seq: u32::MAX, + file_index: 41, + mime: String::new(), + }, + ]; + for m in &cases { + assert_eq!(&ClipFetch::decode(&m.encode()).unwrap(), m); + } + // Trailing + truncation both rejected (exact-length mime check). + let bytes = cases[0].encode(); + assert!(ClipFetch::decode(&[bytes.as_slice(), &[0]].concat()).is_err()); + assert!(ClipFetch::decode(&bytes[..bytes.len() - 1]).is_err()); + // A fetch-stream message must not decode as a control-stream offer, and vice-versa. + assert!(ClipOffer::decode(&cases[0].encode()).is_err()); + assert!(ClipFetch::decode( + &ClipOffer { + seq: 1, + kinds: vec![] + } + .encode() + ) + .is_err()); +} + +#[test] +fn clip_fetch_hdr_roundtrip() { + for (status, total_size) in [ + (CLIP_FETCH_OK, 15u64), + (CLIP_FETCH_STALE, 0), + (CLIP_FETCH_UNAVAILABLE, 0), + (CLIP_FETCH_DENIED, 0), + (CLIP_FETCH_OK, u64::MAX), + ] { + let m = ClipFetchHdr { status, total_size }; + assert_eq!(ClipFetchHdr::decode(&m.encode()).unwrap(), m); + } + let bytes = ClipFetchHdr { + status: CLIP_FETCH_OK, + total_size: 1, + } + .encode(); + assert!(ClipFetchHdr::decode(&[bytes.as_slice(), &[0]].concat()).is_err()); + assert!(ClipFetchHdr::decode(&bytes[..bytes.len() - 1]).is_err()); +} + +#[test] +fn host_cap_clipboard_bit_is_distinct_and_survives_welcome() { + // The new cap packs into the existing trailing host_caps byte with no layout change. + assert_ne!(HOST_CAP_CLIPBOARD, HOST_CAP_GAMEPAD_STATE); + let mut w = Welcome { + abi_version: 1, + udp_port: 1, + mode: Mode { + width: 1920, + height: 1080, + refresh_hz: 60, + }, + fec: FecConfig { + scheme: FecScheme::Gf16, + fec_percent: 0, + max_data_per_block: 1024, + }, + shard_payload: 1024, + encrypt: false, + key: [0; 16], + salt: [0; 4], + frames: 0, + compositor: CompositorPref::Auto, + gamepad: GamepadPref::Auto, + bitrate_kbps: 0, + bit_depth: 8, + color: ColorInfo::SDR_BT709, + chroma_format: CHROMA_IDC_420, + audio_channels: 2, + codec: CODEC_HEVC, + host_caps: HOST_CAP_GAMEPAD_STATE | HOST_CAP_CLIPBOARD, + }; + let got = Welcome::decode(&w.encode()).unwrap(); + assert_eq!(got.host_caps & HOST_CAP_CLIPBOARD, HOST_CAP_CLIPBOARD); + assert_eq!( + got.host_caps & HOST_CAP_GAMEPAD_STATE, + HOST_CAP_GAMEPAD_STATE + ); + // Clipboard-off host: the bit is clear, gamepad bit still set. + w.host_caps = HOST_CAP_GAMEPAD_STATE; + assert_eq!( + Welcome::decode(&w.encode()).unwrap().host_caps & HOST_CAP_CLIPBOARD, + 0 + ); +} + +// ---- In-process QUIC loopback: the real clipstream fetch transport, both success and cancel ---- + +mod clip_loopback { + use super::*; + use crate::quic::clipstream; + + /// Stand up two loopback quinn endpoints, connect, and return + /// `(server_ep, client_ep, host_conn, client_conn)`. Both endpoints are returned so the caller + /// keeps them in scope — dropping a `quinn::Endpoint` tears down its connections. + async fn connect_pair() -> ( + quinn::Endpoint, + quinn::Endpoint, + quinn::Connection, + quinn::Connection, + ) { + let server = endpoint::server("127.0.0.1:0".parse().unwrap()).unwrap(); + let addr = server.local_addr().unwrap(); + let client = endpoint::client_insecure().unwrap(); + let accept = tokio::spawn(async move { + let incoming = server.accept().await.expect("incoming connection"); + let conn = incoming.await.expect("host side connects"); + (server, conn) + }); + let client_conn = client + .connect(addr, "punktfunk") + .unwrap() + .await + .expect("client side connects"); + let (server, host_conn) = accept.await.unwrap(); + (server, client, host_conn, client_conn) + } + + #[tokio::test] + async fn fetch_text_transfers_then_cancel_resets() { + let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await; + + let payload = b"hello clipboard \xf0\x9f\x93\x8b".to_vec(); // text + a 4-byte emoji + let holder_payload = payload.clone(); + + // Holder = the host side: accept two fetch streams. Serve the first; cancel the second. + let holder = tokio::spawn(async move { + // Fetch #1 — serve the payload. + let (mut send, mut recv) = host_conn.accept_bi().await.expect("accept fetch #1"); + let kind = clipstream::read_stream_header(&mut recv) + .await + .expect("stream header #1"); + assert_eq!(kind, clipstream::CLIP_STREAM_KIND_FETCH); + let req = clipstream::read_fetch(&mut recv) + .await + .expect("fetch req #1"); + assert_eq!(req.seq, 1); + assert_eq!(req.file_index, CLIP_FILE_INDEX_NONE); + assert_eq!(req.mime, "text/plain;charset=utf-8"); + clipstream::write_fetch_hdr( + &mut send, + &ClipFetchHdr { + status: CLIP_FETCH_OK, + total_size: holder_payload.len() as u64, + }, + ) + .await + .expect("write hdr #1"); + clipstream::write_data(&mut send, &holder_payload) + .await + .expect("write data #1"); + + // Fetch #2 — read the request, then cancel mid-transfer with RESET_STREAM. + let (mut send2, mut recv2) = host_conn.accept_bi().await.expect("accept fetch #2"); + clipstream::read_stream_header(&mut recv2) + .await + .expect("stream header #2"); + let _ = clipstream::read_fetch(&mut recv2) + .await + .expect("fetch req #2"); + send2.reset(clipstream::cancelled_code()).unwrap(); + + host_conn // keep alive until the requester side is done + }); + + // Requester = the client side. + // #1: full lazy fetch of the text payload. + let req = ClipFetch { + seq: 1, + file_index: CLIP_FILE_INDEX_NONE, + mime: "text/plain;charset=utf-8".into(), + }; + let (_send, mut recv) = clipstream::open_fetch(&client_conn, &req) + .await + .expect("open fetch #1"); + let hdr = clipstream::read_fetch_hdr(&mut recv) + .await + .expect("read hdr #1"); + assert_eq!(hdr.status, CLIP_FETCH_OK); + assert_eq!(hdr.total_size as usize, payload.len()); + let got = clipstream::read_data(&mut recv, 8 << 20) + .await + .expect("read data #1"); + assert_eq!(got, payload); + + // #2: the holder resets the stream — the requester surfaces an error rather than hanging. + let req2 = ClipFetch { + seq: 2, + file_index: CLIP_FILE_INDEX_NONE, + mime: "text/plain;charset=utf-8".into(), + }; + let (_send2, mut recv2) = clipstream::open_fetch(&client_conn, &req2) + .await + .expect("open fetch #2"); + assert!( + clipstream::read_fetch_hdr(&mut recv2).await.is_err(), + "a cancelled fetch must surface as an error, not a hang" + ); + + let _host_conn = holder.await.unwrap(); + } + + #[tokio::test] + async fn read_data_enforces_size_cap() { + let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await; + + let big = vec![0xABu8; 200_000]; // > the 64 KiB chunk, and > the cap we set below + let holder_payload = big.clone(); + let holder = tokio::spawn(async move { + let (mut send, mut recv) = host_conn.accept_bi().await.expect("accept"); + clipstream::read_stream_header(&mut recv).await.unwrap(); + let _ = clipstream::read_fetch(&mut recv).await.unwrap(); + clipstream::write_fetch_hdr( + &mut send, + &ClipFetchHdr { + status: CLIP_FETCH_OK, + total_size: holder_payload.len() as u64, + }, + ) + .await + .unwrap(); + let _ = clipstream::write_data(&mut send, &holder_payload).await; + host_conn + }); + + let req = ClipFetch { + seq: 1, + file_index: CLIP_FILE_INDEX_NONE, + mime: "application/octet-stream".into(), + }; + let (_send, mut recv) = clipstream::open_fetch(&client_conn, &req).await.unwrap(); + assert_eq!( + clipstream::read_fetch_hdr(&mut recv).await.unwrap().status, + CLIP_FETCH_OK + ); + // Cap below the payload size ⇒ read_data errors instead of buffering unboundedly. + assert!(clipstream::read_data(&mut recv, 64 * 1024).await.is_err()); + + let _host_conn = holder.await.unwrap(); + } +} diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 8848f14d..8f342b28 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -41,6 +41,10 @@ pf-inject = { path = "../pf-inject" } # Virtual-display orchestration (per-compositor Linux backends + the Windows IddCx driver backend), # extracted to a subsystem crate (plan §W6). The DDC panel control + KWin zkde protocol moved with it. pf-vdisplay = { path = "../pf-vdisplay" } +# Shared clipboard (design/clipboard-and-file-transfer.md §4): the per-OS session-clipboard +# backends + coordinator. Portable dep — the crate's facade (policy / ClipCoordCmd / start) +# compiles everywhere; the backends are cfg-gated inside it. +pf-clipboard = { path = "../pf-clipboard" } # M3 native control plane (the `punktfunk/1` QUIC handshake; data plane stays native-thread UDP). quinn = "0.11" anyhow = "1" diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index 5982a3c2..c8ceca87 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -847,6 +847,17 @@ async fn serve_session( let fec_target_ctl = fec_target.clone(); // The session's negotiated rate — the pin PyroWave retarget-refusals ack (§4.6). let session_bitrate_kbps = welcome.bitrate_kbps; + // Shared-clipboard enable state (client `ClipControl` → host). The coordinator reads it to + // decide whether to forward host copies; the control task flips it on each `ClipControl`. + let clip_enabled = Arc::new(AtomicBool::new(false)); + // Start the host clipboard coordinator. On success it watches the session clipboard, forwards + // host copies as `ClipOffer`s (`clip.offer_rx` → control task → client), installs client + // offers as a lazy source, and owns the fetch-stream accept loop. `available` is false when + // there's no backend (gamescope / older GNOME / an unsupported platform) — the control task + // then answers `ClipControl` with `BACKEND_UNAVAILABLE` and the decline loop below handles + // stray fetch streams. + let clip = pf_clipboard::start(conn.clone(), clip_enabled.clone(), compositor.is_some()).await; + let clip_available = clip.available; tokio::spawn(control::run( ctrl_send, ctrl_recv, @@ -863,7 +874,14 @@ async fn serve_session( probe_tx, probe_result_rx, reconfig_result_rx, + clip_enabled, + clip, )); + // Fetch streams with no backend behind them are answered `CLIP_FETCH_UNAVAILABLE` instead of + // hanging (the coordinator owns `accept_bi` when a backend is live — exactly one consumer). + if !clip_available && pf_clipboard::enabled() { + pf_clipboard::spawn_decline_loop(conn.clone()); + } // Input plane: QUIC datagrams → channel → a native per-session thread. Pointer/keyboard // events are forwarded to the host-lifetime [`InjectorService`] (`inj_tx`) so the portal @@ -1731,6 +1749,138 @@ mod tests { host.join().unwrap().unwrap(); } + /// Shared clipboard end to end over a real synthetic session + /// (`design/clipboard-and-file-transfer.md`): with the operator policy enabled, the host + /// advertises the capability, acknowledges an enable with a `ClipState`, and — a synthetic + /// session mirrors no compositor, so no clipboard backend binds — declines a fetch with an + /// `Error` the client surfaces. Exercises the whole 0x40-0x44 control+fetch path across two real + /// endpoints (client `NativeClient` ↔ host `serve_session`). The live-backend paths (a real + /// compositor) are covered by the on-glass test against GNOME/Hyprland. + #[test] + fn clipboard_control_and_fetch_decline_over_session() { + let _serial = SESSION_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + use punktfunk_core::client::NativeClient; + use punktfunk_core::clipboard::ClipEventCore; + use punktfunk_core::quic::{ + CLIP_FILE_INDEX_NONE, CLIP_FLAG_FILES, CLIP_POLICY_FILES, HOST_CAP_CLIPBOARD, + }; + + // Restore the env even on a panicking assert (the poisoned lock is recovered above, so a + // leaked var could otherwise reach the next session test). + struct EnvGuard(&'static str); + impl Drop for EnvGuard { + fn drop(&mut self) { + std::env::remove_var(self.0); + } + } + let _env = EnvGuard("PUNKTFUNK_CLIPBOARD"); + // Operator policy on. Session tests serialize on SESSION_TEST_LOCK, and only the session + // path (a session test) reads this env, so the mutation is race-free here. + std::env::set_var("PUNKTFUNK_CLIPBOARD", "1"); + + let host = std::thread::spawn(|| { + run(Punktfunk1Options { + port: 19781, + source: Punktfunk1Source::Synthetic, + seconds: 0, + frames: 600, // keep the session alive well past the control exchange + max_sessions: 1, + max_concurrent: 1, + require_pairing: false, + allow_pairing: false, + pairing_pin: None, + paired_store: None, + data_port: None, + idle_timeout: None, + mdns: false, + }) + }); + std::thread::sleep(std::time::Duration::from_millis(500)); + + let mode = punktfunk_core::Mode { + width: 1280, + height: 720, + refresh_hz: 60, + }; + let client = NativeClient::connect( + "127.0.0.1", + 19781, + mode, + CompositorPref::Auto, + GamepadPref::Auto, + 0, // bitrate_kbps + 0, // video_caps + 2, // audio_channels + 0, // video_codecs (HEVC-only) + 0, // preferred_codec + None, // display_hdr + None, // launch + None, // pin (TOFU) + None, // identity (host doesn't require pairing) + std::time::Duration::from_secs(10), + ) + .expect("client connects to synthetic host"); + + assert_ne!( + client.host_caps() & HOST_CAP_CLIPBOARD, + 0, + "an enabled host advertises HOST_CAP_CLIPBOARD" + ); + + // A bounded poll over the clipboard event plane. + let poll = |pred: &dyn Fn(&ClipEventCore) -> bool| -> Option { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while std::time::Instant::now() < deadline { + match client.next_clip(std::time::Duration::from_millis(200)) { + Ok(ev) if pred(&ev) => return Some(ev), + Ok(_) => {} + Err(punktfunk_core::PunktfunkError::NoFrame) => {} + Err(_) => break, // session closed + } + } + None + }; + + // Enable sync (requesting files) → the host acks with a ClipState. A synthetic session + // mirrors no compositor, so no clipboard backend binds: the host refuses the enable with + // `BACKEND_UNAVAILABLE` while still reporting the operator policy (files permitted). + client.clip_control(true, CLIP_FLAG_FILES).unwrap(); + let state = poll(&|e| matches!(e, ClipEventCore::State { .. })) + .expect("host replies with a ClipState ack"); + match state { + ClipEventCore::State { + enabled, + policy, + reason, + } => { + assert!(!enabled, "no backend for a synthetic session → not enabled"); + assert_eq!( + reason, + punktfunk_core::quic::CLIP_REASON_BACKEND_UNAVAILABLE, + "the refusal reason is BACKEND_UNAVAILABLE" + ); + assert_ne!( + policy & CLIP_POLICY_FILES, + 0, + "PUNKTFUNK_CLIPBOARD=1 permits files" + ); + } + _ => unreachable!(), + } + + // Fetch the host clipboard: a synthetic session has no backend, so the host declines and + // the client surfaces an Error for that transfer id. + let xfer = client + .clip_fetch(1, "text/plain;charset=utf-8".into(), CLIP_FILE_INDEX_NONE) + .unwrap(); + let err = poll(&|e| matches!(e, ClipEventCore::Error { id, .. } if *id == xfer)) + .expect("host declines the fetch (no backend) → Error event"); + assert!(matches!(err, ClipEventCore::Error { .. })); + + drop(client); + host.join().unwrap().unwrap(); + } + fn test_paired_path() -> std::path::PathBuf { std::env::temp_dir().join(format!("punktfunk-paired-test-{}.json", std::process::id())) } diff --git a/crates/punktfunk-host/src/native/control.rs b/crates/punktfunk-host/src/native/control.rs index a4e1af48..8cd7fa4c 100644 --- a/crates/punktfunk-host/src/native/control.rs +++ b/crates/punktfunk-host/src/native/control.rs @@ -6,10 +6,13 @@ //! the data-plane thread over the session's mpsc bridges. use super::*; +use pf_clipboard::ClipCoordCmd; +use punktfunk_core::quic::{ClipControl, ClipOffer, ClipState}; /// Run the control task for one live session. Owns the control streams (`serve_session` hands them -/// off after negotiation) plus every channel end that bridges to the data-plane thread. Returns -/// when the control stream closes or a data-plane channel drops. +/// off after negotiation) plus every channel end that bridges to the data-plane thread, and the +/// [`pf_clipboard::ClipCoord`] handle bridging to the clipboard coordinator. Returns when the +/// control stream closes or a data-plane channel drops. #[allow(clippy::too_many_arguments)] pub(super) async fn run( mut ctrl_send: quinn::SendStream, @@ -27,7 +30,17 @@ pub(super) async fn run( probe_tx: std::sync::mpsc::Sender, mut probe_result_rx: tokio::sync::mpsc::UnboundedReceiver, mut reconfig_result_rx: tokio::sync::mpsc::UnboundedReceiver, + clip_enabled: Arc, + clip: pf_clipboard::ClipCoord, ) { + let pf_clipboard::ClipCoord { + available: clip_available, + cmd_tx: clip_cmd_tx, + offer_rx: mut clip_offer_rx, + } = clip; + // Set once `clip_offer_rx` closes (coordinator gone / inert handle) so its `select!` branch + // stops firing on a perpetually-ready `None`. + let mut clip_offer_closed = false; let mut active = initial_mode; // Host-side switch rate limit (a backstop against a hostile/broken client spamming // Reconfigure into pipeline-rebuild churn — the drain-to-newest in the data plane already @@ -180,6 +193,61 @@ pub(super) async fn run( if io::write_msg(&mut ctrl_send, &echo.encode()).await.is_err() { break; } + } else if let Ok(ctl) = ClipControl::decode(&msg) { + // Shared clipboard enable/disable (design/clipboard-and-file-transfer.md + // §3.1). Reply with the resolved state; the operator policy is authoritative + // over the client's request. When the policy allows it but no backend bound + // (gamescope / older GNOME), enable is refused with BACKEND_UNAVAILABLE so the + // client can say *why*. The resolved `enabled` gates the coordinator. + let policy = pf_clipboard::policy(); + let (enabled, resolved_policy, reason) = match policy { + None => (false, 0, punktfunk_core::quic::CLIP_REASON_POLICY_DISABLED), + Some(p) if ctl.enabled && !clip_available => { + (false, p, punktfunk_core::quic::CLIP_REASON_BACKEND_UNAVAILABLE) + } + Some(p) => { + let files_ok = p & punktfunk_core::quic::CLIP_POLICY_FILES != 0; + let wants_files = + ctl.flags & punktfunk_core::quic::CLIP_FLAG_FILES != 0; + let reason = if wants_files && !files_ok { + punktfunk_core::quic::CLIP_REASON_NO_FILES + } else { + punktfunk_core::quic::CLIP_REASON_OK + }; + (ctl.enabled, p, reason) + } + }; + clip_enabled.store(enabled, Ordering::SeqCst); + // Drive the coordinator: enable re-announces the current host clipboard, + // disable drops any selection we own. A dropped send (inert handle) is fine. + let _ = clip_cmd_tx.send(ClipCoordCmd::SetEnabled(enabled)); + tracing::info!( + enabled, + files = enabled + && resolved_policy & punktfunk_core::quic::CLIP_POLICY_FILES != 0, + "clipboard control" + ); + let state = ClipState { + enabled, + policy: resolved_policy, + reason, + }; + if io::write_msg(&mut ctrl_send, &state.encode()).await.is_err() { + break; + } + } else if let Ok(offer) = ClipOffer::decode(&msg) { + // The client copied: hand its lazy format list to the coordinator, which + // installs a host-side source that fetches from the client on host paste. + tracing::debug!( + seq = offer.seq, + kinds = offer.kinds.len(), + "clipboard offer from client" + ); + let mimes = offer.kinds.iter().map(|k| k.mime.clone()).collect(); + let _ = clip_cmd_tx.send(ClipCoordCmd::RemoteOffer { + seq: offer.seq, + mimes, + }); } else { tracing::warn!("unknown control message — ignoring"); } @@ -190,6 +258,21 @@ pub(super) async fn run( break; } } + offer = clip_offer_rx.recv(), if !clip_offer_closed => { + // Host copied → the coordinator minted a `ClipOffer`; forward it to the client + // (only while sync is on — a race with a just-received disable would otherwise + // leak a stale offer). `None` = coordinator gone; disable this branch. + match offer { + Some(offer) => { + if clip_enabled.load(Ordering::SeqCst) + && io::write_msg(&mut ctrl_send, &offer.encode()).await.is_err() + { + break; + } + } + None => clip_offer_closed = true, + } + } correction = reconfig_result_rx.recv() => { // H2 rollback/correction ack: the data plane reports the mode ACTUALLY live // after a rebuild that failed (stayed at the old mode) or that the backend diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index 6ec1cd8a..cd15c083 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -365,8 +365,16 @@ pub(super) async fn negotiate( // assuming HEVC. codec: codec_bit, // This host applies sequence-gated gamepad-state snapshots (InputKind::GamepadState), - // so capable clients send those instead of the loss-fragile per-transition events. - host_caps: punktfunk_core::quic::HOST_CAP_GAMEPAD_STATE, + // so capable clients send those instead of the loss-fragile per-transition events. The + // clipboard bit is advertised only when the operator policy enables it (design + // clipboard-and-file-transfer.md §3.1) AND this platform has a backend — see + // `pf_clipboard::cap_advertised` for the deliberate compositor-lacks-data-control case. + host_caps: punktfunk_core::quic::HOST_CAP_GAMEPAD_STATE + | if pf_clipboard::cap_advertised() { + punktfunk_core::quic::HOST_CAP_CLIPBOARD + } else { + 0 + }, }; io::write_msg(send, &welcome.encode()).await?; diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index a15253fd..f06e7704 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -33,7 +33,11 @@ // application close) and the `PunktfunkStatus` −20 block itself. Additive — the close codes are // new application-close vocabulary an old peer simply never sends/reads, so [`WIRE_VERSION`] is // unchanged. -#define ABI_VERSION 7 +// v8: added the shared-clipboard client surface — `punktfunk_connection_host_caps` and +// `punktfunk_connection_clipboard_{control,offer,fetch,serve,cancel}` + +// `punktfunk_connection_next_clipboard`. Additive; the wire grows only backward-compatible control +// messages (0x40-0x44) and a new `Welcome::host_caps` bit, so [`WIRE_VERSION`] is unchanged. +#define ABI_VERSION 8 // The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. // Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** @@ -192,6 +196,15 @@ // (design/pyrowave-codec-plan.md §3). (Mirrors `quic::CODEC_PYROWAVE`.) #define PUNKTFUNK_CODEC_PYROWAVE 8 +// Host-capability bit in [`punktfunk_connection_host_caps`]: the host applies gamepad-state +// snapshots (a capable client sends full-state snapshots instead of per-transition events). +// (Mirrors `quic::HOST_CAP_GAMEPAD_STATE`.) +#define PUNKTFUNK_HOST_CAP_GAMEPAD_STATE 1 + +// Host-capability bit in [`punktfunk_connection_host_caps`]: the host supports the shared +// clipboard, so a client may offer the toggle. (Mirrors `quic::HOST_CAP_CLIPBOARD`.) +#define PUNKTFUNK_HOST_CAP_CLIPBOARD 2 + // `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble // datagram — an old host that sent no self-termination lease. The client then falls back to its // own staleness heuristic for that update instead of a host-supplied deadline. @@ -202,6 +215,32 @@ // writes the device — the Steam Deck's dedupe-defeat. #define PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER 1 +// [`PunktfunkClipEvent::kind`] — the host announced clipboard content is available +// (`transfer_id` = the offer `seq`; `data`/`len` = a `\n`-separated `"\t"` +// format list). Fetch it lazily (only on a local paste) via +// [`punktfunk_connection_clipboard_fetch`]. +#define PUNKTFUNK_CLIP_REMOTE_OFFER 1 + +// [`PunktfunkClipEvent::kind`] — host ack / policy / backend update (`enabled`/`policy`/`reason` +// valid). Reflect it in the toggle UI. +#define PUNKTFUNK_CLIP_STATE 2 + +// [`PunktfunkClipEvent::kind`] — the host is pasting our offered data: answer with +// [`punktfunk_connection_clipboard_serve`] (`transfer_id` = `req_id`; `seq`/`file_index` valid; +// `data`/`len` = the requested MIME). +#define PUNKTFUNK_CLIP_FETCH_REQUEST 3 + +// [`PunktfunkClipEvent::kind`] — bytes for a fetch we started (`transfer_id` = `xfer_id`; +// `data`/`len` = the payload, borrowed until the next `next_clipboard`; `last` = final chunk). +#define PUNKTFUNK_CLIP_DATA 4 + +// [`PunktfunkClipEvent::kind`] — a transfer was cancelled (`transfer_id` = the id). +#define PUNKTFUNK_CLIP_CANCELLED 5 + +// [`PunktfunkClipEvent::kind`] — a transfer failed (`transfer_id` = the id; `status` = a +// `PunktfunkStatus` code). +#define PUNKTFUNK_CLIP_ERROR 6 + #if defined(PUNKTFUNK_FEATURE_QUIC) // The uniform no-TTL-host staleness bound: a legacy host refreshes state every 500 ms, so two // missed refreshes = quiet host → silence. Replaces the per-platform zoo (1.6 s / 60 s / 1.5 s / @@ -209,6 +248,20 @@ #define LEGACY_STALE_MS 1000 #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Per-fetch requester-side size cap (bytes). A holder that streams more than this is treated as a +// cap breach and the fetch fails rather than buffering unboundedly (§7). Phase 0 uses one fixed +// value; a future host-policy `PUNKTFUNK_CLIP_MAX_MB` tightens it per session. +#define CLIP_FETCH_CAP (64 << 20) +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Inbound-serve `req_id`s carry this high bit so they never collide with the client-assigned +// outbound-fetch `xfer_id`s (which count up from 1). A single [`ClipCommand::Cancel`] `id` can +// then be routed to the right table. +#define INBOUND_REQ_FLAG 2147483648 +#endif + // 16-byte AEAD authentication tag appended by GCM. #define TAG_LEN 16 @@ -396,6 +449,16 @@ #define HOST_CAP_GAMEPAD_STATE 1 #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`Welcome::host_caps`] bit: the host has a shared-clipboard service (a working OS backend) +// **and** its operator policy does not hard-disable it, so the client may offer the clipboard +// toggle. Absent (an older host, or `PUNKTFUNK_CLIPBOARD` off) ⇒ the client greys the toggle +// out. Purely additive: nothing clipboard-related happens until a [`ClipControl`]`{ enabled: +// true }` crosses (see `design/clipboard-and-file-transfer.md` §3.1). Packs into the existing +// trailing `host_caps` byte — no wire-layout change. +#define HOST_CAP_CLIPBOARD 2 +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** // encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST @@ -535,6 +598,119 @@ #define MSG_CLOCK_ECHO 49 #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Type byte of [`ClipControl`] (client → host): enable/disable the shared clipboard for this +// session. Idempotent; opt-in is enforced here, not just in UI. +#define MSG_CLIP_CONTROL 64 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Type byte of [`ClipState`] (host → client): ack + unsolicited policy/backend updates. +#define MSG_CLIP_STATE 65 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Type byte of [`ClipOffer`] (symmetric): the lazy announcement — format list only, no bytes. +#define MSG_CLIP_OFFER 66 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Type byte of [`ClipFetch`] (requester → holder, **fetch stream only**): pull one format of the +// current offer. +#define MSG_CLIP_FETCH 67 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Type byte of [`ClipFetchHdr`] (holder → requester, **fetch stream only**): the fetch response +// header that precedes the data chunks. +#define MSG_CLIP_FETCH_HDR 68 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`ClipControl::flags`] bit: the client permits file kinds to be offered/fetched this session. +// Absent ⇒ files are filtered out of offers in both directions (text/rich/image only). +#define CLIP_FLAG_FILES 1 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`ClipState::policy`] bit: the host permits non-file formats (text/RTF/HTML/image). Always set +// while enabled unless a future direction limit clears it. +#define CLIP_POLICY_TEXT 1 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`ClipState::policy`] bit: the host permits file formats. Cleared by the operator `no-files` +// / `text-only` policy so the client can grey out "Include files". +#define CLIP_POLICY_FILES 2 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`ClipState::reason`]: normal ack, nothing exceptional. +#define CLIP_REASON_OK 0 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`ClipState::reason`]: this session type has no working clipboard backend (e.g. a gamescope +// session with no data-control global) — the client shows "not supported in this session type". +#define CLIP_REASON_BACKEND_UNAVAILABLE 1 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`ClipState::reason`]: another client took over the single per-desktop clipboard binding; this +// one was disabled (last `ClipControl{enabled}` wins). +#define CLIP_REASON_TAKEN_OVER 2 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`ClipState::reason`]: the host operator policy (`PUNKTFUNK_CLIPBOARD=off`) disables clipboard. +#define CLIP_REASON_POLICY_DISABLED 3 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`ClipState::reason`]: enabled, but the host policy forbids file transfer (`no-files` / +// `text-only`) — surfaced so the client greys "Include files" with a footnote. +#define CLIP_REASON_NO_FILES 4 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`ClipFetchHdr::status`]: the requested format is being served; data chunks follow until FIN. +#define CLIP_FETCH_OK 0 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`ClipFetchHdr::status`]: the fetch named a `seq` that is no longer the holder's current offer; +// the requester degrades the paste to "nothing inserted" rather than wrong data. No chunks follow. +#define CLIP_FETCH_STALE 1 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`ClipFetchHdr::status`]: the format/index is not available (no backend, or it vanished). No +// chunks follow. +#define CLIP_FETCH_UNAVAILABLE 2 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`ClipFetchHdr::status`]: policy/cap denies this fetch (e.g. a file fetch under `no-files`). No +// chunks follow. +#define CLIP_FETCH_DENIED 3 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Maximum number of [`ClipKind`] entries in one [`ClipOffer`] (resource cap, §7). +#define CLIP_MAX_KINDS 16 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Maximum length in bytes of a [`ClipKind::mime`] string (resource cap, §7). +#define CLIP_MAX_MIME 128 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`ClipFetch::file_index`] sentinel meaning "not a file fetch" (a whole non-file format, or the +// file *manifest* itself). Real file fetches use `0..n`. +#define CLIP_FILE_INDEX_NONE UINT32_MAX +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams, // demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host), @@ -674,6 +850,27 @@ #define MSG_PAIR_RESULT 19 #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Stream-kind byte: a clipboard fetch (request/response of one format). Future stream kinds +// (e.g. a bulk file-content push) mux under the same [`STREAM_MAGIC`] with a different byte. +#define CLIP_STREAM_KIND_FETCH 1 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// QUIC application error code used to `reset`/`stop` a clipboard fetch stream on cancel — sync +// disabled mid-transfer, paste timed out, size cap exceeded, teardown. Distinct from the +// connection close codes ([`super::QUIT_CLOSE_CODE`] `0x51` / [`super::APP_EXITED_CLOSE_CODE`] +// `0x52`), the connection reject code `0x42`, and the pairing-rejection close block +// `0x60`–`0x67` — stream reset codes and connection close codes are separate QUIC namespaces, +// but the vocabularies stay disjoint on purpose so a captured code is unambiguous. +#define CLIP_CANCELLED_CODE 112 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Chunk size for streaming fetch data (64 KiB writes — matches the control-frame bound). +#define CLIP_CHUNK (64 * 1024) +#endif + // Consecutive no-output AUs that force a keyframe request. ~50 ms at 60 Hz — long enough not to fire // on a one-frame decoder hiccup, short enough that a lost initial IDR (or a mid-GOP join) unfreezes // almost immediately instead of never. @@ -729,12 +926,6 @@ // The client's wire (protocol) version does not match the host's — one side needs updating. #define WIRE_VERSION_CLOSE_CODE 103 -// Minimum supported multiplier (renders under native, upscaled on present). -#define MIN_SCALE 0.5 - -// Maximum supported multiplier (supersamples, clamped to the codec ceiling per axis). -#define MAX_SCALE 4.0 - // Stable C ABI status codes. `Ok` is 0; all errors are negative so callers can // test `rc < 0`. Do not renumber existing variants — only append. enum PunktfunkStatus @@ -1107,6 +1298,47 @@ typedef struct { } PunktfunkRichInputEx; #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// One advertised clipboard format passed to [`punktfunk_connection_clipboard_offer`]. +typedef struct { + // NUL-terminated UTF-8 wire MIME (e.g. `text/plain;charset=utf-8`). ≤ 128 bytes on the wire. + const char *mime; + // Best-effort size in bytes; `0` = unknown. + uint64_t size_hint; +} PunktfunkClipKind; +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// A shared-clipboard event, filled by [`punktfunk_connection_next_clipboard`]. A flat tagged +// struct (like `PunktfunkHidOutput`): read the fields named in the `kind`'s doc; the rest are 0. +typedef struct { + // One of `PUNKTFUNK_CLIP_*`. + uint8_t kind; + // `State`: 1 = enabled, 0 = disabled. + uint8_t enabled; + // `State`: bitfield of `quic::CLIP_POLICY_*` — what the host currently permits. + uint8_t policy; + // `State`: one of `quic::CLIP_REASON_*`. + uint8_t reason; + // `Data`: 1 = final chunk of this transfer. + uint8_t last; + // Per-transfer id: the offer `seq` (RemoteOffer), the `req_id` (FetchRequest), or the + // `xfer_id` (Data/Cancelled/Error). + uint32_t transfer_id; + // `FetchRequest`: the offer `seq` the request is against. + uint32_t seq; + // `FetchRequest`: file index, or `quic::CLIP_FILE_INDEX_NONE`. + uint32_t file_index; + // `Error`: a `PunktfunkStatus` code (negative); 0 otherwise. + int32_t status; + // RemoteOffer/FetchRequest/Data: a pointer into a per-connection slot, valid until the next + // `next_clipboard` call; NULL for the other kinds. + const uint8_t *data; + // Byte length of `data` (0 when `data` is NULL). + uintptr_t len; +} PunktfunkClipEvent; +#endif + // A speed-test measurement, filled by [`punktfunk_connection_probe_result`]. `done` is 0 until // the host's end-of-burst report lands, then 1 (the numbers are final). `throughput_kbps` is the // delivered wire throughput to drive a bitrate choice from; `loss_pct` is the link loss and @@ -1138,10 +1370,6 @@ typedef struct { -// The multipliers a picker offers. `1.0` (Native) is the default; the rest are the round stops -// users reason about. Shared so every client's list stays identical. -#define PRESETS { 0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, } - #ifdef __cplusplus extern "C" { #endif // __cplusplus @@ -1838,6 +2066,96 @@ PunktfunkStatus punktfunk_connection_mode(const PunktfunkConnection *c, PunktfunkStatus punktfunk_connection_gamepad(const PunktfunkConnection *c, uint32_t *gamepad); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// The host capability bitfield the session's `Welcome` carried — a bitfield of +// `PUNKTFUNK_HOST_CAP_GAMEPAD_STATE` / `PUNKTFUNK_HOST_CAP_CLIPBOARD`. A client tests +// `caps & PUNKTFUNK_HOST_CAP_CLIPBOARD` to decide whether to offer the shared-clipboard toggle. +// Safe any time after connect. +// +// # Safety +// `c` is a valid connection handle; `caps` is writable (NULL is skipped). +PunktfunkStatus punktfunk_connection_host_caps(const PunktfunkConnection *c, uint8_t *caps); +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Enable or disable the shared clipboard for this session (`design` §3.1). Opt-in: nothing is +// announced or served until this is called with `enabled = true`. `flags` carries +// `quic::CLIP_FLAG_FILES` (allow file transfer). The host replies with a `State` event. +// +// # Safety +// `c` is a valid connection handle. +PunktfunkStatus punktfunk_connection_clipboard_control(const PunktfunkConnection *c, + bool enabled, + uint8_t flags); +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Announce that the local clipboard changed — the lazy format-list offer. `seq` is a monotonic +// per-sender counter (newest wins); `kinds`/`n` is the advertised formats (≤ 16). The bytes cross +// only if the host later fetches. +// +// # Safety +// `c` is a valid connection handle; `kinds` points to `n` `PunktfunkClipKind`s (NULL allowed only +// when `n == 0`), each `mime` a valid NUL-terminated UTF-8 string. +PunktfunkStatus punktfunk_connection_clipboard_offer(const PunktfunkConnection *c, + uint32_t seq, + const PunktfunkClipKind *kinds, + uintptr_t n); +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Start pulling one format (`mime`) of the host's current offer `seq` — lazily, on a local paste. +// `file_index` selects a file for a file transfer, or `quic::CLIP_FILE_INDEX_NONE` for a non-file +// format. Writes the transfer id (echoed on the resulting `Data`/`Error`/`Cancelled` event) to +// `xfer_id_out`. +// +// # Safety +// `c` is a valid connection handle; `mime` is a valid NUL-terminated UTF-8 string; `xfer_id_out` +// is writable (NULL is skipped). +PunktfunkStatus punktfunk_connection_clipboard_fetch(const PunktfunkConnection *c, + uint32_t seq, + const char *mime, + uint32_t file_index, + uint32_t *xfer_id_out); +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Provide bytes answering a `FetchRequest` event (the host is pasting our offered data). Call +// repeatedly to stream a large payload; `last = true` completes it. `data` may be NULL only when +// `len == 0` (e.g. a final empty chunk). `punktfunk_connection_clipboard_cancel(req_id)` aborts. +// +// # Safety +// `c` is a valid connection handle; `data` points to `len` bytes (NULL allowed only when +// `len == 0`). +PunktfunkStatus punktfunk_connection_clipboard_serve(const PunktfunkConnection *c, + uint32_t req_id, + const uint8_t *data, + uintptr_t len, + bool last); +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Cancel a clipboard transfer by id — either an outbound fetch (`xfer_id` from +// [`punktfunk_connection_clipboard_fetch`]) or an inbound serve (`req_id` from a `FetchRequest`). +// +// # Safety +// `c` is a valid connection handle. +PunktfunkStatus punktfunk_connection_clipboard_cancel(const PunktfunkConnection *c, uint32_t id); +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Pull the next shared-clipboard event into `*out`. [`PunktfunkStatus::NoFrame`] on timeout, +// [`PunktfunkStatus::Closed`] once the session ended. A native client drains this on its own +// thread and drives the OS pasteboard from it. The `data`/`len` pointer (when non-NULL) borrows a +// per-connection buffer valid until the next `next_clipboard` call on this handle. +// +// # Safety +// `c` is a valid connection handle; `out` is writable for one `PunktfunkClipEvent`. +PunktfunkStatus punktfunk_connection_next_clipboard(PunktfunkConnection *c, + PunktfunkClipEvent *out, + uint32_t timeout_ms); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // The compositor backend the host actually resolved for this session (one of the // `PUNKTFUNK_COMPOSITOR_*` values; the `Welcome`'s echo of the [`punktfunk_connect_ex`]