diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 7a6a0e1e..93b30c58 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) => { @@ -2456,6 +2471,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.rs b/crates/punktfunk-core/src/client.rs index 670d4870..5b13140d 100644 --- a/crates/punktfunk-core/src/client.rs +++ b/crates/punktfunk-core/src/client.rs @@ -12,15 +12,16 @@ //! channel. All methods are safe to call from any single embedder thread. use crate::abr::BitrateController; +use crate::clipboard::{ClipCommand, ClipEventCore}; use crate::config::{CompositorPref, GamepadPref, Mode, Role}; use crate::error::{PunktfunkError, Result}; use crate::input::InputEvent; use crate::packet::FLAG_PROBE; use crate::quic::{ - accept_resync, endpoint, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClockEcho, - ClockResync, ColorInfo, HdrMeta, Hello, HidOutput, LossReport, ProbeRequest, ProbeResult, - Reconfigure, Reconfigured, RequestKeyframe, ResyncStep, RfiRequest, RichInput, SetBitrate, - Start, Welcome, + accept_resync, endpoint, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipControl, + ClipKind, ClipOffer, ClipState, ClockEcho, ClockResync, ColorInfo, HdrMeta, Hello, HidOutput, + LossReport, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, ResyncStep, + RfiRequest, RichInput, SetBitrate, Start, Welcome, }; use crate::session::{Frame, Session}; use crate::transport::UdpTransport; @@ -62,6 +63,12 @@ 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 @@ -95,6 +102,10 @@ struct Negotiated { audio_channels: u8, /// The single codec the host will emit (`quic::CODEC_*`). codec: u8, + /// The host capability bitfield ([`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. + host_caps: u8, } /// Accumulated state of an in-flight / finished speed test. The data-plane pump mirrors the @@ -368,6 +379,12 @@ const HDR_META_QUEUE: usize = 8; /// harmless, it's per-frame observability, not state. 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. +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 { @@ -480,6 +497,19 @@ 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 ([`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, @@ -694,6 +724,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)); @@ -761,6 +794,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, @@ -795,6 +830,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, @@ -1309,6 +1348,83 @@ impl NativeClient { self.input_tx.send(*ev).map_err(|_| PunktfunkError::Closed) } + /// The host capability bitfield the [`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. @@ -1408,6 +1524,8 @@ struct WorkerArgs { rich_input_rx: tokio::sync::mpsc::UnboundedReceiver, ctrl_rx: tokio::sync::mpsc::Receiver, ctrl_tx: tokio::sync::mpsc::Sender, + clip_event_tx: SyncSender, + clip_cmd_rx: tokio::sync::mpsc::UnboundedReceiver, ready_tx: std::sync::mpsc::Sender>, shutdown: Arc, /// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set. @@ -1466,6 +1584,8 @@ async fn worker_main(args: WorkerArgs) { mut rich_input_rx, mut ctrl_rx, ctrl_tx, + clip_event_tx, + clip_cmd_rx, ready_tx, shutdown, quit, @@ -1630,6 +1750,7 @@ async fn worker_main(args: WorkerArgs) { audio_channels: welcome.audio_channels, codec: welcome.codec, shard_payload: welcome.shard_payload, + host_caps: welcome.host_caps, }, welcome.host_caps, )) @@ -1653,7 +1774,7 @@ async fn worker_main(args: WorkerArgs) { return; } }; - // Copies the pump needs after `negotiated` is handed over to `connect`. + // Copies the worker needs after `negotiated` is handed over to `connect`. let clock_rtt_ns = negotiated.clock_rtt_ns; let resolved_bitrate_kbps = negotiated.bitrate_kbps; let negotiated_codec = negotiated.codec; @@ -1830,6 +1951,9 @@ async fn worker_main(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 @@ -1859,6 +1983,8 @@ async fn worker_main(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; @@ -1940,6 +2066,21 @@ async fn worker_main(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(), @@ -2020,6 +2161,17 @@ async fn worker_main(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/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/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/mod.rs b/crates/punktfunk-core/src/quic/mod.rs index f03c665d..4998f0db 100644 --- a/crates/punktfunk-core/src/quic/mod.rs +++ b/crates/punktfunk-core/src/quic/mod.rs @@ -49,6 +49,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 [`msgs`], 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/msgs.rs b/crates/punktfunk-core/src/quic/msgs.rs index 3e92eace..f096750f 100644 --- a/crates/punktfunk-core/src/quic/msgs.rs +++ b/crates/punktfunk-core/src/quic/msgs.rs @@ -147,6 +147,14 @@ pub use crate::reject::*; /// 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. @@ -527,6 +535,147 @@ pub const MSG_CLOCK_PROBE: u8 = 0x30; /// Type byte of [`ClockEcho`]. pub const MSG_CLOCK_ECHO: u8 = 0x31; +// --------------------------------------------------------------------------------------------- +// 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, +} + // --------------------------------------------------------------------------------------------- // Pairing ceremony (typed control messages): instead of a session Hello, a client may open // the control stream with PairRequest. The host shows a short PIN out-of-band (log/UI); the @@ -1293,6 +1442,174 @@ impl ClockEcho { } } +/// 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()), + }) + } +} + /// Frame a message for the control stream: `u16 LE length || payload`. pub fn frame(payload: &[u8]) -> Vec { let mut b = Vec::with_capacity(2 + payload.len()); 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/include/punktfunk_core.h b/include/punktfunk_core.h index c93ff526..37c32c37 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,11 +196,60 @@ // (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. #define PUNKTFUNK_RUMBLE_NO_TTL 4294967295 +// [`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) +// 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 @@ -496,6 +549,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 @@ -606,6 +669,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) // Type byte of [`PairRequest`]. #define MSG_PAIR_REQUEST 16 @@ -662,6 +838,27 @@ #define ColorInfo_MC_BT2020_NCL 9 #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. @@ -1089,6 +1286,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 @@ -1772,6 +2010,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`]