Files
punktfunk/crates/punktfunk-core/src/quic/clipstream.rs
T
enricobuehler af3a7d8cd5 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>
2026-07-12 19:08:21 +02:00

116 lines
5.6 KiB
Rust

//! 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)
}