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 v8 (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).
Ported from feat/shared-clipboard (af3a7d8c, pre-W6 base) onto current main;
three deliberate deviations from the original commit:
- ABI v6 → v8: main took v6 (reanchor gate) and v7 (typed connect rejection)
in the meantime; the clipboard C surface re-lands as v8.
- CLIP_CANCELLED_CODE 0x60 → 0x70: main's pairing-rejection close codes claimed
the 0x60–0x67 block; the vocabularies stay disjoint on purpose.
- Negotiated.host_caps coexists with main's 6-tuple host_caps plumbing: main
needs the worker-local copy for gamepad snapshots, the clipboard path needs it
across ready_tx to build the NativeClient handle (punktfunk_connection_host_caps).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -527,6 +527,10 @@ pub struct PunktfunkConnection {
|
||||
/// `inner.audio_channels`; `pcm` is a fixed-capacity reusable buffer the returned pointer
|
||||
/// borrows until the next PCM call (same contract as `last_audio`).
|
||||
audio_pcm: std::sync::Mutex<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
|
||||
@@ -948,6 +952,14 @@ pub const PUNKTFUNK_CODEC_AV1: u8 = 0x04;
|
||||
/// (design/pyrowave-codec-plan.md §3). (Mirrors `quic::CODEC_PYROWAVE`.)
|
||||
pub const PUNKTFUNK_CODEC_PYROWAVE: u8 = 0x08;
|
||||
|
||||
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host applies gamepad-state
|
||||
/// snapshots (a capable client sends full-state snapshots instead of per-transition events).
|
||||
/// (Mirrors `quic::HOST_CAP_GAMEPAD_STATE`.)
|
||||
pub const PUNKTFUNK_HOST_CAP_GAMEPAD_STATE: u8 = 0x01;
|
||||
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host supports the shared
|
||||
/// clipboard, so a client may offer the toggle. (Mirrors `quic::HOST_CAP_CLIPBOARD`.)
|
||||
pub const PUNKTFUNK_HOST_CAP_CLIPBOARD: u8 = 0x02;
|
||||
|
||||
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
|
||||
#[cfg(feature = "quic")]
|
||||
const _: () = {
|
||||
@@ -958,6 +970,8 @@ const _: () = {
|
||||
assert!(PUNKTFUNK_CODEC_HEVC == crate::quic::CODEC_HEVC);
|
||||
assert!(PUNKTFUNK_CODEC_AV1 == crate::quic::CODEC_AV1);
|
||||
assert!(PUNKTFUNK_CODEC_PYROWAVE == crate::quic::CODEC_PYROWAVE);
|
||||
assert!(PUNKTFUNK_HOST_CAP_GAMEPAD_STATE == crate::quic::HOST_CAP_GAMEPAD_STATE);
|
||||
assert!(PUNKTFUNK_HOST_CAP_CLIPBOARD == crate::quic::HOST_CAP_CLIPBOARD);
|
||||
};
|
||||
|
||||
// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
|
||||
@@ -1552,6 +1566,7 @@ unsafe fn connect_ex_impl(
|
||||
last: std::sync::Mutex::new(None),
|
||||
last_audio: std::sync::Mutex::new(None),
|
||||
audio_pcm: std::sync::Mutex::new(AudioPcmState::default()),
|
||||
last_clip: std::sync::Mutex::new(None),
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -2456,6 +2471,397 @@ pub unsafe extern "C" fn punktfunk_connection_gamepad(
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================================
|
||||
// Shared clipboard (design/clipboard-and-file-transfer.md §5.1). Additive, ABI v6. All poll/serve
|
||||
// bytes ride the mTLS-pinned QUIC session; nothing here opens a new listener or port.
|
||||
// ============================================================================================
|
||||
|
||||
/// [`PunktfunkClipEvent::kind`] — the host announced clipboard content is available
|
||||
/// (`transfer_id` = the offer `seq`; `data`/`len` = a `\n`-separated `"<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
|
||||
/// `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
|
||||
|
||||
@@ -12,15 +12,16 @@
|
||||
//! channel. All methods are safe to call from any single embedder thread.
|
||||
|
||||
use crate::abr::BitrateController;
|
||||
use crate::clipboard::{ClipCommand, ClipEventCore};
|
||||
use crate::config::{CompositorPref, GamepadPref, Mode, Role};
|
||||
use crate::error::{PunktfunkError, Result};
|
||||
use crate::input::InputEvent;
|
||||
use crate::packet::FLAG_PROBE;
|
||||
use crate::quic::{
|
||||
accept_resync, endpoint, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClockEcho,
|
||||
ClockResync, ColorInfo, HdrMeta, Hello, HidOutput, LossReport, ProbeRequest, ProbeResult,
|
||||
Reconfigure, Reconfigured, RequestKeyframe, ResyncStep, RfiRequest, RichInput, SetBitrate,
|
||||
Start, Welcome,
|
||||
accept_resync, endpoint, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipControl,
|
||||
ClipKind, ClipOffer, ClipState, ClockEcho, ClockResync, ColorInfo, HdrMeta, Hello, HidOutput,
|
||||
LossReport, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, ResyncStep,
|
||||
RfiRequest, RichInput, SetBitrate, Start, Welcome,
|
||||
};
|
||||
use crate::session::{Frame, Session};
|
||||
use crate::transport::UdpTransport;
|
||||
@@ -62,6 +63,12 @@ enum CtrlRequest {
|
||||
/// its report tick after the first no-op clock flush — the "the clock stepped under me"
|
||||
/// signal; the control task also self-triggers one every [`CLOCK_RESYNC_INTERVAL`].
|
||||
ClockResync,
|
||||
/// Shared-clipboard enable/disable for this session (`design/clipboard-and-file-transfer.md`
|
||||
/// §3.1). Idempotent; carries the file-permission flag.
|
||||
ClipControl(ClipControl),
|
||||
/// Announce that the local clipboard changed — the lazy format-list offer (bytes cross later on
|
||||
/// a fetch stream). Symmetric message; the host may send one too.
|
||||
ClipOffer(ClipOffer),
|
||||
}
|
||||
|
||||
/// What the worker reports to [`NativeClient::connect`] once the handshake lands: the
|
||||
@@ -95,6 +102,10 @@ struct Negotiated {
|
||||
audio_channels: u8,
|
||||
/// The single codec the host will emit (`quic::CODEC_*`).
|
||||
codec: u8,
|
||||
/// The host capability bitfield ([`Welcome::host_caps`]): [`crate::quic::HOST_CAP_GAMEPAD_STATE`],
|
||||
/// [`crate::quic::HOST_CAP_CLIPBOARD`]. Exposed to the embedder via
|
||||
/// [`NativeClient::host_caps`] so a native client greys out unsupported toggles.
|
||||
host_caps: u8,
|
||||
}
|
||||
|
||||
/// Accumulated state of an in-flight / finished speed test. The data-plane pump mirrors the
|
||||
@@ -368,6 +379,12 @@ const HDR_META_QUEUE: usize = 8;
|
||||
/// harmless, it's per-frame observability, not state.
|
||||
const HOST_TIMING_QUEUE: usize = 512;
|
||||
|
||||
/// Clipboard event plane depth (offers, host acks, fetch-requests, fetched payloads). Clipboard
|
||||
/// activity is human-paced and sparse; a small ring is ample. Overflow drops the newest event
|
||||
/// (try_send), same discipline as the other planes — a dropped offer heals on the next copy, and
|
||||
/// a dropped fetch-request makes the serving stream time out and reset cleanly.
|
||||
const CLIP_EVENT_QUEUE: usize = 32;
|
||||
|
||||
/// One Opus packet from the host's audio datagram stream (48 kHz stereo, 5 ms frames).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AudioPacket {
|
||||
@@ -480,6 +497,19 @@ pub struct NativeClient {
|
||||
/// Bounded ([`CTRL_QUEUE`]) — the requests are sparse; a full queue means the control task
|
||||
/// is wedged/dead, and callers treat it like a closed session.
|
||||
ctrl_tx: tokio::sync::mpsc::Sender<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.
|
||||
probe: Arc<Mutex<ProbeState>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
@@ -694,6 +724,9 @@ impl NativeClient {
|
||||
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 (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 shutdown = Arc::new(AtomicBool::new(false));
|
||||
let quit = Arc::new(AtomicBool::new(false));
|
||||
@@ -761,6 +794,8 @@ impl NativeClient {
|
||||
rich_input_rx,
|
||||
ctrl_rx,
|
||||
ctrl_tx: ctrl_tx_pump,
|
||||
clip_event_tx,
|
||||
clip_cmd_rx,
|
||||
ready_tx,
|
||||
shutdown: shutdown_w,
|
||||
quit: quit_w,
|
||||
@@ -795,6 +830,10 @@ impl NativeClient {
|
||||
mic_tx,
|
||||
rich_input_tx,
|
||||
ctrl_tx,
|
||||
clip: Mutex::new(clip_event_rx),
|
||||
clip_cmd_tx,
|
||||
next_xfer_id: AtomicU32::new(1),
|
||||
host_caps: negotiated.host_caps,
|
||||
probe,
|
||||
shutdown,
|
||||
quit,
|
||||
@@ -1309,6 +1348,83 @@ impl NativeClient {
|
||||
self.input_tx.send(*ev).map_err(|_| PunktfunkError::Closed)
|
||||
}
|
||||
|
||||
/// The host capability bitfield the [`Welcome`] carried ([`crate::quic::HOST_CAP_GAMEPAD_STATE`],
|
||||
/// [`crate::quic::HOST_CAP_CLIPBOARD`]). A native client tests
|
||||
/// `host_caps() & HOST_CAP_CLIPBOARD` to decide whether to offer the shared-clipboard toggle.
|
||||
pub fn host_caps(&self) -> u8 {
|
||||
self.host_caps
|
||||
}
|
||||
|
||||
/// Enable or disable the shared clipboard for this session (`design/clipboard-and-file-transfer.md`
|
||||
/// §3.1). Opt-in: nothing is announced or served until this crosses with `enabled = true`.
|
||||
/// `flags` carries [`crate::quic::CLIP_FLAG_FILES`]. Non-blocking; the host replies with a
|
||||
/// `State` event ([`NativeClient::next_clip`]).
|
||||
pub fn clip_control(&self, enabled: bool, flags: u8) -> Result<()> {
|
||||
self.ctrl_tx
|
||||
.try_send(CtrlRequest::ClipControl(ClipControl { enabled, flags }))
|
||||
.map_err(|_| PunktfunkError::Closed)
|
||||
}
|
||||
|
||||
/// Announce that the local clipboard changed — the lazy format-list offer. `seq` is a
|
||||
/// monotonic per-sender counter (newest wins); `kinds` is the advertised formats (≤
|
||||
/// [`crate::quic::CLIP_MAX_KINDS`]). The bytes cross only if the host later fetches.
|
||||
pub fn clip_offer(&self, seq: u32, kinds: Vec<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
|
||||
/// [`next_audio`](Self::next_audio)). `seq`/`pts_ns` are the caller's own counters (the host
|
||||
/// uses them only for diagnostics). The host decodes it into a virtual microphone source.
|
||||
@@ -1408,6 +1524,8 @@ struct WorkerArgs {
|
||||
rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>,
|
||||
ctrl_rx: tokio::sync::mpsc::Receiver<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>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
/// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set.
|
||||
@@ -1466,6 +1584,8 @@ async fn worker_main(args: WorkerArgs) {
|
||||
mut rich_input_rx,
|
||||
mut ctrl_rx,
|
||||
ctrl_tx,
|
||||
clip_event_tx,
|
||||
clip_cmd_rx,
|
||||
ready_tx,
|
||||
shutdown,
|
||||
quit,
|
||||
@@ -1630,6 +1750,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
audio_channels: welcome.audio_channels,
|
||||
codec: welcome.codec,
|
||||
shard_payload: welcome.shard_payload,
|
||||
host_caps: welcome.host_caps,
|
||||
},
|
||||
welcome.host_caps,
|
||||
))
|
||||
@@ -1653,7 +1774,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Copies the pump needs after `negotiated` is handed over to `connect`.
|
||||
// Copies the worker needs after `negotiated` is handed over to `connect`.
|
||||
let clock_rtt_ns = negotiated.clock_rtt_ns;
|
||||
let resolved_bitrate_kbps = negotiated.bitrate_kbps;
|
||||
let negotiated_codec = negotiated.codec;
|
||||
@@ -1830,6 +1951,9 @@ async fn worker_main(args: WorkerArgs) {
|
||||
let bitrate_ack = bitrate_ack.clone();
|
||||
let clock_offset = clock_offset.clone();
|
||||
let clock_gen = clock_gen.clone();
|
||||
// The control task feeds clipboard metadata events (ClipState/ClipOffer) onto the same event
|
||||
// plane the clipboard task uses for fetch data; the original tx goes to that task below.
|
||||
let clip_event_tx = clip_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
|
||||
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
|
||||
@@ -1859,6 +1983,8 @@ async fn worker_main(args: WorkerArgs) {
|
||||
}
|
||||
resync.begin(wall_clock_ns()).encode()
|
||||
}
|
||||
CtrlRequest::ClipControl(c) => c.encode(),
|
||||
CtrlRequest::ClipOffer(o) => o.encode(),
|
||||
};
|
||||
if io::write_msg(&mut ctrl_send, &bytes).await.is_err() {
|
||||
break;
|
||||
@@ -1940,6 +2066,21 @@ async fn worker_main(args: WorkerArgs) {
|
||||
}
|
||||
ResyncStep::Idle => {}
|
||||
}
|
||||
} else if let Ok(state) = ClipState::decode(&msg) {
|
||||
// Host ack / policy / backend update for the toggle UI (try_send: a
|
||||
// lagging embedder drops the newest — a stale toggle heals on the next).
|
||||
let _ = clip_event_tx.try_send(ClipEventCore::State {
|
||||
enabled: state.enabled,
|
||||
policy: state.policy,
|
||||
reason: state.reason,
|
||||
});
|
||||
} else if let Ok(offer) = ClipOffer::decode(&msg) {
|
||||
// The host copied something: surface the lazy format list; the embedder
|
||||
// fetches only if a local app pastes.
|
||||
let _ = clip_event_tx.try_send(ClipEventCore::RemoteOffer {
|
||||
seq: offer.seq,
|
||||
kinds: offer.kinds,
|
||||
});
|
||||
} else {
|
||||
tracing::warn!(
|
||||
tag = ?msg.first(),
|
||||
@@ -2020,6 +2161,17 @@ async fn worker_main(args: WorkerArgs) {
|
||||
}
|
||||
});
|
||||
|
||||
// Clipboard task: the fetch-stream accept loop (host pulls what we offered) + outbound fetches
|
||||
// (we pull what the host offered). Metadata (enable/offer/state) rides the control task above;
|
||||
// only bulk bytes flow here. Dies with the connection (accept_bi errors) or when the embedder
|
||||
// drops the command sender. Always spawned — a host without HOST_CAP_CLIPBOARD simply never
|
||||
// opens a clip stream, and our control-plane offers hit its "unknown message" arm harmlessly.
|
||||
tokio::spawn(crate::clipboard::run(
|
||||
conn.clone(),
|
||||
clip_event_tx,
|
||||
clip_cmd_rx,
|
||||
));
|
||||
|
||||
// Watch for connection close → stop the pump.
|
||||
{
|
||||
let shutdown = shutdown.clone();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,11 @@ mod abr;
|
||||
pub mod audio;
|
||||
#[cfg(feature = "quic")]
|
||||
pub mod client;
|
||||
/// Client-side shared-clipboard transport: the per-session task that runs the fetch-stream accept
|
||||
/// loop, drives outbound fetches, and serves inbound ones — surfaced to the embedder as poll
|
||||
/// events. Wire codecs live in [`quic`]; the OS pasteboard integration lives in the native client.
|
||||
#[cfg(feature = "quic")]
|
||||
pub mod clipboard;
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod error;
|
||||
@@ -73,7 +78,11 @@ pub use stats::Stats;
|
||||
/// application close) and the `PunktfunkStatus` −20 block itself. Additive — the close codes are
|
||||
/// new application-close vocabulary an old peer simply never sends/reads, so [`WIRE_VERSION`] is
|
||||
/// unchanged.
|
||||
pub const ABI_VERSION: u32 = 7;
|
||||
/// v8: added the shared-clipboard client surface — `punktfunk_connection_host_caps` and
|
||||
/// `punktfunk_connection_clipboard_{control,offer,fetch,serve,cancel}` +
|
||||
/// `punktfunk_connection_next_clipboard`. Additive; the wire grows only backward-compatible control
|
||||
/// messages (0x40-0x44) and a new `Welcome::host_caps` bit, so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 8;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -49,6 +49,10 @@ pub mod endpoint;
|
||||
/// Async framed-message IO over a quinn stream (`u16 LE length || payload`).
|
||||
pub mod io;
|
||||
|
||||
/// Per-transfer clipboard fetch bi-streams (`PKFs` magic + kind byte, then request/response). The
|
||||
/// transport half of the shared clipboard; wire codecs are in [`msgs`], state lives per side.
|
||||
pub mod clipstream;
|
||||
|
||||
/// SPAKE2 over Ed25519 for the pairing ceremony. The two roles use the asymmetric flow so
|
||||
/// the identities are ordered; each side binds **both** certificate fingerprints as the
|
||||
/// SPAKE2 identities, so the derived key only matches when client and host agree on the PIN
|
||||
|
||||
@@ -147,6 +147,14 @@ pub use crate::reject::*;
|
||||
/// button/axis events; toward a host that doesn't set the bit it keeps the legacy events.
|
||||
pub const HOST_CAP_GAMEPAD_STATE: u8 = 0x01;
|
||||
|
||||
/// [`Welcome::host_caps`] bit: the host has a shared-clipboard service (a working OS backend)
|
||||
/// **and** its operator policy does not hard-disable it, so the client may offer the clipboard
|
||||
/// toggle. Absent (an older host, or `PUNKTFUNK_CLIPBOARD` off) ⇒ the client greys the toggle
|
||||
/// out. Purely additive: nothing clipboard-related happens until a [`ClipControl`]`{ enabled:
|
||||
/// true }` crosses (see `design/clipboard-and-file-transfer.md` §3.1). Packs into the existing
|
||||
/// trailing `host_caps` byte — no wire-layout change.
|
||||
pub const HOST_CAP_CLIPBOARD: u8 = 0x02;
|
||||
|
||||
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
/// advertise this.
|
||||
@@ -527,6 +535,147 @@ pub const MSG_CLOCK_PROBE: u8 = 0x30;
|
||||
/// Type byte of [`ClockEcho`].
|
||||
pub const MSG_CLOCK_ECHO: u8 = 0x31;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Shared clipboard & file transfer (design/clipboard-and-file-transfer.md §3). The small
|
||||
// metadata messages ride the control stream (0x40-0x42); the two fetch-stream messages
|
||||
// (0x43-0x44) travel on a per-transfer bi-stream (see the [`super::clipstream`] helpers), never
|
||||
// the control stream, so they are never dispatched by the control loops. All are typed
|
||||
// (`CTL_MAGIC` + type byte), so an older peer hits its "unknown control message" arm and drops
|
||||
// any it doesn't know — the whole feature is forward-safe.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Type byte of [`ClipControl`] (client → host): enable/disable the shared clipboard for this
|
||||
/// session. Idempotent; opt-in is enforced here, not just in UI.
|
||||
pub const MSG_CLIP_CONTROL: u8 = 0x40;
|
||||
/// Type byte of [`ClipState`] (host → client): ack + unsolicited policy/backend updates.
|
||||
pub const MSG_CLIP_STATE: u8 = 0x41;
|
||||
/// Type byte of [`ClipOffer`] (symmetric): the lazy announcement — format list only, no bytes.
|
||||
pub const MSG_CLIP_OFFER: u8 = 0x42;
|
||||
/// Type byte of [`ClipFetch`] (requester → holder, **fetch stream only**): pull one format of the
|
||||
/// current offer.
|
||||
pub const MSG_CLIP_FETCH: u8 = 0x43;
|
||||
/// Type byte of [`ClipFetchHdr`] (holder → requester, **fetch stream only**): the fetch response
|
||||
/// header that precedes the data chunks.
|
||||
pub const MSG_CLIP_FETCH_HDR: u8 = 0x44;
|
||||
|
||||
/// [`ClipControl::flags`] bit: the client permits file kinds to be offered/fetched this session.
|
||||
/// Absent ⇒ files are filtered out of offers in both directions (text/rich/image only).
|
||||
pub const CLIP_FLAG_FILES: u8 = 0x01;
|
||||
|
||||
/// [`ClipState::policy`] bit: the host permits non-file formats (text/RTF/HTML/image). Always set
|
||||
/// while enabled unless a future direction limit clears it.
|
||||
pub const CLIP_POLICY_TEXT: u8 = 0x01;
|
||||
/// [`ClipState::policy`] bit: the host permits file formats. Cleared by the operator `no-files`
|
||||
/// / `text-only` policy so the client can grey out "Include files".
|
||||
pub const CLIP_POLICY_FILES: u8 = 0x02;
|
||||
|
||||
/// [`ClipState::reason`]: normal ack, nothing exceptional.
|
||||
pub const CLIP_REASON_OK: u8 = 0;
|
||||
/// [`ClipState::reason`]: this session type has no working clipboard backend (e.g. a gamescope
|
||||
/// session with no data-control global) — the client shows "not supported in this session type".
|
||||
pub const CLIP_REASON_BACKEND_UNAVAILABLE: u8 = 1;
|
||||
/// [`ClipState::reason`]: another client took over the single per-desktop clipboard binding; this
|
||||
/// one was disabled (last `ClipControl{enabled}` wins).
|
||||
pub const CLIP_REASON_TAKEN_OVER: u8 = 2;
|
||||
/// [`ClipState::reason`]: the host operator policy (`PUNKTFUNK_CLIPBOARD=off`) disables clipboard.
|
||||
pub const CLIP_REASON_POLICY_DISABLED: u8 = 3;
|
||||
/// [`ClipState::reason`]: enabled, but the host policy forbids file transfer (`no-files` /
|
||||
/// `text-only`) — surfaced so the client greys "Include files" with a footnote.
|
||||
pub const CLIP_REASON_NO_FILES: u8 = 4;
|
||||
|
||||
/// [`ClipFetchHdr::status`]: the requested format is being served; data chunks follow until FIN.
|
||||
pub const CLIP_FETCH_OK: u8 = 0;
|
||||
/// [`ClipFetchHdr::status`]: the fetch named a `seq` that is no longer the holder's current offer;
|
||||
/// the requester degrades the paste to "nothing inserted" rather than wrong data. No chunks follow.
|
||||
pub const CLIP_FETCH_STALE: u8 = 1;
|
||||
/// [`ClipFetchHdr::status`]: the format/index is not available (no backend, or it vanished). No
|
||||
/// chunks follow.
|
||||
pub const CLIP_FETCH_UNAVAILABLE: u8 = 2;
|
||||
/// [`ClipFetchHdr::status`]: policy/cap denies this fetch (e.g. a file fetch under `no-files`). No
|
||||
/// chunks follow.
|
||||
pub const CLIP_FETCH_DENIED: u8 = 3;
|
||||
|
||||
/// Maximum number of [`ClipKind`] entries in one [`ClipOffer`] (resource cap, §7).
|
||||
pub const CLIP_MAX_KINDS: usize = 16;
|
||||
/// Maximum length in bytes of a [`ClipKind::mime`] string (resource cap, §7).
|
||||
pub const CLIP_MAX_MIME: usize = 128;
|
||||
/// [`ClipFetch::file_index`] sentinel meaning "not a file fetch" (a whole non-file format, or the
|
||||
/// file *manifest* itself). Real file fetches use `0..n`.
|
||||
pub const CLIP_FILE_INDEX_NONE: u32 = u32::MAX;
|
||||
|
||||
/// One advertised clipboard format inside a [`ClipOffer`] — a portable MIME name plus a size hint.
|
||||
/// The bytes never ride here; they cross lazily on a fetch stream only when the destination pastes.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ClipKind {
|
||||
/// Portable wire MIME, e.g. `text/plain;charset=utf-8`, `text/html`, `image/png`,
|
||||
/// `application/x-punktfunk-files`. Each end maps it to a platform type at fetch time. ≤
|
||||
/// [`CLIP_MAX_MIME`] bytes; a longer one is rejected on decode.
|
||||
pub mime: String,
|
||||
/// Best-effort total size of this format in bytes; `0` = unknown (a streaming provider).
|
||||
pub size_hint: u64,
|
||||
}
|
||||
|
||||
/// `client → host` ([`MSG_CLIP_CONTROL`]): flip the shared clipboard on/off for this session.
|
||||
/// Sent when the user toggles the per-host pref and once at session start if it is on. **Nothing
|
||||
/// clipboard-related happens on either side until an `enabled: true` arrives** — opt-in at the
|
||||
/// protocol layer.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ClipControl {
|
||||
pub enabled: bool,
|
||||
/// Bitfield of [`CLIP_FLAG_FILES`] (+ reserved bits for future direction limits).
|
||||
pub flags: u8,
|
||||
}
|
||||
|
||||
/// `host → client` ([`MSG_CLIP_STATE`]): acknowledge a [`ClipControl`] and push unsolicited
|
||||
/// updates (policy changed, backend lost). The client surfaces `reason`/`policy` in the toggle UI
|
||||
/// instead of failing silently.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ClipState {
|
||||
pub enabled: bool,
|
||||
/// Bitfield of [`CLIP_POLICY_TEXT`] / [`CLIP_POLICY_FILES`] — what the host currently permits.
|
||||
pub policy: u8,
|
||||
/// One of the `CLIP_REASON_*` values explaining `enabled`/`policy`.
|
||||
pub reason: u8,
|
||||
}
|
||||
|
||||
/// Symmetric ([`MSG_CLIP_OFFER`], either direction): the lazy announcement. Sent when the local
|
||||
/// clipboard changes; carries the **format list only** (comfortably inside the 64 KiB control
|
||||
/// frame). A new offer replaces the sender's previous one; `seq` lets the holder reject stale
|
||||
/// fetches (§3.4). Files are announced as one `application/x-punktfunk-files` kind — the file
|
||||
/// list itself is fetched lazily, never inlined here.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ClipOffer {
|
||||
/// Monotonic per sender; newest wins.
|
||||
pub seq: u32,
|
||||
/// ≤ [`CLIP_MAX_KINDS`] entries.
|
||||
pub kinds: Vec<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
|
||||
// the control stream with PairRequest. The host shows a short PIN out-of-band (log/UI); the
|
||||
@@ -1293,6 +1442,174 @@ impl ClockEcho {
|
||||
}
|
||||
}
|
||||
|
||||
/// Append one [`ClipKind`] to `b`: `mime_len u8 || mime bytes || size_hint u64 LE`.
|
||||
fn put_clip_kind(b: &mut Vec<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`.
|
||||
pub fn frame(payload: &[u8]) -> Vec<u8> {
|
||||
let mut b = Vec::with_capacity(2 + payload.len());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+329
-1
@@ -33,7 +33,11 @@
|
||||
// application close) and the `PunktfunkStatus` −20 block itself. Additive — the close codes are
|
||||
// new application-close vocabulary an old peer simply never sends/reads, so [`WIRE_VERSION`] is
|
||||
// unchanged.
|
||||
#define ABI_VERSION 7
|
||||
// v8: added the shared-clipboard client surface — `punktfunk_connection_host_caps` and
|
||||
// `punktfunk_connection_clipboard_{control,offer,fetch,serve,cancel}` +
|
||||
// `punktfunk_connection_next_clipboard`. Additive; the wire grows only backward-compatible control
|
||||
// messages (0x40-0x44) and a new `Welcome::host_caps` bit, so [`WIRE_VERSION`] is unchanged.
|
||||
#define ABI_VERSION 8
|
||||
|
||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
@@ -192,11 +196,60 @@
|
||||
// (design/pyrowave-codec-plan.md §3). (Mirrors `quic::CODEC_PYROWAVE`.)
|
||||
#define PUNKTFUNK_CODEC_PYROWAVE 8
|
||||
|
||||
// Host-capability bit in [`punktfunk_connection_host_caps`]: the host applies gamepad-state
|
||||
// snapshots (a capable client sends full-state snapshots instead of per-transition events).
|
||||
// (Mirrors `quic::HOST_CAP_GAMEPAD_STATE`.)
|
||||
#define PUNKTFUNK_HOST_CAP_GAMEPAD_STATE 1
|
||||
|
||||
// Host-capability bit in [`punktfunk_connection_host_caps`]: the host supports the shared
|
||||
// clipboard, so a client may offer the toggle. (Mirrors `quic::HOST_CAP_CLIPBOARD`.)
|
||||
#define PUNKTFUNK_HOST_CAP_CLIPBOARD 2
|
||||
|
||||
// `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble
|
||||
// datagram — an old host that sent no self-termination lease. The client then falls back to its
|
||||
// own staleness heuristic for that update instead of a host-supplied deadline.
|
||||
#define PUNKTFUNK_RUMBLE_NO_TTL 4294967295
|
||||
|
||||
// [`PunktfunkClipEvent::kind`] — the host announced clipboard content is available
|
||||
// (`transfer_id` = the offer `seq`; `data`/`len` = a `\n`-separated `"<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.
|
||||
#define TAG_LEN 16
|
||||
|
||||
@@ -496,6 +549,16 @@
|
||||
#define HOST_CAP_GAMEPAD_STATE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Welcome::host_caps`] bit: the host has a shared-clipboard service (a working OS backend)
|
||||
// **and** its operator policy does not hard-disable it, so the client may offer the clipboard
|
||||
// toggle. Absent (an older host, or `PUNKTFUNK_CLIPBOARD` off) ⇒ the client greys the toggle
|
||||
// out. Purely additive: nothing clipboard-related happens until a [`ClipControl`]`{ enabled:
|
||||
// true }` crosses (see `design/clipboard-and-file-transfer.md` §3.1). Packs into the existing
|
||||
// trailing `host_caps` byte — no wire-layout change.
|
||||
#define HOST_CAP_CLIPBOARD 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
@@ -606,6 +669,119 @@
|
||||
#define MSG_CLOCK_ECHO 49
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipControl`] (client → host): enable/disable the shared clipboard for this
|
||||
// session. Idempotent; opt-in is enforced here, not just in UI.
|
||||
#define MSG_CLIP_CONTROL 64
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipState`] (host → client): ack + unsolicited policy/backend updates.
|
||||
#define MSG_CLIP_STATE 65
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipOffer`] (symmetric): the lazy announcement — format list only, no bytes.
|
||||
#define MSG_CLIP_OFFER 66
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipFetch`] (requester → holder, **fetch stream only**): pull one format of the
|
||||
// current offer.
|
||||
#define MSG_CLIP_FETCH 67
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipFetchHdr`] (holder → requester, **fetch stream only**): the fetch response
|
||||
// header that precedes the data chunks.
|
||||
#define MSG_CLIP_FETCH_HDR 68
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipControl::flags`] bit: the client permits file kinds to be offered/fetched this session.
|
||||
// Absent ⇒ files are filtered out of offers in both directions (text/rich/image only).
|
||||
#define CLIP_FLAG_FILES 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::policy`] bit: the host permits non-file formats (text/RTF/HTML/image). Always set
|
||||
// while enabled unless a future direction limit clears it.
|
||||
#define CLIP_POLICY_TEXT 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::policy`] bit: the host permits file formats. Cleared by the operator `no-files`
|
||||
// / `text-only` policy so the client can grey out "Include files".
|
||||
#define CLIP_POLICY_FILES 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::reason`]: normal ack, nothing exceptional.
|
||||
#define CLIP_REASON_OK 0
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::reason`]: this session type has no working clipboard backend (e.g. a gamescope
|
||||
// session with no data-control global) — the client shows "not supported in this session type".
|
||||
#define CLIP_REASON_BACKEND_UNAVAILABLE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::reason`]: another client took over the single per-desktop clipboard binding; this
|
||||
// one was disabled (last `ClipControl{enabled}` wins).
|
||||
#define CLIP_REASON_TAKEN_OVER 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::reason`]: the host operator policy (`PUNKTFUNK_CLIPBOARD=off`) disables clipboard.
|
||||
#define CLIP_REASON_POLICY_DISABLED 3
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::reason`]: enabled, but the host policy forbids file transfer (`no-files` /
|
||||
// `text-only`) — surfaced so the client greys "Include files" with a footnote.
|
||||
#define CLIP_REASON_NO_FILES 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipFetchHdr::status`]: the requested format is being served; data chunks follow until FIN.
|
||||
#define CLIP_FETCH_OK 0
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipFetchHdr::status`]: the fetch named a `seq` that is no longer the holder's current offer;
|
||||
// the requester degrades the paste to "nothing inserted" rather than wrong data. No chunks follow.
|
||||
#define CLIP_FETCH_STALE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipFetchHdr::status`]: the format/index is not available (no backend, or it vanished). No
|
||||
// chunks follow.
|
||||
#define CLIP_FETCH_UNAVAILABLE 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipFetchHdr::status`]: policy/cap denies this fetch (e.g. a file fetch under `no-files`). No
|
||||
// chunks follow.
|
||||
#define CLIP_FETCH_DENIED 3
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Maximum number of [`ClipKind`] entries in one [`ClipOffer`] (resource cap, §7).
|
||||
#define CLIP_MAX_KINDS 16
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Maximum length in bytes of a [`ClipKind::mime`] string (resource cap, §7).
|
||||
#define CLIP_MAX_MIME 128
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipFetch::file_index`] sentinel meaning "not a file fetch" (a whole non-file format, or the
|
||||
// file *manifest* itself). Real file fetches use `0..n`.
|
||||
#define CLIP_FILE_INDEX_NONE UINT32_MAX
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`PairRequest`].
|
||||
#define MSG_PAIR_REQUEST 16
|
||||
@@ -662,6 +838,27 @@
|
||||
#define ColorInfo_MC_BT2020_NCL 9
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Stream-kind byte: a clipboard fetch (request/response of one format). Future stream kinds
|
||||
// (e.g. a bulk file-content push) mux under the same [`STREAM_MAGIC`] with a different byte.
|
||||
#define CLIP_STREAM_KIND_FETCH 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// QUIC application error code used to `reset`/`stop` a clipboard fetch stream on cancel — sync
|
||||
// disabled mid-transfer, paste timed out, size cap exceeded, teardown. Distinct from the
|
||||
// connection close codes ([`super::QUIT_CLOSE_CODE`] `0x51` / [`super::APP_EXITED_CLOSE_CODE`]
|
||||
// `0x52`), the connection reject code `0x42`, and the pairing-rejection close block
|
||||
// `0x60`–`0x67` — stream reset codes and connection close codes are separate QUIC namespaces,
|
||||
// but the vocabularies stay disjoint on purpose so a captured code is unambiguous.
|
||||
#define CLIP_CANCELLED_CODE 112
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Chunk size for streaming fetch data (64 KiB writes — matches the control-frame bound).
|
||||
#define CLIP_CHUNK (64 * 1024)
|
||||
#endif
|
||||
|
||||
// Consecutive no-output AUs that force a keyframe request. ~50 ms at 60 Hz — long enough not to fire
|
||||
// on a one-frame decoder hiccup, short enough that a lost initial IDR (or a mid-GOP join) unfreezes
|
||||
// almost immediately instead of never.
|
||||
@@ -1089,6 +1286,47 @@ typedef struct {
|
||||
} PunktfunkRichInputEx;
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// One advertised clipboard format passed to [`punktfunk_connection_clipboard_offer`].
|
||||
typedef struct {
|
||||
// NUL-terminated UTF-8 wire MIME (e.g. `text/plain;charset=utf-8`). ≤ 128 bytes on the wire.
|
||||
const char *mime;
|
||||
// Best-effort size in bytes; `0` = unknown.
|
||||
uint64_t size_hint;
|
||||
} PunktfunkClipKind;
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// A shared-clipboard event, filled by [`punktfunk_connection_next_clipboard`]. A flat tagged
|
||||
// struct (like `PunktfunkHidOutput`): read the fields named in the `kind`'s doc; the rest are 0.
|
||||
typedef struct {
|
||||
// One of `PUNKTFUNK_CLIP_*`.
|
||||
uint8_t kind;
|
||||
// `State`: 1 = enabled, 0 = disabled.
|
||||
uint8_t enabled;
|
||||
// `State`: bitfield of `quic::CLIP_POLICY_*` — what the host currently permits.
|
||||
uint8_t policy;
|
||||
// `State`: one of `quic::CLIP_REASON_*`.
|
||||
uint8_t reason;
|
||||
// `Data`: 1 = final chunk of this transfer.
|
||||
uint8_t last;
|
||||
// Per-transfer id: the offer `seq` (RemoteOffer), the `req_id` (FetchRequest), or the
|
||||
// `xfer_id` (Data/Cancelled/Error).
|
||||
uint32_t transfer_id;
|
||||
// `FetchRequest`: the offer `seq` the request is against.
|
||||
uint32_t seq;
|
||||
// `FetchRequest`: file index, or `quic::CLIP_FILE_INDEX_NONE`.
|
||||
uint32_t file_index;
|
||||
// `Error`: a `PunktfunkStatus` code (negative); 0 otherwise.
|
||||
int32_t status;
|
||||
// RemoteOffer/FetchRequest/Data: a pointer into a per-connection slot, valid until the next
|
||||
// `next_clipboard` call; NULL for the other kinds.
|
||||
const uint8_t *data;
|
||||
// Byte length of `data` (0 when `data` is NULL).
|
||||
uintptr_t len;
|
||||
} PunktfunkClipEvent;
|
||||
#endif
|
||||
|
||||
// A speed-test measurement, filled by [`punktfunk_connection_probe_result`]. `done` is 0 until
|
||||
// the host's end-of-burst report lands, then 1 (the numbers are final). `throughput_kbps` is the
|
||||
// delivered wire throughput to drive a bitrate choice from; `loss_pct` is the link loss and
|
||||
@@ -1772,6 +2010,96 @@ PunktfunkStatus punktfunk_connection_mode(const PunktfunkConnection *c,
|
||||
PunktfunkStatus punktfunk_connection_gamepad(const PunktfunkConnection *c, uint32_t *gamepad);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// The host capability bitfield the session's `Welcome` carried — a bitfield of
|
||||
// `PUNKTFUNK_HOST_CAP_GAMEPAD_STATE` / `PUNKTFUNK_HOST_CAP_CLIPBOARD`. A client tests
|
||||
// `caps & PUNKTFUNK_HOST_CAP_CLIPBOARD` to decide whether to offer the shared-clipboard toggle.
|
||||
// Safe any time after connect.
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; `caps` is writable (NULL is skipped).
|
||||
PunktfunkStatus punktfunk_connection_host_caps(const PunktfunkConnection *c, uint8_t *caps);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Enable or disable the shared clipboard for this session (`design` §3.1). Opt-in: nothing is
|
||||
// announced or served until this is called with `enabled = true`. `flags` carries
|
||||
// `quic::CLIP_FLAG_FILES` (allow file transfer). The host replies with a `State` event.
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle.
|
||||
PunktfunkStatus punktfunk_connection_clipboard_control(const PunktfunkConnection *c,
|
||||
bool enabled,
|
||||
uint8_t flags);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Announce that the local clipboard changed — the lazy format-list offer. `seq` is a monotonic
|
||||
// per-sender counter (newest wins); `kinds`/`n` is the advertised formats (≤ 16). The bytes cross
|
||||
// only if the host later fetches.
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; `kinds` points to `n` `PunktfunkClipKind`s (NULL allowed only
|
||||
// when `n == 0`), each `mime` a valid NUL-terminated UTF-8 string.
|
||||
PunktfunkStatus punktfunk_connection_clipboard_offer(const PunktfunkConnection *c,
|
||||
uint32_t seq,
|
||||
const PunktfunkClipKind *kinds,
|
||||
uintptr_t n);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Start pulling one format (`mime`) of the host's current offer `seq` — lazily, on a local paste.
|
||||
// `file_index` selects a file for a file transfer, or `quic::CLIP_FILE_INDEX_NONE` for a non-file
|
||||
// format. Writes the transfer id (echoed on the resulting `Data`/`Error`/`Cancelled` event) to
|
||||
// `xfer_id_out`.
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; `mime` is a valid NUL-terminated UTF-8 string; `xfer_id_out`
|
||||
// is writable (NULL is skipped).
|
||||
PunktfunkStatus punktfunk_connection_clipboard_fetch(const PunktfunkConnection *c,
|
||||
uint32_t seq,
|
||||
const char *mime,
|
||||
uint32_t file_index,
|
||||
uint32_t *xfer_id_out);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Provide bytes answering a `FetchRequest` event (the host is pasting our offered data). Call
|
||||
// repeatedly to stream a large payload; `last = true` completes it. `data` may be NULL only when
|
||||
// `len == 0` (e.g. a final empty chunk). `punktfunk_connection_clipboard_cancel(req_id)` aborts.
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; `data` points to `len` bytes (NULL allowed only when
|
||||
// `len == 0`).
|
||||
PunktfunkStatus punktfunk_connection_clipboard_serve(const PunktfunkConnection *c,
|
||||
uint32_t req_id,
|
||||
const uint8_t *data,
|
||||
uintptr_t len,
|
||||
bool last);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Cancel a clipboard transfer by id — either an outbound fetch (`xfer_id` from
|
||||
// [`punktfunk_connection_clipboard_fetch`]) or an inbound serve (`req_id` from a `FetchRequest`).
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle.
|
||||
PunktfunkStatus punktfunk_connection_clipboard_cancel(const PunktfunkConnection *c, uint32_t id);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Pull the next shared-clipboard event into `*out`. [`PunktfunkStatus::NoFrame`] on timeout,
|
||||
// [`PunktfunkStatus::Closed`] once the session ended. A native client drains this on its own
|
||||
// thread and drives the OS pasteboard from it. The `data`/`len` pointer (when non-NULL) borrows a
|
||||
// per-connection buffer valid until the next `next_clipboard` call on this handle.
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; `out` is writable for one `PunktfunkClipEvent`.
|
||||
PunktfunkStatus punktfunk_connection_next_clipboard(PunktfunkConnection *c,
|
||||
PunktfunkClipEvent *out,
|
||||
uint32_t timeout_ms);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// The compositor backend the host actually resolved for this session (one of the
|
||||
// `PUNKTFUNK_COMPOSITOR_*` values; the `Welcome`'s echo of the [`punktfunk_connect_ex`]
|
||||
|
||||
Reference in New Issue
Block a user