feat(clipboard): wire protocol + client-core task for shared clipboard (Phase 0)

The portable shared-clipboard plane in punktfunk-core, all behind the `quic`
feature (design/clipboard-and-file-transfer.md §3):

- Control messages 0x40–0x44 (ClipControl / ClipOffer / ClipFetch...) and the
  HOST_CAP_CLIPBOARD capability bit, negotiated in the Welcome caps.
- Per-transfer QUIC bi-streams ("PKFs" magic) for lazy fetch of offered content,
  with ClipFetchHdr status/size framing (quic::clipstream).
- The §3.5 portable wire-MIME vocabulary (text/plain;utf-8, text/html, text/rtf,
  image/png) shared by both ends.
- Client-side clipboard task (client.rs) + C ABI surface bumped to v6 (abi.rs,
  regenerated include/punktfunk_core.h).
- Loopback transport tests (quic::tests).

No OS clipboard integration yet — that is the host backends (Phase 1/3) and the
macOS client (Phase 1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 19:08:21 +02:00
parent 76594f27c1
commit af3a7d8cd5
9 changed files with 2029 additions and 10 deletions
+406
View File
@@ -526,6 +526,10 @@ pub struct PunktfunkConnection {
/// `inner.audio_channels`; `pcm` is a fixed-capacity reusable buffer the returned pointer /// `inner.audio_channels`; `pcm` is a fixed-capacity reusable buffer the returned pointer
/// borrows until the next PCM call (same contract as `last_audio`). /// borrows until the next PCM call (same contract as `last_audio`).
audio_pcm: std::sync::Mutex<AudioPcmState>, audio_pcm: std::sync::Mutex<AudioPcmState>,
/// 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<Option<Vec<u8>>>,
} }
/// Lazily-initialized in-core Opus decode state. A coupled-1-stream multistream decoder is /// Lazily-initialized in-core Opus decode state. A coupled-1-stream multistream decoder is
@@ -922,6 +926,14 @@ pub const PUNKTFUNK_CODEC_HEVC: u8 = 0x02;
/// Codec bit: AV1. (Mirrors `quic::CODEC_AV1`.) /// Codec bit: AV1. (Mirrors `quic::CODEC_AV1`.)
pub const PUNKTFUNK_CODEC_AV1: u8 = 0x04; pub const PUNKTFUNK_CODEC_AV1: u8 = 0x04;
/// 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). // Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
#[cfg(feature = "quic")] #[cfg(feature = "quic")]
const _: () = { const _: () = {
@@ -931,6 +943,8 @@ const _: () = {
assert!(PUNKTFUNK_CODEC_H264 == crate::quic::CODEC_H264); assert!(PUNKTFUNK_CODEC_H264 == crate::quic::CODEC_H264);
assert!(PUNKTFUNK_CODEC_HEVC == crate::quic::CODEC_HEVC); assert!(PUNKTFUNK_CODEC_HEVC == crate::quic::CODEC_HEVC);
assert!(PUNKTFUNK_CODEC_AV1 == crate::quic::CODEC_AV1); assert!(PUNKTFUNK_CODEC_AV1 == crate::quic::CODEC_AV1);
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). // Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
@@ -1395,6 +1409,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex7(
last: std::sync::Mutex::new(None), last: std::sync::Mutex::new(None),
last_audio: std::sync::Mutex::new(None), last_audio: std::sync::Mutex::new(None),
audio_pcm: std::sync::Mutex::new(AudioPcmState::default()), audio_pcm: std::sync::Mutex::new(AudioPcmState::default()),
last_clip: std::sync::Mutex::new(None),
})) }))
} }
Err(_) => std::ptr::null_mut(), Err(_) => std::ptr::null_mut(),
@@ -2261,6 +2276,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 `"<mime>\t<size_hint>"`
/// 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<Vec<u8>>,
) -> 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 /// 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`] /// `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 /// preference). `PUNKTFUNK_COMPOSITOR_AUTO` = an older host that didn't say. Clients use it for
+159 -8
View File
@@ -12,15 +12,16 @@
//! channel. All methods are safe to call from any single embedder thread. //! channel. All methods are safe to call from any single embedder thread.
use crate::abr::BitrateController; use crate::abr::BitrateController;
use crate::clipboard::{ClipCommand, ClipEventCore};
use crate::config::{CompositorPref, GamepadPref, Mode, Role}; use crate::config::{CompositorPref, GamepadPref, Mode, Role};
use crate::error::{PunktfunkError, Result}; use crate::error::{PunktfunkError, Result};
use crate::input::InputEvent; use crate::input::InputEvent;
use crate::packet::FLAG_PROBE; use crate::packet::FLAG_PROBE;
use crate::quic::{ use crate::quic::{
accept_resync, endpoint, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClockEcho, accept_resync, endpoint, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipControl,
ClockResync, ColorInfo, HdrMeta, Hello, HidOutput, LossReport, ProbeRequest, ProbeResult, ClipKind, ClipOffer, ClipState, ClockEcho, ClockResync, ColorInfo, HdrMeta, Hello, HidOutput,
Reconfigure, Reconfigured, RequestKeyframe, ResyncStep, RfiRequest, RichInput, SetBitrate, LossReport, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, ResyncStep,
Start, Welcome, RfiRequest, RichInput, SetBitrate, Start, Welcome,
}; };
use crate::session::{Frame, Session}; use crate::session::{Frame, Session};
use crate::transport::UdpTransport; 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" /// 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`]. /// signal; the control task also self-triggers one every [`CLOCK_RESYNC_INTERVAL`].
ClockResync, 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 /// What the worker reports to [`NativeClient::connect`] once the handshake lands: the
@@ -93,6 +100,10 @@ struct Negotiated {
audio_channels: u8, audio_channels: u8,
/// The single codec the host will emit (`quic::CODEC_*`). /// The single codec the host will emit (`quic::CODEC_*`).
codec: u8, 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 /// Accumulated state of an in-flight / finished speed test. The data-plane pump mirrors the
@@ -351,6 +362,12 @@ const HDR_META_QUEUE: usize = 8;
/// harmless, it's per-frame observability, not state. /// harmless, it's per-frame observability, not state.
const HOST_TIMING_QUEUE: usize = 512; 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). /// One Opus packet from the host's audio datagram stream (48 kHz stereo, 5 ms frames).
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct AudioPacket { pub struct AudioPacket {
@@ -463,6 +480,19 @@ pub struct NativeClient {
/// Bounded ([`CTRL_QUEUE`]) — the requests are sparse; a full queue means the control task /// 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. /// is wedged/dead, and callers treat it like a closed session.
ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>, ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>,
/// 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<Receiver<ClipEventCore>>,
/// 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<ClipCommand>,
/// 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. /// Speed-test accumulator, shared with the data-plane pump + control task.
probe: Arc<Mutex<ProbeState>>, probe: Arc<Mutex<ProbeState>>,
shutdown: Arc<AtomicBool>, shutdown: Arc<AtomicBool>,
@@ -666,6 +696,9 @@ impl NativeClient {
let (mic_tx, mic_rx) = tokio::sync::mpsc::channel::<(u32, u64, Vec<u8>)>(MIC_QUEUE); let (mic_tx, mic_rx) = tokio::sync::mpsc::channel::<(u32, u64, Vec<u8>)>(MIC_QUEUE);
let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::<RichInput>(); let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::<RichInput>();
let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::channel::<CtrlRequest>(CTRL_QUEUE); let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::channel::<CtrlRequest>(CTRL_QUEUE);
let (clip_event_tx, clip_event_rx) =
std::sync::mpsc::sync_channel::<ClipEventCore>(CLIP_EVENT_QUEUE);
let (clip_cmd_tx, clip_cmd_rx) = tokio::sync::mpsc::unbounded_channel::<ClipCommand>();
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>(); let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
let shutdown = Arc::new(AtomicBool::new(false)); let shutdown = Arc::new(AtomicBool::new(false));
let quit = Arc::new(AtomicBool::new(false)); let quit = Arc::new(AtomicBool::new(false));
@@ -731,6 +764,8 @@ impl NativeClient {
rich_input_rx, rich_input_rx,
ctrl_rx, ctrl_rx,
ctrl_tx: ctrl_tx_pump, ctrl_tx: ctrl_tx_pump,
clip_event_tx,
clip_cmd_rx,
ready_tx, ready_tx,
shutdown: shutdown_w, shutdown: shutdown_w,
quit: quit_w, quit: quit_w,
@@ -764,6 +799,10 @@ impl NativeClient {
mic_tx, mic_tx,
rich_input_tx, rich_input_tx,
ctrl_tx, ctrl_tx,
clip: Mutex::new(clip_event_rx),
clip_cmd_tx,
next_xfer_id: AtomicU32::new(1),
host_caps: negotiated.host_caps,
probe, probe,
shutdown, shutdown,
quit, quit,
@@ -1240,6 +1279,83 @@ impl NativeClient {
self.input_tx.send(*ev).map_err(|_| PunktfunkError::Closed) 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<ClipKind>) -> 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<u32> {
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<u8>, 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<ClipEventCore> {
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 /// 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 /// [`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. /// uses them only for diagnostics). The host decodes it into a virtual microphone source.
@@ -1339,6 +1455,8 @@ struct WorkerArgs {
rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>, rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>,
ctrl_rx: tokio::sync::mpsc::Receiver<CtrlRequest>, ctrl_rx: tokio::sync::mpsc::Receiver<CtrlRequest>,
ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>, ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>,
clip_event_tx: SyncSender<ClipEventCore>,
clip_cmd_rx: tokio::sync::mpsc::UnboundedReceiver<ClipCommand>,
ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>, ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
shutdown: Arc<AtomicBool>, shutdown: Arc<AtomicBool>,
/// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set. /// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set.
@@ -1382,6 +1500,8 @@ async fn worker_main(args: WorkerArgs) {
mut rich_input_rx, mut rich_input_rx,
mut ctrl_rx, mut ctrl_rx,
ctrl_tx, ctrl_tx,
clip_event_tx,
clip_cmd_rx,
ready_tx, ready_tx,
shutdown, shutdown,
quit, quit,
@@ -1532,22 +1652,22 @@ async fn worker_main(args: WorkerArgs) {
chroma_format: welcome.chroma_format, chroma_format: welcome.chroma_format,
audio_channels: welcome.audio_channels, audio_channels: welcome.audio_channels,
codec: welcome.codec, codec: welcome.codec,
host_caps: welcome.host_caps,
}, },
welcome.host_caps,
)) ))
}; };
let (conn, mut session, mut ctrl_send, mut ctrl_recv, negotiated, host_caps) = match setup.await let (conn, mut session, mut ctrl_send, mut ctrl_recv, negotiated) = match setup.await {
{
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
let _ = ready_tx.send(Err(e)); let _ = ready_tx.send(Err(e));
return; 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 clock_rtt_ns = negotiated.clock_rtt_ns;
let resolved_bitrate_kbps = negotiated.bitrate_kbps; let resolved_bitrate_kbps = negotiated.bitrate_kbps;
let host_caps = negotiated.host_caps;
// Seed the live offset with the connect-time estimate BEFORE the embedder can observe the // Seed the live offset with the connect-time estimate BEFORE the embedder can observe the
// client (ready_tx): clock_offset_now_ns() never reads a pre-handshake 0 on a skewed pair. // client (ready_tx): clock_offset_now_ns() never reads a pre-handshake 0 on a skewed pair.
clock_offset.store(negotiated.clock_offset_ns, Ordering::Relaxed); clock_offset.store(negotiated.clock_offset_ns, Ordering::Relaxed);
@@ -1639,6 +1759,9 @@ async fn worker_main(args: WorkerArgs) {
let bitrate_ack = bitrate_ack.clone(); let bitrate_ack = bitrate_ack.clone();
let clock_offset = clock_offset.clone(); let clock_offset = clock_offset.clone();
let clock_gen = clock_gen.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 { tokio::spawn(async move {
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every // Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after // CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
@@ -1668,6 +1791,8 @@ async fn worker_main(args: WorkerArgs) {
} }
resync.begin(wall_clock_ns()).encode() 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() { if io::write_msg(&mut ctrl_send, &bytes).await.is_err() {
break; break;
@@ -1749,6 +1874,21 @@ async fn worker_main(args: WorkerArgs) {
} }
ResyncStep::Idle => {} 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 { } else {
tracing::warn!("unknown control message — ignoring"); tracing::warn!("unknown control message — ignoring");
} }
@@ -1825,6 +1965,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. // Watch for connection close → stop the pump.
{ {
let shutdown = shutdown.clone(); let shutdown = shutdown.clone();
+304
View File
@@ -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<ClipKind> },
/// 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<u8>,
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<u8>,
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<Mutex<HashMap<u32, oneshot::Sender<Option<Vec<u8>>>>>>;
/// 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<ClipEventCore>,
mut cmd_rx: UnboundedReceiver<ClipCommand>,
) {
// 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<u32, Vec<u8>> = HashMap::new();
// Outbound fetch cancel triggers: xfer_id -> a oneshot that aborts the fetch task.
let mut fetch_cancels: HashMap<u32, oneshot::Sender<()>> = 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<ClipEventCore>,
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<ClipEventCore>,
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,
});
}
}
}
+10 -1
View File
@@ -30,6 +30,11 @@ mod abr;
pub mod audio; pub mod audio;
#[cfg(feature = "quic")] #[cfg(feature = "quic")]
pub mod client; 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 config;
pub mod crypto; pub mod crypto;
pub mod error; pub mod error;
@@ -61,7 +66,11 @@ pub use stats::Stats;
/// TTL of a v2 envelope; `punktfunk_connection_next_rumble` is unchanged and drops it). Additive — /// TTL of a v2 envelope; `punktfunk_connection_next_rumble` is unchanged and drops it). Additive —
/// the wire is backward-compatible (the envelope is a length-tolerant tail on 0xCA), so /// the wire is backward-compatible (the envelope is a length-tolerant tail on 0xCA), so
/// [`WIRE_VERSION`] is unchanged. /// [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 5; /// v6: 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 = 6;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. /// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** /// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
@@ -0,0 +1,115 @@
//! 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`) and the connection reject code `0x42`.
pub const CLIP_CANCELLED_CODE: u32 = 0x60;
/// 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<u8> {
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<ClipFetch> {
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<ClipFetchHdr> {
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<Vec<u8>> {
recv.read_to_end(max_bytes)
.await
.map_err(std::io::Error::other)
}
+4
View File
@@ -49,6 +49,10 @@ pub mod endpoint;
/// Async framed-message IO over a quinn stream (`u16 LE length || payload`). /// Async framed-message IO over a quinn stream (`u16 LE length || payload`).
pub mod io; 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 /// 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 /// 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 /// SPAKE2 identities, so the derived key only matches when client and host agree on the PIN
+317
View File
@@ -142,6 +142,14 @@ pub const APP_EXITED_CLOSE_CODE: u32 = 0x52;
/// button/axis events; toward a host that doesn't set the bit it keeps the legacy events. /// 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; 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** /// [`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 /// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
/// advertise this. /// advertise this.
@@ -508,6 +516,147 @@ pub const MSG_CLOCK_PROBE: u8 = 0x30;
/// Type byte of [`ClockEcho`]. /// Type byte of [`ClockEcho`].
pub const MSG_CLOCK_ECHO: u8 = 0x31; 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<ClipKind>,
}
/// `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 // 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 // the control stream with PairRequest. The host shows a short PIN out-of-band (log/UI); the
@@ -1267,6 +1416,174 @@ impl ClockEcho {
} }
} }
/// Append one [`ClipKind`] to `b`: `mime_len u8 || mime bytes || size_hint u64 LE`.
fn put_clip_kind(b: &mut Vec<u8>, 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<u8> {
// 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<ClipControl> {
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<u8> {
// 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<ClipState> {
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<u8> {
// 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<ClipOffer> {
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<u8> {
// 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<ClipFetch> {
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<u8> {
// 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<ClipFetchHdr> {
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`. /// Frame a message for the control stream: `u16 LE length || payload`.
pub fn frame(payload: &[u8]) -> Vec<u8> { pub fn frame(payload: &[u8]) -> Vec<u8> {
let mut b = Vec::with_capacity(2 + payload.len()); let mut b = Vec::with_capacity(2 + payload.len());
+387
View File
@@ -1159,3 +1159,390 @@ fn fingerprint_is_sha256_of_der() {
assert_eq!(a, endpoint::cert_fingerprint(b"cert-a")); assert_eq!(a, endpoint::cert_fingerprint(b"cert-a"));
assert_ne!(a, endpoint::cert_fingerprint(b"cert-b")); 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();
}
}
+327 -1
View File
@@ -25,7 +25,11 @@
// TTL of a v2 envelope; `punktfunk_connection_next_rumble` is unchanged and drops it). Additive — // TTL of a v2 envelope; `punktfunk_connection_next_rumble` is unchanged and drops it). Additive —
// the wire is backward-compatible (the envelope is a length-tolerant tail on 0xCA), so // the wire is backward-compatible (the envelope is a length-tolerant tail on 0xCA), so
// [`WIRE_VERSION`] is unchanged. // [`WIRE_VERSION`] is unchanged.
#define ABI_VERSION 5 // v6: 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 6
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. // The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** // Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
@@ -159,11 +163,60 @@
// Codec bit: AV1. (Mirrors `quic::CODEC_AV1`.) // Codec bit: AV1. (Mirrors `quic::CODEC_AV1`.)
#define PUNKTFUNK_CODEC_AV1 4 #define PUNKTFUNK_CODEC_AV1 4
// 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 // `*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 // 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. // own staleness heuristic for that update instead of a host-supplied deadline.
#define PUNKTFUNK_RUMBLE_NO_TTL 4294967295 #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 `"<mime>\t<size_hint>"`
// 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. // 16-byte AEAD authentication tag appended by GCM.
#define TAG_LEN 16 #define TAG_LEN 16
@@ -434,6 +487,16 @@
#define HOST_CAP_GAMEPAD_STATE 1 #define HOST_CAP_GAMEPAD_STATE 1
#endif #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) #if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** // [`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 // encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
@@ -530,6 +593,119 @@
#define MSG_CLOCK_ECHO 49 #define MSG_CLOCK_ECHO 49
#endif #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) #if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`PairRequest`]. // Type byte of [`PairRequest`].
#define MSG_PAIR_REQUEST 16 #define MSG_PAIR_REQUEST 16
@@ -586,6 +762,25 @@
#define ColorInfo_MC_BT2020_NCL 9 #define ColorInfo_MC_BT2020_NCL 9
#endif #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`) and the connection reject code `0x42`.
#define CLIP_CANCELLED_CODE 96
#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
// Stable C ABI status codes. `Ok` is 0; all errors are negative so callers can // 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. // test `rc < 0`. Do not renumber existing variants — only append.
enum PunktfunkStatus enum PunktfunkStatus
@@ -915,6 +1110,47 @@ typedef struct {
} PunktfunkRichInputEx; } PunktfunkRichInputEx;
#endif #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 // 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 // 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 // delivered wire throughput to drive a bitrate choice from; `loss_pct` is the link loss and
@@ -1554,6 +1790,96 @@ PunktfunkStatus punktfunk_connection_mode(const PunktfunkConnection *c,
PunktfunkStatus punktfunk_connection_gamepad(const PunktfunkConnection *c, uint32_t *gamepad); PunktfunkStatus punktfunk_connection_gamepad(const PunktfunkConnection *c, uint32_t *gamepad);
#endif #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) #if defined(PUNKTFUNK_FEATURE_QUIC)
// The compositor backend the host actually resolved for this session (one of the // 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`] // `PUNKTFUNK_COMPOSITOR_*` values; the `Welcome`'s echo of the [`punktfunk_connect_ex`]