merge(core): reconcile the W7/W8 client refactor with origin's shared-clipboard feature
origin/main landed the shared clipboard (design/clipboard-and-file-transfer.md) while
this branch split quic/msgs.rs -> quic/{caps,control,...} and client.rs ->
client/{mod,control,worker,pump,planes,...} (W7) and deleted the two monoliths. The
feature had modified both deleted files, so its delta is re-applied onto the split
instead of resurrecting the monoliths:
- HOST_CAP_CLIPBOARD -> quic/caps.rs
- MSG_CLIP_* / CLIP_* consts, the six Clip*
structs, and their encode/decode impls -> quic/control.rs (beside the clock codecs)
- CtrlRequest::{ClipControl,ClipOffer} +
Negotiated.host_caps -> client/control.rs
- WorkerArgs.{clip_event_tx,clip_cmd_rx} -> client/worker.rs
- CLIP_EVENT_QUEUE -> client/planes.rs
- NativeClient clip fields, the 7 clip_* /
host_caps / next_clip methods, connect()
channel wiring -> client/mod.rs
- the control-task encode/decode arms and
the clipboard-task spawn -> client/pump.rs
Cargo.lock reconciled (adds pf-clipboard), punktfunk-host/Cargo.toml unions the W6
pf-* subsystem deps with pf-clipboard, and include/punktfunk_core.h is the cbindgen
union (clipboard + rumble C-ABI). punktfunk-core builds --all-features and its 174
lib tests pass, including quic::tests::clip_loopback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,14 @@ pub const VIDEO_CAP_PROBE_SEQ: u8 = 0x10;
|
||||
/// button/axis events; toward a host that doesn't set the bit it keeps the legacy events.
|
||||
pub const HOST_CAP_GAMEPAD_STATE: u8 = 0x01;
|
||||
|
||||
/// [`Welcome::host_caps`] bit: the host has a shared-clipboard service (a working OS backend)
|
||||
/// **and** its operator policy does not hard-disable it, so the client may offer the clipboard
|
||||
/// toggle. Absent (an older host, or `PUNKTFUNK_CLIPBOARD` off) ⇒ the client greys the toggle
|
||||
/// out. Purely additive: nothing clipboard-related happens until a [`ClipControl`]`{ enabled:
|
||||
/// true }` crosses (see `design/clipboard-and-file-transfer.md` §3.1). Packs into the existing
|
||||
/// trailing `host_caps` byte — no wire-layout change.
|
||||
pub const HOST_CAP_CLIPBOARD: u8 = 0x02;
|
||||
|
||||
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
/// advertise this.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Per-transfer clipboard fetch streams (`design/clipboard-and-file-transfer.md` §3.3).
|
||||
//!
|
||||
//! Bulk clipboard / file bytes never ride the control stream (u16-capped) or datagrams (lossy,
|
||||
//! single-packet). The **requester opens a fresh QUIC bi-stream** toward the data holder, writes a
|
||||
//! small stream header + a [`ClipFetch`]; the holder replies with a [`ClipFetchHdr`] then raw data
|
||||
//! chunks until FIN. One transfer per stream ⇒ natural flow control, clean cancelation
|
||||
//! (`RESET_STREAM`), and no head-of-line blocking against control or other transfers.
|
||||
//!
|
||||
//! These helpers are the transport half only; they hold no clipboard state, so the host and the
|
||||
//! client-core reuse the exact same open/accept/serve wire dance (the accept-loop that dispatches
|
||||
//! by stream kind lives on each side, since the two sides own their connections differently).
|
||||
|
||||
use super::{io, ClipFetch, ClipFetchHdr};
|
||||
|
||||
/// First bytes an opener writes on a freshly-opened clipboard bi-stream: a magic keeping this
|
||||
/// stream namespace disjoint from any future stream kind, plus a 1-byte kind discriminator. A
|
||||
/// distinct magic means a stream opened for some other future purpose can never be misrouted here.
|
||||
pub const STREAM_MAGIC: &[u8; 4] = b"PKFs";
|
||||
|
||||
/// Stream-kind byte: a clipboard fetch (request/response of one format). Future stream kinds
|
||||
/// (e.g. a bulk file-content push) mux under the same [`STREAM_MAGIC`] with a different byte.
|
||||
pub const CLIP_STREAM_KIND_FETCH: u8 = 0x01;
|
||||
|
||||
/// QUIC application error code used to `reset`/`stop` a clipboard fetch stream on cancel — sync
|
||||
/// disabled mid-transfer, paste timed out, size cap exceeded, teardown. Distinct from the
|
||||
/// connection close codes ([`super::QUIT_CLOSE_CODE`] `0x51` / [`super::APP_EXITED_CLOSE_CODE`]
|
||||
/// `0x52`), the connection reject code `0x42`, and the pairing-rejection close block
|
||||
/// `0x60`–`0x67` — stream reset codes and connection close codes are separate QUIC namespaces,
|
||||
/// but the vocabularies stay disjoint on purpose so a captured code is unambiguous.
|
||||
pub const CLIP_CANCELLED_CODE: u32 = 0x70;
|
||||
|
||||
/// Chunk size for streaming fetch data (64 KiB writes — matches the control-frame bound).
|
||||
pub const CLIP_CHUNK: usize = 64 * 1024;
|
||||
|
||||
/// The `VarInt` form of [`CLIP_CANCELLED_CODE`], for `SendStream::reset` / `RecvStream::stop`.
|
||||
pub fn cancelled_code() -> quinn::VarInt {
|
||||
quinn::VarInt::from_u32(CLIP_CANCELLED_CODE)
|
||||
}
|
||||
|
||||
/// Requester side: open a fresh bi-stream toward the holder, deprioritize it under the control
|
||||
/// stream, write the stream header + the [`ClipFetch`], and hand back both halves. The send half
|
||||
/// is returned so the caller can `reset`/`finish` for cancelation; the recv half is positioned to
|
||||
/// read the [`ClipFetchHdr`] next (see [`read_fetch_hdr`]).
|
||||
pub async fn open_fetch(
|
||||
conn: &quinn::Connection,
|
||||
req: &ClipFetch,
|
||||
) -> std::io::Result<(quinn::SendStream, quinn::RecvStream)> {
|
||||
let (mut send, recv) = conn.open_bi().await.map_err(std::io::Error::other)?;
|
||||
// Yield to the control stream (default priority 0) so a large paste never head-of-line-blocks
|
||||
// the input/audio/control traffic sharing this connection.
|
||||
let _ = send.set_priority(-1);
|
||||
// The opener MUST write before the peer's `accept_bi()` can return (quinn contract), so send
|
||||
// the whole request eagerly.
|
||||
let mut hdr = Vec::with_capacity(5);
|
||||
hdr.extend_from_slice(STREAM_MAGIC);
|
||||
hdr.push(CLIP_STREAM_KIND_FETCH);
|
||||
send.write_all(&hdr).await.map_err(std::io::Error::other)?;
|
||||
io::write_msg(&mut send, &req.encode()).await?;
|
||||
Ok((send, recv))
|
||||
}
|
||||
|
||||
/// Holder side, step 1: after `accept_bi()`, read and validate the 5-byte stream header. Returns
|
||||
/// the kind byte (e.g. [`CLIP_STREAM_KIND_FETCH`]); an unknown magic is an error and the caller
|
||||
/// should `stop` the stream.
|
||||
pub async fn read_stream_header(recv: &mut quinn::RecvStream) -> std::io::Result<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)
|
||||
}
|
||||
@@ -468,3 +468,317 @@ pub fn frame(payload: &[u8]) -> Vec<u8> {
|
||||
b.extend_from_slice(payload);
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Shared clipboard & file transfer — wire codecs (ported from the pre-W7 quic/msgs.rs on the
|
||||
// clipboard-feature merge; the control-stream metadata messages live beside the clock codecs).
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Shared clipboard & file transfer (design/clipboard-and-file-transfer.md §3). The small
|
||||
// metadata messages ride the control stream (0x40-0x42); the two fetch-stream messages
|
||||
// (0x43-0x44) travel on a per-transfer bi-stream (see the [`super::clipstream`] helpers), never
|
||||
// the control stream, so they are never dispatched by the control loops. All are typed
|
||||
// (`CTL_MAGIC` + type byte), so an older peer hits its "unknown control message" arm and drops
|
||||
// any it doesn't know — the whole feature is forward-safe.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Type byte of [`ClipControl`] (client → host): enable/disable the shared clipboard for this
|
||||
/// session. Idempotent; opt-in is enforced here, not just in UI.
|
||||
pub const MSG_CLIP_CONTROL: u8 = 0x40;
|
||||
/// Type byte of [`ClipState`] (host → client): ack + unsolicited policy/backend updates.
|
||||
pub const MSG_CLIP_STATE: u8 = 0x41;
|
||||
/// Type byte of [`ClipOffer`] (symmetric): the lazy announcement — format list only, no bytes.
|
||||
pub const MSG_CLIP_OFFER: u8 = 0x42;
|
||||
/// Type byte of [`ClipFetch`] (requester → holder, **fetch stream only**): pull one format of the
|
||||
/// current offer.
|
||||
pub const MSG_CLIP_FETCH: u8 = 0x43;
|
||||
/// Type byte of [`ClipFetchHdr`] (holder → requester, **fetch stream only**): the fetch response
|
||||
/// header that precedes the data chunks.
|
||||
pub const MSG_CLIP_FETCH_HDR: u8 = 0x44;
|
||||
|
||||
/// [`ClipControl::flags`] bit: the client permits file kinds to be offered/fetched this session.
|
||||
/// Absent ⇒ files are filtered out of offers in both directions (text/rich/image only).
|
||||
pub const CLIP_FLAG_FILES: u8 = 0x01;
|
||||
|
||||
/// [`ClipState::policy`] bit: the host permits non-file formats (text/RTF/HTML/image). Always set
|
||||
/// while enabled unless a future direction limit clears it.
|
||||
pub const CLIP_POLICY_TEXT: u8 = 0x01;
|
||||
/// [`ClipState::policy`] bit: the host permits file formats. Cleared by the operator `no-files`
|
||||
/// / `text-only` policy so the client can grey out "Include files".
|
||||
pub const CLIP_POLICY_FILES: u8 = 0x02;
|
||||
|
||||
/// [`ClipState::reason`]: normal ack, nothing exceptional.
|
||||
pub const CLIP_REASON_OK: u8 = 0;
|
||||
/// [`ClipState::reason`]: this session type has no working clipboard backend (e.g. a gamescope
|
||||
/// session with no data-control global) — the client shows "not supported in this session type".
|
||||
pub const CLIP_REASON_BACKEND_UNAVAILABLE: u8 = 1;
|
||||
/// [`ClipState::reason`]: another client took over the single per-desktop clipboard binding; this
|
||||
/// one was disabled (last `ClipControl{enabled}` wins).
|
||||
pub const CLIP_REASON_TAKEN_OVER: u8 = 2;
|
||||
/// [`ClipState::reason`]: the host operator policy (`PUNKTFUNK_CLIPBOARD=off`) disables clipboard.
|
||||
pub const CLIP_REASON_POLICY_DISABLED: u8 = 3;
|
||||
/// [`ClipState::reason`]: enabled, but the host policy forbids file transfer (`no-files` /
|
||||
/// `text-only`) — surfaced so the client greys "Include files" with a footnote.
|
||||
pub const CLIP_REASON_NO_FILES: u8 = 4;
|
||||
|
||||
/// [`ClipFetchHdr::status`]: the requested format is being served; data chunks follow until FIN.
|
||||
pub const CLIP_FETCH_OK: u8 = 0;
|
||||
/// [`ClipFetchHdr::status`]: the fetch named a `seq` that is no longer the holder's current offer;
|
||||
/// the requester degrades the paste to "nothing inserted" rather than wrong data. No chunks follow.
|
||||
pub const CLIP_FETCH_STALE: u8 = 1;
|
||||
/// [`ClipFetchHdr::status`]: the format/index is not available (no backend, or it vanished). No
|
||||
/// chunks follow.
|
||||
pub const CLIP_FETCH_UNAVAILABLE: u8 = 2;
|
||||
/// [`ClipFetchHdr::status`]: policy/cap denies this fetch (e.g. a file fetch under `no-files`). No
|
||||
/// chunks follow.
|
||||
pub const CLIP_FETCH_DENIED: u8 = 3;
|
||||
|
||||
/// Maximum number of [`ClipKind`] entries in one [`ClipOffer`] (resource cap, §7).
|
||||
pub const CLIP_MAX_KINDS: usize = 16;
|
||||
/// Maximum length in bytes of a [`ClipKind::mime`] string (resource cap, §7).
|
||||
pub const CLIP_MAX_MIME: usize = 128;
|
||||
/// [`ClipFetch::file_index`] sentinel meaning "not a file fetch" (a whole non-file format, or the
|
||||
/// file *manifest* itself). Real file fetches use `0..n`.
|
||||
pub const CLIP_FILE_INDEX_NONE: u32 = u32::MAX;
|
||||
|
||||
/// One advertised clipboard format inside a [`ClipOffer`] — a portable MIME name plus a size hint.
|
||||
/// The bytes never ride here; they cross lazily on a fetch stream only when the destination pastes.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ClipKind {
|
||||
/// Portable wire MIME, e.g. `text/plain;charset=utf-8`, `text/html`, `image/png`,
|
||||
/// `application/x-punktfunk-files`. Each end maps it to a platform type at fetch time. ≤
|
||||
/// [`CLIP_MAX_MIME`] bytes; a longer one is rejected on decode.
|
||||
pub mime: String,
|
||||
/// Best-effort total size of this format in bytes; `0` = unknown (a streaming provider).
|
||||
pub size_hint: u64,
|
||||
}
|
||||
|
||||
/// `client → host` ([`MSG_CLIP_CONTROL`]): flip the shared clipboard on/off for this session.
|
||||
/// Sent when the user toggles the per-host pref and once at session start if it is on. **Nothing
|
||||
/// clipboard-related happens on either side until an `enabled: true` arrives** — opt-in at the
|
||||
/// protocol layer.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ClipControl {
|
||||
pub enabled: bool,
|
||||
/// Bitfield of [`CLIP_FLAG_FILES`] (+ reserved bits for future direction limits).
|
||||
pub flags: u8,
|
||||
}
|
||||
|
||||
/// `host → client` ([`MSG_CLIP_STATE`]): acknowledge a [`ClipControl`] and push unsolicited
|
||||
/// updates (policy changed, backend lost). The client surfaces `reason`/`policy` in the toggle UI
|
||||
/// instead of failing silently.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ClipState {
|
||||
pub enabled: bool,
|
||||
/// Bitfield of [`CLIP_POLICY_TEXT`] / [`CLIP_POLICY_FILES`] — what the host currently permits.
|
||||
pub policy: u8,
|
||||
/// One of the `CLIP_REASON_*` values explaining `enabled`/`policy`.
|
||||
pub reason: u8,
|
||||
}
|
||||
|
||||
/// Symmetric ([`MSG_CLIP_OFFER`], either direction): the lazy announcement. Sent when the local
|
||||
/// clipboard changes; carries the **format list only** (comfortably inside the 64 KiB control
|
||||
/// frame). A new offer replaces the sender's previous one; `seq` lets the holder reject stale
|
||||
/// fetches (§3.4). Files are announced as one `application/x-punktfunk-files` kind — the file
|
||||
/// list itself is fetched lazily, never inlined here.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ClipOffer {
|
||||
/// Monotonic per sender; newest wins.
|
||||
pub seq: u32,
|
||||
/// ≤ [`CLIP_MAX_KINDS`] entries.
|
||||
pub kinds: Vec<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,
|
||||
}
|
||||
|
||||
/// 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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ pub mod endpoint;
|
||||
/// Async framed-message IO over a quinn stream (`u16 LE length || payload`).
|
||||
pub mod io;
|
||||
|
||||
/// Per-transfer clipboard fetch bi-streams (`PKFs` magic + kind byte, then request/response). The
|
||||
/// transport half of the shared clipboard; wire codecs are in [`control`], state lives per side.
|
||||
pub mod clipstream;
|
||||
|
||||
/// SPAKE2 over Ed25519 for the pairing ceremony. The two roles use the asymmetric flow so
|
||||
/// the identities are ordered; each side binds **both** certificate fingerprints as the
|
||||
/// SPAKE2 identities, so the derived key only matches when client and host agree on the PIN
|
||||
|
||||
@@ -1316,3 +1316,390 @@ fn fingerprint_is_sha256_of_der() {
|
||||
assert_eq!(a, endpoint::cert_fingerprint(b"cert-a"));
|
||||
assert_ne!(a, endpoint::cert_fingerprint(b"cert-b"));
|
||||
}
|
||||
|
||||
// ---- Shared clipboard control + fetch-stream message codecs (0x40-0x44) -----------------------
|
||||
|
||||
#[test]
|
||||
fn clip_control_roundtrip() {
|
||||
for (enabled, flags) in [
|
||||
(true, 0u8),
|
||||
(false, 0),
|
||||
(true, CLIP_FLAG_FILES),
|
||||
(false, 0xFF),
|
||||
] {
|
||||
let m = ClipControl { enabled, flags };
|
||||
assert_eq!(ClipControl::decode(&m.encode()).unwrap(), m);
|
||||
}
|
||||
// Disjoint from its host→client sibling (type byte + length) and exact length.
|
||||
assert!(ClipControl::decode(
|
||||
&ClipState {
|
||||
enabled: true,
|
||||
policy: 0,
|
||||
reason: 0
|
||||
}
|
||||
.encode()
|
||||
)
|
||||
.is_err());
|
||||
let bytes = ClipControl {
|
||||
enabled: true,
|
||||
flags: 0,
|
||||
}
|
||||
.encode();
|
||||
assert!(ClipControl::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||
assert!(ClipControl::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_state_roundtrip() {
|
||||
let cases = [
|
||||
ClipState {
|
||||
enabled: true,
|
||||
policy: CLIP_POLICY_TEXT | CLIP_POLICY_FILES,
|
||||
reason: CLIP_REASON_OK,
|
||||
},
|
||||
ClipState {
|
||||
enabled: false,
|
||||
policy: 0,
|
||||
reason: CLIP_REASON_BACKEND_UNAVAILABLE,
|
||||
},
|
||||
ClipState {
|
||||
enabled: true,
|
||||
policy: CLIP_POLICY_TEXT,
|
||||
reason: CLIP_REASON_NO_FILES,
|
||||
},
|
||||
];
|
||||
for m in cases {
|
||||
assert_eq!(ClipState::decode(&m.encode()).unwrap(), m);
|
||||
}
|
||||
// A ClipControl must not decode as a ClipState (type byte).
|
||||
assert!(ClipState::decode(
|
||||
&ClipControl {
|
||||
enabled: true,
|
||||
flags: 0
|
||||
}
|
||||
.encode()
|
||||
)
|
||||
.is_err());
|
||||
let bytes = cases[0].encode();
|
||||
assert!(ClipState::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_offer_roundtrip() {
|
||||
// Empty offer, one kind, and a full multi-format offer (text/rich/image/files).
|
||||
let cases = [
|
||||
ClipOffer {
|
||||
seq: 0,
|
||||
kinds: vec![],
|
||||
},
|
||||
ClipOffer {
|
||||
seq: 1,
|
||||
kinds: vec![ClipKind {
|
||||
mime: "text/plain;charset=utf-8".into(),
|
||||
size_hint: 12,
|
||||
}],
|
||||
},
|
||||
ClipOffer {
|
||||
seq: u32::MAX,
|
||||
kinds: vec![
|
||||
ClipKind {
|
||||
mime: "text/plain;charset=utf-8".into(),
|
||||
size_hint: 0,
|
||||
},
|
||||
ClipKind {
|
||||
mime: "text/html".into(),
|
||||
size_hint: 4096,
|
||||
},
|
||||
ClipKind {
|
||||
mime: "image/png".into(),
|
||||
size_hint: 1 << 30,
|
||||
},
|
||||
ClipKind {
|
||||
mime: "application/x-punktfunk-files".into(),
|
||||
size_hint: 5_000_000_000,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
for m in &cases {
|
||||
assert_eq!(&ClipOffer::decode(&m.encode()).unwrap(), m);
|
||||
}
|
||||
// Trailing bytes are rejected (get_clip_kind consumes exactly to the end).
|
||||
let mut padded = cases[1].encode();
|
||||
padded.push(0);
|
||||
assert!(ClipOffer::decode(&padded).is_err());
|
||||
// A count byte over the cap is rejected before allocating.
|
||||
let mut over = cases[0].encode();
|
||||
over[9] = (CLIP_MAX_KINDS + 1) as u8;
|
||||
assert!(ClipOffer::decode(&over).is_err());
|
||||
// Disjoint from a same-family control message.
|
||||
assert!(ClipOffer::decode(
|
||||
&ClipControl {
|
||||
enabled: true,
|
||||
flags: 0
|
||||
}
|
||||
.encode()
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_fetch_roundtrip() {
|
||||
let cases = [
|
||||
ClipFetch {
|
||||
seq: 1,
|
||||
file_index: CLIP_FILE_INDEX_NONE,
|
||||
mime: "text/plain;charset=utf-8".into(),
|
||||
},
|
||||
ClipFetch {
|
||||
seq: 7,
|
||||
file_index: 0,
|
||||
mime: "application/x-punktfunk-files".into(),
|
||||
},
|
||||
ClipFetch {
|
||||
seq: u32::MAX,
|
||||
file_index: 41,
|
||||
mime: String::new(),
|
||||
},
|
||||
];
|
||||
for m in &cases {
|
||||
assert_eq!(&ClipFetch::decode(&m.encode()).unwrap(), m);
|
||||
}
|
||||
// Trailing + truncation both rejected (exact-length mime check).
|
||||
let bytes = cases[0].encode();
|
||||
assert!(ClipFetch::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||
assert!(ClipFetch::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||
// A fetch-stream message must not decode as a control-stream offer, and vice-versa.
|
||||
assert!(ClipOffer::decode(&cases[0].encode()).is_err());
|
||||
assert!(ClipFetch::decode(
|
||||
&ClipOffer {
|
||||
seq: 1,
|
||||
kinds: vec![]
|
||||
}
|
||||
.encode()
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_fetch_hdr_roundtrip() {
|
||||
for (status, total_size) in [
|
||||
(CLIP_FETCH_OK, 15u64),
|
||||
(CLIP_FETCH_STALE, 0),
|
||||
(CLIP_FETCH_UNAVAILABLE, 0),
|
||||
(CLIP_FETCH_DENIED, 0),
|
||||
(CLIP_FETCH_OK, u64::MAX),
|
||||
] {
|
||||
let m = ClipFetchHdr { status, total_size };
|
||||
assert_eq!(ClipFetchHdr::decode(&m.encode()).unwrap(), m);
|
||||
}
|
||||
let bytes = ClipFetchHdr {
|
||||
status: CLIP_FETCH_OK,
|
||||
total_size: 1,
|
||||
}
|
||||
.encode();
|
||||
assert!(ClipFetchHdr::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||
assert!(ClipFetchHdr::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_cap_clipboard_bit_is_distinct_and_survives_welcome() {
|
||||
// The new cap packs into the existing trailing host_caps byte with no layout change.
|
||||
assert_ne!(HOST_CAP_CLIPBOARD, HOST_CAP_GAMEPAD_STATE);
|
||||
let mut w = Welcome {
|
||||
abi_version: 1,
|
||||
udp_port: 1,
|
||||
mode: Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
},
|
||||
fec: FecConfig {
|
||||
scheme: FecScheme::Gf16,
|
||||
fec_percent: 0,
|
||||
max_data_per_block: 1024,
|
||||
},
|
||||
shard_payload: 1024,
|
||||
encrypt: false,
|
||||
key: [0; 16],
|
||||
salt: [0; 4],
|
||||
frames: 0,
|
||||
compositor: CompositorPref::Auto,
|
||||
gamepad: GamepadPref::Auto,
|
||||
bitrate_kbps: 0,
|
||||
bit_depth: 8,
|
||||
color: ColorInfo::SDR_BT709,
|
||||
chroma_format: CHROMA_IDC_420,
|
||||
audio_channels: 2,
|
||||
codec: CODEC_HEVC,
|
||||
host_caps: HOST_CAP_GAMEPAD_STATE | HOST_CAP_CLIPBOARD,
|
||||
};
|
||||
let got = Welcome::decode(&w.encode()).unwrap();
|
||||
assert_eq!(got.host_caps & HOST_CAP_CLIPBOARD, HOST_CAP_CLIPBOARD);
|
||||
assert_eq!(
|
||||
got.host_caps & HOST_CAP_GAMEPAD_STATE,
|
||||
HOST_CAP_GAMEPAD_STATE
|
||||
);
|
||||
// Clipboard-off host: the bit is clear, gamepad bit still set.
|
||||
w.host_caps = HOST_CAP_GAMEPAD_STATE;
|
||||
assert_eq!(
|
||||
Welcome::decode(&w.encode()).unwrap().host_caps & HOST_CAP_CLIPBOARD,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
// ---- In-process QUIC loopback: the real clipstream fetch transport, both success and cancel ----
|
||||
|
||||
mod clip_loopback {
|
||||
use super::*;
|
||||
use crate::quic::clipstream;
|
||||
|
||||
/// Stand up two loopback quinn endpoints, connect, and return
|
||||
/// `(server_ep, client_ep, host_conn, client_conn)`. Both endpoints are returned so the caller
|
||||
/// keeps them in scope — dropping a `quinn::Endpoint` tears down its connections.
|
||||
async fn connect_pair() -> (
|
||||
quinn::Endpoint,
|
||||
quinn::Endpoint,
|
||||
quinn::Connection,
|
||||
quinn::Connection,
|
||||
) {
|
||||
let server = endpoint::server("127.0.0.1:0".parse().unwrap()).unwrap();
|
||||
let addr = server.local_addr().unwrap();
|
||||
let client = endpoint::client_insecure().unwrap();
|
||||
let accept = tokio::spawn(async move {
|
||||
let incoming = server.accept().await.expect("incoming connection");
|
||||
let conn = incoming.await.expect("host side connects");
|
||||
(server, conn)
|
||||
});
|
||||
let client_conn = client
|
||||
.connect(addr, "punktfunk")
|
||||
.unwrap()
|
||||
.await
|
||||
.expect("client side connects");
|
||||
let (server, host_conn) = accept.await.unwrap();
|
||||
(server, client, host_conn, client_conn)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_text_transfers_then_cancel_resets() {
|
||||
let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await;
|
||||
|
||||
let payload = b"hello clipboard \xf0\x9f\x93\x8b".to_vec(); // text + a 4-byte emoji
|
||||
let holder_payload = payload.clone();
|
||||
|
||||
// Holder = the host side: accept two fetch streams. Serve the first; cancel the second.
|
||||
let holder = tokio::spawn(async move {
|
||||
// Fetch #1 — serve the payload.
|
||||
let (mut send, mut recv) = host_conn.accept_bi().await.expect("accept fetch #1");
|
||||
let kind = clipstream::read_stream_header(&mut recv)
|
||||
.await
|
||||
.expect("stream header #1");
|
||||
assert_eq!(kind, clipstream::CLIP_STREAM_KIND_FETCH);
|
||||
let req = clipstream::read_fetch(&mut recv)
|
||||
.await
|
||||
.expect("fetch req #1");
|
||||
assert_eq!(req.seq, 1);
|
||||
assert_eq!(req.file_index, CLIP_FILE_INDEX_NONE);
|
||||
assert_eq!(req.mime, "text/plain;charset=utf-8");
|
||||
clipstream::write_fetch_hdr(
|
||||
&mut send,
|
||||
&ClipFetchHdr {
|
||||
status: CLIP_FETCH_OK,
|
||||
total_size: holder_payload.len() as u64,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("write hdr #1");
|
||||
clipstream::write_data(&mut send, &holder_payload)
|
||||
.await
|
||||
.expect("write data #1");
|
||||
|
||||
// Fetch #2 — read the request, then cancel mid-transfer with RESET_STREAM.
|
||||
let (mut send2, mut recv2) = host_conn.accept_bi().await.expect("accept fetch #2");
|
||||
clipstream::read_stream_header(&mut recv2)
|
||||
.await
|
||||
.expect("stream header #2");
|
||||
let _ = clipstream::read_fetch(&mut recv2)
|
||||
.await
|
||||
.expect("fetch req #2");
|
||||
send2.reset(clipstream::cancelled_code()).unwrap();
|
||||
|
||||
host_conn // keep alive until the requester side is done
|
||||
});
|
||||
|
||||
// Requester = the client side.
|
||||
// #1: full lazy fetch of the text payload.
|
||||
let req = ClipFetch {
|
||||
seq: 1,
|
||||
file_index: CLIP_FILE_INDEX_NONE,
|
||||
mime: "text/plain;charset=utf-8".into(),
|
||||
};
|
||||
let (_send, mut recv) = clipstream::open_fetch(&client_conn, &req)
|
||||
.await
|
||||
.expect("open fetch #1");
|
||||
let hdr = clipstream::read_fetch_hdr(&mut recv)
|
||||
.await
|
||||
.expect("read hdr #1");
|
||||
assert_eq!(hdr.status, CLIP_FETCH_OK);
|
||||
assert_eq!(hdr.total_size as usize, payload.len());
|
||||
let got = clipstream::read_data(&mut recv, 8 << 20)
|
||||
.await
|
||||
.expect("read data #1");
|
||||
assert_eq!(got, payload);
|
||||
|
||||
// #2: the holder resets the stream — the requester surfaces an error rather than hanging.
|
||||
let req2 = ClipFetch {
|
||||
seq: 2,
|
||||
file_index: CLIP_FILE_INDEX_NONE,
|
||||
mime: "text/plain;charset=utf-8".into(),
|
||||
};
|
||||
let (_send2, mut recv2) = clipstream::open_fetch(&client_conn, &req2)
|
||||
.await
|
||||
.expect("open fetch #2");
|
||||
assert!(
|
||||
clipstream::read_fetch_hdr(&mut recv2).await.is_err(),
|
||||
"a cancelled fetch must surface as an error, not a hang"
|
||||
);
|
||||
|
||||
let _host_conn = holder.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_data_enforces_size_cap() {
|
||||
let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await;
|
||||
|
||||
let big = vec![0xABu8; 200_000]; // > the 64 KiB chunk, and > the cap we set below
|
||||
let holder_payload = big.clone();
|
||||
let holder = tokio::spawn(async move {
|
||||
let (mut send, mut recv) = host_conn.accept_bi().await.expect("accept");
|
||||
clipstream::read_stream_header(&mut recv).await.unwrap();
|
||||
let _ = clipstream::read_fetch(&mut recv).await.unwrap();
|
||||
clipstream::write_fetch_hdr(
|
||||
&mut send,
|
||||
&ClipFetchHdr {
|
||||
status: CLIP_FETCH_OK,
|
||||
total_size: holder_payload.len() as u64,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let _ = clipstream::write_data(&mut send, &holder_payload).await;
|
||||
host_conn
|
||||
});
|
||||
|
||||
let req = ClipFetch {
|
||||
seq: 1,
|
||||
file_index: CLIP_FILE_INDEX_NONE,
|
||||
mime: "application/octet-stream".into(),
|
||||
};
|
||||
let (_send, mut recv) = clipstream::open_fetch(&client_conn, &req).await.unwrap();
|
||||
assert_eq!(
|
||||
clipstream::read_fetch_hdr(&mut recv).await.unwrap().status,
|
||||
CLIP_FETCH_OK
|
||||
);
|
||||
// Cap below the payload size ⇒ read_data errors instead of buffering unboundedly.
|
||||
assert!(clipstream::read_data(&mut recv, 64 * 1024).await.is_err());
|
||||
|
||||
let _host_conn = holder.await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user