Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bd8c18b4d | |||
| af3a7d8cd5 |
@@ -526,6 +526,10 @@ pub struct PunktfunkConnection {
|
|||||||
/// `inner.audio_channels`; `pcm` is a fixed-capacity reusable buffer the returned pointer
|
/// `inner.audio_channels`; `pcm` is a fixed-capacity reusable buffer the returned pointer
|
||||||
/// borrows until the next PCM call (same contract as `last_audio`).
|
/// borrows until the next PCM call (same contract as `last_audio`).
|
||||||
audio_pcm: std::sync::Mutex<AudioPcmState>,
|
audio_pcm: std::sync::Mutex<AudioPcmState>,
|
||||||
|
/// Backs the `data`/`len` pointer of the last `punktfunk_connection_next_clipboard` event
|
||||||
|
/// (a fetched payload, an offer's format list, or a fetch-request's MIME) —
|
||||||
|
/// borrow-until-next-call, same contract as `last`.
|
||||||
|
last_clip: std::sync::Mutex<Option<Vec<u8>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lazily-initialized in-core Opus decode state. A coupled-1-stream multistream decoder is
|
/// Lazily-initialized in-core Opus decode state. A coupled-1-stream multistream decoder is
|
||||||
@@ -922,6 +926,14 @@ pub const PUNKTFUNK_CODEC_HEVC: u8 = 0x02;
|
|||||||
/// Codec bit: AV1. (Mirrors `quic::CODEC_AV1`.)
|
/// Codec bit: AV1. (Mirrors `quic::CODEC_AV1`.)
|
||||||
pub const PUNKTFUNK_CODEC_AV1: u8 = 0x04;
|
pub const PUNKTFUNK_CODEC_AV1: u8 = 0x04;
|
||||||
|
|
||||||
|
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host applies gamepad-state
|
||||||
|
/// snapshots (a capable client sends full-state snapshots instead of per-transition events).
|
||||||
|
/// (Mirrors `quic::HOST_CAP_GAMEPAD_STATE`.)
|
||||||
|
pub const PUNKTFUNK_HOST_CAP_GAMEPAD_STATE: u8 = 0x01;
|
||||||
|
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host supports the shared
|
||||||
|
/// clipboard, so a client may offer the toggle. (Mirrors `quic::HOST_CAP_CLIPBOARD`.)
|
||||||
|
pub const PUNKTFUNK_HOST_CAP_CLIPBOARD: u8 = 0x02;
|
||||||
|
|
||||||
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
|
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
|
||||||
#[cfg(feature = "quic")]
|
#[cfg(feature = "quic")]
|
||||||
const _: () = {
|
const _: () = {
|
||||||
@@ -931,6 +943,8 @@ const _: () = {
|
|||||||
assert!(PUNKTFUNK_CODEC_H264 == crate::quic::CODEC_H264);
|
assert!(PUNKTFUNK_CODEC_H264 == crate::quic::CODEC_H264);
|
||||||
assert!(PUNKTFUNK_CODEC_HEVC == crate::quic::CODEC_HEVC);
|
assert!(PUNKTFUNK_CODEC_HEVC == crate::quic::CODEC_HEVC);
|
||||||
assert!(PUNKTFUNK_CODEC_AV1 == crate::quic::CODEC_AV1);
|
assert!(PUNKTFUNK_CODEC_AV1 == crate::quic::CODEC_AV1);
|
||||||
|
assert!(PUNKTFUNK_HOST_CAP_GAMEPAD_STATE == crate::quic::HOST_CAP_GAMEPAD_STATE);
|
||||||
|
assert!(PUNKTFUNK_HOST_CAP_CLIPBOARD == crate::quic::HOST_CAP_CLIPBOARD);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
|
// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
|
||||||
@@ -1395,6 +1409,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex7(
|
|||||||
last: std::sync::Mutex::new(None),
|
last: std::sync::Mutex::new(None),
|
||||||
last_audio: std::sync::Mutex::new(None),
|
last_audio: std::sync::Mutex::new(None),
|
||||||
audio_pcm: std::sync::Mutex::new(AudioPcmState::default()),
|
audio_pcm: std::sync::Mutex::new(AudioPcmState::default()),
|
||||||
|
last_clip: std::sync::Mutex::new(None),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
Err(_) => std::ptr::null_mut(),
|
Err(_) => std::ptr::null_mut(),
|
||||||
@@ -2261,6 +2276,397 @@ pub unsafe extern "C" fn punktfunk_connection_gamepad(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================================
|
||||||
|
// Shared clipboard (design/clipboard-and-file-transfer.md §5.1). Additive, ABI v6. All poll/serve
|
||||||
|
// bytes ride the mTLS-pinned QUIC session; nothing here opens a new listener or port.
|
||||||
|
// ============================================================================================
|
||||||
|
|
||||||
|
/// [`PunktfunkClipEvent::kind`] — the host announced clipboard content is available
|
||||||
|
/// (`transfer_id` = the offer `seq`; `data`/`len` = a `\n`-separated `"<mime>\t<size_hint>"`
|
||||||
|
/// format list). Fetch it lazily (only on a local paste) via
|
||||||
|
/// [`punktfunk_connection_clipboard_fetch`].
|
||||||
|
pub const PUNKTFUNK_CLIP_REMOTE_OFFER: u8 = 1;
|
||||||
|
/// [`PunktfunkClipEvent::kind`] — host ack / policy / backend update (`enabled`/`policy`/`reason`
|
||||||
|
/// valid). Reflect it in the toggle UI.
|
||||||
|
pub const PUNKTFUNK_CLIP_STATE: u8 = 2;
|
||||||
|
/// [`PunktfunkClipEvent::kind`] — the host is pasting our offered data: answer with
|
||||||
|
/// [`punktfunk_connection_clipboard_serve`] (`transfer_id` = `req_id`; `seq`/`file_index` valid;
|
||||||
|
/// `data`/`len` = the requested MIME).
|
||||||
|
pub const PUNKTFUNK_CLIP_FETCH_REQUEST: u8 = 3;
|
||||||
|
/// [`PunktfunkClipEvent::kind`] — bytes for a fetch we started (`transfer_id` = `xfer_id`;
|
||||||
|
/// `data`/`len` = the payload, borrowed until the next `next_clipboard`; `last` = final chunk).
|
||||||
|
pub const PUNKTFUNK_CLIP_DATA: u8 = 4;
|
||||||
|
/// [`PunktfunkClipEvent::kind`] — a transfer was cancelled (`transfer_id` = the id).
|
||||||
|
pub const PUNKTFUNK_CLIP_CANCELLED: u8 = 5;
|
||||||
|
/// [`PunktfunkClipEvent::kind`] — a transfer failed (`transfer_id` = the id; `status` = a
|
||||||
|
/// `PunktfunkStatus` code).
|
||||||
|
pub const PUNKTFUNK_CLIP_ERROR: u8 = 6;
|
||||||
|
|
||||||
|
/// One advertised clipboard format passed to [`punktfunk_connection_clipboard_offer`].
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct PunktfunkClipKind {
|
||||||
|
/// NUL-terminated UTF-8 wire MIME (e.g. `text/plain;charset=utf-8`). ≤ 128 bytes on the wire.
|
||||||
|
pub mime: *const std::os::raw::c_char,
|
||||||
|
/// Best-effort size in bytes; `0` = unknown.
|
||||||
|
pub size_hint: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A shared-clipboard event, filled by [`punktfunk_connection_next_clipboard`]. A flat tagged
|
||||||
|
/// struct (like `PunktfunkHidOutput`): read the fields named in the `kind`'s doc; the rest are 0.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct PunktfunkClipEvent {
|
||||||
|
/// One of `PUNKTFUNK_CLIP_*`.
|
||||||
|
pub kind: u8,
|
||||||
|
/// `State`: 1 = enabled, 0 = disabled.
|
||||||
|
pub enabled: u8,
|
||||||
|
/// `State`: bitfield of `quic::CLIP_POLICY_*` — what the host currently permits.
|
||||||
|
pub policy: u8,
|
||||||
|
/// `State`: one of `quic::CLIP_REASON_*`.
|
||||||
|
pub reason: u8,
|
||||||
|
/// `Data`: 1 = final chunk of this transfer.
|
||||||
|
pub last: u8,
|
||||||
|
/// Per-transfer id: the offer `seq` (RemoteOffer), the `req_id` (FetchRequest), or the
|
||||||
|
/// `xfer_id` (Data/Cancelled/Error).
|
||||||
|
pub transfer_id: u32,
|
||||||
|
/// `FetchRequest`: the offer `seq` the request is against.
|
||||||
|
pub seq: u32,
|
||||||
|
/// `FetchRequest`: file index, or `quic::CLIP_FILE_INDEX_NONE`.
|
||||||
|
pub file_index: u32,
|
||||||
|
/// `Error`: a `PunktfunkStatus` code (negative); 0 otherwise.
|
||||||
|
pub status: i32,
|
||||||
|
/// RemoteOffer/FetchRequest/Data: a pointer into a per-connection slot, valid until the next
|
||||||
|
/// `next_clipboard` call; NULL for the other kinds.
|
||||||
|
pub data: *const u8,
|
||||||
|
/// Byte length of `data` (0 when `data` is NULL).
|
||||||
|
pub len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill a [`PunktfunkClipEvent`] from a core event, parking any variable-length bytes in `slot`
|
||||||
|
/// (borrow-until-next-call) and pointing `data`/`len` at them.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
fn build_clip_event(
|
||||||
|
ev: crate::clipboard::ClipEventCore,
|
||||||
|
slot: &mut Option<Vec<u8>>,
|
||||||
|
) -> PunktfunkClipEvent {
|
||||||
|
use crate::clipboard::ClipEventCore as E;
|
||||||
|
let mut out = PunktfunkClipEvent {
|
||||||
|
kind: 0,
|
||||||
|
enabled: 0,
|
||||||
|
policy: 0,
|
||||||
|
reason: 0,
|
||||||
|
last: 0,
|
||||||
|
transfer_id: 0,
|
||||||
|
seq: 0,
|
||||||
|
file_index: 0,
|
||||||
|
status: 0,
|
||||||
|
data: std::ptr::null(),
|
||||||
|
len: 0,
|
||||||
|
};
|
||||||
|
*slot = None;
|
||||||
|
match ev {
|
||||||
|
E::RemoteOffer { seq, kinds } => {
|
||||||
|
out.kind = PUNKTFUNK_CLIP_REMOTE_OFFER;
|
||||||
|
out.transfer_id = seq;
|
||||||
|
let mut blob = String::new();
|
||||||
|
for k in &kinds {
|
||||||
|
blob.push_str(&k.mime);
|
||||||
|
blob.push('\t');
|
||||||
|
blob.push_str(&k.size_hint.to_string());
|
||||||
|
blob.push('\n');
|
||||||
|
}
|
||||||
|
*slot = Some(blob.into_bytes());
|
||||||
|
}
|
||||||
|
E::State {
|
||||||
|
enabled,
|
||||||
|
policy,
|
||||||
|
reason,
|
||||||
|
} => {
|
||||||
|
out.kind = PUNKTFUNK_CLIP_STATE;
|
||||||
|
out.enabled = enabled as u8;
|
||||||
|
out.policy = policy;
|
||||||
|
out.reason = reason;
|
||||||
|
}
|
||||||
|
E::FetchRequest {
|
||||||
|
req_id,
|
||||||
|
seq,
|
||||||
|
file_index,
|
||||||
|
mime,
|
||||||
|
} => {
|
||||||
|
out.kind = PUNKTFUNK_CLIP_FETCH_REQUEST;
|
||||||
|
out.transfer_id = req_id;
|
||||||
|
out.seq = seq;
|
||||||
|
out.file_index = file_index;
|
||||||
|
*slot = Some(mime.into_bytes());
|
||||||
|
}
|
||||||
|
E::Data {
|
||||||
|
xfer_id,
|
||||||
|
bytes,
|
||||||
|
last,
|
||||||
|
} => {
|
||||||
|
out.kind = PUNKTFUNK_CLIP_DATA;
|
||||||
|
out.transfer_id = xfer_id;
|
||||||
|
out.last = last as u8;
|
||||||
|
*slot = Some(bytes);
|
||||||
|
}
|
||||||
|
E::Cancelled { id } => {
|
||||||
|
out.kind = PUNKTFUNK_CLIP_CANCELLED;
|
||||||
|
out.transfer_id = id;
|
||||||
|
}
|
||||||
|
E::Error { id, code } => {
|
||||||
|
out.kind = PUNKTFUNK_CLIP_ERROR;
|
||||||
|
out.transfer_id = id;
|
||||||
|
out.status = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(v) = slot.as_ref() {
|
||||||
|
out.data = v.as_ptr();
|
||||||
|
out.len = v.len();
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host capability bitfield the session's `Welcome` carried — a bitfield of
|
||||||
|
/// `PUNKTFUNK_HOST_CAP_GAMEPAD_STATE` / `PUNKTFUNK_HOST_CAP_CLIPBOARD`. A client tests
|
||||||
|
/// `caps & PUNKTFUNK_HOST_CAP_CLIPBOARD` to decide whether to offer the shared-clipboard toggle.
|
||||||
|
/// Safe any time after connect.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a valid connection handle; `caps` is writable (NULL is skipped).
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_host_caps(
|
||||||
|
c: *const PunktfunkConnection,
|
||||||
|
caps: *mut u8,
|
||||||
|
) -> PunktfunkStatus {
|
||||||
|
guard(|| {
|
||||||
|
let c = match unsafe { c.as_ref() } {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
if !caps.is_null() {
|
||||||
|
*caps = c.inner.host_caps();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PunktfunkStatus::Ok
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable the shared clipboard for this session (`design` §3.1). Opt-in: nothing is
|
||||||
|
/// announced or served until this is called with `enabled = true`. `flags` carries
|
||||||
|
/// `quic::CLIP_FLAG_FILES` (allow file transfer). The host replies with a `State` event.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a valid connection handle.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_clipboard_control(
|
||||||
|
c: *const PunktfunkConnection,
|
||||||
|
enabled: bool,
|
||||||
|
flags: u8,
|
||||||
|
) -> PunktfunkStatus {
|
||||||
|
guard(|| {
|
||||||
|
let c = match unsafe { c.as_ref() } {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
};
|
||||||
|
match c.inner.clip_control(enabled, flags) {
|
||||||
|
Ok(()) => PunktfunkStatus::Ok,
|
||||||
|
Err(e) => e.status(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Announce that the local clipboard changed — the lazy format-list offer. `seq` is a monotonic
|
||||||
|
/// per-sender counter (newest wins); `kinds`/`n` is the advertised formats (≤ 16). The bytes cross
|
||||||
|
/// only if the host later fetches.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a valid connection handle; `kinds` points to `n` `PunktfunkClipKind`s (NULL allowed only
|
||||||
|
/// when `n == 0`), each `mime` a valid NUL-terminated UTF-8 string.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_clipboard_offer(
|
||||||
|
c: *const PunktfunkConnection,
|
||||||
|
seq: u32,
|
||||||
|
kinds: *const PunktfunkClipKind,
|
||||||
|
n: usize,
|
||||||
|
) -> PunktfunkStatus {
|
||||||
|
guard(|| {
|
||||||
|
let c = match unsafe { c.as_ref() } {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
};
|
||||||
|
if kinds.is_null() && n != 0 {
|
||||||
|
return PunktfunkStatus::NullPointer;
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(n);
|
||||||
|
if n != 0 {
|
||||||
|
let slice = unsafe { std::slice::from_raw_parts(kinds, n) };
|
||||||
|
for k in slice {
|
||||||
|
let mime = if k.mime.is_null() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
match unsafe { std::ffi::CStr::from_ptr(k.mime) }.to_str() {
|
||||||
|
Ok(s) => s.to_string(),
|
||||||
|
Err(_) => return PunktfunkStatus::InvalidArg,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
out.push(crate::quic::ClipKind {
|
||||||
|
mime,
|
||||||
|
size_hint: k.size_hint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match c.inner.clip_offer(seq, out) {
|
||||||
|
Ok(()) => PunktfunkStatus::Ok,
|
||||||
|
Err(e) => e.status(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start pulling one format (`mime`) of the host's current offer `seq` — lazily, on a local paste.
|
||||||
|
/// `file_index` selects a file for a file transfer, or `quic::CLIP_FILE_INDEX_NONE` for a non-file
|
||||||
|
/// format. Writes the transfer id (echoed on the resulting `Data`/`Error`/`Cancelled` event) to
|
||||||
|
/// `xfer_id_out`.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a valid connection handle; `mime` is a valid NUL-terminated UTF-8 string; `xfer_id_out`
|
||||||
|
/// is writable (NULL is skipped).
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_clipboard_fetch(
|
||||||
|
c: *const PunktfunkConnection,
|
||||||
|
seq: u32,
|
||||||
|
mime: *const std::os::raw::c_char,
|
||||||
|
file_index: u32,
|
||||||
|
xfer_id_out: *mut u32,
|
||||||
|
) -> PunktfunkStatus {
|
||||||
|
guard(|| {
|
||||||
|
let c = match unsafe { c.as_ref() } {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
};
|
||||||
|
if mime.is_null() {
|
||||||
|
return PunktfunkStatus::NullPointer;
|
||||||
|
}
|
||||||
|
let mime = match unsafe { std::ffi::CStr::from_ptr(mime) }.to_str() {
|
||||||
|
Ok(s) => s.to_string(),
|
||||||
|
Err(_) => return PunktfunkStatus::InvalidArg,
|
||||||
|
};
|
||||||
|
match c.inner.clip_fetch(seq, mime, file_index) {
|
||||||
|
Ok(xfer_id) => {
|
||||||
|
unsafe {
|
||||||
|
if !xfer_id_out.is_null() {
|
||||||
|
*xfer_id_out = xfer_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PunktfunkStatus::Ok
|
||||||
|
}
|
||||||
|
Err(e) => e.status(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provide bytes answering a `FetchRequest` event (the host is pasting our offered data). Call
|
||||||
|
/// repeatedly to stream a large payload; `last = true` completes it. `data` may be NULL only when
|
||||||
|
/// `len == 0` (e.g. a final empty chunk). `punktfunk_connection_clipboard_cancel(req_id)` aborts.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a valid connection handle; `data` points to `len` bytes (NULL allowed only when
|
||||||
|
/// `len == 0`).
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_clipboard_serve(
|
||||||
|
c: *const PunktfunkConnection,
|
||||||
|
req_id: u32,
|
||||||
|
data: *const u8,
|
||||||
|
len: usize,
|
||||||
|
last: bool,
|
||||||
|
) -> PunktfunkStatus {
|
||||||
|
guard(|| {
|
||||||
|
let c = match unsafe { c.as_ref() } {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
};
|
||||||
|
if data.is_null() && len != 0 {
|
||||||
|
return PunktfunkStatus::NullPointer;
|
||||||
|
}
|
||||||
|
let bytes = if len == 0 {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
unsafe { std::slice::from_raw_parts(data, len) }.to_vec()
|
||||||
|
};
|
||||||
|
match c.inner.clip_serve(req_id, bytes, last) {
|
||||||
|
Ok(()) => PunktfunkStatus::Ok,
|
||||||
|
Err(e) => e.status(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel a clipboard transfer by id — either an outbound fetch (`xfer_id` from
|
||||||
|
/// [`punktfunk_connection_clipboard_fetch`]) or an inbound serve (`req_id` from a `FetchRequest`).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a valid connection handle.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_clipboard_cancel(
|
||||||
|
c: *const PunktfunkConnection,
|
||||||
|
id: u32,
|
||||||
|
) -> PunktfunkStatus {
|
||||||
|
guard(|| {
|
||||||
|
let c = match unsafe { c.as_ref() } {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
};
|
||||||
|
match c.inner.clip_cancel(id) {
|
||||||
|
Ok(()) => PunktfunkStatus::Ok,
|
||||||
|
Err(e) => e.status(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the next shared-clipboard event into `*out`. [`PunktfunkStatus::NoFrame`] on timeout,
|
||||||
|
/// [`PunktfunkStatus::Closed`] once the session ended. A native client drains this on its own
|
||||||
|
/// thread and drives the OS pasteboard from it. The `data`/`len` pointer (when non-NULL) borrows a
|
||||||
|
/// per-connection buffer valid until the next `next_clipboard` call on this handle.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkClipEvent`.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_next_clipboard(
|
||||||
|
c: *mut PunktfunkConnection,
|
||||||
|
out: *mut PunktfunkClipEvent,
|
||||||
|
timeout_ms: u32,
|
||||||
|
) -> PunktfunkStatus {
|
||||||
|
guard(|| {
|
||||||
|
let c = match unsafe { c.as_ref() } {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
};
|
||||||
|
if out.is_null() {
|
||||||
|
return PunktfunkStatus::NullPointer;
|
||||||
|
}
|
||||||
|
match c
|
||||||
|
.inner
|
||||||
|
.next_clip(std::time::Duration::from_millis(timeout_ms as u64))
|
||||||
|
{
|
||||||
|
Ok(ev) => {
|
||||||
|
let mut slot = c.last_clip.lock().unwrap();
|
||||||
|
let out_ev = build_clip_event(ev, &mut slot);
|
||||||
|
unsafe { *out = out_ev };
|
||||||
|
PunktfunkStatus::Ok
|
||||||
|
}
|
||||||
|
Err(e) => e.status(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// The compositor backend the host actually resolved for this session (one of the
|
/// The compositor backend the host actually resolved for this session (one of the
|
||||||
/// `PUNKTFUNK_COMPOSITOR_*` values; the `Welcome`'s echo of the [`punktfunk_connect_ex`]
|
/// `PUNKTFUNK_COMPOSITOR_*` values; the `Welcome`'s echo of the [`punktfunk_connect_ex`]
|
||||||
/// preference). `PUNKTFUNK_COMPOSITOR_AUTO` = an older host that didn't say. Clients use it for
|
/// preference). `PUNKTFUNK_COMPOSITOR_AUTO` = an older host that didn't say. Clients use it for
|
||||||
|
|||||||
@@ -12,15 +12,16 @@
|
|||||||
//! channel. All methods are safe to call from any single embedder thread.
|
//! channel. All methods are safe to call from any single embedder thread.
|
||||||
|
|
||||||
use crate::abr::BitrateController;
|
use crate::abr::BitrateController;
|
||||||
|
use crate::clipboard::{ClipCommand, ClipEventCore};
|
||||||
use crate::config::{CompositorPref, GamepadPref, Mode, Role};
|
use crate::config::{CompositorPref, GamepadPref, Mode, Role};
|
||||||
use crate::error::{PunktfunkError, Result};
|
use crate::error::{PunktfunkError, Result};
|
||||||
use crate::input::InputEvent;
|
use crate::input::InputEvent;
|
||||||
use crate::packet::FLAG_PROBE;
|
use crate::packet::FLAG_PROBE;
|
||||||
use crate::quic::{
|
use crate::quic::{
|
||||||
accept_resync, endpoint, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClockEcho,
|
accept_resync, endpoint, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipControl,
|
||||||
ClockResync, ColorInfo, HdrMeta, Hello, HidOutput, LossReport, ProbeRequest, ProbeResult,
|
ClipKind, ClipOffer, ClipState, ClockEcho, ClockResync, ColorInfo, HdrMeta, Hello, HidOutput,
|
||||||
Reconfigure, Reconfigured, RequestKeyframe, ResyncStep, RfiRequest, RichInput, SetBitrate,
|
LossReport, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, ResyncStep,
|
||||||
Start, Welcome,
|
RfiRequest, RichInput, SetBitrate, Start, Welcome,
|
||||||
};
|
};
|
||||||
use crate::session::{Frame, Session};
|
use crate::session::{Frame, Session};
|
||||||
use crate::transport::UdpTransport;
|
use crate::transport::UdpTransport;
|
||||||
@@ -62,6 +63,12 @@ enum CtrlRequest {
|
|||||||
/// its report tick after the first no-op clock flush — the "the clock stepped under me"
|
/// its report tick after the first no-op clock flush — the "the clock stepped under me"
|
||||||
/// signal; the control task also self-triggers one every [`CLOCK_RESYNC_INTERVAL`].
|
/// signal; the control task also self-triggers one every [`CLOCK_RESYNC_INTERVAL`].
|
||||||
ClockResync,
|
ClockResync,
|
||||||
|
/// Shared-clipboard enable/disable for this session (`design/clipboard-and-file-transfer.md`
|
||||||
|
/// §3.1). Idempotent; carries the file-permission flag.
|
||||||
|
ClipControl(ClipControl),
|
||||||
|
/// Announce that the local clipboard changed — the lazy format-list offer (bytes cross later on
|
||||||
|
/// a fetch stream). Symmetric message; the host may send one too.
|
||||||
|
ClipOffer(ClipOffer),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the worker reports to [`NativeClient::connect`] once the handshake lands: the
|
/// What the worker reports to [`NativeClient::connect`] once the handshake lands: the
|
||||||
@@ -93,6 +100,10 @@ struct Negotiated {
|
|||||||
audio_channels: u8,
|
audio_channels: u8,
|
||||||
/// The single codec the host will emit (`quic::CODEC_*`).
|
/// The single codec the host will emit (`quic::CODEC_*`).
|
||||||
codec: u8,
|
codec: u8,
|
||||||
|
/// The host capability bitfield ([`Welcome::host_caps`]): [`crate::quic::HOST_CAP_GAMEPAD_STATE`],
|
||||||
|
/// [`crate::quic::HOST_CAP_CLIPBOARD`]. Exposed to the embedder via
|
||||||
|
/// [`NativeClient::host_caps`] so a native client greys out unsupported toggles.
|
||||||
|
host_caps: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accumulated state of an in-flight / finished speed test. The data-plane pump mirrors the
|
/// Accumulated state of an in-flight / finished speed test. The data-plane pump mirrors the
|
||||||
@@ -351,6 +362,12 @@ const HDR_META_QUEUE: usize = 8;
|
|||||||
/// harmless, it's per-frame observability, not state.
|
/// harmless, it's per-frame observability, not state.
|
||||||
const HOST_TIMING_QUEUE: usize = 512;
|
const HOST_TIMING_QUEUE: usize = 512;
|
||||||
|
|
||||||
|
/// Clipboard event plane depth (offers, host acks, fetch-requests, fetched payloads). Clipboard
|
||||||
|
/// activity is human-paced and sparse; a small ring is ample. Overflow drops the newest event
|
||||||
|
/// (try_send), same discipline as the other planes — a dropped offer heals on the next copy, and
|
||||||
|
/// a dropped fetch-request makes the serving stream time out and reset cleanly.
|
||||||
|
const CLIP_EVENT_QUEUE: usize = 32;
|
||||||
|
|
||||||
/// One Opus packet from the host's audio datagram stream (48 kHz stereo, 5 ms frames).
|
/// One Opus packet from the host's audio datagram stream (48 kHz stereo, 5 ms frames).
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct AudioPacket {
|
pub struct AudioPacket {
|
||||||
@@ -463,6 +480,19 @@ pub struct NativeClient {
|
|||||||
/// Bounded ([`CTRL_QUEUE`]) — the requests are sparse; a full queue means the control task
|
/// Bounded ([`CTRL_QUEUE`]) — the requests are sparse; a full queue means the control task
|
||||||
/// is wedged/dead, and callers treat it like a closed session.
|
/// is wedged/dead, and callers treat it like a closed session.
|
||||||
ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>,
|
ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>,
|
||||||
|
/// Inbound shared-clipboard events (remote offers, host acks, fetch-requests, fetched
|
||||||
|
/// payloads), drained by [`NativeClient::next_clip`] → the C ABI poll. Fed by the control task
|
||||||
|
/// (metadata) and the clipboard task (fetch data).
|
||||||
|
clip: Mutex<Receiver<ClipEventCore>>,
|
||||||
|
/// Outbound clipboard fetch/serve/cancel commands → the worker's clipboard task
|
||||||
|
/// ([`crate::clipboard::run`]). Unbounded like `input_tx`; the commands are sparse and each
|
||||||
|
/// carries at most one paste's bytes.
|
||||||
|
clip_cmd_tx: tokio::sync::mpsc::UnboundedSender<ClipCommand>,
|
||||||
|
/// Monotonic id for outbound fetches ([`NativeClient::clip_fetch`]); stays below
|
||||||
|
/// [`crate::clipboard::INBOUND_REQ_FLAG`] so it never collides with an inbound serve `req_id`.
|
||||||
|
next_xfer_id: AtomicU32,
|
||||||
|
/// The host capability bitfield ([`Welcome::host_caps`]) — see [`NativeClient::host_caps`].
|
||||||
|
pub host_caps: u8,
|
||||||
/// Speed-test accumulator, shared with the data-plane pump + control task.
|
/// Speed-test accumulator, shared with the data-plane pump + control task.
|
||||||
probe: Arc<Mutex<ProbeState>>,
|
probe: Arc<Mutex<ProbeState>>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
@@ -666,6 +696,9 @@ impl NativeClient {
|
|||||||
let (mic_tx, mic_rx) = tokio::sync::mpsc::channel::<(u32, u64, Vec<u8>)>(MIC_QUEUE);
|
let (mic_tx, mic_rx) = tokio::sync::mpsc::channel::<(u32, u64, Vec<u8>)>(MIC_QUEUE);
|
||||||
let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::<RichInput>();
|
let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::<RichInput>();
|
||||||
let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::channel::<CtrlRequest>(CTRL_QUEUE);
|
let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::channel::<CtrlRequest>(CTRL_QUEUE);
|
||||||
|
let (clip_event_tx, clip_event_rx) =
|
||||||
|
std::sync::mpsc::sync_channel::<ClipEventCore>(CLIP_EVENT_QUEUE);
|
||||||
|
let (clip_cmd_tx, clip_cmd_rx) = tokio::sync::mpsc::unbounded_channel::<ClipCommand>();
|
||||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
|
||||||
let shutdown = Arc::new(AtomicBool::new(false));
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
let quit = Arc::new(AtomicBool::new(false));
|
let quit = Arc::new(AtomicBool::new(false));
|
||||||
@@ -731,6 +764,8 @@ impl NativeClient {
|
|||||||
rich_input_rx,
|
rich_input_rx,
|
||||||
ctrl_rx,
|
ctrl_rx,
|
||||||
ctrl_tx: ctrl_tx_pump,
|
ctrl_tx: ctrl_tx_pump,
|
||||||
|
clip_event_tx,
|
||||||
|
clip_cmd_rx,
|
||||||
ready_tx,
|
ready_tx,
|
||||||
shutdown: shutdown_w,
|
shutdown: shutdown_w,
|
||||||
quit: quit_w,
|
quit: quit_w,
|
||||||
@@ -764,6 +799,10 @@ impl NativeClient {
|
|||||||
mic_tx,
|
mic_tx,
|
||||||
rich_input_tx,
|
rich_input_tx,
|
||||||
ctrl_tx,
|
ctrl_tx,
|
||||||
|
clip: Mutex::new(clip_event_rx),
|
||||||
|
clip_cmd_tx,
|
||||||
|
next_xfer_id: AtomicU32::new(1),
|
||||||
|
host_caps: negotiated.host_caps,
|
||||||
probe,
|
probe,
|
||||||
shutdown,
|
shutdown,
|
||||||
quit,
|
quit,
|
||||||
@@ -1240,6 +1279,83 @@ impl NativeClient {
|
|||||||
self.input_tx.send(*ev).map_err(|_| PunktfunkError::Closed)
|
self.input_tx.send(*ev).map_err(|_| PunktfunkError::Closed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The host capability bitfield the [`Welcome`] carried ([`crate::quic::HOST_CAP_GAMEPAD_STATE`],
|
||||||
|
/// [`crate::quic::HOST_CAP_CLIPBOARD`]). A native client tests
|
||||||
|
/// `host_caps() & HOST_CAP_CLIPBOARD` to decide whether to offer the shared-clipboard toggle.
|
||||||
|
pub fn host_caps(&self) -> u8 {
|
||||||
|
self.host_caps
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable the shared clipboard for this session (`design/clipboard-and-file-transfer.md`
|
||||||
|
/// §3.1). Opt-in: nothing is announced or served until this crosses with `enabled = true`.
|
||||||
|
/// `flags` carries [`crate::quic::CLIP_FLAG_FILES`]. Non-blocking; the host replies with a
|
||||||
|
/// `State` event ([`NativeClient::next_clip`]).
|
||||||
|
pub fn clip_control(&self, enabled: bool, flags: u8) -> Result<()> {
|
||||||
|
self.ctrl_tx
|
||||||
|
.try_send(CtrlRequest::ClipControl(ClipControl { enabled, flags }))
|
||||||
|
.map_err(|_| PunktfunkError::Closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Announce that the local clipboard changed — the lazy format-list offer. `seq` is a
|
||||||
|
/// monotonic per-sender counter (newest wins); `kinds` is the advertised formats (≤
|
||||||
|
/// [`crate::quic::CLIP_MAX_KINDS`]). The bytes cross only if the host later fetches.
|
||||||
|
pub fn clip_offer(&self, seq: u32, kinds: Vec<ClipKind>) -> Result<()> {
|
||||||
|
self.ctrl_tx
|
||||||
|
.try_send(CtrlRequest::ClipOffer(ClipOffer { seq, kinds }))
|
||||||
|
.map_err(|_| PunktfunkError::Closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start pulling one format (`mime`) of the host's current offer `seq` — lazily, when a local
|
||||||
|
/// app pastes. `file_index` selects a file for a file transfer, or
|
||||||
|
/// [`crate::quic::CLIP_FILE_INDEX_NONE`] for a non-file format. Returns the `xfer_id` echoed on
|
||||||
|
/// the resulting `Data` / `Error` / `Cancelled` event.
|
||||||
|
pub fn clip_fetch(&self, seq: u32, mime: String, file_index: u32) -> Result<u32> {
|
||||||
|
let xfer_id = self.next_xfer_id.fetch_add(1, Ordering::Relaxed);
|
||||||
|
// Stay in the low id space (inbound serve ids carry the high bit); wrap defensively.
|
||||||
|
let xfer_id = xfer_id & !crate::clipboard::INBOUND_REQ_FLAG;
|
||||||
|
self.clip_cmd_tx
|
||||||
|
.send(ClipCommand::Fetch {
|
||||||
|
xfer_id,
|
||||||
|
seq,
|
||||||
|
file_index,
|
||||||
|
mime,
|
||||||
|
})
|
||||||
|
.map_err(|_| PunktfunkError::Closed)?;
|
||||||
|
Ok(xfer_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provide bytes answering a `FetchRequest` event (the host is pasting our offered data). Call
|
||||||
|
/// repeatedly to stream a large payload; `last = true` completes it. `clip_cancel(req_id)`
|
||||||
|
/// aborts instead.
|
||||||
|
pub fn clip_serve(&self, req_id: u32, bytes: Vec<u8>, last: bool) -> Result<()> {
|
||||||
|
self.clip_cmd_tx
|
||||||
|
.send(ClipCommand::Serve {
|
||||||
|
req_id,
|
||||||
|
bytes,
|
||||||
|
last,
|
||||||
|
})
|
||||||
|
.map_err(|_| PunktfunkError::Closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel a clipboard transfer by id — either an outbound fetch (`xfer_id` from
|
||||||
|
/// [`NativeClient::clip_fetch`]) or an inbound serve (`req_id` from a `FetchRequest` event).
|
||||||
|
pub fn clip_cancel(&self, id: u32) -> Result<()> {
|
||||||
|
self.clip_cmd_tx
|
||||||
|
.send(ClipCommand::Cancel { id })
|
||||||
|
.map_err(|_| PunktfunkError::Closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the next shared-clipboard event (remote offer, host ack/state, fetch-request, fetched
|
||||||
|
/// data, cancel, error); same timeout/closed semantics as [`NativeClient::next_hidout`]. A
|
||||||
|
/// native client drains this on its own thread and drives the OS pasteboard from it.
|
||||||
|
pub fn next_clip(&self, timeout: Duration) -> Result<ClipEventCore> {
|
||||||
|
match self.clip.lock().unwrap().recv_timeout(timeout) {
|
||||||
|
Ok(e) => Ok(e),
|
||||||
|
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
|
||||||
|
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Queue one Opus mic frame for delivery as a 0xCB uplink datagram (the inverse of
|
/// Queue one Opus mic frame for delivery as a 0xCB uplink datagram (the inverse of
|
||||||
/// [`next_audio`](Self::next_audio)). `seq`/`pts_ns` are the caller's own counters (the host
|
/// [`next_audio`](Self::next_audio)). `seq`/`pts_ns` are the caller's own counters (the host
|
||||||
/// uses them only for diagnostics). The host decodes it into a virtual microphone source.
|
/// uses them only for diagnostics). The host decodes it into a virtual microphone source.
|
||||||
@@ -1339,6 +1455,8 @@ struct WorkerArgs {
|
|||||||
rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>,
|
rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>,
|
||||||
ctrl_rx: tokio::sync::mpsc::Receiver<CtrlRequest>,
|
ctrl_rx: tokio::sync::mpsc::Receiver<CtrlRequest>,
|
||||||
ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>,
|
ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>,
|
||||||
|
clip_event_tx: SyncSender<ClipEventCore>,
|
||||||
|
clip_cmd_rx: tokio::sync::mpsc::UnboundedReceiver<ClipCommand>,
|
||||||
ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
|
ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
/// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set.
|
/// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set.
|
||||||
@@ -1382,6 +1500,8 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
mut rich_input_rx,
|
mut rich_input_rx,
|
||||||
mut ctrl_rx,
|
mut ctrl_rx,
|
||||||
ctrl_tx,
|
ctrl_tx,
|
||||||
|
clip_event_tx,
|
||||||
|
clip_cmd_rx,
|
||||||
ready_tx,
|
ready_tx,
|
||||||
shutdown,
|
shutdown,
|
||||||
quit,
|
quit,
|
||||||
@@ -1532,22 +1652,22 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
chroma_format: welcome.chroma_format,
|
chroma_format: welcome.chroma_format,
|
||||||
audio_channels: welcome.audio_channels,
|
audio_channels: welcome.audio_channels,
|
||||||
codec: welcome.codec,
|
codec: welcome.codec,
|
||||||
|
host_caps: welcome.host_caps,
|
||||||
},
|
},
|
||||||
welcome.host_caps,
|
|
||||||
))
|
))
|
||||||
};
|
};
|
||||||
|
|
||||||
let (conn, mut session, mut ctrl_send, mut ctrl_recv, negotiated, host_caps) = match setup.await
|
let (conn, mut session, mut ctrl_send, mut ctrl_recv, negotiated) = match setup.await {
|
||||||
{
|
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = ready_tx.send(Err(e));
|
let _ = ready_tx.send(Err(e));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Copies the pump needs after `negotiated` is handed over to `connect`.
|
// Copies the worker needs after `negotiated` is handed over to `connect`.
|
||||||
let clock_rtt_ns = negotiated.clock_rtt_ns;
|
let clock_rtt_ns = negotiated.clock_rtt_ns;
|
||||||
let resolved_bitrate_kbps = negotiated.bitrate_kbps;
|
let resolved_bitrate_kbps = negotiated.bitrate_kbps;
|
||||||
|
let host_caps = negotiated.host_caps;
|
||||||
// Seed the live offset with the connect-time estimate BEFORE the embedder can observe the
|
// Seed the live offset with the connect-time estimate BEFORE the embedder can observe the
|
||||||
// client (ready_tx): clock_offset_now_ns() never reads a pre-handshake 0 on a skewed pair.
|
// client (ready_tx): clock_offset_now_ns() never reads a pre-handshake 0 on a skewed pair.
|
||||||
clock_offset.store(negotiated.clock_offset_ns, Ordering::Relaxed);
|
clock_offset.store(negotiated.clock_offset_ns, Ordering::Relaxed);
|
||||||
@@ -1639,6 +1759,9 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
let bitrate_ack = bitrate_ack.clone();
|
let bitrate_ack = bitrate_ack.clone();
|
||||||
let clock_offset = clock_offset.clone();
|
let clock_offset = clock_offset.clone();
|
||||||
let clock_gen = clock_gen.clone();
|
let clock_gen = clock_gen.clone();
|
||||||
|
// The control task feeds clipboard metadata events (ClipState/ClipOffer) onto the same event
|
||||||
|
// plane the clipboard task uses for fetch data; the original tx goes to that task below.
|
||||||
|
let clip_event_tx = clip_event_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
|
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
|
||||||
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
|
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
|
||||||
@@ -1668,6 +1791,8 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
}
|
}
|
||||||
resync.begin(wall_clock_ns()).encode()
|
resync.begin(wall_clock_ns()).encode()
|
||||||
}
|
}
|
||||||
|
CtrlRequest::ClipControl(c) => c.encode(),
|
||||||
|
CtrlRequest::ClipOffer(o) => o.encode(),
|
||||||
};
|
};
|
||||||
if io::write_msg(&mut ctrl_send, &bytes).await.is_err() {
|
if io::write_msg(&mut ctrl_send, &bytes).await.is_err() {
|
||||||
break;
|
break;
|
||||||
@@ -1749,6 +1874,21 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
}
|
}
|
||||||
ResyncStep::Idle => {}
|
ResyncStep::Idle => {}
|
||||||
}
|
}
|
||||||
|
} else if let Ok(state) = ClipState::decode(&msg) {
|
||||||
|
// Host ack / policy / backend update for the toggle UI (try_send: a
|
||||||
|
// lagging embedder drops the newest — a stale toggle heals on the next).
|
||||||
|
let _ = clip_event_tx.try_send(ClipEventCore::State {
|
||||||
|
enabled: state.enabled,
|
||||||
|
policy: state.policy,
|
||||||
|
reason: state.reason,
|
||||||
|
});
|
||||||
|
} else if let Ok(offer) = ClipOffer::decode(&msg) {
|
||||||
|
// The host copied something: surface the lazy format list; the embedder
|
||||||
|
// fetches only if a local app pastes.
|
||||||
|
let _ = clip_event_tx.try_send(ClipEventCore::RemoteOffer {
|
||||||
|
seq: offer.seq,
|
||||||
|
kinds: offer.kinds,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!("unknown control message — ignoring");
|
tracing::warn!("unknown control message — ignoring");
|
||||||
}
|
}
|
||||||
@@ -1825,6 +1965,17 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clipboard task: the fetch-stream accept loop (host pulls what we offered) + outbound fetches
|
||||||
|
// (we pull what the host offered). Metadata (enable/offer/state) rides the control task above;
|
||||||
|
// only bulk bytes flow here. Dies with the connection (accept_bi errors) or when the embedder
|
||||||
|
// drops the command sender. Always spawned — a host without HOST_CAP_CLIPBOARD simply never
|
||||||
|
// opens a clip stream, and our control-plane offers hit its "unknown message" arm harmlessly.
|
||||||
|
tokio::spawn(crate::clipboard::run(
|
||||||
|
conn.clone(),
|
||||||
|
clip_event_tx,
|
||||||
|
clip_cmd_rx,
|
||||||
|
));
|
||||||
|
|
||||||
// Watch for connection close → stop the pump.
|
// Watch for connection close → stop the pump.
|
||||||
{
|
{
|
||||||
let shutdown = shutdown.clone();
|
let shutdown = shutdown.clone();
|
||||||
|
|||||||
@@ -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;
|
pub mod audio;
|
||||||
#[cfg(feature = "quic")]
|
#[cfg(feature = "quic")]
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
/// Client-side shared-clipboard transport: the per-session task that runs the fetch-stream accept
|
||||||
|
/// loop, drives outbound fetches, and serves inbound ones — surfaced to the embedder as poll
|
||||||
|
/// events. Wire codecs live in [`quic`]; the OS pasteboard integration lives in the native client.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
pub mod clipboard;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
@@ -61,7 +66,11 @@ pub use stats::Stats;
|
|||||||
/// TTL of a v2 envelope; `punktfunk_connection_next_rumble` is unchanged and drops it). Additive —
|
/// TTL of a v2 envelope; `punktfunk_connection_next_rumble` is unchanged and drops it). Additive —
|
||||||
/// the wire is backward-compatible (the envelope is a length-tolerant tail on 0xCA), so
|
/// the wire is backward-compatible (the envelope is a length-tolerant tail on 0xCA), so
|
||||||
/// [`WIRE_VERSION`] is unchanged.
|
/// [`WIRE_VERSION`] is unchanged.
|
||||||
pub const ABI_VERSION: u32 = 5;
|
/// v6: added the shared-clipboard client surface — `punktfunk_connection_host_caps` and
|
||||||
|
/// `punktfunk_connection_clipboard_{control,offer,fetch,serve,cancel}` +
|
||||||
|
/// `punktfunk_connection_next_clipboard`. Additive; the wire grows only backward-compatible control
|
||||||
|
/// messages (0x40-0x44) and a new `Welcome::host_caps` bit, so [`WIRE_VERSION`] is unchanged.
|
||||||
|
pub const ABI_VERSION: u32 = 6;
|
||||||
|
|
||||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
//! Per-transfer clipboard fetch streams (`design/clipboard-and-file-transfer.md` §3.3).
|
||||||
|
//!
|
||||||
|
//! Bulk clipboard / file bytes never ride the control stream (u16-capped) or datagrams (lossy,
|
||||||
|
//! single-packet). The **requester opens a fresh QUIC bi-stream** toward the data holder, writes a
|
||||||
|
//! small stream header + a [`ClipFetch`]; the holder replies with a [`ClipFetchHdr`] then raw data
|
||||||
|
//! chunks until FIN. One transfer per stream ⇒ natural flow control, clean cancelation
|
||||||
|
//! (`RESET_STREAM`), and no head-of-line blocking against control or other transfers.
|
||||||
|
//!
|
||||||
|
//! These helpers are the transport half only; they hold no clipboard state, so the host and the
|
||||||
|
//! client-core reuse the exact same open/accept/serve wire dance (the accept-loop that dispatches
|
||||||
|
//! by stream kind lives on each side, since the two sides own their connections differently).
|
||||||
|
|
||||||
|
use super::{io, ClipFetch, ClipFetchHdr};
|
||||||
|
|
||||||
|
/// First bytes an opener writes on a freshly-opened clipboard bi-stream: a magic keeping this
|
||||||
|
/// stream namespace disjoint from any future stream kind, plus a 1-byte kind discriminator. A
|
||||||
|
/// distinct magic means a stream opened for some other future purpose can never be misrouted here.
|
||||||
|
pub const STREAM_MAGIC: &[u8; 4] = b"PKFs";
|
||||||
|
|
||||||
|
/// Stream-kind byte: a clipboard fetch (request/response of one format). Future stream kinds
|
||||||
|
/// (e.g. a bulk file-content push) mux under the same [`STREAM_MAGIC`] with a different byte.
|
||||||
|
pub const CLIP_STREAM_KIND_FETCH: u8 = 0x01;
|
||||||
|
|
||||||
|
/// QUIC application error code used to `reset`/`stop` a clipboard fetch stream on cancel — sync
|
||||||
|
/// disabled mid-transfer, paste timed out, size cap exceeded, teardown. Distinct from the
|
||||||
|
/// connection close codes ([`super::QUIT_CLOSE_CODE`] `0x51` / [`super::APP_EXITED_CLOSE_CODE`]
|
||||||
|
/// `0x52`) and the connection reject code `0x42`.
|
||||||
|
pub const CLIP_CANCELLED_CODE: u32 = 0x60;
|
||||||
|
|
||||||
|
/// Chunk size for streaming fetch data (64 KiB writes — matches the control-frame bound).
|
||||||
|
pub const CLIP_CHUNK: usize = 64 * 1024;
|
||||||
|
|
||||||
|
/// The `VarInt` form of [`CLIP_CANCELLED_CODE`], for `SendStream::reset` / `RecvStream::stop`.
|
||||||
|
pub fn cancelled_code() -> quinn::VarInt {
|
||||||
|
quinn::VarInt::from_u32(CLIP_CANCELLED_CODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requester side: open a fresh bi-stream toward the holder, deprioritize it under the control
|
||||||
|
/// stream, write the stream header + the [`ClipFetch`], and hand back both halves. The send half
|
||||||
|
/// is returned so the caller can `reset`/`finish` for cancelation; the recv half is positioned to
|
||||||
|
/// read the [`ClipFetchHdr`] next (see [`read_fetch_hdr`]).
|
||||||
|
pub async fn open_fetch(
|
||||||
|
conn: &quinn::Connection,
|
||||||
|
req: &ClipFetch,
|
||||||
|
) -> std::io::Result<(quinn::SendStream, quinn::RecvStream)> {
|
||||||
|
let (mut send, recv) = conn.open_bi().await.map_err(std::io::Error::other)?;
|
||||||
|
// Yield to the control stream (default priority 0) so a large paste never head-of-line-blocks
|
||||||
|
// the input/audio/control traffic sharing this connection.
|
||||||
|
let _ = send.set_priority(-1);
|
||||||
|
// The opener MUST write before the peer's `accept_bi()` can return (quinn contract), so send
|
||||||
|
// the whole request eagerly.
|
||||||
|
let mut hdr = Vec::with_capacity(5);
|
||||||
|
hdr.extend_from_slice(STREAM_MAGIC);
|
||||||
|
hdr.push(CLIP_STREAM_KIND_FETCH);
|
||||||
|
send.write_all(&hdr).await.map_err(std::io::Error::other)?;
|
||||||
|
io::write_msg(&mut send, &req.encode()).await?;
|
||||||
|
Ok((send, recv))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holder side, step 1: after `accept_bi()`, read and validate the 5-byte stream header. Returns
|
||||||
|
/// the kind byte (e.g. [`CLIP_STREAM_KIND_FETCH`]); an unknown magic is an error and the caller
|
||||||
|
/// should `stop` the stream.
|
||||||
|
pub async fn read_stream_header(recv: &mut quinn::RecvStream) -> std::io::Result<u8> {
|
||||||
|
let mut hdr = [0u8; 5];
|
||||||
|
recv.read_exact(&mut hdr)
|
||||||
|
.await
|
||||||
|
.map_err(std::io::Error::other)?;
|
||||||
|
if &hdr[0..4] != STREAM_MAGIC {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"bad clip stream magic",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(hdr[4])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holder side, step 2: read the [`ClipFetch`] request that follows the header.
|
||||||
|
pub async fn read_fetch(recv: &mut quinn::RecvStream) -> std::io::Result<ClipFetch> {
|
||||||
|
let raw = io::read_msg(recv).await?;
|
||||||
|
ClipFetch::decode(&raw)
|
||||||
|
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "bad ClipFetch"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holder side, step 3: send the response header (before any data chunks).
|
||||||
|
pub async fn write_fetch_hdr(
|
||||||
|
send: &mut quinn::SendStream,
|
||||||
|
hdr: &ClipFetchHdr,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
io::write_msg(send, &hdr.encode()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holder side, step 4 (only when the header was [`super::CLIP_FETCH_OK`]): stream `data` as
|
||||||
|
/// 64 KiB chunks then FIN so the requester's [`read_data`] terminates.
|
||||||
|
pub async fn write_data(send: &mut quinn::SendStream, data: &[u8]) -> std::io::Result<()> {
|
||||||
|
for chunk in data.chunks(CLIP_CHUNK) {
|
||||||
|
send.write_all(chunk).await.map_err(std::io::Error::other)?;
|
||||||
|
}
|
||||||
|
send.finish().map_err(std::io::Error::other)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requester side: read the [`ClipFetchHdr`] the holder sends before any data chunks.
|
||||||
|
pub async fn read_fetch_hdr(recv: &mut quinn::RecvStream) -> std::io::Result<ClipFetchHdr> {
|
||||||
|
let raw = io::read_msg(recv).await?;
|
||||||
|
ClipFetchHdr::decode(&raw)
|
||||||
|
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "bad ClipFetchHdr"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requester side: after an OK [`ClipFetchHdr`], drain the data chunks to a `Vec`, bounded by
|
||||||
|
/// `max_bytes` (the requester's size cap — a breach errors, and the caller resets the stream).
|
||||||
|
pub async fn read_data(recv: &mut quinn::RecvStream, max_bytes: usize) -> std::io::Result<Vec<u8>> {
|
||||||
|
recv.read_to_end(max_bytes)
|
||||||
|
.await
|
||||||
|
.map_err(std::io::Error::other)
|
||||||
|
}
|
||||||
@@ -49,6 +49,10 @@ pub mod endpoint;
|
|||||||
/// Async framed-message IO over a quinn stream (`u16 LE length || payload`).
|
/// Async framed-message IO over a quinn stream (`u16 LE length || payload`).
|
||||||
pub mod io;
|
pub mod io;
|
||||||
|
|
||||||
|
/// Per-transfer clipboard fetch bi-streams (`PKFs` magic + kind byte, then request/response). The
|
||||||
|
/// transport half of the shared clipboard; wire codecs are in [`msgs`], state lives per side.
|
||||||
|
pub mod clipstream;
|
||||||
|
|
||||||
/// SPAKE2 over Ed25519 for the pairing ceremony. The two roles use the asymmetric flow so
|
/// SPAKE2 over Ed25519 for the pairing ceremony. The two roles use the asymmetric flow so
|
||||||
/// the identities are ordered; each side binds **both** certificate fingerprints as the
|
/// the identities are ordered; each side binds **both** certificate fingerprints as the
|
||||||
/// SPAKE2 identities, so the derived key only matches when client and host agree on the PIN
|
/// SPAKE2 identities, so the derived key only matches when client and host agree on the PIN
|
||||||
|
|||||||
@@ -142,6 +142,14 @@ pub const APP_EXITED_CLOSE_CODE: u32 = 0x52;
|
|||||||
/// button/axis events; toward a host that doesn't set the bit it keeps the legacy events.
|
/// button/axis events; toward a host that doesn't set the bit it keeps the legacy events.
|
||||||
pub const HOST_CAP_GAMEPAD_STATE: u8 = 0x01;
|
pub const HOST_CAP_GAMEPAD_STATE: u8 = 0x01;
|
||||||
|
|
||||||
|
/// [`Welcome::host_caps`] bit: the host has a shared-clipboard service (a working OS backend)
|
||||||
|
/// **and** its operator policy does not hard-disable it, so the client may offer the clipboard
|
||||||
|
/// toggle. Absent (an older host, or `PUNKTFUNK_CLIPBOARD` off) ⇒ the client greys the toggle
|
||||||
|
/// out. Purely additive: nothing clipboard-related happens until a [`ClipControl`]`{ enabled:
|
||||||
|
/// true }` crosses (see `design/clipboard-and-file-transfer.md` §3.1). Packs into the existing
|
||||||
|
/// trailing `host_caps` byte — no wire-layout change.
|
||||||
|
pub const HOST_CAP_CLIPBOARD: u8 = 0x02;
|
||||||
|
|
||||||
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||||
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||||
/// advertise this.
|
/// advertise this.
|
||||||
@@ -508,6 +516,147 @@ pub const MSG_CLOCK_PROBE: u8 = 0x30;
|
|||||||
/// Type byte of [`ClockEcho`].
|
/// Type byte of [`ClockEcho`].
|
||||||
pub const MSG_CLOCK_ECHO: u8 = 0x31;
|
pub const MSG_CLOCK_ECHO: u8 = 0x31;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Shared clipboard & file transfer (design/clipboard-and-file-transfer.md §3). The small
|
||||||
|
// metadata messages ride the control stream (0x40-0x42); the two fetch-stream messages
|
||||||
|
// (0x43-0x44) travel on a per-transfer bi-stream (see the [`super::clipstream`] helpers), never
|
||||||
|
// the control stream, so they are never dispatched by the control loops. All are typed
|
||||||
|
// (`CTL_MAGIC` + type byte), so an older peer hits its "unknown control message" arm and drops
|
||||||
|
// any it doesn't know — the whole feature is forward-safe.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Type byte of [`ClipControl`] (client → host): enable/disable the shared clipboard for this
|
||||||
|
/// session. Idempotent; opt-in is enforced here, not just in UI.
|
||||||
|
pub const MSG_CLIP_CONTROL: u8 = 0x40;
|
||||||
|
/// Type byte of [`ClipState`] (host → client): ack + unsolicited policy/backend updates.
|
||||||
|
pub const MSG_CLIP_STATE: u8 = 0x41;
|
||||||
|
/// Type byte of [`ClipOffer`] (symmetric): the lazy announcement — format list only, no bytes.
|
||||||
|
pub const MSG_CLIP_OFFER: u8 = 0x42;
|
||||||
|
/// Type byte of [`ClipFetch`] (requester → holder, **fetch stream only**): pull one format of the
|
||||||
|
/// current offer.
|
||||||
|
pub const MSG_CLIP_FETCH: u8 = 0x43;
|
||||||
|
/// Type byte of [`ClipFetchHdr`] (holder → requester, **fetch stream only**): the fetch response
|
||||||
|
/// header that precedes the data chunks.
|
||||||
|
pub const MSG_CLIP_FETCH_HDR: u8 = 0x44;
|
||||||
|
|
||||||
|
/// [`ClipControl::flags`] bit: the client permits file kinds to be offered/fetched this session.
|
||||||
|
/// Absent ⇒ files are filtered out of offers in both directions (text/rich/image only).
|
||||||
|
pub const CLIP_FLAG_FILES: u8 = 0x01;
|
||||||
|
|
||||||
|
/// [`ClipState::policy`] bit: the host permits non-file formats (text/RTF/HTML/image). Always set
|
||||||
|
/// while enabled unless a future direction limit clears it.
|
||||||
|
pub const CLIP_POLICY_TEXT: u8 = 0x01;
|
||||||
|
/// [`ClipState::policy`] bit: the host permits file formats. Cleared by the operator `no-files`
|
||||||
|
/// / `text-only` policy so the client can grey out "Include files".
|
||||||
|
pub const CLIP_POLICY_FILES: u8 = 0x02;
|
||||||
|
|
||||||
|
/// [`ClipState::reason`]: normal ack, nothing exceptional.
|
||||||
|
pub const CLIP_REASON_OK: u8 = 0;
|
||||||
|
/// [`ClipState::reason`]: this session type has no working clipboard backend (e.g. a gamescope
|
||||||
|
/// session with no data-control global) — the client shows "not supported in this session type".
|
||||||
|
pub const CLIP_REASON_BACKEND_UNAVAILABLE: u8 = 1;
|
||||||
|
/// [`ClipState::reason`]: another client took over the single per-desktop clipboard binding; this
|
||||||
|
/// one was disabled (last `ClipControl{enabled}` wins).
|
||||||
|
pub const CLIP_REASON_TAKEN_OVER: u8 = 2;
|
||||||
|
/// [`ClipState::reason`]: the host operator policy (`PUNKTFUNK_CLIPBOARD=off`) disables clipboard.
|
||||||
|
pub const CLIP_REASON_POLICY_DISABLED: u8 = 3;
|
||||||
|
/// [`ClipState::reason`]: enabled, but the host policy forbids file transfer (`no-files` /
|
||||||
|
/// `text-only`) — surfaced so the client greys "Include files" with a footnote.
|
||||||
|
pub const CLIP_REASON_NO_FILES: u8 = 4;
|
||||||
|
|
||||||
|
/// [`ClipFetchHdr::status`]: the requested format is being served; data chunks follow until FIN.
|
||||||
|
pub const CLIP_FETCH_OK: u8 = 0;
|
||||||
|
/// [`ClipFetchHdr::status`]: the fetch named a `seq` that is no longer the holder's current offer;
|
||||||
|
/// the requester degrades the paste to "nothing inserted" rather than wrong data. No chunks follow.
|
||||||
|
pub const CLIP_FETCH_STALE: u8 = 1;
|
||||||
|
/// [`ClipFetchHdr::status`]: the format/index is not available (no backend, or it vanished). No
|
||||||
|
/// chunks follow.
|
||||||
|
pub const CLIP_FETCH_UNAVAILABLE: u8 = 2;
|
||||||
|
/// [`ClipFetchHdr::status`]: policy/cap denies this fetch (e.g. a file fetch under `no-files`). No
|
||||||
|
/// chunks follow.
|
||||||
|
pub const CLIP_FETCH_DENIED: u8 = 3;
|
||||||
|
|
||||||
|
/// Maximum number of [`ClipKind`] entries in one [`ClipOffer`] (resource cap, §7).
|
||||||
|
pub const CLIP_MAX_KINDS: usize = 16;
|
||||||
|
/// Maximum length in bytes of a [`ClipKind::mime`] string (resource cap, §7).
|
||||||
|
pub const CLIP_MAX_MIME: usize = 128;
|
||||||
|
/// [`ClipFetch::file_index`] sentinel meaning "not a file fetch" (a whole non-file format, or the
|
||||||
|
/// file *manifest* itself). Real file fetches use `0..n`.
|
||||||
|
pub const CLIP_FILE_INDEX_NONE: u32 = u32::MAX;
|
||||||
|
|
||||||
|
/// One advertised clipboard format inside a [`ClipOffer`] — a portable MIME name plus a size hint.
|
||||||
|
/// The bytes never ride here; they cross lazily on a fetch stream only when the destination pastes.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ClipKind {
|
||||||
|
/// Portable wire MIME, e.g. `text/plain;charset=utf-8`, `text/html`, `image/png`,
|
||||||
|
/// `application/x-punktfunk-files`. Each end maps it to a platform type at fetch time. ≤
|
||||||
|
/// [`CLIP_MAX_MIME`] bytes; a longer one is rejected on decode.
|
||||||
|
pub mime: String,
|
||||||
|
/// Best-effort total size of this format in bytes; `0` = unknown (a streaming provider).
|
||||||
|
pub size_hint: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `client → host` ([`MSG_CLIP_CONTROL`]): flip the shared clipboard on/off for this session.
|
||||||
|
/// Sent when the user toggles the per-host pref and once at session start if it is on. **Nothing
|
||||||
|
/// clipboard-related happens on either side until an `enabled: true` arrives** — opt-in at the
|
||||||
|
/// protocol layer.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ClipControl {
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Bitfield of [`CLIP_FLAG_FILES`] (+ reserved bits for future direction limits).
|
||||||
|
pub flags: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `host → client` ([`MSG_CLIP_STATE`]): acknowledge a [`ClipControl`] and push unsolicited
|
||||||
|
/// updates (policy changed, backend lost). The client surfaces `reason`/`policy` in the toggle UI
|
||||||
|
/// instead of failing silently.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ClipState {
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Bitfield of [`CLIP_POLICY_TEXT`] / [`CLIP_POLICY_FILES`] — what the host currently permits.
|
||||||
|
pub policy: u8,
|
||||||
|
/// One of the `CLIP_REASON_*` values explaining `enabled`/`policy`.
|
||||||
|
pub reason: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Symmetric ([`MSG_CLIP_OFFER`], either direction): the lazy announcement. Sent when the local
|
||||||
|
/// clipboard changes; carries the **format list only** (comfortably inside the 64 KiB control
|
||||||
|
/// frame). A new offer replaces the sender's previous one; `seq` lets the holder reject stale
|
||||||
|
/// fetches (§3.4). Files are announced as one `application/x-punktfunk-files` kind — the file
|
||||||
|
/// list itself is fetched lazily, never inlined here.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ClipOffer {
|
||||||
|
/// Monotonic per sender; newest wins.
|
||||||
|
pub seq: u32,
|
||||||
|
/// ≤ [`CLIP_MAX_KINDS`] entries.
|
||||||
|
pub kinds: Vec<ClipKind>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `requester → holder` ([`MSG_CLIP_FETCH`], **fetch stream only**): the first message on a
|
||||||
|
/// per-transfer bi-stream, naming which format (and, for files, which entry) of `seq` to pull.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ClipFetch {
|
||||||
|
/// The offer `seq` this fetch is against; the holder answers [`CLIP_FETCH_STALE`] if it is no
|
||||||
|
/// longer current.
|
||||||
|
pub seq: u32,
|
||||||
|
/// File index for a file transfer, or [`CLIP_FILE_INDEX_NONE`] for a non-file format / the
|
||||||
|
/// file manifest.
|
||||||
|
pub file_index: u32,
|
||||||
|
/// The requested wire MIME (≤ [`CLIP_MAX_MIME`] bytes).
|
||||||
|
pub mime: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `holder → requester` ([`MSG_CLIP_FETCH_HDR`], **fetch stream only**): the response header that
|
||||||
|
/// precedes the raw data chunks (which run until the stream's FIN). When `status` is anything
|
||||||
|
/// other than [`CLIP_FETCH_OK`] no chunks follow.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ClipFetchHdr {
|
||||||
|
/// One of the `CLIP_FETCH_*` values.
|
||||||
|
pub status: u8,
|
||||||
|
/// Total byte count that will follow; `0` = unknown (a streaming provider — FIN ends it).
|
||||||
|
pub total_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
// Pairing ceremony (typed control messages): instead of a session Hello, a client may open
|
// Pairing ceremony (typed control messages): instead of a session Hello, a client may open
|
||||||
// the control stream with PairRequest. The host shows a short PIN out-of-band (log/UI); the
|
// the control stream with PairRequest. The host shows a short PIN out-of-band (log/UI); the
|
||||||
@@ -1267,6 +1416,174 @@ impl ClockEcho {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Append one [`ClipKind`] to `b`: `mime_len u8 || mime bytes || size_hint u64 LE`.
|
||||||
|
fn put_clip_kind(b: &mut Vec<u8>, k: &ClipKind) {
|
||||||
|
let mime = k.mime.as_bytes();
|
||||||
|
let n = mime.len().min(CLIP_MAX_MIME);
|
||||||
|
b.push(n as u8);
|
||||||
|
b.extend_from_slice(&mime[..n]);
|
||||||
|
b.extend_from_slice(&k.size_hint.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one [`ClipKind`] at `off`, returning it and the next offset.
|
||||||
|
fn get_clip_kind(b: &[u8], off: usize) -> Result<(ClipKind, usize)> {
|
||||||
|
if off >= b.len() {
|
||||||
|
return Err(PunktfunkError::InvalidArg("truncated ClipKind"));
|
||||||
|
}
|
||||||
|
let n = b[off] as usize;
|
||||||
|
if n > CLIP_MAX_MIME {
|
||||||
|
return Err(PunktfunkError::InvalidArg("ClipKind mime too long"));
|
||||||
|
}
|
||||||
|
let mime_start = off + 1;
|
||||||
|
let size_start = mime_start + n;
|
||||||
|
if size_start + 8 > b.len() {
|
||||||
|
return Err(PunktfunkError::InvalidArg("ClipKind overruns message"));
|
||||||
|
}
|
||||||
|
let mime = String::from_utf8_lossy(&b[mime_start..size_start]).into_owned();
|
||||||
|
let size_hint = u64::from_le_bytes(b[size_start..size_start + 8].try_into().unwrap());
|
||||||
|
Ok((ClipKind { mime, size_hint }, size_start + 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClipControl {
|
||||||
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
|
// magic[0..4] type[4] enabled[5] flags[6]
|
||||||
|
let mut b = Vec::with_capacity(7);
|
||||||
|
b.extend_from_slice(CTL_MAGIC);
|
||||||
|
b.push(MSG_CLIP_CONTROL);
|
||||||
|
b.push(self.enabled as u8);
|
||||||
|
b.push(self.flags);
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(b: &[u8]) -> Result<ClipControl> {
|
||||||
|
if b.len() != 7 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CLIP_CONTROL {
|
||||||
|
return Err(PunktfunkError::InvalidArg("bad ClipControl"));
|
||||||
|
}
|
||||||
|
Ok(ClipControl {
|
||||||
|
enabled: b[5] != 0,
|
||||||
|
flags: b[6],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClipState {
|
||||||
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
|
// magic[0..4] type[4] enabled[5] policy[6] reason[7]
|
||||||
|
let mut b = Vec::with_capacity(8);
|
||||||
|
b.extend_from_slice(CTL_MAGIC);
|
||||||
|
b.push(MSG_CLIP_STATE);
|
||||||
|
b.push(self.enabled as u8);
|
||||||
|
b.push(self.policy);
|
||||||
|
b.push(self.reason);
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(b: &[u8]) -> Result<ClipState> {
|
||||||
|
if b.len() != 8 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CLIP_STATE {
|
||||||
|
return Err(PunktfunkError::InvalidArg("bad ClipState"));
|
||||||
|
}
|
||||||
|
Ok(ClipState {
|
||||||
|
enabled: b[5] != 0,
|
||||||
|
policy: b[6],
|
||||||
|
reason: b[7],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClipOffer {
|
||||||
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
|
// magic[0..4] type[4] seq[5..9] count[9] then `count` ClipKinds
|
||||||
|
let mut b = Vec::with_capacity(10 + self.kinds.len() * 16);
|
||||||
|
b.extend_from_slice(CTL_MAGIC);
|
||||||
|
b.push(MSG_CLIP_OFFER);
|
||||||
|
b.extend_from_slice(&self.seq.to_le_bytes());
|
||||||
|
let count = self.kinds.len().min(CLIP_MAX_KINDS);
|
||||||
|
b.push(count as u8);
|
||||||
|
for k in &self.kinds[..count] {
|
||||||
|
put_clip_kind(&mut b, k);
|
||||||
|
}
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(b: &[u8]) -> Result<ClipOffer> {
|
||||||
|
if b.len() < 10 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CLIP_OFFER {
|
||||||
|
return Err(PunktfunkError::InvalidArg("bad ClipOffer"));
|
||||||
|
}
|
||||||
|
let seq = u32::from_le_bytes(b[5..9].try_into().unwrap());
|
||||||
|
let count = b[9] as usize;
|
||||||
|
if count > CLIP_MAX_KINDS {
|
||||||
|
return Err(PunktfunkError::InvalidArg("ClipOffer too many kinds"));
|
||||||
|
}
|
||||||
|
let mut kinds = Vec::with_capacity(count);
|
||||||
|
let mut off = 10;
|
||||||
|
for _ in 0..count {
|
||||||
|
let (k, next) = get_clip_kind(b, off)?;
|
||||||
|
kinds.push(k);
|
||||||
|
off = next;
|
||||||
|
}
|
||||||
|
if off != b.len() {
|
||||||
|
return Err(PunktfunkError::InvalidArg("trailing bytes"));
|
||||||
|
}
|
||||||
|
Ok(ClipOffer { seq, kinds })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClipFetch {
|
||||||
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
|
// magic[0..4] type[4] seq[5..9] file_index[9..13] mime(len u8 || bytes)[13..]
|
||||||
|
let mime = self.mime.as_bytes();
|
||||||
|
let n = mime.len().min(CLIP_MAX_MIME);
|
||||||
|
let mut b = Vec::with_capacity(14 + n);
|
||||||
|
b.extend_from_slice(CTL_MAGIC);
|
||||||
|
b.push(MSG_CLIP_FETCH);
|
||||||
|
b.extend_from_slice(&self.seq.to_le_bytes());
|
||||||
|
b.extend_from_slice(&self.file_index.to_le_bytes());
|
||||||
|
b.push(n as u8);
|
||||||
|
b.extend_from_slice(&mime[..n]);
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(b: &[u8]) -> Result<ClipFetch> {
|
||||||
|
if b.len() < 14 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CLIP_FETCH {
|
||||||
|
return Err(PunktfunkError::InvalidArg("bad ClipFetch"));
|
||||||
|
}
|
||||||
|
let seq = u32::from_le_bytes(b[5..9].try_into().unwrap());
|
||||||
|
let file_index = u32::from_le_bytes(b[9..13].try_into().unwrap());
|
||||||
|
let n = b[13] as usize;
|
||||||
|
if n > CLIP_MAX_MIME || b.len() != 14 + n {
|
||||||
|
return Err(PunktfunkError::InvalidArg("bad ClipFetch mime"));
|
||||||
|
}
|
||||||
|
let mime = String::from_utf8_lossy(&b[14..14 + n]).into_owned();
|
||||||
|
Ok(ClipFetch {
|
||||||
|
seq,
|
||||||
|
file_index,
|
||||||
|
mime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClipFetchHdr {
|
||||||
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
|
// magic[0..4] type[4] status[5] total_size[6..14]
|
||||||
|
let mut b = Vec::with_capacity(14);
|
||||||
|
b.extend_from_slice(CTL_MAGIC);
|
||||||
|
b.push(MSG_CLIP_FETCH_HDR);
|
||||||
|
b.push(self.status);
|
||||||
|
b.extend_from_slice(&self.total_size.to_le_bytes());
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(b: &[u8]) -> Result<ClipFetchHdr> {
|
||||||
|
if b.len() != 14 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CLIP_FETCH_HDR {
|
||||||
|
return Err(PunktfunkError::InvalidArg("bad ClipFetchHdr"));
|
||||||
|
}
|
||||||
|
Ok(ClipFetchHdr {
|
||||||
|
status: b[5],
|
||||||
|
total_size: u64::from_le_bytes(b[6..14].try_into().unwrap()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Frame a message for the control stream: `u16 LE length || payload`.
|
/// Frame a message for the control stream: `u16 LE length || payload`.
|
||||||
pub fn frame(payload: &[u8]) -> Vec<u8> {
|
pub fn frame(payload: &[u8]) -> Vec<u8> {
|
||||||
let mut b = Vec::with_capacity(2 + payload.len());
|
let mut b = Vec::with_capacity(2 + payload.len());
|
||||||
|
|||||||
@@ -1159,3 +1159,390 @@ fn fingerprint_is_sha256_of_der() {
|
|||||||
assert_eq!(a, endpoint::cert_fingerprint(b"cert-a"));
|
assert_eq!(a, endpoint::cert_fingerprint(b"cert-a"));
|
||||||
assert_ne!(a, endpoint::cert_fingerprint(b"cert-b"));
|
assert_ne!(a, endpoint::cert_fingerprint(b"cert-b"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Shared clipboard control + fetch-stream message codecs (0x40-0x44) -----------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clip_control_roundtrip() {
|
||||||
|
for (enabled, flags) in [
|
||||||
|
(true, 0u8),
|
||||||
|
(false, 0),
|
||||||
|
(true, CLIP_FLAG_FILES),
|
||||||
|
(false, 0xFF),
|
||||||
|
] {
|
||||||
|
let m = ClipControl { enabled, flags };
|
||||||
|
assert_eq!(ClipControl::decode(&m.encode()).unwrap(), m);
|
||||||
|
}
|
||||||
|
// Disjoint from its host→client sibling (type byte + length) and exact length.
|
||||||
|
assert!(ClipControl::decode(
|
||||||
|
&ClipState {
|
||||||
|
enabled: true,
|
||||||
|
policy: 0,
|
||||||
|
reason: 0
|
||||||
|
}
|
||||||
|
.encode()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
let bytes = ClipControl {
|
||||||
|
enabled: true,
|
||||||
|
flags: 0,
|
||||||
|
}
|
||||||
|
.encode();
|
||||||
|
assert!(ClipControl::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||||
|
assert!(ClipControl::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clip_state_roundtrip() {
|
||||||
|
let cases = [
|
||||||
|
ClipState {
|
||||||
|
enabled: true,
|
||||||
|
policy: CLIP_POLICY_TEXT | CLIP_POLICY_FILES,
|
||||||
|
reason: CLIP_REASON_OK,
|
||||||
|
},
|
||||||
|
ClipState {
|
||||||
|
enabled: false,
|
||||||
|
policy: 0,
|
||||||
|
reason: CLIP_REASON_BACKEND_UNAVAILABLE,
|
||||||
|
},
|
||||||
|
ClipState {
|
||||||
|
enabled: true,
|
||||||
|
policy: CLIP_POLICY_TEXT,
|
||||||
|
reason: CLIP_REASON_NO_FILES,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for m in cases {
|
||||||
|
assert_eq!(ClipState::decode(&m.encode()).unwrap(), m);
|
||||||
|
}
|
||||||
|
// A ClipControl must not decode as a ClipState (type byte).
|
||||||
|
assert!(ClipState::decode(
|
||||||
|
&ClipControl {
|
||||||
|
enabled: true,
|
||||||
|
flags: 0
|
||||||
|
}
|
||||||
|
.encode()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
let bytes = cases[0].encode();
|
||||||
|
assert!(ClipState::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clip_offer_roundtrip() {
|
||||||
|
// Empty offer, one kind, and a full multi-format offer (text/rich/image/files).
|
||||||
|
let cases = [
|
||||||
|
ClipOffer {
|
||||||
|
seq: 0,
|
||||||
|
kinds: vec![],
|
||||||
|
},
|
||||||
|
ClipOffer {
|
||||||
|
seq: 1,
|
||||||
|
kinds: vec![ClipKind {
|
||||||
|
mime: "text/plain;charset=utf-8".into(),
|
||||||
|
size_hint: 12,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
ClipOffer {
|
||||||
|
seq: u32::MAX,
|
||||||
|
kinds: vec![
|
||||||
|
ClipKind {
|
||||||
|
mime: "text/plain;charset=utf-8".into(),
|
||||||
|
size_hint: 0,
|
||||||
|
},
|
||||||
|
ClipKind {
|
||||||
|
mime: "text/html".into(),
|
||||||
|
size_hint: 4096,
|
||||||
|
},
|
||||||
|
ClipKind {
|
||||||
|
mime: "image/png".into(),
|
||||||
|
size_hint: 1 << 30,
|
||||||
|
},
|
||||||
|
ClipKind {
|
||||||
|
mime: "application/x-punktfunk-files".into(),
|
||||||
|
size_hint: 5_000_000_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for m in &cases {
|
||||||
|
assert_eq!(&ClipOffer::decode(&m.encode()).unwrap(), m);
|
||||||
|
}
|
||||||
|
// Trailing bytes are rejected (get_clip_kind consumes exactly to the end).
|
||||||
|
let mut padded = cases[1].encode();
|
||||||
|
padded.push(0);
|
||||||
|
assert!(ClipOffer::decode(&padded).is_err());
|
||||||
|
// A count byte over the cap is rejected before allocating.
|
||||||
|
let mut over = cases[0].encode();
|
||||||
|
over[9] = (CLIP_MAX_KINDS + 1) as u8;
|
||||||
|
assert!(ClipOffer::decode(&over).is_err());
|
||||||
|
// Disjoint from a same-family control message.
|
||||||
|
assert!(ClipOffer::decode(
|
||||||
|
&ClipControl {
|
||||||
|
enabled: true,
|
||||||
|
flags: 0
|
||||||
|
}
|
||||||
|
.encode()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clip_fetch_roundtrip() {
|
||||||
|
let cases = [
|
||||||
|
ClipFetch {
|
||||||
|
seq: 1,
|
||||||
|
file_index: CLIP_FILE_INDEX_NONE,
|
||||||
|
mime: "text/plain;charset=utf-8".into(),
|
||||||
|
},
|
||||||
|
ClipFetch {
|
||||||
|
seq: 7,
|
||||||
|
file_index: 0,
|
||||||
|
mime: "application/x-punktfunk-files".into(),
|
||||||
|
},
|
||||||
|
ClipFetch {
|
||||||
|
seq: u32::MAX,
|
||||||
|
file_index: 41,
|
||||||
|
mime: String::new(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for m in &cases {
|
||||||
|
assert_eq!(&ClipFetch::decode(&m.encode()).unwrap(), m);
|
||||||
|
}
|
||||||
|
// Trailing + truncation both rejected (exact-length mime check).
|
||||||
|
let bytes = cases[0].encode();
|
||||||
|
assert!(ClipFetch::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||||
|
assert!(ClipFetch::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||||
|
// A fetch-stream message must not decode as a control-stream offer, and vice-versa.
|
||||||
|
assert!(ClipOffer::decode(&cases[0].encode()).is_err());
|
||||||
|
assert!(ClipFetch::decode(
|
||||||
|
&ClipOffer {
|
||||||
|
seq: 1,
|
||||||
|
kinds: vec![]
|
||||||
|
}
|
||||||
|
.encode()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clip_fetch_hdr_roundtrip() {
|
||||||
|
for (status, total_size) in [
|
||||||
|
(CLIP_FETCH_OK, 15u64),
|
||||||
|
(CLIP_FETCH_STALE, 0),
|
||||||
|
(CLIP_FETCH_UNAVAILABLE, 0),
|
||||||
|
(CLIP_FETCH_DENIED, 0),
|
||||||
|
(CLIP_FETCH_OK, u64::MAX),
|
||||||
|
] {
|
||||||
|
let m = ClipFetchHdr { status, total_size };
|
||||||
|
assert_eq!(ClipFetchHdr::decode(&m.encode()).unwrap(), m);
|
||||||
|
}
|
||||||
|
let bytes = ClipFetchHdr {
|
||||||
|
status: CLIP_FETCH_OK,
|
||||||
|
total_size: 1,
|
||||||
|
}
|
||||||
|
.encode();
|
||||||
|
assert!(ClipFetchHdr::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||||
|
assert!(ClipFetchHdr::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_cap_clipboard_bit_is_distinct_and_survives_welcome() {
|
||||||
|
// The new cap packs into the existing trailing host_caps byte with no layout change.
|
||||||
|
assert_ne!(HOST_CAP_CLIPBOARD, HOST_CAP_GAMEPAD_STATE);
|
||||||
|
let mut w = Welcome {
|
||||||
|
abi_version: 1,
|
||||||
|
udp_port: 1,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
fec: FecConfig {
|
||||||
|
scheme: FecScheme::Gf16,
|
||||||
|
fec_percent: 0,
|
||||||
|
max_data_per_block: 1024,
|
||||||
|
},
|
||||||
|
shard_payload: 1024,
|
||||||
|
encrypt: false,
|
||||||
|
key: [0; 16],
|
||||||
|
salt: [0; 4],
|
||||||
|
frames: 0,
|
||||||
|
compositor: CompositorPref::Auto,
|
||||||
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
bit_depth: 8,
|
||||||
|
color: ColorInfo::SDR_BT709,
|
||||||
|
chroma_format: CHROMA_IDC_420,
|
||||||
|
audio_channels: 2,
|
||||||
|
codec: CODEC_HEVC,
|
||||||
|
host_caps: HOST_CAP_GAMEPAD_STATE | HOST_CAP_CLIPBOARD,
|
||||||
|
};
|
||||||
|
let got = Welcome::decode(&w.encode()).unwrap();
|
||||||
|
assert_eq!(got.host_caps & HOST_CAP_CLIPBOARD, HOST_CAP_CLIPBOARD);
|
||||||
|
assert_eq!(
|
||||||
|
got.host_caps & HOST_CAP_GAMEPAD_STATE,
|
||||||
|
HOST_CAP_GAMEPAD_STATE
|
||||||
|
);
|
||||||
|
// Clipboard-off host: the bit is clear, gamepad bit still set.
|
||||||
|
w.host_caps = HOST_CAP_GAMEPAD_STATE;
|
||||||
|
assert_eq!(
|
||||||
|
Welcome::decode(&w.encode()).unwrap().host_caps & HOST_CAP_CLIPBOARD,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- In-process QUIC loopback: the real clipstream fetch transport, both success and cancel ----
|
||||||
|
|
||||||
|
mod clip_loopback {
|
||||||
|
use super::*;
|
||||||
|
use crate::quic::clipstream;
|
||||||
|
|
||||||
|
/// Stand up two loopback quinn endpoints, connect, and return
|
||||||
|
/// `(server_ep, client_ep, host_conn, client_conn)`. Both endpoints are returned so the caller
|
||||||
|
/// keeps them in scope — dropping a `quinn::Endpoint` tears down its connections.
|
||||||
|
async fn connect_pair() -> (
|
||||||
|
quinn::Endpoint,
|
||||||
|
quinn::Endpoint,
|
||||||
|
quinn::Connection,
|
||||||
|
quinn::Connection,
|
||||||
|
) {
|
||||||
|
let server = endpoint::server("127.0.0.1:0".parse().unwrap()).unwrap();
|
||||||
|
let addr = server.local_addr().unwrap();
|
||||||
|
let client = endpoint::client_insecure().unwrap();
|
||||||
|
let accept = tokio::spawn(async move {
|
||||||
|
let incoming = server.accept().await.expect("incoming connection");
|
||||||
|
let conn = incoming.await.expect("host side connects");
|
||||||
|
(server, conn)
|
||||||
|
});
|
||||||
|
let client_conn = client
|
||||||
|
.connect(addr, "punktfunk")
|
||||||
|
.unwrap()
|
||||||
|
.await
|
||||||
|
.expect("client side connects");
|
||||||
|
let (server, host_conn) = accept.await.unwrap();
|
||||||
|
(server, client, host_conn, client_conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_text_transfers_then_cancel_resets() {
|
||||||
|
let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await;
|
||||||
|
|
||||||
|
let payload = b"hello clipboard \xf0\x9f\x93\x8b".to_vec(); // text + a 4-byte emoji
|
||||||
|
let holder_payload = payload.clone();
|
||||||
|
|
||||||
|
// Holder = the host side: accept two fetch streams. Serve the first; cancel the second.
|
||||||
|
let holder = tokio::spawn(async move {
|
||||||
|
// Fetch #1 — serve the payload.
|
||||||
|
let (mut send, mut recv) = host_conn.accept_bi().await.expect("accept fetch #1");
|
||||||
|
let kind = clipstream::read_stream_header(&mut recv)
|
||||||
|
.await
|
||||||
|
.expect("stream header #1");
|
||||||
|
assert_eq!(kind, clipstream::CLIP_STREAM_KIND_FETCH);
|
||||||
|
let req = clipstream::read_fetch(&mut recv)
|
||||||
|
.await
|
||||||
|
.expect("fetch req #1");
|
||||||
|
assert_eq!(req.seq, 1);
|
||||||
|
assert_eq!(req.file_index, CLIP_FILE_INDEX_NONE);
|
||||||
|
assert_eq!(req.mime, "text/plain;charset=utf-8");
|
||||||
|
clipstream::write_fetch_hdr(
|
||||||
|
&mut send,
|
||||||
|
&ClipFetchHdr {
|
||||||
|
status: CLIP_FETCH_OK,
|
||||||
|
total_size: holder_payload.len() as u64,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("write hdr #1");
|
||||||
|
clipstream::write_data(&mut send, &holder_payload)
|
||||||
|
.await
|
||||||
|
.expect("write data #1");
|
||||||
|
|
||||||
|
// Fetch #2 — read the request, then cancel mid-transfer with RESET_STREAM.
|
||||||
|
let (mut send2, mut recv2) = host_conn.accept_bi().await.expect("accept fetch #2");
|
||||||
|
clipstream::read_stream_header(&mut recv2)
|
||||||
|
.await
|
||||||
|
.expect("stream header #2");
|
||||||
|
let _ = clipstream::read_fetch(&mut recv2)
|
||||||
|
.await
|
||||||
|
.expect("fetch req #2");
|
||||||
|
send2.reset(clipstream::cancelled_code()).unwrap();
|
||||||
|
|
||||||
|
host_conn // keep alive until the requester side is done
|
||||||
|
});
|
||||||
|
|
||||||
|
// Requester = the client side.
|
||||||
|
// #1: full lazy fetch of the text payload.
|
||||||
|
let req = ClipFetch {
|
||||||
|
seq: 1,
|
||||||
|
file_index: CLIP_FILE_INDEX_NONE,
|
||||||
|
mime: "text/plain;charset=utf-8".into(),
|
||||||
|
};
|
||||||
|
let (_send, mut recv) = clipstream::open_fetch(&client_conn, &req)
|
||||||
|
.await
|
||||||
|
.expect("open fetch #1");
|
||||||
|
let hdr = clipstream::read_fetch_hdr(&mut recv)
|
||||||
|
.await
|
||||||
|
.expect("read hdr #1");
|
||||||
|
assert_eq!(hdr.status, CLIP_FETCH_OK);
|
||||||
|
assert_eq!(hdr.total_size as usize, payload.len());
|
||||||
|
let got = clipstream::read_data(&mut recv, 8 << 20)
|
||||||
|
.await
|
||||||
|
.expect("read data #1");
|
||||||
|
assert_eq!(got, payload);
|
||||||
|
|
||||||
|
// #2: the holder resets the stream — the requester surfaces an error rather than hanging.
|
||||||
|
let req2 = ClipFetch {
|
||||||
|
seq: 2,
|
||||||
|
file_index: CLIP_FILE_INDEX_NONE,
|
||||||
|
mime: "text/plain;charset=utf-8".into(),
|
||||||
|
};
|
||||||
|
let (_send2, mut recv2) = clipstream::open_fetch(&client_conn, &req2)
|
||||||
|
.await
|
||||||
|
.expect("open fetch #2");
|
||||||
|
assert!(
|
||||||
|
clipstream::read_fetch_hdr(&mut recv2).await.is_err(),
|
||||||
|
"a cancelled fetch must surface as an error, not a hang"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _host_conn = holder.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_data_enforces_size_cap() {
|
||||||
|
let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await;
|
||||||
|
|
||||||
|
let big = vec![0xABu8; 200_000]; // > the 64 KiB chunk, and > the cap we set below
|
||||||
|
let holder_payload = big.clone();
|
||||||
|
let holder = tokio::spawn(async move {
|
||||||
|
let (mut send, mut recv) = host_conn.accept_bi().await.expect("accept");
|
||||||
|
clipstream::read_stream_header(&mut recv).await.unwrap();
|
||||||
|
let _ = clipstream::read_fetch(&mut recv).await.unwrap();
|
||||||
|
clipstream::write_fetch_hdr(
|
||||||
|
&mut send,
|
||||||
|
&ClipFetchHdr {
|
||||||
|
status: CLIP_FETCH_OK,
|
||||||
|
total_size: holder_payload.len() as u64,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let _ = clipstream::write_data(&mut send, &holder_payload).await;
|
||||||
|
host_conn
|
||||||
|
});
|
||||||
|
|
||||||
|
let req = ClipFetch {
|
||||||
|
seq: 1,
|
||||||
|
file_index: CLIP_FILE_INDEX_NONE,
|
||||||
|
mime: "application/octet-stream".into(),
|
||||||
|
};
|
||||||
|
let (_send, mut recv) = clipstream::open_fetch(&client_conn, &req).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
clipstream::read_fetch_hdr(&mut recv).await.unwrap().status,
|
||||||
|
CLIP_FETCH_OK
|
||||||
|
);
|
||||||
|
// Cap below the payload size ⇒ read_data errors instead of buffering unboundedly.
|
||||||
|
assert!(clipstream::read_data(&mut recv, 64 * 1024).await.is_err());
|
||||||
|
|
||||||
|
let _host_conn = holder.await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,9 +88,11 @@ openh264 = "0.9"
|
|||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
# `screencast` gates the ScreenCast portal module; `remote_desktop` adds the RemoteDesktop
|
# `screencast` gates the ScreenCast portal module; `remote_desktop` adds the RemoteDesktop
|
||||||
# portal we use for libei input on KWin/GNOME; `tokio` is the default runtime.
|
# portal we use for libei input on KWin/GNOME; `tokio` is the default runtime. `open_pipe_wire_remote`
|
||||||
# `open_pipe_wire_remote` is unconditional, so ashpd's own `pipewire` feature is not
|
# is unconditional, so ashpd's own `pipewire` feature is not needed — we drive PipeWire with the
|
||||||
# needed — we drive PipeWire with the `pipewire` crate below.
|
# `pipewire` crate below. (The GNOME shared-clipboard backend uses Mutter's *direct*
|
||||||
|
# `org.gnome.Mutter.RemoteDesktop.Session` clipboard via raw `ashpd::zbus` — NOT the xdg
|
||||||
|
# `org.freedesktop.portal.Clipboard`, which needs interactive approval — so no `clipboard` feature.)
|
||||||
ashpd = { version = "0.13", features = ["screencast", "remote_desktop"] }
|
ashpd = { version = "0.13", features = ["screencast", "remote_desktop"] }
|
||||||
ffmpeg-next = "8"
|
ffmpeg-next = "8"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
@@ -115,7 +117,7 @@ wayland-protocols-wlr = { version = "0.3", features = ["client"] }
|
|||||||
wayland-protocols-misc = { version = "0.3", features = ["client"] }
|
wayland-protocols-misc = { version = "0.3", features = ["client"] }
|
||||||
# `xdg-output` (zxdg_output_v1): the per-output *logical* geometry (post-scale size + global
|
# `xdg-output` (zxdg_output_v1): the per-output *logical* geometry (post-scale size + global
|
||||||
# position), used by the KWin fake_input backend to map absolute coordinates under display scaling.
|
# position), used by the KWin fake_input backend to map absolute coordinates under display scaling.
|
||||||
wayland-protocols = { version = "0.32", features = ["client"] }
|
wayland-protocols = { version = "0.32", features = ["client", "staging"] }
|
||||||
# Codegen for KDE's `zkde_screencast_unstable_v1` (vendored in `protocols/`): create a KWin
|
# Codegen for KDE's `zkde_screencast_unstable_v1` (vendored in `protocols/`): create a KWin
|
||||||
# virtual output sized to the client's resolution and get its PipeWire node (KRdp's path).
|
# virtual output sized to the client's resolution and get its PipeWire node (KRdp's path).
|
||||||
# `wayland-backend` is referenced by the generated interface tables.
|
# `wayland-backend` is referenced by the generated interface tables.
|
||||||
@@ -200,6 +202,11 @@ windows = { version = "0.62", features = [
|
|||||||
# See capture/windows/dxgi.rs `install_gpu_pref_hook`. No trampoline (we fully replace the fn) → no detour
|
# See capture/windows/dxgi.rs `install_gpu_pref_hook`. No trampoline (we fully replace the fn) → no detour
|
||||||
# crate / no C length-disassembler dep; a 12-byte absolute-jmp prologue patch suffices.
|
# crate / no C length-disassembler dep; a 12-byte absolute-jmp prologue patch suffices.
|
||||||
"Win32_System_Memory",
|
"Win32_System_Memory",
|
||||||
|
# Shared-clipboard host backend (src/clipboard/windows.rs): the Win32 clipboard API
|
||||||
|
# (Open/Get/SetClipboardData, AddClipboardFormatListener, delayed rendering) plus the
|
||||||
|
# CLIPBOARD_FORMAT / CF_UNICODETEXT newtype from Ole (design/clipboard-and-file-transfer.md §3).
|
||||||
|
"Win32_System_DataExchange",
|
||||||
|
"Win32_System_Ole",
|
||||||
# Per-monitor-v2 DPI awareness — IDXGIOutput5::DuplicateOutput1 (the modern capture path Apollo
|
# Per-monitor-v2 DPI awareness — IDXGIOutput5::DuplicateOutput1 (the modern capture path Apollo
|
||||||
# uses; FP16/format-list, robust to overlay/format churn) requires the process to be DPI-aware.
|
# uses; FP16/format-list, robust to overlay/format churn) requires the process to be DPI-aware.
|
||||||
"Win32_UI_HiDpi",
|
"Win32_UI_HiDpi",
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
//! Host-side shared-clipboard backend.
|
||||||
|
//!
|
||||||
|
//! The wire protocol and the client half live in `punktfunk-core`
|
||||||
|
//! (`punktfunk_core::quic` + `punktfunk_core::clipboard`); this module drives the **host's** real
|
||||||
|
//! session clipboard so it can offer what a host app copied and paste what the remote client
|
||||||
|
//! offered (`design/clipboard-and-file-transfer.md` §4).
|
||||||
|
//!
|
||||||
|
//! Concrete backends, selected at session start ([`HostClipboard::open`]) and presented as one
|
||||||
|
//! [`HostClipboard`] to the [`session`] coordinator:
|
||||||
|
//! * [`wayland`] (Linux) — `ext-data-control-v1` (KWin, wlroots / Sway, Hyprland). Preferred when present.
|
||||||
|
//! * [`mutter`] (Linux) — GNOME. Mutter implements **no** wlr/ext data-control, but its *direct*
|
||||||
|
//! `org.gnome.Mutter.RemoteDesktop.Session` D-Bus API carries the same clipboard operations (the
|
||||||
|
//! xdg `org.freedesktop.portal.Clipboard` would need an interactive grant a headless host can't
|
||||||
|
//! answer — so we skip it and talk to Mutter directly, as the input injector already does).
|
||||||
|
//! * [`windows`] — the Win32 clipboard: a hidden message-only window watches `WM_CLIPBOARDUPDATE`
|
||||||
|
//! and serves client content via OLE delayed rendering (`WM_RENDERFORMAT`).
|
||||||
|
//!
|
||||||
|
//! The `zwlr-data-control-unstable-v1` fallback (older wlroots/KWin) is a follow-up. The module
|
||||||
|
//! compiles on Linux and Windows; the [`session`] coordinator is backend-agnostic.
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod mutter;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod wayland;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod windows;
|
||||||
|
/// Pure Win32-clipboard ↔ wire byte conversions (CF_HTML offset math, UTF-16 text, RTF NUL
|
||||||
|
/// trimming). Free of any Win32 dependency, so it compiles — and its unit tests run — on any host
|
||||||
|
/// (`cfg(test)`); the Windows backend is the only production consumer.
|
||||||
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
mod winfmt;
|
||||||
|
|
||||||
|
pub mod session;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use std::io::Write as _;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use std::os::fd::OwnedFd;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// A clipboard event surfaced by a host backend to the [`session`] coordinator. Both the
|
||||||
|
/// data-control and Mutter backends emit this identical shape.
|
||||||
|
pub enum ClipEvent {
|
||||||
|
/// The host selection changed (a host app copied). `mimes` are the **wire** MIMEs offered (empty
|
||||||
|
/// = the clipboard was cleared). The coordinator forwards these as a `ClipOffer` to the client;
|
||||||
|
/// bytes cross only if the client later fetches.
|
||||||
|
Selection { mimes: Vec<String> },
|
||||||
|
/// A host app is pasting content the client offered. The coordinator fetches the wire-`mime`
|
||||||
|
/// bytes from the client and hands them to `responder`.
|
||||||
|
Paste {
|
||||||
|
mime: String,
|
||||||
|
responder: PasteResponder,
|
||||||
|
},
|
||||||
|
/// The backend ended (compositor / session gone).
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a backend receives the bytes answering a [`ClipEvent::Paste`]. The two host clipboard
|
||||||
|
/// mechanisms complete a paste differently, so the coordinator stays agnostic by handing bytes to
|
||||||
|
/// whichever responder the backend attached.
|
||||||
|
pub enum PasteResponder {
|
||||||
|
/// data-control: the compositor handed us the destination pipe on the `send` event — write the
|
||||||
|
/// bytes and close it (EOF completes the paste).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
Fd(OwnedFd),
|
||||||
|
/// Mutter: hand the bytes back to the backend actor, which owns the `SelectionWrite` fd and the
|
||||||
|
/// trailing `SelectionWriteDone` call that Mutter's transfer requires.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
Channel(tokio::sync::oneshot::Sender<Vec<u8>>),
|
||||||
|
/// Windows: hand the bytes to the `WM_RENDERFORMAT` handler blocking the clipboard message-loop
|
||||||
|
/// thread, which then `SetClipboardData`s them for the pasting app (`std::sync::mpsc`, since that
|
||||||
|
/// thread waits synchronously — see [`windows`]).
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
Sync(std::sync::mpsc::Sender<Vec<u8>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PasteResponder {
|
||||||
|
/// Deliver the fetched bytes (empty on a failed fetch → an empty paste, never a hang).
|
||||||
|
pub async fn respond(self, bytes: Vec<u8>) {
|
||||||
|
match self {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
PasteResponder::Fd(fd) => {
|
||||||
|
let _ = tokio::task::spawn_blocking(move || fulfill_paste(fd, &bytes)).await;
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
PasteResponder::Channel(tx) => {
|
||||||
|
let _ = tx.send(bytes);
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
PasteResponder::Sync(tx) => {
|
||||||
|
let _ = tx.send(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `bytes` into a paste pipe `fd` and close it (EOF signals the reader). Blocking — run off the
|
||||||
|
/// reactor for large payloads.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn fulfill_paste(fd: OwnedFd, bytes: &[u8]) -> std::io::Result<()> {
|
||||||
|
let mut file = std::fs::File::from(fd);
|
||||||
|
file.write_all(bytes)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active host clipboard backend, chosen per session: `ext-data-control`
|
||||||
|
/// (KWin/wlroots/Hyprland/Sway) or Mutter's direct RemoteDesktop clipboard (GNOME) on Linux, or the
|
||||||
|
/// Win32 clipboard on Windows. Presented as one type so the [`session`] coordinator is
|
||||||
|
/// backend-agnostic.
|
||||||
|
pub enum HostClipboard {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
DataControl(wayland::ClipboardBackend),
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
Mutter(mutter::MutterClipboard),
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
Windows(windows::WindowsClipboard),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostClipboard {
|
||||||
|
/// Open whichever backend this session supports. Linux tries data-control first
|
||||||
|
/// (KWin/wlroots/Hyprland/Sway) then Mutter's direct clipboard (GNOME); Windows opens the Win32
|
||||||
|
/// clipboard. Errors when none is available (gamescope, no live compositor) — the caller then
|
||||||
|
/// reports `BACKEND_UNAVAILABLE`.
|
||||||
|
pub async fn open() -> anyhow::Result<(
|
||||||
|
HostClipboard,
|
||||||
|
tokio::sync::mpsc::UnboundedReceiver<ClipEvent>,
|
||||||
|
)> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
// data-control's bind does blocking Wayland roundtrips — keep them off the reactor.
|
||||||
|
let dc = tokio::task::spawn_blocking(wayland::ClipboardBackend::open)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("data-control open join: {e}"))?;
|
||||||
|
match dc {
|
||||||
|
Ok((b, rx)) => return Ok((HostClipboard::DataControl(b), rx)),
|
||||||
|
Err(e) => tracing::debug!(
|
||||||
|
error = format!("{e:#}"),
|
||||||
|
"no ext-data-control — trying Mutter direct clipboard"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
let (m, rx) = mutter::MutterClipboard::open().await.map_err(|e| {
|
||||||
|
e.context("no clipboard backend (neither ext-data-control nor Mutter)")
|
||||||
|
})?;
|
||||||
|
Ok((HostClipboard::Mutter(m), rx))
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let (b, rx) = windows::WindowsClipboard::open().await?;
|
||||||
|
Ok((HostClipboard::Windows(b), rx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current host selection's wire MIMEs (empty = nothing to offer).
|
||||||
|
pub fn current_wire_mimes(&self) -> Vec<String> {
|
||||||
|
match self {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
HostClipboard::DataControl(b) => b.current_wire_mimes(),
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
HostClipboard::Mutter(m) => m.current_wire_mimes(),
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
HostClipboard::Windows(w) => w.current_wire_mimes(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install a client's offered formats as the host selection.
|
||||||
|
pub fn set_offer(&self, wire_mimes: &[String]) -> anyhow::Result<()> {
|
||||||
|
match self {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
HostClipboard::DataControl(b) => b.set_offer(wire_mimes),
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
HostClipboard::Mutter(m) => {
|
||||||
|
m.set_offer(wire_mimes);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
HostClipboard::Windows(w) => {
|
||||||
|
w.set_offer(wire_mimes);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the host selection we own.
|
||||||
|
pub fn clear_offer(&self) -> anyhow::Result<()> {
|
||||||
|
match self {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
HostClipboard::DataControl(b) => b.clear_offer(),
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
HostClipboard::Mutter(m) => {
|
||||||
|
m.clear_offer();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
HostClipboard::Windows(w) => {
|
||||||
|
w.clear_offer();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one wire format of the current host selection (a client's fetch). Async: data-control
|
||||||
|
/// blocks on a pipe (offloaded), Mutter round-trips D-Bus + reads a pipe, Windows reads the
|
||||||
|
/// clipboard on a blocking thread.
|
||||||
|
pub async fn read_current(self: &Arc<Self>, wire_mime: &str) -> anyhow::Result<Vec<u8>> {
|
||||||
|
match &**self {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
HostClipboard::DataControl(_) => {
|
||||||
|
let me = Arc::clone(self);
|
||||||
|
let wire = wire_mime.to_string();
|
||||||
|
tokio::task::spawn_blocking(move || match &*me {
|
||||||
|
HostClipboard::DataControl(b) => b.read_current(&wire),
|
||||||
|
_ => unreachable!("variant checked above"),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("data-control read join: {e}"))?
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
HostClipboard::Mutter(m) => m.read_current(wire_mime).await,
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
HostClipboard::Windows(w) => w.read_current(wire_mime).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Format normalization (design/clipboard-and-file-transfer.md §3.5) ------------------------
|
||||||
|
//
|
||||||
|
// One portable vocabulary crosses the wire; each end maps to platform types at fetch time. Phase 1
|
||||||
|
// covers text / RTF / HTML / PNG (files are Phase 2). The wire MIMEs match the core's table.
|
||||||
|
|
||||||
|
/// Wire MIME for UTF-8 plain text.
|
||||||
|
pub const WIRE_TEXT: &str = "text/plain;charset=utf-8";
|
||||||
|
/// Wire MIME for HTML.
|
||||||
|
pub const WIRE_HTML: &str = "text/html";
|
||||||
|
/// Wire MIME for rich text.
|
||||||
|
pub const WIRE_RTF: &str = "text/rtf";
|
||||||
|
/// Wire MIME for a PNG image.
|
||||||
|
pub const WIRE_PNG: &str = "image/png";
|
||||||
|
|
||||||
|
/// Map a Wayland selection MIME to its canonical wire MIME, or `None` to drop it (internal targets
|
||||||
|
/// like `TARGETS`/`TIMESTAMP`/`SAVE_TARGETS`, and formats we don't sync in Phase 1). Aliases
|
||||||
|
/// collapse onto one canonical wire name so the offered list dedups cleanly.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn wayland_to_wire(wl: &str) -> Option<&'static str> {
|
||||||
|
// Strip any parameter noise for the plain-text aliases (some apps send `text/plain;charset=...`
|
||||||
|
// with odd charsets, or bare `text/plain`).
|
||||||
|
let base = wl.split(';').next().unwrap_or(wl).trim();
|
||||||
|
match wl {
|
||||||
|
"text/html" => Some(WIRE_HTML),
|
||||||
|
"text/rtf" | "application/rtf" | "text/richtext" => Some(WIRE_RTF),
|
||||||
|
"image/png" => Some(WIRE_PNG),
|
||||||
|
_ => match base {
|
||||||
|
"text/plain" | "UTF8_STRING" | "STRING" | "TEXT" => Some(WIRE_TEXT),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Wayland MIME candidates to request, in preference order, when a client fetches `wire` from
|
||||||
|
/// the host clipboard. The first one present in the current offer is used.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn wayland_candidates(wire: &str) -> &'static [&'static str] {
|
||||||
|
match wire {
|
||||||
|
WIRE_TEXT => &[
|
||||||
|
"text/plain;charset=utf-8",
|
||||||
|
"text/plain",
|
||||||
|
"UTF8_STRING",
|
||||||
|
"STRING",
|
||||||
|
"TEXT",
|
||||||
|
],
|
||||||
|
WIRE_HTML => &["text/html"],
|
||||||
|
WIRE_RTF => &["text/rtf", "application/rtf", "text/richtext"],
|
||||||
|
WIRE_PNG => &["image/png"],
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick the Wayland MIME to `receive()` for a wire fetch: the first [`wayland_candidates`] entry the
|
||||||
|
/// current selection actually advertises.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn pick_wayland_mime(wire: &str, available: &[String]) -> Option<String> {
|
||||||
|
wayland_candidates(wire)
|
||||||
|
.iter()
|
||||||
|
.find(|c| available.iter().any(|a| a == *c))
|
||||||
|
.map(|c| c.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize a raw Wayland offer's MIME list into the deduplicated wire MIME list announced to the
|
||||||
|
/// client (drops internal targets; collapses aliases; preserves a stable order).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn offer_wire_mimes(raw: &[String]) -> Vec<&'static str> {
|
||||||
|
let mut out: Vec<&'static str> = Vec::new();
|
||||||
|
for m in raw {
|
||||||
|
if let Some(wire) = wayland_to_wire(m) {
|
||||||
|
if !out.contains(&wire) {
|
||||||
|
out.push(wire);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Wayland MIMEs to advertise when installing a source for a client's offer. Each wire MIME
|
||||||
|
/// expands to its canonical Wayland name(s); a rich-text-only offer also advertises `text/plain`
|
||||||
|
/// so plain-text targets always paste (§3.5 synthesis — destination-side, one direction only).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn wayland_offers_for(wire_mimes: &[String]) -> Vec<String> {
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
let mut push = |s: &str| {
|
||||||
|
if !out.iter().any(|o| o == s) {
|
||||||
|
out.push(s.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut has_plain = false;
|
||||||
|
let mut has_rich = false;
|
||||||
|
for w in wire_mimes {
|
||||||
|
match w.as_str() {
|
||||||
|
WIRE_TEXT => {
|
||||||
|
has_plain = true;
|
||||||
|
push("text/plain;charset=utf-8");
|
||||||
|
push("text/plain");
|
||||||
|
push("UTF8_STRING");
|
||||||
|
push("STRING");
|
||||||
|
}
|
||||||
|
WIRE_HTML => {
|
||||||
|
has_rich = true;
|
||||||
|
push("text/html");
|
||||||
|
}
|
||||||
|
WIRE_RTF => {
|
||||||
|
has_rich = true;
|
||||||
|
push("text/rtf");
|
||||||
|
}
|
||||||
|
WIRE_PNG => push("image/png"),
|
||||||
|
other => push(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Synthesis: rich text without plain text → also advertise plain (the source derives it lazily).
|
||||||
|
if has_rich && !has_plain {
|
||||||
|
push("text/plain;charset=utf-8");
|
||||||
|
push("text/plain");
|
||||||
|
push("UTF8_STRING");
|
||||||
|
push("STRING");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, target_os = "linux"))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wayland_to_wire_canonicalizes_and_drops_targets() {
|
||||||
|
assert_eq!(wayland_to_wire("text/plain"), Some(WIRE_TEXT));
|
||||||
|
assert_eq!(wayland_to_wire("UTF8_STRING"), Some(WIRE_TEXT));
|
||||||
|
assert_eq!(wayland_to_wire("text/plain;charset=utf-8"), Some(WIRE_TEXT));
|
||||||
|
assert_eq!(wayland_to_wire("text/html"), Some(WIRE_HTML));
|
||||||
|
assert_eq!(wayland_to_wire("application/rtf"), Some(WIRE_RTF));
|
||||||
|
assert_eq!(wayland_to_wire("image/png"), Some(WIRE_PNG));
|
||||||
|
// Internal targets and unsupported formats are dropped.
|
||||||
|
assert_eq!(wayland_to_wire("TARGETS"), None);
|
||||||
|
assert_eq!(wayland_to_wire("TIMESTAMP"), None);
|
||||||
|
assert_eq!(wayland_to_wire("image/jpeg"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn offer_wire_mimes_dedups_aliases() {
|
||||||
|
let raw = vec![
|
||||||
|
"TARGETS".to_string(),
|
||||||
|
"UTF8_STRING".to_string(),
|
||||||
|
"text/plain;charset=utf-8".to_string(),
|
||||||
|
"text/plain".to_string(),
|
||||||
|
"text/html".to_string(),
|
||||||
|
];
|
||||||
|
// text aliases collapse to one WIRE_TEXT; TARGETS dropped; html kept.
|
||||||
|
assert_eq!(offer_wire_mimes(&raw), vec![WIRE_TEXT, WIRE_HTML]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pick_wayland_mime_prefers_canonical() {
|
||||||
|
let avail = vec!["text/plain".to_string(), "UTF8_STRING".to_string()];
|
||||||
|
// Canonical charset form isn't present, so it falls to the next candidate.
|
||||||
|
assert_eq!(
|
||||||
|
pick_wayland_mime(WIRE_TEXT, &avail),
|
||||||
|
Some("text/plain".to_string())
|
||||||
|
);
|
||||||
|
let avail2 = vec![
|
||||||
|
"text/plain;charset=utf-8".to_string(),
|
||||||
|
"text/plain".to_string(),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
pick_wayland_mime(WIRE_TEXT, &avail2),
|
||||||
|
Some("text/plain;charset=utf-8".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(pick_wayland_mime(WIRE_PNG, &avail2), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wayland_offers_synthesizes_plain_for_rich_only() {
|
||||||
|
let offers = wayland_offers_for(&[WIRE_HTML.to_string()]);
|
||||||
|
assert!(offers.iter().any(|m| m == "text/html"));
|
||||||
|
assert!(
|
||||||
|
offers.iter().any(|m| m == "text/plain;charset=utf-8"),
|
||||||
|
"rich-only offer must synthesize plain text: {offers:?}"
|
||||||
|
);
|
||||||
|
// Plain already present → no duplicate synthesis, and text aliases included.
|
||||||
|
let offers2 = wayland_offers_for(&[WIRE_TEXT.to_string()]);
|
||||||
|
assert!(offers2.iter().any(|m| m == "UTF8_STRING"));
|
||||||
|
assert_eq!(offers2.iter().filter(|m| *m == "text/plain").count(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
//! GNOME clipboard backend via Mutter's **direct** `org.gnome.Mutter.RemoteDesktop.Session` D-Bus
|
||||||
|
//! API (`design/clipboard-and-file-transfer.md` §4.1).
|
||||||
|
//!
|
||||||
|
//! Mutter implements no wlr/ext `data-control` (a deliberate privacy stance), so [`super::wayland`]
|
||||||
|
//! can't bind on GNOME. But Mutter's own RemoteDesktop session — the same interface the input
|
||||||
|
//! injector drives directly to dodge the xdg-portal approval dialog (`inject/linux/libei.rs`
|
||||||
|
//! `connect_mutter`) — carries the full clipboard surface: `EnableClipboard`, `SetSelection`,
|
||||||
|
//! `SelectionRead`/`SelectionWrite`/`SelectionWriteDone`, and the `SelectionOwnerChanged` /
|
||||||
|
//! `SelectionTransfer` signals. We open our **own** standalone session for it (it coexists with the
|
||||||
|
//! injector's input session; validated on GNOME 50), so this backend is self-contained just like the
|
||||||
|
//! data-control one.
|
||||||
|
//!
|
||||||
|
//! One actor task owns the zbus connection + session and multiplexes the two signals with a command
|
||||||
|
//! channel; the fds Mutter hands out are **non-blocking**, so reads/writes flip them to blocking and
|
||||||
|
//! run on a blocking thread. Option/signal dict keys are hyphenated: `mime-types`, `session-is-owner`.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{Read as _, Write as _};
|
||||||
|
use std::os::fd::{AsRawFd, OwnedFd};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use ashpd::zbus::{
|
||||||
|
self,
|
||||||
|
zvariant::{OwnedObjectPath, OwnedValue, Value},
|
||||||
|
};
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use tokio::sync::{mpsc, oneshot};
|
||||||
|
|
||||||
|
use super::{ClipEvent, PasteResponder};
|
||||||
|
|
||||||
|
const RD_BUS: &str = "org.gnome.Mutter.RemoteDesktop";
|
||||||
|
const RD_PATH: &str = "/org/gnome/Mutter/RemoteDesktop";
|
||||||
|
const RD_IFACE: &str = "org.gnome.Mutter.RemoteDesktop";
|
||||||
|
const SESSION_IFACE: &str = "org.gnome.Mutter.RemoteDesktop.Session";
|
||||||
|
|
||||||
|
/// Upper bound on one `SelectionRead` (matches the wire clipboard cap, §7).
|
||||||
|
const CLIP_READ_CAP: u64 = 64 << 20;
|
||||||
|
|
||||||
|
/// Handle to the Mutter clipboard actor, held (inside a [`super::HostClipboard`]) by the session
|
||||||
|
/// coordinator.
|
||||||
|
pub struct MutterClipboard {
|
||||||
|
cmd_tx: mpsc::UnboundedSender<Cmd>,
|
||||||
|
/// Raw MIMEs the current host selection advertises (empty when we own it, or nothing is copied).
|
||||||
|
/// Written by the actor on `SelectionOwnerChanged`; read for `current_wire_mimes` / fetches.
|
||||||
|
current_raw: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Cmd {
|
||||||
|
SetOffer(Vec<String>),
|
||||||
|
ClearOffer,
|
||||||
|
ReadCurrent {
|
||||||
|
wire: String,
|
||||||
|
reply: oneshot::Sender<Result<Vec<u8>>>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MutterClipboard {
|
||||||
|
/// Create a standalone Mutter RemoteDesktop session, `Start` + `EnableClipboard` it, and spawn
|
||||||
|
/// the actor. Errors when Mutter isn't running (not GNOME) — the caller falls through to
|
||||||
|
/// `BACKEND_UNAVAILABLE`.
|
||||||
|
pub async fn open() -> Result<(MutterClipboard, mpsc::UnboundedReceiver<ClipEvent>)> {
|
||||||
|
let conn = zbus::Connection::session()
|
||||||
|
.await
|
||||||
|
.context("session D-Bus (Mutter clipboard)")?;
|
||||||
|
let rd = zbus::Proxy::new(&conn, RD_BUS, RD_PATH, RD_IFACE)
|
||||||
|
.await
|
||||||
|
.context("Mutter RemoteDesktop proxy (is gnome-shell running?)")?;
|
||||||
|
let session_path: OwnedObjectPath = rd
|
||||||
|
.call("CreateSession", &())
|
||||||
|
.await
|
||||||
|
.context("Mutter RemoteDesktop.CreateSession (clipboard)")?;
|
||||||
|
let session = zbus::Proxy::new(&conn, RD_BUS, session_path, SESSION_IFACE)
|
||||||
|
.await
|
||||||
|
.context("Mutter RemoteDesktop.Session proxy")?;
|
||||||
|
session
|
||||||
|
.call_method("Start", &())
|
||||||
|
.await
|
||||||
|
.context("Mutter RemoteDesktop.Session.Start (clipboard)")?;
|
||||||
|
let empty: HashMap<&str, Value> = HashMap::new();
|
||||||
|
session
|
||||||
|
.call_method("EnableClipboard", &(empty,))
|
||||||
|
.await
|
||||||
|
.context("Mutter EnableClipboard")?;
|
||||||
|
|
||||||
|
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
||||||
|
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
||||||
|
let current_raw = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
|
||||||
|
tokio::spawn(actor(conn, session, cmd_rx, event_tx, current_raw.clone()));
|
||||||
|
tracing::info!("clipboard backend bound (Mutter RemoteDesktop direct)");
|
||||||
|
Ok((
|
||||||
|
MutterClipboard {
|
||||||
|
cmd_tx,
|
||||||
|
current_raw,
|
||||||
|
},
|
||||||
|
event_rx,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install a client's offered formats as the host selection (fire-and-forget on the actor).
|
||||||
|
pub fn set_offer(&self, wire_mimes: &[String]) {
|
||||||
|
let _ = self.cmd_tx.send(Cmd::SetOffer(wire_mimes.to_vec()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relinquish the selection we own.
|
||||||
|
pub fn clear_offer(&self) {
|
||||||
|
let _ = self.cmd_tx.send(Cmd::ClearOffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current host selection's wire MIMEs (empty = nothing / we own it).
|
||||||
|
pub fn current_wire_mimes(&self) -> Vec<String> {
|
||||||
|
super::offer_wire_mimes(&self.current_raw.lock().unwrap())
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one wire format of the current host selection (a client's fetch). Round-trips the actor
|
||||||
|
/// (SelectionRead + a blocking fd read).
|
||||||
|
pub async fn read_current(&self, wire: &str) -> Result<Vec<u8>> {
|
||||||
|
let (reply, rx) = oneshot::channel();
|
||||||
|
self.cmd_tx
|
||||||
|
.send(Cmd::ReadCurrent {
|
||||||
|
wire: wire.to_string(),
|
||||||
|
reply,
|
||||||
|
})
|
||||||
|
.map_err(|_| anyhow!("Mutter clipboard actor gone"))?;
|
||||||
|
rx.await
|
||||||
|
.map_err(|_| anyhow!("Mutter clipboard read dropped"))?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The actor: owns the connection + session, subscribes to the two clipboard signals, and serves
|
||||||
|
/// commands. Exits when the command channel closes (session ending) or a signal stream ends.
|
||||||
|
async fn actor(
|
||||||
|
conn: zbus::Connection,
|
||||||
|
session: zbus::Proxy<'static>,
|
||||||
|
mut cmd_rx: mpsc::UnboundedReceiver<Cmd>,
|
||||||
|
event_tx: mpsc::UnboundedSender<ClipEvent>,
|
||||||
|
current_raw: Arc<Mutex<Vec<String>>>,
|
||||||
|
) {
|
||||||
|
let (mut owner, mut transfer) = match (
|
||||||
|
session.receive_signal("SelectionOwnerChanged").await,
|
||||||
|
session.receive_signal("SelectionTransfer").await,
|
||||||
|
) {
|
||||||
|
(Ok(o), Ok(t)) => (o, t),
|
||||||
|
_ => {
|
||||||
|
tracing::warn!("Mutter clipboard: could not subscribe to selection signals");
|
||||||
|
let _ = event_tx.send(ClipEvent::Closed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
sig = owner.next() => {
|
||||||
|
let Some(msg) = sig else { break };
|
||||||
|
let Ok((opts,)) = msg.body().deserialize::<(HashMap<String, OwnedValue>,)>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let is_owner = dict_bool(&opts, "session-is-owner").unwrap_or(false);
|
||||||
|
let raw = dict_mimes(&opts, "mime-types");
|
||||||
|
if is_owner {
|
||||||
|
// Our own offer (the client's content) — not host clipboard; don't announce it,
|
||||||
|
// and clear `current_raw` so a fetch never reads our own source back.
|
||||||
|
current_raw.lock().unwrap().clear();
|
||||||
|
} else {
|
||||||
|
*current_raw.lock().unwrap() = raw.clone();
|
||||||
|
let wire = super::offer_wire_mimes(&raw)
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect();
|
||||||
|
if event_tx.send(ClipEvent::Selection { mimes: wire }).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sig = transfer.next() => {
|
||||||
|
let Some(msg) = sig else { break };
|
||||||
|
let Ok((mime, serial)) = msg.body().deserialize::<(String, u32)>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match super::wayland_to_wire(&mime) {
|
||||||
|
Some(wire) => {
|
||||||
|
// A host app pastes our offer: hand the fetch to the coordinator, then serve
|
||||||
|
// the returned bytes into the SelectionWrite fd off-task. NB Mutter issues
|
||||||
|
// *two* transfers per read (a size probe + the real read), so the coordinator
|
||||||
|
// fetches from the client twice per paste — correct, just not deduplicated.
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
if event_tx
|
||||||
|
.send(ClipEvent::Paste {
|
||||||
|
mime: wire.to_string(),
|
||||||
|
responder: PasteResponder::Channel(tx),
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let session = session.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let bytes = rx.await.unwrap_or_default();
|
||||||
|
serve_write(&session, serial, bytes).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Format we don't sync — fail the transfer cleanly.
|
||||||
|
None => serve_write(&session, serial, Vec::new()).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd = cmd_rx.recv() => {
|
||||||
|
let Some(cmd) = cmd else { break }; // coordinator gone → session ending
|
||||||
|
match cmd {
|
||||||
|
Cmd::SetOffer(wire) => {
|
||||||
|
let wl = super::wayland_offers_for(&wire);
|
||||||
|
if let Err(e) = set_selection(&session, &wl).await {
|
||||||
|
tracing::debug!(error = %e, "Mutter SetSelection failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cmd::ClearOffer => {
|
||||||
|
if let Err(e) = set_selection(&session, &[]).await {
|
||||||
|
tracing::debug!(error = %e, "Mutter clear selection failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cmd::ReadCurrent { wire, reply } => {
|
||||||
|
let raw = current_raw.lock().unwrap().clone();
|
||||||
|
let _ = reply.send(read_selection(&session, &wire, &raw).await);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep the connection owned for the actor's whole life (Mutter ties the session to it).
|
||||||
|
drop(conn);
|
||||||
|
let _ = event_tx.send(ClipEvent::Closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offer `wl_mimes` as the host selection; an empty list relinquishes ownership.
|
||||||
|
async fn set_selection(session: &zbus::Proxy<'_>, wl_mimes: &[String]) -> Result<()> {
|
||||||
|
let mut opts: HashMap<&str, Value> = HashMap::new();
|
||||||
|
if !wl_mimes.is_empty() {
|
||||||
|
let refs: Vec<&str> = wl_mimes.iter().map(String::as_str).collect();
|
||||||
|
opts.insert("mime-types", Value::from(refs));
|
||||||
|
}
|
||||||
|
session
|
||||||
|
.call_method("SetSelection", &(opts,))
|
||||||
|
.await
|
||||||
|
.context("Mutter SetSelection")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the current selection's `wire` format: pick a concrete offered MIME, `SelectionRead` it, and
|
||||||
|
/// read the (non-blocking) fd to EOF on a blocking thread.
|
||||||
|
async fn read_selection(session: &zbus::Proxy<'_>, wire: &str, raw: &[String]) -> Result<Vec<u8>> {
|
||||||
|
let mime =
|
||||||
|
super::pick_wayland_mime(wire, raw).context("format not offered by the host clipboard")?;
|
||||||
|
let fd: zbus::zvariant::OwnedFd = session
|
||||||
|
.call("SelectionRead", &(mime.as_str(),))
|
||||||
|
.await
|
||||||
|
.context("Mutter SelectionRead")?;
|
||||||
|
let fd = OwnedFd::from(fd);
|
||||||
|
tokio::task::spawn_blocking(move || read_fd_to_end(fd))
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("SelectionRead join: {e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve one `SelectionTransfer`: `SelectionWrite` → write `bytes` → `SelectionWriteDone`. Any write
|
||||||
|
/// failure still reports done (success=false) so Mutter completes the transfer.
|
||||||
|
async fn serve_write(session: &zbus::Proxy<'_>, serial: u32, bytes: Vec<u8>) {
|
||||||
|
let ok = match write_selection(session, serial, bytes).await {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(error = %e, "Mutter SelectionWrite failed");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = session
|
||||||
|
.call_method("SelectionWriteDone", &(serial, ok))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_selection(session: &zbus::Proxy<'_>, serial: u32, bytes: Vec<u8>) -> Result<()> {
|
||||||
|
let fd: zbus::zvariant::OwnedFd = session
|
||||||
|
.call("SelectionWrite", &(serial,))
|
||||||
|
.await
|
||||||
|
.context("Mutter SelectionWrite")?;
|
||||||
|
let fd = OwnedFd::from(fd);
|
||||||
|
tokio::task::spawn_blocking(move || write_fd(fd, &bytes))
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("SelectionWrite join: {e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a Mutter clipboard fd to EOF (capped). The fd is `O_NONBLOCK`; flip it to blocking first.
|
||||||
|
fn read_fd_to_end(fd: OwnedFd) -> Result<Vec<u8>> {
|
||||||
|
set_blocking(&fd)?;
|
||||||
|
let file = std::fs::File::from(fd);
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
file.take(CLIP_READ_CAP)
|
||||||
|
.read_to_end(&mut buf)
|
||||||
|
.context("read SelectionRead fd")?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `bytes` into a Mutter clipboard fd and close it (EOF). Flip the `O_NONBLOCK` fd to blocking.
|
||||||
|
fn write_fd(fd: OwnedFd, bytes: &[u8]) -> Result<()> {
|
||||||
|
set_blocking(&fd)?;
|
||||||
|
let mut file = std::fs::File::from(fd);
|
||||||
|
file.write_all(bytes).context("write SelectionWrite fd")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Peel any `Value::Value` (variant) wrappers to the concrete value. The `a{sv}` dict values Mutter
|
||||||
|
/// sends arrive as variants, so a plain `TryFrom<OwnedValue>` (which matches the concrete type) never
|
||||||
|
/// sees through them — this strips the layer first.
|
||||||
|
fn peel<'a>(v: &'a Value<'a>) -> &'a Value<'a> {
|
||||||
|
let mut cur = v;
|
||||||
|
while let Value::Value(inner) = cur {
|
||||||
|
cur = inner;
|
||||||
|
}
|
||||||
|
cur
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a boolean dict entry (e.g. `session-is-owner`), unwrapping the variant.
|
||||||
|
fn dict_bool(opts: &HashMap<String, OwnedValue>, key: &str) -> Option<bool> {
|
||||||
|
match peel(opts.get(key)?) {
|
||||||
|
Value::Bool(b) => Some(*b),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a string-array dict entry (e.g. `mime-types`), unwrapping the variant. Mutter wraps the
|
||||||
|
/// array in a single-field struct (`(as)`, seen on `SelectionOwnerChanged`), so unwrap that too.
|
||||||
|
fn dict_mimes(opts: &HashMap<String, OwnedValue>, key: &str) -> Vec<String> {
|
||||||
|
let Some(v) = opts.get(key) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut val = peel(v);
|
||||||
|
if let Value::Structure(s) = val {
|
||||||
|
match s.fields().first() {
|
||||||
|
Some(first) => val = peel(first),
|
||||||
|
None => return Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Value::Array(arr) = val else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
arr.inner()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| match peel(e) {
|
||||||
|
Value::Str(s) => Some(s.to_string()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear `O_NONBLOCK` on a Mutter clipboard fd so a blocking `spawn_blocking` read/write works.
|
||||||
|
fn set_blocking(fd: &OwnedFd) -> Result<()> {
|
||||||
|
let raw = fd.as_raw_fd();
|
||||||
|
// SAFETY: `raw` is a valid fd owned by `fd` for the duration of these fcntl calls.
|
||||||
|
let flags = unsafe { libc::fcntl(raw, libc::F_GETFL) };
|
||||||
|
if flags < 0 {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"fcntl F_GETFL: {}",
|
||||||
|
std::io::Error::last_os_error()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// SAFETY: as above; clearing O_NONBLOCK on our own fd.
|
||||||
|
let rc = unsafe { libc::fcntl(raw, libc::F_SETFL, flags & !libc::O_NONBLOCK) };
|
||||||
|
if rc < 0 {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"fcntl F_SETFL: {}",
|
||||||
|
std::io::Error::last_os_error()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On-glass tests against a **live GNOME/Mutter** session (`WAYLAND_DISPLAY=wayland-0`). `#[ignore]`d
|
||||||
|
/// — run explicitly under GNOME (Mutter has no `wl-clipboard`, so a second Mutter session plays the
|
||||||
|
/// "host app"):
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// cargo test -p punktfunk-host --bin punktfunk-host -- --ignored --nocapture clipboard::mutter::live
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Skips (does not fail) when Mutter isn't running, so `--ignored` off-GNOME is a clean no-op.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod live {
|
||||||
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// A second Mutter session standing in for a host clipboard app.
|
||||||
|
struct Helper {
|
||||||
|
session: zbus::Proxy<'static>,
|
||||||
|
_conn: zbus::Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Helper {
|
||||||
|
async fn open() -> Result<Helper> {
|
||||||
|
let conn = zbus::Connection::session().await?;
|
||||||
|
let rd = zbus::Proxy::new(&conn, RD_BUS, RD_PATH, RD_IFACE).await?;
|
||||||
|
let path: OwnedObjectPath = rd.call("CreateSession", &()).await?;
|
||||||
|
let session = zbus::Proxy::new(&conn, RD_BUS, path, SESSION_IFACE).await?;
|
||||||
|
session.call_method("Start", &()).await?;
|
||||||
|
let empty: HashMap<&str, Value> = HashMap::new();
|
||||||
|
session.call_method("EnableClipboard", &(empty,)).await?;
|
||||||
|
Ok(Helper {
|
||||||
|
session,
|
||||||
|
_conn: conn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Own the selection offering plain text, serving `payload` on every transfer request.
|
||||||
|
async fn offer_text(&self, payload: &'static [u8]) {
|
||||||
|
let mut transfer = self
|
||||||
|
.session
|
||||||
|
.receive_signal("SelectionTransfer")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let session = self.session.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(msg) = transfer.next().await {
|
||||||
|
if let Ok((_mime, serial)) = msg.body().deserialize::<(String, u32)>() {
|
||||||
|
serve_write(&session, serial, payload.to_vec()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
set_selection(
|
||||||
|
&self.session,
|
||||||
|
&[
|
||||||
|
"text/plain;charset=utf-8".to_string(),
|
||||||
|
"text/plain".to_string(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paste the current selection's plain text.
|
||||||
|
async fn read_text(&self) -> Vec<u8> {
|
||||||
|
let fd: zbus::zvariant::OwnedFd = self
|
||||||
|
.session
|
||||||
|
.call("SelectionRead", &("text/plain;charset=utf-8",))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let fd = OwnedFd::from(fd);
|
||||||
|
tokio::task::spawn_blocking(move || read_fd_to_end(fd))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn next_selection(
|
||||||
|
rx: &mut mpsc::UnboundedReceiver<ClipEvent>,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Option<Vec<String>> {
|
||||||
|
tokio::time::timeout(timeout, async {
|
||||||
|
loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Some(ClipEvent::Selection { mimes }) if !mimes.is_empty() => {
|
||||||
|
return Some(mimes)
|
||||||
|
}
|
||||||
|
Some(_) => continue,
|
||||||
|
None => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "needs a live GNOME/Mutter session (WAYLAND_DISPLAY=wayland-0)"]
|
||||||
|
fn live_mutter_roundtrip() {
|
||||||
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
rt.block_on(async {
|
||||||
|
let (backend, mut rx) = match MutterClipboard::open().await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("SKIP: no Mutter clipboard (not GNOME?): {e:#}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let helper = Helper::open().await.expect("helper Mutter session");
|
||||||
|
|
||||||
|
// --- host copy → backend observes Selection + read_current returns the bytes ---
|
||||||
|
helper.offer_text(b"gnome-host-copied").await;
|
||||||
|
let mimes = next_selection(&mut rx, Duration::from_secs(3))
|
||||||
|
.await
|
||||||
|
.expect("Selection after the helper offered text");
|
||||||
|
assert!(
|
||||||
|
mimes.iter().any(|m| m == super::super::WIRE_TEXT),
|
||||||
|
"offer carries wire text: {mimes:?}"
|
||||||
|
);
|
||||||
|
let got = backend
|
||||||
|
.read_current(super::super::WIRE_TEXT)
|
||||||
|
.await
|
||||||
|
.expect("read_current text");
|
||||||
|
assert_eq!(got, b"gnome-host-copied");
|
||||||
|
|
||||||
|
// --- backend offers client content → the host app (helper) pastes it ---
|
||||||
|
backend.set_offer(&[super::super::WIRE_TEXT.to_string()]);
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await; // let SetSelection take effect
|
||||||
|
// Answer every Paste request the host app (helper) triggers, until the read completes.
|
||||||
|
let paste_side = async {
|
||||||
|
while let Some(ev) = rx.recv().await {
|
||||||
|
if let ClipEvent::Paste { responder, .. } = ev {
|
||||||
|
responder.respond(b"punktfunk-served".to_vec()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let read = tokio::select! {
|
||||||
|
r = helper.read_text() => r,
|
||||||
|
_ = paste_side => Vec::new(),
|
||||||
|
};
|
||||||
|
assert_eq!(read, b"punktfunk-served");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
//! Host clipboard coordinator (`design/clipboard-and-file-transfer.md` §4.2).
|
||||||
|
//!
|
||||||
|
//! One async task per streaming session that bridges the real session clipboard (whichever
|
||||||
|
//! [`super::HostClipboard`] backend this platform opened) to the QUIC clipboard plane. It owns all
|
||||||
|
//! four data paths:
|
||||||
|
//!
|
||||||
|
//! * **host copy → client** — a backend [`ClipEvent::Selection`] becomes a [`ClipOffer`] pushed to the
|
||||||
|
//! control loop (`offer_tx`), which forwards it to the client.
|
||||||
|
//! * **client fetch of the host clipboard** — the fetch-stream `accept_bi` loop lives here; each
|
||||||
|
//! stream is answered by reading the current host selection ([`ClipboardBackend::read_current`]).
|
||||||
|
//! * **client copy → host** — a [`ClipCoordCmd::RemoteOffer`] installs the client's formats as a lazy
|
||||||
|
//! host selection ([`HostClipboard::set_offer`]).
|
||||||
|
//! * **host paste of client content** — a backend [`ClipEvent::Paste`] triggers an *outbound* fetch to
|
||||||
|
//! the client, whose bytes are handed to the backend's [`PasteResponder`].
|
||||||
|
//!
|
||||||
|
//! The coordinator is backend-agnostic (Linux data-control / Mutter, Windows Win32); the control loop
|
||||||
|
//! reaches it through the portable [`ClipCoordCmd`] channel so `punktfunk1` compiles on every host
|
||||||
|
//! platform.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||||
|
|
||||||
|
use punktfunk_core::clipboard::CLIP_FETCH_CAP;
|
||||||
|
use punktfunk_core::quic::{
|
||||||
|
clipstream, ClipFetch, ClipFetchHdr, ClipKind, ClipOffer, CLIP_FETCH_OK, CLIP_FETCH_STALE,
|
||||||
|
CLIP_FETCH_UNAVAILABLE, CLIP_FILE_INDEX_NONE,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{ClipEvent, HostClipboard, PasteResponder};
|
||||||
|
use crate::punktfunk1::ClipCoordCmd;
|
||||||
|
|
||||||
|
/// Upper bound on one outbound fetch (host pasting client content). A client that never answers must
|
||||||
|
/// not hang the pasting host app's pipe read (§3.4) — the paste falls back to empty instead.
|
||||||
|
const FETCH_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// Open whichever host clipboard backend this session supports (data-control, else Mutter direct) and
|
||||||
|
/// spawn the coordinator. Returns `true` when a live backend was bound (the caller's control loop
|
||||||
|
/// then serves real clipboard data); `false` when none is available (gamescope, no live compositor),
|
||||||
|
/// in which case the channels are dropped so the control loop reports `CLIP_REASON_BACKEND_UNAVAILABLE`
|
||||||
|
/// and declines fetches defensively.
|
||||||
|
pub async fn start(
|
||||||
|
conn: quinn::Connection,
|
||||||
|
clip_enabled: Arc<AtomicBool>,
|
||||||
|
cmd_rx: UnboundedReceiver<ClipCoordCmd>,
|
||||||
|
offer_tx: UnboundedSender<ClipOffer>,
|
||||||
|
) -> bool {
|
||||||
|
match HostClipboard::open().await {
|
||||||
|
Ok((backend, clip_rx)) => {
|
||||||
|
tokio::spawn(run(
|
||||||
|
conn,
|
||||||
|
Arc::new(backend),
|
||||||
|
clip_rx,
|
||||||
|
clip_enabled,
|
||||||
|
cmd_rx,
|
||||||
|
offer_tx,
|
||||||
|
));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::info!(error = %format!("{e:#}"), "clipboard backend unavailable — fetches will be declined");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The coordinator loop. Multiplexes control-loop commands, backend clipboard events, and inbound
|
||||||
|
/// fetch streams; exits when any of the three peers goes away (session ending).
|
||||||
|
async fn run(
|
||||||
|
conn: quinn::Connection,
|
||||||
|
backend: Arc<HostClipboard>,
|
||||||
|
mut clip_rx: UnboundedReceiver<ClipEvent>,
|
||||||
|
clip_enabled: Arc<AtomicBool>,
|
||||||
|
mut cmd_rx: UnboundedReceiver<ClipCoordCmd>,
|
||||||
|
offer_tx: UnboundedSender<ClipOffer>,
|
||||||
|
) {
|
||||||
|
// Seq of the offer the host most recently announced; a client fetch naming a different seq is
|
||||||
|
// stale (the host clipboard moved on) and is declined.
|
||||||
|
let host_seq = Arc::new(AtomicU32::new(0));
|
||||||
|
let mut next_seq: u32 = 1;
|
||||||
|
// Seq of the client's most recent offer, echoed on the outbound fetch we open when a host app
|
||||||
|
// pastes client content (informational for the client's serve side).
|
||||||
|
let mut client_seq: u32 = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
cmd = cmd_rx.recv() => {
|
||||||
|
let Some(cmd) = cmd else { break }; // control loop gone → session ending
|
||||||
|
match cmd {
|
||||||
|
ClipCoordCmd::SetEnabled(true) => {
|
||||||
|
// A just-enabled client should see whatever the host already has copied.
|
||||||
|
let mimes = backend.current_wire_mimes();
|
||||||
|
if !mimes.is_empty() {
|
||||||
|
let _ = offer_tx.send(build_offer(&mut next_seq, &host_seq, mimes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClipCoordCmd::SetEnabled(false) => {
|
||||||
|
if let Err(e) = backend.clear_offer() {
|
||||||
|
tracing::debug!(error = %e, "clipboard clear_offer failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClipCoordCmd::RemoteOffer { seq, mimes } => {
|
||||||
|
client_seq = seq;
|
||||||
|
let res = if mimes.is_empty() {
|
||||||
|
backend.clear_offer()
|
||||||
|
} else {
|
||||||
|
backend.set_offer(&mimes)
|
||||||
|
};
|
||||||
|
if let Err(e) = res {
|
||||||
|
tracing::debug!(error = %e, "clipboard apply remote offer failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ev = clip_rx.recv() => {
|
||||||
|
let Some(ev) = ev else { break }; // backend dispatch thread ended
|
||||||
|
match ev {
|
||||||
|
ClipEvent::Selection { mimes } => {
|
||||||
|
// Forward host copies (empty `mimes` = the clipboard was cleared) only while
|
||||||
|
// the client has sync on — the offer is metadata; bytes still cross lazily.
|
||||||
|
if clip_enabled.load(Ordering::SeqCst) {
|
||||||
|
let _ = offer_tx.send(build_offer(&mut next_seq, &host_seq, mimes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClipEvent::Paste { mime, responder } => {
|
||||||
|
// A host app is pasting the client's offered content: pull that format from
|
||||||
|
// the client and hand it to the backend's responder. Off-task so the loop
|
||||||
|
// keeps serving.
|
||||||
|
tokio::spawn(fetch_into_pipe(conn.clone(), client_seq, mime, responder));
|
||||||
|
}
|
||||||
|
ClipEvent::Closed => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accepted = conn.accept_bi() => {
|
||||||
|
let Ok((send, recv)) = accepted else { break }; // connection gone
|
||||||
|
// The control stream is already accepted at the handshake, so every stream here is a
|
||||||
|
// clipboard fetch. Serve it off-task (the read blocks on the source app's pipe).
|
||||||
|
tokio::spawn(serve_fetch(
|
||||||
|
send,
|
||||||
|
recv,
|
||||||
|
Arc::clone(&backend),
|
||||||
|
Arc::clone(&host_seq),
|
||||||
|
clip_enabled.load(Ordering::SeqCst),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Session ending: don't leave our lazy source as the compositor's active selection.
|
||||||
|
let _ = backend.clear_offer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a [`ClipOffer`] for `mimes`, advancing the host offer seq (skipping 0, the "never offered"
|
||||||
|
/// sentinel) and publishing it as the current one for staleness checks.
|
||||||
|
fn build_offer(next_seq: &mut u32, host_seq: &AtomicU32, mimes: Vec<String>) -> ClipOffer {
|
||||||
|
let seq = *next_seq;
|
||||||
|
*next_seq = next_seq.wrapping_add(1);
|
||||||
|
if *next_seq == 0 {
|
||||||
|
*next_seq = 1;
|
||||||
|
}
|
||||||
|
host_seq.store(seq, Ordering::SeqCst);
|
||||||
|
let kinds = mimes
|
||||||
|
.into_iter()
|
||||||
|
.map(|mime| ClipKind { mime, size_hint: 0 })
|
||||||
|
.collect();
|
||||||
|
ClipOffer { seq, kinds }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve one inbound fetch stream (a client pulling the host clipboard): validate the header +
|
||||||
|
/// request, then answer with the current host selection's bytes for the requested wire MIME.
|
||||||
|
async fn serve_fetch(
|
||||||
|
mut send: quinn::SendStream,
|
||||||
|
mut recv: quinn::RecvStream,
|
||||||
|
backend: Arc<HostClipboard>,
|
||||||
|
host_seq: Arc<AtomicU32>,
|
||||||
|
enabled: bool,
|
||||||
|
) {
|
||||||
|
let _ = send.set_priority(-1);
|
||||||
|
match clipstream::read_stream_header(&mut recv).await {
|
||||||
|
Ok(k) if k == 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
let decline = |status: u8| ClipFetchHdr {
|
||||||
|
status,
|
||||||
|
total_size: 0,
|
||||||
|
};
|
||||||
|
if !enabled {
|
||||||
|
let _ = clipstream::write_fetch_hdr(&mut send, &decline(CLIP_FETCH_UNAVAILABLE)).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if req.seq != host_seq.load(Ordering::SeqCst) {
|
||||||
|
let _ = clipstream::write_fetch_hdr(&mut send, &decline(CLIP_FETCH_STALE)).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `read_current` reads the host selection (a blocking pipe read, offloaded by the backend).
|
||||||
|
match backend.read_current(&req.mime).await {
|
||||||
|
Ok(data) => {
|
||||||
|
let hdr = ClipFetchHdr {
|
||||||
|
status: CLIP_FETCH_OK,
|
||||||
|
total_size: data.len() as u64,
|
||||||
|
};
|
||||||
|
if clipstream::write_fetch_hdr(&mut send, &hdr).await.is_ok() {
|
||||||
|
let _ = clipstream::write_data(&mut send, &data).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The format vanished (clipboard changed mid-fetch) or the read failed → nothing to send.
|
||||||
|
Err(_) => {
|
||||||
|
let _ = clipstream::write_fetch_hdr(&mut send, &decline(CLIP_FETCH_UNAVAILABLE)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull `mime` of the client's current offer (`seq`) over an outbound fetch stream and hand the bytes
|
||||||
|
/// to the backend's paste `responder`. Any failure (timeout, decline, I/O) responds with empty bytes
|
||||||
|
/// so the pasting host app gets an empty paste instead of hanging.
|
||||||
|
async fn fetch_into_pipe(
|
||||||
|
conn: quinn::Connection,
|
||||||
|
seq: u32,
|
||||||
|
mime: String,
|
||||||
|
responder: PasteResponder,
|
||||||
|
) {
|
||||||
|
let req = ClipFetch {
|
||||||
|
seq,
|
||||||
|
file_index: CLIP_FILE_INDEX_NONE,
|
||||||
|
mime,
|
||||||
|
};
|
||||||
|
let fetched = tokio::time::timeout(FETCH_TIMEOUT, async {
|
||||||
|
let (send, mut recv) = clipstream::open_fetch(&conn, &req).await.ok()?;
|
||||||
|
let hdr = clipstream::read_fetch_hdr(&mut recv).await.ok()?;
|
||||||
|
if hdr.status != CLIP_FETCH_OK {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let data = clipstream::read_data(&mut recv, CLIP_FETCH_CAP)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
drop(send); // clean close of our half
|
||||||
|
Some(data)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
responder.respond(fetched.unwrap_or_default()).await;
|
||||||
|
}
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
//! `ext-data-control-v1` clipboard backend (`design/clipboard-and-file-transfer.md` §4.1).
|
||||||
|
//!
|
||||||
|
//! A dedicated thread owns the `wayland-client` [`EventQueue`] and runs a poll loop that dispatches
|
||||||
|
//! selection + paste events, emitting them over a channel. Everything else — installing a lazy
|
||||||
|
//! source (a client's offer) and `receive()`-ing the host selection (a client's fetch) — is issued
|
||||||
|
//! from the session thread on the shared, `Send + Sync` proxy handles; only *dispatch* is
|
||||||
|
//! single-threaded (per the wayland-client contract). Templated on `inject/linux/wlr.rs`.
|
||||||
|
//!
|
||||||
|
//! The `zwlr-data-control-unstable-v1` fallback for older wlroots/KWin is a mechanical parallel of
|
||||||
|
//! this file (the protocols are 1:1) — a follow-up.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use wayland_client::backend::ObjectId;
|
||||||
|
use wayland_client::protocol::wl_registry;
|
||||||
|
use wayland_client::protocol::wl_seat::WlSeat;
|
||||||
|
use wayland_client::{event_created_child, Connection, Dispatch, Proxy, QueueHandle};
|
||||||
|
use wayland_protocols::ext::data_control::v1::client::{
|
||||||
|
ext_data_control_device_v1::{self, ExtDataControlDeviceV1},
|
||||||
|
ext_data_control_manager_v1::ExtDataControlManagerV1,
|
||||||
|
ext_data_control_offer_v1::{self, ExtDataControlOfferV1},
|
||||||
|
ext_data_control_source_v1::{self, ExtDataControlSourceV1},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{ClipEvent, PasteResponder};
|
||||||
|
|
||||||
|
/// Upper bound on bytes read from one `receive()` transfer (matches the wire clipboard cap, §7) so a
|
||||||
|
/// hostile host app can't stream unboundedly into our buffer.
|
||||||
|
const CLIP_READ_CAP: u64 = 64 << 20;
|
||||||
|
|
||||||
|
/// The current host selection, shared between the dispatch thread (writer) and the session thread
|
||||||
|
/// (reader, for `receive()`).
|
||||||
|
struct CurrentSelection {
|
||||||
|
offer: ExtDataControlOfferV1,
|
||||||
|
/// Raw Wayland MIMEs the offer advertises (what `receive()` accepts).
|
||||||
|
mimes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch-thread state. Also collects the manager + seat during the bind roundtrip.
|
||||||
|
struct State {
|
||||||
|
mgr: Option<ExtDataControlManagerV1>,
|
||||||
|
seat: Option<WlSeat>,
|
||||||
|
/// Offers accumulating their MIME list before the `selection` event promotes one.
|
||||||
|
pending: HashMap<ObjectId, Vec<String>>,
|
||||||
|
current: Arc<Mutex<Option<CurrentSelection>>>,
|
||||||
|
/// Pending count of our own `set_selection`s whose `selection` echo must be dropped rather than
|
||||||
|
/// announced back to the client (loop prevention, §3.4). Bumped by the session before each set;
|
||||||
|
/// each of our sets produces exactly one echo on wlroots/KWin, so one decrement per echo pairs
|
||||||
|
/// them up — a counter (not a bool) keeps rapid back-to-back offers from leaking a self-echo.
|
||||||
|
suppress_echoes: Arc<AtomicU32>,
|
||||||
|
tx: tokio::sync::mpsc::UnboundedSender<ClipEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<wl_registry::WlRegistry, ()> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
registry: &wl_registry::WlRegistry,
|
||||||
|
event: wl_registry::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
qh: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
if let wl_registry::Event::Global {
|
||||||
|
name,
|
||||||
|
interface,
|
||||||
|
version,
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
match interface.as_str() {
|
||||||
|
"ext_data_control_manager_v1" => {
|
||||||
|
state.mgr = Some(registry.bind(name, version.min(1), qh, ()));
|
||||||
|
}
|
||||||
|
"wl_seat" => {
|
||||||
|
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager + seat emit nothing we consume.
|
||||||
|
impl Dispatch<ExtDataControlManagerV1, ()> for State {
|
||||||
|
fn event(
|
||||||
|
_: &mut Self,
|
||||||
|
_: &ExtDataControlManagerV1,
|
||||||
|
_: <ExtDataControlManagerV1 as Proxy>::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Dispatch<WlSeat, ()> for State {
|
||||||
|
fn event(
|
||||||
|
_: &mut Self,
|
||||||
|
_: &WlSeat,
|
||||||
|
_: <WlSeat as Proxy>::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<ExtDataControlDeviceV1, ()> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
_dev: &ExtDataControlDeviceV1,
|
||||||
|
event: ext_data_control_device_v1::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
use ext_data_control_device_v1::Event;
|
||||||
|
match event {
|
||||||
|
// A new offer is being introduced; its `offer` events follow before `selection`.
|
||||||
|
Event::DataOffer { id } => {
|
||||||
|
state.pending.insert(id.id(), Vec::new());
|
||||||
|
}
|
||||||
|
// The active selection changed. `Some` = a new clipboard; `None` = cleared.
|
||||||
|
Event::Selection { id } => {
|
||||||
|
// Consume one pending self-echo if any (atomic vs. the session thread's bumps; the
|
||||||
|
// dispatch thread is the only decrementer). `Ok` = there was one → suppress.
|
||||||
|
let suppressed = state
|
||||||
|
.suppress_echoes
|
||||||
|
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |c| c.checked_sub(1))
|
||||||
|
.is_ok();
|
||||||
|
match id {
|
||||||
|
Some(offer) => {
|
||||||
|
let mimes = state.pending.remove(&offer.id()).unwrap_or_default();
|
||||||
|
if suppressed {
|
||||||
|
// Our own source's echo — don't store it as the host clipboard and
|
||||||
|
// don't announce it back to the client.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let wire = super::offer_wire_mimes(&mimes)
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
*state.current.lock().unwrap() = Some(CurrentSelection { offer, mimes });
|
||||||
|
let _ = state.tx.send(ClipEvent::Selection { mimes: wire });
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
*state.current.lock().unwrap() = None;
|
||||||
|
if !suppressed {
|
||||||
|
let _ = state.tx.send(ClipEvent::Selection { mimes: Vec::new() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Finished => {
|
||||||
|
let _ = state.tx.send(ClipEvent::Closed);
|
||||||
|
}
|
||||||
|
// Primary selection is out of scope for the shared clipboard.
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event_created_child!(State, ExtDataControlDeviceV1, [
|
||||||
|
ext_data_control_device_v1::EVT_DATA_OFFER_OPCODE => (ExtDataControlOfferV1, ()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<ExtDataControlOfferV1, ()> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
offer: &ExtDataControlOfferV1,
|
||||||
|
event: ext_data_control_offer_v1::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
if let ext_data_control_offer_v1::Event::Offer { mime_type } = event {
|
||||||
|
if let Some(list) = state.pending.get_mut(&offer.id()) {
|
||||||
|
list.push(mime_type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<ExtDataControlSourceV1, ()> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
_src: &ExtDataControlSourceV1,
|
||||||
|
event: ext_data_control_source_v1::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
use ext_data_control_source_v1::Event;
|
||||||
|
match event {
|
||||||
|
// A host app pasted our (the client's) offered data.
|
||||||
|
Event::Send { mime_type, fd } => match super::wayland_to_wire(&mime_type) {
|
||||||
|
Some(wire) => {
|
||||||
|
let _ = state.tx.send(ClipEvent::Paste {
|
||||||
|
mime: wire.to_string(),
|
||||||
|
responder: PasteResponder::Fd(fd),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// We can't satisfy this format — closing the fd yields an empty paste.
|
||||||
|
None => drop(fd),
|
||||||
|
},
|
||||||
|
// Our source was superseded (a host app or another client set a new selection).
|
||||||
|
Event::Cancelled => {}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host clipboard backend handle used by the session thread.
|
||||||
|
pub struct ClipboardBackend {
|
||||||
|
conn: Connection,
|
||||||
|
mgr: ExtDataControlManagerV1,
|
||||||
|
device: ExtDataControlDeviceV1,
|
||||||
|
qh: QueueHandle<State>,
|
||||||
|
current: Arc<Mutex<Option<CurrentSelection>>>,
|
||||||
|
suppress_echoes: Arc<AtomicU32>,
|
||||||
|
active_source: Mutex<Option<ExtDataControlSourceV1>>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
thread: Option<std::thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClipboardBackend {
|
||||||
|
/// Connect to the active session's Wayland display (env already applied by
|
||||||
|
/// `vdisplay::apply_session_env`), bind `ext_data_control`, and start the dispatch thread.
|
||||||
|
/// Returns the handle plus the event stream. Errors if the compositor lacks the protocol
|
||||||
|
/// (caller reports `BackendUnavailable`).
|
||||||
|
pub fn open() -> Result<(
|
||||||
|
ClipboardBackend,
|
||||||
|
tokio::sync::mpsc::UnboundedReceiver<ClipEvent>,
|
||||||
|
)> {
|
||||||
|
let conn = Connection::connect_to_env()
|
||||||
|
.context("connect to Wayland for clipboard (WAYLAND_DISPLAY/XDG_RUNTIME_DIR set?)")?;
|
||||||
|
let mut queue = conn.new_event_queue();
|
||||||
|
let qh = queue.handle();
|
||||||
|
let _registry = conn.display().get_registry(&qh, ());
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let current = Arc::new(Mutex::new(None));
|
||||||
|
let suppress_echoes = Arc::new(AtomicU32::new(0));
|
||||||
|
let mut state = State {
|
||||||
|
mgr: None,
|
||||||
|
seat: None,
|
||||||
|
pending: HashMap::new(),
|
||||||
|
current: current.clone(),
|
||||||
|
suppress_echoes: suppress_echoes.clone(),
|
||||||
|
tx,
|
||||||
|
};
|
||||||
|
queue
|
||||||
|
.roundtrip(&mut state)
|
||||||
|
.context("Wayland registry roundtrip")?;
|
||||||
|
|
||||||
|
let mgr = state
|
||||||
|
.mgr
|
||||||
|
.clone()
|
||||||
|
.context("compositor lacks ext_data_control_manager_v1")?;
|
||||||
|
let seat = state
|
||||||
|
.seat
|
||||||
|
.clone()
|
||||||
|
.context("compositor advertised no wl_seat")?;
|
||||||
|
let device = mgr.get_data_device(&seat, &qh, ());
|
||||||
|
// Second roundtrip: the compositor sends the initial selection for the freshly-bound device
|
||||||
|
// (the current host clipboard), which the session announces to the client.
|
||||||
|
queue
|
||||||
|
.roundtrip(&mut state)
|
||||||
|
.context("Wayland get_data_device roundtrip")?;
|
||||||
|
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let thread = {
|
||||||
|
let conn = conn.clone();
|
||||||
|
let stop = stop.clone();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("punktfunk-clipboard".into())
|
||||||
|
.spawn(move || dispatch_loop(conn, queue, state, stop))
|
||||||
|
.context("spawn clipboard dispatch thread")?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
ClipboardBackend {
|
||||||
|
conn,
|
||||||
|
mgr,
|
||||||
|
device,
|
||||||
|
qh,
|
||||||
|
current,
|
||||||
|
suppress_echoes,
|
||||||
|
active_source: Mutex::new(None),
|
||||||
|
stop,
|
||||||
|
thread: Some(thread),
|
||||||
|
},
|
||||||
|
rx,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install a lazy source advertising a client's offered formats (wire MIMEs) as the host
|
||||||
|
/// selection. A later host-app paste fires a [`ClipEvent::Paste`]. Replaces any previous offer.
|
||||||
|
pub fn set_offer(&self, wire_mimes: &[String]) -> Result<()> {
|
||||||
|
let wl_mimes = super::wayland_offers_for(wire_mimes);
|
||||||
|
if wl_mimes.is_empty() {
|
||||||
|
return self.clear_offer();
|
||||||
|
}
|
||||||
|
let src = self.mgr.create_data_source(&self.qh, ());
|
||||||
|
for m in &wl_mimes {
|
||||||
|
src.offer(m.clone());
|
||||||
|
}
|
||||||
|
// Suppress the selection echo our own set triggers (loop prevention).
|
||||||
|
self.suppress_echoes.fetch_add(1, Ordering::SeqCst);
|
||||||
|
self.device.set_selection(Some(&src));
|
||||||
|
self.conn.flush().context("flush set_selection")?;
|
||||||
|
let mut slot = self.active_source.lock().unwrap();
|
||||||
|
if let Some(old) = slot.take() {
|
||||||
|
old.destroy();
|
||||||
|
}
|
||||||
|
*slot = Some(src);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the host selection we own (client disabled sync / offered nothing).
|
||||||
|
pub fn clear_offer(&self) -> Result<()> {
|
||||||
|
let mut slot = self.active_source.lock().unwrap();
|
||||||
|
if let Some(old) = slot.take() {
|
||||||
|
self.suppress_echoes.fetch_add(1, Ordering::SeqCst);
|
||||||
|
self.device.set_selection(None);
|
||||||
|
old.destroy();
|
||||||
|
self.conn.flush().context("flush clear selection")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current host selection's wire MIMEs (what a client offer announcement would carry), or
|
||||||
|
/// empty if the clipboard is empty. Used to answer an immediate query.
|
||||||
|
pub fn current_wire_mimes(&self) -> Vec<String> {
|
||||||
|
match self.current.lock().unwrap().as_ref() {
|
||||||
|
Some(sel) => super::offer_wire_mimes(&sel.mimes)
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect(),
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one format (`wire_mime`) of the current host selection into a byte vector — a client's
|
||||||
|
/// lazy fetch. BLOCKS on the pipe until the source app finishes, so call from a blocking
|
||||||
|
/// context (e.g. `spawn_blocking`). Errors if there is no selection or the format isn't offered.
|
||||||
|
pub fn read_current(&self, wire_mime: &str) -> Result<Vec<u8>> {
|
||||||
|
let (offer, wl_mime) = {
|
||||||
|
let cur = self.current.lock().unwrap();
|
||||||
|
let sel = cur.as_ref().context("no current host selection")?;
|
||||||
|
let wl = super::pick_wayland_mime(wire_mime, &sel.mimes)
|
||||||
|
.context("format not offered by the host clipboard")?;
|
||||||
|
(sel.offer.clone(), wl)
|
||||||
|
};
|
||||||
|
let (read_fd, write_fd) = make_pipe()?;
|
||||||
|
offer.receive(wl_mime, write_fd.as_fd());
|
||||||
|
self.conn.flush().context("flush receive")?;
|
||||||
|
// Close our write end so the pipe reaches EOF once the source app closes its dup.
|
||||||
|
drop(write_fd);
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
// `read_fd` is a fresh, uniquely-owned pipe read end; `File` takes sole ownership and closes
|
||||||
|
// it on drop.
|
||||||
|
let file = std::fs::File::from(read_fd);
|
||||||
|
file.take(CLIP_READ_CAP)
|
||||||
|
.read_to_end(&mut buf)
|
||||||
|
.context("read clipboard transfer")?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ClipboardBackend {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop.store(true, Ordering::SeqCst);
|
||||||
|
if let Some(t) = self.thread.take() {
|
||||||
|
let _ = t.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dispatch thread: poll the Wayland socket with a short timeout so `stop` is honored promptly,
|
||||||
|
/// dispatching selection/paste events into `state`.
|
||||||
|
fn dispatch_loop(
|
||||||
|
conn: Connection,
|
||||||
|
mut queue: wayland_client::EventQueue<State>,
|
||||||
|
mut state: State,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) {
|
||||||
|
while !stop.load(Ordering::SeqCst) {
|
||||||
|
if queue.dispatch_pending(&mut state).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if conn.flush().is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Some(guard) = conn.prepare_read() else {
|
||||||
|
// Events are already queued; loop to dispatch them.
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let raw_fd = guard.connection_fd().as_raw_fd();
|
||||||
|
let mut pfd = libc::pollfd {
|
||||||
|
fd: raw_fd,
|
||||||
|
events: libc::POLLIN,
|
||||||
|
revents: 0,
|
||||||
|
};
|
||||||
|
// SAFETY: `pfd` is a single valid pollfd; `poll` reads/writes exactly it for 200 ms.
|
||||||
|
let rc = unsafe { libc::poll(&mut pfd, 1, 200) };
|
||||||
|
if rc < 0 {
|
||||||
|
let err = std::io::Error::last_os_error();
|
||||||
|
drop(guard);
|
||||||
|
if err.kind() == std::io::ErrorKind::Interrupted {
|
||||||
|
continue; // EINTR — recheck stop, retry
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if rc == 0 {
|
||||||
|
drop(guard); // timeout — recheck stop
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if pfd.revents & libc::POLLIN != 0 {
|
||||||
|
if guard.read().is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
drop(guard); // POLLHUP / POLLERR — connection gone
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = state.tx.send(ClipEvent::Closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a `pipe2(O_CLOEXEC)`, returning `(read_end, write_end)` as owned fds.
|
||||||
|
fn make_pipe() -> Result<(OwnedFd, OwnedFd)> {
|
||||||
|
let mut fds = [0 as libc::c_int; 2];
|
||||||
|
// SAFETY: `pipe2` fully initializes the 2-element `fds` on success (returns 0); on failure (-1)
|
||||||
|
// we bail before reading it. Each returned fd is fresh and owned by exactly one `OwnedFd`.
|
||||||
|
let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
|
||||||
|
if rc < 0 {
|
||||||
|
return Err(anyhow!("pipe2 failed: {}", std::io::Error::last_os_error()));
|
||||||
|
}
|
||||||
|
// SAFETY: `fds[0]`/`fds[1]` are the fresh, uniquely-owned pipe ends from the checked `pipe2`.
|
||||||
|
let read_fd = unsafe { OwnedFd::from_raw_fd(fds[0]) };
|
||||||
|
// SAFETY: as above for the write end.
|
||||||
|
let write_fd = unsafe { OwnedFd::from_raw_fd(fds[1]) };
|
||||||
|
Ok((read_fd, write_fd))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On-glass tests against a **live** `data-control` compositor (Hyprland / Sway / KWin). `#[ignore]`d
|
||||||
|
/// — run explicitly under such a session with `wl-clipboard` present:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// WAYLAND_DISPLAY=wayland-1 cargo test -p punktfunk-host --bin punktfunk-host \
|
||||||
|
/// -- --ignored --nocapture clipboard::wayland::live
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Each test skips (does not fail) when `open()` finds no backend — so `--ignored` on GNOME (no
|
||||||
|
/// data-control) or a headless CI runner is a clean no-op instead of a false failure.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod live {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Poll the event channel (sync `try_recv`, no runtime) until `pred` matches or `timeout`.
|
||||||
|
fn wait_event(
|
||||||
|
rx: &mut tokio::sync::mpsc::UnboundedReceiver<ClipEvent>,
|
||||||
|
timeout: Duration,
|
||||||
|
mut pred: impl FnMut(&ClipEvent) -> bool,
|
||||||
|
) -> Option<ClipEvent> {
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(ev) if pred(&ev) => return Some(ev),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the compositor selection from a "host app" (`wl-copy`, which forks a server that holds it).
|
||||||
|
fn wl_copy(bytes: &[u8], mime: &str) {
|
||||||
|
let mut child = Command::new("wl-copy")
|
||||||
|
.arg("--type")
|
||||||
|
.arg(mime)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.expect("spawn wl-copy");
|
||||||
|
child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.unwrap()
|
||||||
|
.write_all(bytes)
|
||||||
|
.expect("write to wl-copy");
|
||||||
|
let _ = child.wait(); // foreground exits; the fork keeps serving
|
||||||
|
std::thread::sleep(Duration::from_millis(150));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_or_skip() -> Option<(
|
||||||
|
ClipboardBackend,
|
||||||
|
tokio::sync::mpsc::UnboundedReceiver<ClipEvent>,
|
||||||
|
)> {
|
||||||
|
if Command::new("wl-copy").arg("--version").output().is_err() {
|
||||||
|
eprintln!("SKIP: wl-clipboard not installed");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match ClipboardBackend::open() {
|
||||||
|
Ok(v) => Some(v),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("SKIP: no data-control backend on this compositor: {e:#}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host copy → we observe a `Selection` and can `read_current` the exact bytes back — both text
|
||||||
|
/// and PNG (§3.5 format normalization end to end).
|
||||||
|
#[test]
|
||||||
|
#[ignore = "needs a live data-control compositor (WAYLAND_DISPLAY)"]
|
||||||
|
fn live_host_copy_is_readable() {
|
||||||
|
let Some((backend, mut rx)) = open_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Text.
|
||||||
|
wl_copy(b"hello-from-host-app", "text/plain;charset=utf-8");
|
||||||
|
let ev = wait_event(&mut rx, Duration::from_secs(3), |e| {
|
||||||
|
matches!(e, ClipEvent::Selection { mimes } if mimes.iter().any(|m| m == super::super::WIRE_TEXT))
|
||||||
|
})
|
||||||
|
.expect("Selection event carrying text after wl-copy");
|
||||||
|
assert!(matches!(ev, ClipEvent::Selection { .. }));
|
||||||
|
assert_eq!(
|
||||||
|
backend.read_current(super::super::WIRE_TEXT).unwrap(),
|
||||||
|
b"hello-from-host-app"
|
||||||
|
);
|
||||||
|
|
||||||
|
// PNG (arbitrary bytes tagged image/png — data-control is format-agnostic).
|
||||||
|
let png = b"\x89PNG\r\n\x1a\n-fake-but-tagged-image/png";
|
||||||
|
wl_copy(png, "image/png");
|
||||||
|
wait_event(&mut rx, Duration::from_secs(3), |e| {
|
||||||
|
matches!(e, ClipEvent::Selection { mimes } if mimes.iter().any(|m| m == super::super::WIRE_PNG))
|
||||||
|
})
|
||||||
|
.expect("Selection event carrying image/png");
|
||||||
|
assert_eq!(backend.read_current(super::super::WIRE_PNG).unwrap(), png);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// We install a client's offer as the host selection; a host app (`wl-paste`) pasting it fires a
|
||||||
|
/// `Paste` event that we fulfill with bytes, and the host app receives exactly those bytes.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "needs a live data-control compositor (WAYLAND_DISPLAY)"]
|
||||||
|
fn live_set_offer_is_pasteable() {
|
||||||
|
let Some((backend, mut rx)) = open_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
backend
|
||||||
|
.set_offer(&[super::super::WIRE_TEXT.to_string()])
|
||||||
|
.expect("install offer");
|
||||||
|
|
||||||
|
// A host app pastes our offered selection.
|
||||||
|
let child = Command::new("wl-paste")
|
||||||
|
.arg("-n")
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.expect("spawn wl-paste");
|
||||||
|
|
||||||
|
let paste = wait_event(&mut rx, Duration::from_secs(3), |e| {
|
||||||
|
matches!(e, ClipEvent::Paste { .. })
|
||||||
|
})
|
||||||
|
.expect("Paste event after wl-paste reads our offer");
|
||||||
|
match paste {
|
||||||
|
ClipEvent::Paste { mime, responder } => {
|
||||||
|
assert_eq!(
|
||||||
|
mime,
|
||||||
|
super::super::WIRE_TEXT,
|
||||||
|
"paste requested the text format"
|
||||||
|
);
|
||||||
|
match responder {
|
||||||
|
PasteResponder::Fd(fd) => {
|
||||||
|
super::super::fulfill_paste(fd, b"served-by-punktfunk").expect("fulfill");
|
||||||
|
}
|
||||||
|
PasteResponder::Channel(_) => panic!("data-control paste must carry an fd"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = child.wait_with_output().expect("wl-paste output");
|
||||||
|
assert_eq!(out.stdout, b"served-by-punktfunk");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,664 @@
|
|||||||
|
//! Host-side shared-clipboard backend for Windows (`design/clipboard-and-file-transfer.md` §4, Phase
|
||||||
|
//! 3). The Win32 clipboard is thread-affine and message-driven, so the whole backend lives on one
|
||||||
|
//! dedicated **message-loop thread** owning a hidden message-only window:
|
||||||
|
//!
|
||||||
|
//! * **host copy → client** — `AddClipboardFormatListener` delivers `WM_CLIPBOARDUPDATE`; we map the
|
||||||
|
//! available formats to wire MIMEs, cache them, and emit [`ClipEvent::Selection`]. Our own offers
|
||||||
|
//! are suppressed by the owner-check (we never forward what we ourselves put on the clipboard).
|
||||||
|
//! * **client fetch of the host clipboard** — a `Cmd::Read` reads the requested format's HGLOBAL and
|
||||||
|
//! converts it to wire bytes ([`super::winfmt`]).
|
||||||
|
//! * **client copy → host** — a `Cmd::SetOffer` installs the client's formats via OLE **delayed
|
||||||
|
//! rendering**: `SetClipboardData(fmt, NULL)`, so no bytes cross until a host app actually pastes.
|
||||||
|
//! * **host paste of client content** — the paste triggers `WM_RENDERFORMAT`; the message-loop thread
|
||||||
|
//! blocks (bounded) while the coordinator fetches the bytes from the client, then `SetClipboardData`s
|
||||||
|
//! them for the pasting app.
|
||||||
|
//!
|
||||||
|
//! The async coordinator drives the thread through a [`Cmd`] channel woken by `PostMessage(WM_APP_CMD)`
|
||||||
|
//! (`PostMessage` is the documented thread-safe way to poke a message loop). Per-window state hangs
|
||||||
|
//! off `GWLP_USERDATA`, so multiple concurrent sessions each get their own window + state.
|
||||||
|
|
||||||
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Context as _;
|
||||||
|
|
||||||
|
use ::windows::core::{w, PCWSTR};
|
||||||
|
use ::windows::Win32::Foundation::{
|
||||||
|
GetLastError, GlobalFree, HANDLE, HGLOBAL, HINSTANCE, HWND, LPARAM, LRESULT, WPARAM,
|
||||||
|
};
|
||||||
|
use ::windows::Win32::System::DataExchange::{
|
||||||
|
AddClipboardFormatListener, CloseClipboard, EmptyClipboard, GetClipboardData,
|
||||||
|
GetClipboardOwner, IsClipboardFormatAvailable, OpenClipboard, RegisterClipboardFormatW,
|
||||||
|
SetClipboardData,
|
||||||
|
};
|
||||||
|
use ::windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||||
|
use ::windows::Win32::System::Memory::{
|
||||||
|
GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE, GMEM_ZEROINIT,
|
||||||
|
};
|
||||||
|
use ::windows::Win32::System::Ole::CF_UNICODETEXT;
|
||||||
|
use ::windows::Win32::UI::WindowsAndMessaging::{
|
||||||
|
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetMessageW,
|
||||||
|
GetWindowLongPtrW, PostMessageW, PostQuitMessage, RegisterClassW, SetWindowLongPtrW,
|
||||||
|
TranslateMessage, GWLP_USERDATA, HWND_MESSAGE, MSG, WINDOW_EX_STYLE, WINDOW_STYLE, WM_APP,
|
||||||
|
WM_CLIPBOARDUPDATE, WM_DESTROY, WM_RENDERFORMAT, WNDCLASSW,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::winfmt;
|
||||||
|
use super::{ClipEvent, PasteResponder, WIRE_HTML, WIRE_PNG, WIRE_RTF, WIRE_TEXT};
|
||||||
|
|
||||||
|
/// Custom app message that wakes the pump to drain the [`Cmd`] channel.
|
||||||
|
const WM_APP_CMD: u32 = WM_APP + 1;
|
||||||
|
/// Upper bound the message-loop thread waits for the client's bytes during a `WM_RENDERFORMAT` paste.
|
||||||
|
/// The pasting app is frozen until we answer, so this caps how long a paste can hang; on expiry the
|
||||||
|
/// format is left unrendered (an empty paste) rather than blocking indefinitely.
|
||||||
|
const RENDER_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
/// `OpenClipboard` fails while another process transiently holds the clipboard (clipboard managers do
|
||||||
|
/// this constantly); retry briefly before giving up.
|
||||||
|
const OPEN_RETRIES: u32 = 20;
|
||||||
|
const OPEN_RETRY_DELAY: Duration = Duration::from_millis(5);
|
||||||
|
|
||||||
|
/// `RegisterClassW` returns this when the (process-global) class already exists — expected on the 2nd+
|
||||||
|
/// concurrent session, and not an error (we never unregister the class).
|
||||||
|
const ERROR_CLASS_ALREADY_EXISTS: u32 = 1410;
|
||||||
|
|
||||||
|
type ClipTx = tokio::sync::mpsc::UnboundedSender<ClipEvent>;
|
||||||
|
|
||||||
|
/// A command from the async coordinator into the message-loop thread. Delivered over a tokio channel
|
||||||
|
/// and drained on `WM_APP_CMD`.
|
||||||
|
enum Cmd {
|
||||||
|
/// Install the client's wire MIMEs as a delayed-render host selection (empty ⇒ clear).
|
||||||
|
SetOffer(Vec<String>),
|
||||||
|
/// Drop the selection we own.
|
||||||
|
Clear,
|
||||||
|
/// Read one wire format of the current host selection for a client fetch.
|
||||||
|
Read {
|
||||||
|
wire: String,
|
||||||
|
resp: tokio::sync::oneshot::Sender<anyhow::Result<Vec<u8>>>,
|
||||||
|
},
|
||||||
|
/// Tear the window + thread down.
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message-loop-thread-owned state, reached from the `WndProc` via `GWLP_USERDATA`. Only the message
|
||||||
|
/// thread ever dereferences it, so the `RefCell`s are sound (no cross-thread sharing); the fields the
|
||||||
|
/// async handle also touches (`current_wire`) are behind their own `Arc<Mutex>`.
|
||||||
|
struct WinClip {
|
||||||
|
/// Backend → coordinator events.
|
||||||
|
clip_tx: ClipTx,
|
||||||
|
/// The current host selection's wire MIMEs, shared with the [`WindowsClipboard`] handle.
|
||||||
|
current_wire: Arc<Mutex<Vec<String>>>,
|
||||||
|
/// Coordinator → backend commands, drained on `WM_APP_CMD`.
|
||||||
|
cmd_rx: RefCell<tokio::sync::mpsc::UnboundedReceiver<Cmd>>,
|
||||||
|
/// Clipboard format ids we currently promise via delayed rendering (for `WM_RENDERFORMAT`).
|
||||||
|
offered: RefCell<Vec<u32>>,
|
||||||
|
fmt_html: u32,
|
||||||
|
fmt_rtf: u32,
|
||||||
|
fmt_png: u32,
|
||||||
|
/// Our own message window — used for the owner-check and clipboard opens.
|
||||||
|
own_hwnd: HWND,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WinClip {
|
||||||
|
/// `WM_CLIPBOARDUPDATE`: a host app copied (or the clipboard was cleared). Suppress our own
|
||||||
|
/// delayed-render echoes via the owner-check, else announce the new wire MIMEs.
|
||||||
|
fn on_clipboard_update(&self, hwnd: HWND) {
|
||||||
|
// SAFETY: GetClipboardOwner has no preconditions and needs no open clipboard.
|
||||||
|
let owner = unsafe { GetClipboardOwner() }.unwrap_or_default();
|
||||||
|
if owner.0 == hwnd.0 {
|
||||||
|
// Our own offer's echo (we own the clipboard) — not a host copy.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mimes = self.available_wire_mimes();
|
||||||
|
*self.current_wire.lock().unwrap() = mimes.clone();
|
||||||
|
let _ = self.clip_tx.send(ClipEvent::Selection { mimes });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wire MIMEs the current clipboard advertises, in a stable order.
|
||||||
|
fn available_wire_mimes(&self) -> Vec<String> {
|
||||||
|
// SAFETY: IsClipboardFormatAvailable has no preconditions and needs no open clipboard.
|
||||||
|
let avail = |fmt: u32| unsafe { IsClipboardFormatAvailable(fmt) }.is_ok();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if avail(CF_UNICODETEXT.0 as u32) {
|
||||||
|
out.push(WIRE_TEXT.to_string());
|
||||||
|
}
|
||||||
|
if avail(self.fmt_html) {
|
||||||
|
out.push(WIRE_HTML.to_string());
|
||||||
|
}
|
||||||
|
if avail(self.fmt_rtf) {
|
||||||
|
out.push(WIRE_RTF.to_string());
|
||||||
|
}
|
||||||
|
if avail(self.fmt_png) {
|
||||||
|
out.push(WIRE_PNG.to_string());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `WM_APP_CMD`: run every queued coordinator command on this thread. Drained into a `Vec` first so
|
||||||
|
/// the `cmd_rx` borrow is released before any command runs (defensive against re-entry).
|
||||||
|
fn drain_commands(&self, hwnd: HWND) {
|
||||||
|
let mut cmds = Vec::new();
|
||||||
|
{
|
||||||
|
let mut rx = self.cmd_rx.borrow_mut();
|
||||||
|
while let Ok(c) = rx.try_recv() {
|
||||||
|
cmds.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for c in cmds {
|
||||||
|
match c {
|
||||||
|
Cmd::SetOffer(wire) => self.apply_offer(hwnd, &wire),
|
||||||
|
Cmd::Clear => self.clear(hwnd),
|
||||||
|
Cmd::Read { wire, resp } => {
|
||||||
|
let _ = resp.send(self.read(&wire));
|
||||||
|
}
|
||||||
|
Cmd::Shutdown => {
|
||||||
|
// Drop our offer first so no WM_RENDERALLFORMATS fires as the window dies (we do
|
||||||
|
// NOT want the client's content to outlive the session on the host clipboard).
|
||||||
|
self.clear(hwnd);
|
||||||
|
// SAFETY: our own live window; triggers WM_DESTROY → PostQuitMessage → pump exit.
|
||||||
|
unsafe {
|
||||||
|
let _ = DestroyWindow(hwnd);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the client's offer as a delayed-render host selection.
|
||||||
|
fn apply_offer(&self, hwnd: HWND, wire: &[String]) {
|
||||||
|
let fmts = self.formats_for_offer(wire);
|
||||||
|
if fmts.is_empty() {
|
||||||
|
self.clear(hwnd);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if open_clipboard_retry(hwnd).is_err() {
|
||||||
|
tracing::debug!("clipboard: OpenClipboard for set_offer failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _guard = ClipboardGuard;
|
||||||
|
// SAFETY: the clipboard is open (ClipboardGuard closes it); EmptyClipboard makes us the owner,
|
||||||
|
// then each SetClipboardData(_, None) registers a delayed-render promise for that format.
|
||||||
|
unsafe {
|
||||||
|
let _ = EmptyClipboard();
|
||||||
|
for &f in &fmts {
|
||||||
|
let _ = SetClipboardData(f, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*self.offered.borrow_mut() = fmts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the selection we own (empty the clipboard iff we're still its owner).
|
||||||
|
fn clear(&self, hwnd: HWND) {
|
||||||
|
let had = {
|
||||||
|
let mut o = self.offered.borrow_mut();
|
||||||
|
let was = !o.is_empty();
|
||||||
|
o.clear();
|
||||||
|
was
|
||||||
|
};
|
||||||
|
if !had {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: GetClipboardOwner has no preconditions.
|
||||||
|
let owner = unsafe { GetClipboardOwner() }.unwrap_or_default();
|
||||||
|
if owner.0 != hwnd.0 {
|
||||||
|
return; // someone else took the clipboard already
|
||||||
|
}
|
||||||
|
if open_clipboard_retry(hwnd).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _guard = ClipboardGuard;
|
||||||
|
// SAFETY: the clipboard is open (ClipboardGuard closes it); empty it to drop our promises.
|
||||||
|
unsafe {
|
||||||
|
let _ = EmptyClipboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one wire format of the current host selection (a client fetch).
|
||||||
|
fn read(&self, wire: &str) -> anyhow::Result<Vec<u8>> {
|
||||||
|
let fmt = self
|
||||||
|
.format_for_wire(wire)
|
||||||
|
.context("unsupported wire MIME")?;
|
||||||
|
// If we own the clipboard, its content is our own delayed-render offer (the client's copy),
|
||||||
|
// not a host selection — declining avoids GetClipboardData re-entering our own WM_RENDERFORMAT.
|
||||||
|
// SAFETY: GetClipboardOwner has no preconditions.
|
||||||
|
if unsafe { GetClipboardOwner() }.unwrap_or_default().0 == self.own_hwnd.0 {
|
||||||
|
anyhow::bail!("clipboard currently held by our own offer");
|
||||||
|
}
|
||||||
|
open_clipboard_retry(self.own_hwnd)?;
|
||||||
|
let _guard = ClipboardGuard;
|
||||||
|
// SAFETY: the clipboard is open (ClipboardGuard closes it). GetClipboardData hands back a
|
||||||
|
// clipboard-owned HGLOBAL (we must NOT free it); GlobalLock/Size/Unlock are balanced and we
|
||||||
|
// copy exactly GlobalSize bytes out before the lock is released.
|
||||||
|
let raw = unsafe {
|
||||||
|
let handle = GetClipboardData(fmt).context("GetClipboardData")?;
|
||||||
|
let hg = HGLOBAL(handle.0);
|
||||||
|
let p = GlobalLock(hg);
|
||||||
|
if p.is_null() {
|
||||||
|
anyhow::bail!("GlobalLock failed");
|
||||||
|
}
|
||||||
|
let n = GlobalSize(hg);
|
||||||
|
let mut buf = vec![0u8; n];
|
||||||
|
std::ptr::copy_nonoverlapping(p as *const u8, buf.as_mut_ptr(), n);
|
||||||
|
let _ = GlobalUnlock(hg);
|
||||||
|
buf
|
||||||
|
};
|
||||||
|
Ok(convert_from_win(wire, &raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `WM_RENDERFORMAT`: a host app is pasting a format we promised. Fetch the bytes from the client
|
||||||
|
/// (blocking this thread, bounded) and `SetClipboardData` them for the paster.
|
||||||
|
fn on_render_format(&self, fmt: u32) {
|
||||||
|
let Some(wire) = self.wire_for_format(fmt) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||||
|
let ev = ClipEvent::Paste {
|
||||||
|
mime: wire.to_string(),
|
||||||
|
responder: PasteResponder::Sync(tx),
|
||||||
|
};
|
||||||
|
if self.clip_tx.send(ev).is_err() {
|
||||||
|
return; // coordinator gone
|
||||||
|
}
|
||||||
|
let bytes = match rx.recv_timeout(RENDER_TIMEOUT) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => return, // timeout / dropped → leave the format unrendered (empty paste)
|
||||||
|
};
|
||||||
|
let win_bytes = convert_to_win(wire, &bytes);
|
||||||
|
let Ok(hg) = alloc_hglobal(&win_bytes) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Do NOT OpenClipboard here — the pasting app already holds it open across WM_RENDERFORMAT.
|
||||||
|
// SAFETY: `hg` is a freshly-filled moveable HGLOBAL. On success the clipboard takes ownership
|
||||||
|
// (we must not free it); on failure ownership stays with us, so we free it.
|
||||||
|
unsafe {
|
||||||
|
if SetClipboardData(fmt, Some(HANDLE(hg.0))).is_err() {
|
||||||
|
let _ = GlobalFree(Some(hg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Win32 clipboard format id for a wire MIME (`None` = unsupported).
|
||||||
|
fn format_for_wire(&self, wire: &str) -> Option<u32> {
|
||||||
|
match wire {
|
||||||
|
WIRE_TEXT => Some(CF_UNICODETEXT.0 as u32),
|
||||||
|
WIRE_HTML => Some(self.fmt_html),
|
||||||
|
WIRE_RTF => Some(self.fmt_rtf),
|
||||||
|
WIRE_PNG => Some(self.fmt_png),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wire MIME for a Win32 clipboard format id (`None` = one we don't offer).
|
||||||
|
fn wire_for_format(&self, fmt: u32) -> Option<&'static str> {
|
||||||
|
if fmt == CF_UNICODETEXT.0 as u32 {
|
||||||
|
Some(WIRE_TEXT)
|
||||||
|
} else if fmt == self.fmt_html {
|
||||||
|
Some(WIRE_HTML)
|
||||||
|
} else if fmt == self.fmt_rtf {
|
||||||
|
Some(WIRE_RTF)
|
||||||
|
} else if fmt == self.fmt_png {
|
||||||
|
Some(WIRE_PNG)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The clipboard format ids to promise for a client offer (dedup, 1:1 with the wire MIMEs — the OS
|
||||||
|
/// auto-synthesizes CF_TEXT/CF_OEMTEXT from CF_UNICODETEXT, so no manual text fan-out is needed).
|
||||||
|
fn formats_for_offer(&self, wire: &[String]) -> Vec<u32> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for w in wire {
|
||||||
|
if let Some(f) = self.format_for_wire(w) {
|
||||||
|
if !out.contains(&f) {
|
||||||
|
out.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active Windows clipboard backend handle held by [`super::HostClipboard`]. All Win32 work runs
|
||||||
|
/// on the message-loop thread; this is just the async-side control surface.
|
||||||
|
pub struct WindowsClipboard {
|
||||||
|
cmd_tx: tokio::sync::mpsc::UnboundedSender<Cmd>,
|
||||||
|
/// The message window's `HWND` as an `isize` (so the handle stays `Send`/`Sync`); rebuilt for the
|
||||||
|
/// `PostMessage` wakeups, which are documented thread-safe.
|
||||||
|
hwnd: isize,
|
||||||
|
current_wire: Arc<Mutex<Vec<String>>>,
|
||||||
|
join: Option<std::thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowsClipboard {
|
||||||
|
/// Spin up the message-loop thread + hidden window and return once it has bound (or failed).
|
||||||
|
pub async fn open() -> anyhow::Result<(
|
||||||
|
WindowsClipboard,
|
||||||
|
tokio::sync::mpsc::UnboundedReceiver<ClipEvent>,
|
||||||
|
)> {
|
||||||
|
let (clip_tx, clip_rx) = tokio::sync::mpsc::unbounded_channel::<ClipEvent>();
|
||||||
|
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<Cmd>();
|
||||||
|
let current_wire = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
|
||||||
|
// Register the three custom formats up front — process-global and thread-agnostic, so this is
|
||||||
|
// fine off the message thread and lets bring-up fail cleanly if the atoms can't be created.
|
||||||
|
let fmt_html = register_format(w!("HTML Format"))?;
|
||||||
|
let fmt_rtf = register_format(w!("Rich Text Format"))?;
|
||||||
|
let fmt_png = register_format(w!("PNG"))?;
|
||||||
|
|
||||||
|
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<anyhow::Result<isize>>();
|
||||||
|
let cw = Arc::clone(¤t_wire);
|
||||||
|
let join = std::thread::Builder::new()
|
||||||
|
.name("punktfunk-clipboard-win".into())
|
||||||
|
.spawn(move || pump_thread(clip_tx, cmd_rx, cw, fmt_html, fmt_rtf, fmt_png, ready_tx))
|
||||||
|
.context("spawn windows clipboard thread")?;
|
||||||
|
|
||||||
|
let hwnd = match tokio::time::timeout(Duration::from_secs(3), ready_rx).await {
|
||||||
|
Ok(Ok(Ok(h))) => h,
|
||||||
|
Ok(Ok(Err(e))) => return Err(e),
|
||||||
|
Ok(Err(_)) => anyhow::bail!("windows clipboard thread exited during bring-up"),
|
||||||
|
Err(_) => anyhow::bail!("windows clipboard bring-up timed out"),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
WindowsClipboard {
|
||||||
|
cmd_tx,
|
||||||
|
hwnd,
|
||||||
|
current_wire,
|
||||||
|
join: Some(join),
|
||||||
|
},
|
||||||
|
clip_rx,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current host selection's wire MIMEs (empty = nothing to offer).
|
||||||
|
pub fn current_wire_mimes(&self) -> Vec<String> {
|
||||||
|
self.current_wire.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install a client's offered formats as the host selection (fire-and-forget onto the thread).
|
||||||
|
pub fn set_offer(&self, wire_mimes: &[String]) {
|
||||||
|
let _ = self.cmd_tx.send(Cmd::SetOffer(wire_mimes.to_vec()));
|
||||||
|
self.wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the host selection we own (fire-and-forget onto the thread).
|
||||||
|
pub fn clear_offer(&self) {
|
||||||
|
let _ = self.cmd_tx.send(Cmd::Clear);
|
||||||
|
self.wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one wire format of the current host selection (a client's fetch).
|
||||||
|
pub async fn read_current(&self, wire_mime: &str) -> anyhow::Result<Vec<u8>> {
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
self.cmd_tx
|
||||||
|
.send(Cmd::Read {
|
||||||
|
wire: wire_mime.to_string(),
|
||||||
|
resp: tx,
|
||||||
|
})
|
||||||
|
.map_err(|_| anyhow::anyhow!("clipboard thread gone"))?;
|
||||||
|
self.wake();
|
||||||
|
rx.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("clipboard read dropped"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poke the message loop so it drains the command channel.
|
||||||
|
fn wake(&self) {
|
||||||
|
// SAFETY: PostMessageW is documented thread-safe; `hwnd` is our message window (or already
|
||||||
|
// destroyed, in which case the post harmlessly fails and is ignored).
|
||||||
|
let _ = unsafe {
|
||||||
|
PostMessageW(
|
||||||
|
Some(HWND(self.hwnd as *mut core::ffi::c_void)),
|
||||||
|
WM_APP_CMD,
|
||||||
|
WPARAM(0),
|
||||||
|
LPARAM(0),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for WindowsClipboard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.cmd_tx.send(Cmd::Shutdown);
|
||||||
|
self.wake();
|
||||||
|
if let Some(j) = self.join.take() {
|
||||||
|
let _ = j.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RAII `CloseClipboard` guard — pairs with a successful `open_clipboard_retry`, closing on scope exit
|
||||||
|
/// (including early `?`/`bail!` returns).
|
||||||
|
struct ClipboardGuard;
|
||||||
|
|
||||||
|
impl Drop for ClipboardGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: constructed only after a successful OpenClipboard on this thread.
|
||||||
|
unsafe {
|
||||||
|
let _ = CloseClipboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register (or resolve the existing id of) a custom clipboard format.
|
||||||
|
fn register_format(name: PCWSTR) -> anyhow::Result<u32> {
|
||||||
|
// SAFETY: RegisterClipboardFormatW is thread-agnostic and process-global; `name` is a static
|
||||||
|
// NUL-terminated wide literal.
|
||||||
|
let id = unsafe { RegisterClipboardFormatW(name) };
|
||||||
|
if id == 0 {
|
||||||
|
anyhow::bail!("RegisterClipboardFormatW failed");
|
||||||
|
}
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocate a moveable HGLOBAL holding `bytes` (zero-init so an empty payload is still a valid, locked
|
||||||
|
/// buffer). Ownership is transferred to the clipboard by a following `SetClipboardData`.
|
||||||
|
fn alloc_hglobal(bytes: &[u8]) -> anyhow::Result<HGLOBAL> {
|
||||||
|
// SAFETY: allocate at least one byte (GlobalLock of a 0-size block is unreliable), lock it, copy
|
||||||
|
// the payload in, unlock. Alloc/lock/unlock are balanced; on lock failure we free before erroring.
|
||||||
|
unsafe {
|
||||||
|
let hg = GlobalAlloc(GMEM_MOVEABLE | GMEM_ZEROINIT, bytes.len().max(1))
|
||||||
|
.context("GlobalAlloc")?;
|
||||||
|
let p = GlobalLock(hg);
|
||||||
|
if p.is_null() {
|
||||||
|
let _ = GlobalFree(Some(hg));
|
||||||
|
anyhow::bail!("GlobalLock failed");
|
||||||
|
}
|
||||||
|
std::ptr::copy_nonoverlapping(bytes.as_ptr(), p as *mut u8, bytes.len());
|
||||||
|
let _ = GlobalUnlock(hg);
|
||||||
|
Ok(hg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `OpenClipboard(hwnd)` with a brief retry loop (another process often holds it transiently).
|
||||||
|
fn open_clipboard_retry(hwnd: HWND) -> anyhow::Result<()> {
|
||||||
|
for _ in 0..OPEN_RETRIES {
|
||||||
|
// SAFETY: OpenClipboard with our window as owner; balanced by ClipboardGuard/CloseClipboard.
|
||||||
|
if unsafe { OpenClipboard(Some(hwnd)) }.is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
std::thread::sleep(OPEN_RETRY_DELAY);
|
||||||
|
}
|
||||||
|
anyhow::bail!("OpenClipboard failed after retries")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a Win32 clipboard payload to wire bytes.
|
||||||
|
fn convert_from_win(wire: &str, raw: &[u8]) -> Vec<u8> {
|
||||||
|
match wire {
|
||||||
|
WIRE_TEXT => winfmt::text_from_utf16(raw),
|
||||||
|
WIRE_HTML => winfmt::html_from_cf(raw),
|
||||||
|
WIRE_RTF => winfmt::rtf_from_cf(raw),
|
||||||
|
_ => raw.to_vec(), // PNG + anything else: verbatim
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert wire bytes to a Win32 clipboard payload.
|
||||||
|
fn convert_to_win(wire: &str, wire_bytes: &[u8]) -> Vec<u8> {
|
||||||
|
match wire {
|
||||||
|
WIRE_TEXT => winfmt::text_to_utf16(wire_bytes),
|
||||||
|
WIRE_HTML => winfmt::html_to_cf(wire_bytes),
|
||||||
|
_ => wire_bytes.to_vec(), // RTF + PNG + anything else: verbatim
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the hidden message-only window (registering the class once, process-wide).
|
||||||
|
fn create_window() -> anyhow::Result<HWND> {
|
||||||
|
// SAFETY: standard window-class registration + message-only window creation; every argument is a
|
||||||
|
// valid handle / static literal, and `wndproc` matches the WNDPROC ABI.
|
||||||
|
unsafe {
|
||||||
|
let hinstance: HINSTANCE = GetModuleHandleW(PCWSTR::null())
|
||||||
|
.context("GetModuleHandleW")?
|
||||||
|
.into();
|
||||||
|
let class_name = w!("PunktfunkClipboardWindow");
|
||||||
|
let wc = WNDCLASSW {
|
||||||
|
lpfnWndProc: Some(wndproc),
|
||||||
|
hInstance: hinstance,
|
||||||
|
lpszClassName: class_name,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
if RegisterClassW(&wc) == 0 {
|
||||||
|
let code = GetLastError();
|
||||||
|
if code.0 != ERROR_CLASS_ALREADY_EXISTS {
|
||||||
|
anyhow::bail!("RegisterClassW failed: {code:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let hwnd = CreateWindowExW(
|
||||||
|
WINDOW_EX_STYLE(0),
|
||||||
|
class_name,
|
||||||
|
w!(""),
|
||||||
|
WINDOW_STYLE(0),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Some(HWND_MESSAGE),
|
||||||
|
None,
|
||||||
|
Some(hinstance),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.context("CreateWindowExW")?;
|
||||||
|
Ok(hwnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The message-loop thread body: build the window, wire up state, then pump until `WM_QUIT`.
|
||||||
|
fn pump_thread(
|
||||||
|
clip_tx: ClipTx,
|
||||||
|
cmd_rx: tokio::sync::mpsc::UnboundedReceiver<Cmd>,
|
||||||
|
current_wire: Arc<Mutex<Vec<String>>>,
|
||||||
|
fmt_html: u32,
|
||||||
|
fmt_rtf: u32,
|
||||||
|
fmt_png: u32,
|
||||||
|
ready_tx: tokio::sync::oneshot::Sender<anyhow::Result<isize>>,
|
||||||
|
) {
|
||||||
|
let hwnd = match create_window() {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = ready_tx.send(Err(e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// A clone that outlives the boxed state, so we can announce Closed after the pump ends.
|
||||||
|
let closed_tx = clip_tx.clone();
|
||||||
|
|
||||||
|
let state = Box::new(WinClip {
|
||||||
|
clip_tx,
|
||||||
|
current_wire,
|
||||||
|
cmd_rx: RefCell::new(cmd_rx),
|
||||||
|
offered: RefCell::new(Vec::new()),
|
||||||
|
fmt_html,
|
||||||
|
fmt_rtf,
|
||||||
|
fmt_png,
|
||||||
|
own_hwnd: hwnd,
|
||||||
|
});
|
||||||
|
let ptr = Box::into_raw(state);
|
||||||
|
// SAFETY: stash the state pointer for the WndProc; the window was created on this thread and the
|
||||||
|
// pointer stays valid until we reclaim the Box after the pump exits.
|
||||||
|
unsafe {
|
||||||
|
SetWindowLongPtrW(hwnd, GWLP_USERDATA, ptr as isize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot whatever is already on the host clipboard, so the first client `enable` announces it
|
||||||
|
// (AddClipboardFormatListener only delivers *subsequent* changes).
|
||||||
|
{
|
||||||
|
// SAFETY: `ptr` is the live state we just stored; only this thread dereferences it.
|
||||||
|
let st = unsafe { &*ptr };
|
||||||
|
*st.current_wire.lock().unwrap() = st.available_wire_mimes();
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: `hwnd` is our live window; start receiving WM_CLIPBOARDUPDATE.
|
||||||
|
if let Err(e) = unsafe { AddClipboardFormatListener(hwnd) } {
|
||||||
|
// SAFETY: tear down the half-built window and reclaim the leaked state box.
|
||||||
|
unsafe {
|
||||||
|
let _ = DestroyWindow(hwnd);
|
||||||
|
drop(Box::from_raw(ptr));
|
||||||
|
}
|
||||||
|
let _ = ready_tx.send(Err(
|
||||||
|
anyhow::Error::new(e).context("AddClipboardFormatListener")
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = ready_tx.send(Ok(hwnd.0 as isize));
|
||||||
|
|
||||||
|
// SAFETY: the standard Win32 message pump. GetMessageW returns >0 for a message, 0 for WM_QUIT,
|
||||||
|
// and -1 on error — `.0 > 0` exits on both 0 and -1.
|
||||||
|
unsafe {
|
||||||
|
let mut msg = MSG::default();
|
||||||
|
while GetMessageW(&mut msg, None, 0, 0).0 > 0 {
|
||||||
|
let _ = TranslateMessage(&msg);
|
||||||
|
DispatchMessageW(&msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pump exited (window destroyed): reclaim the leaked state box. No WndProc runs after this point.
|
||||||
|
// SAFETY: `ptr` came from Box::into_raw above, is dereferenced only on this thread, and the
|
||||||
|
// message loop has ended so no further access occurs.
|
||||||
|
unsafe {
|
||||||
|
drop(Box::from_raw(ptr));
|
||||||
|
}
|
||||||
|
let _ = closed_tx.send(ClipEvent::Closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window procedure. Reaches per-window state through `GWLP_USERDATA`; runs only on the message
|
||||||
|
/// thread. Registered as the class `WNDPROC` (a safe fn coerces to the `unsafe extern "system"` ABI).
|
||||||
|
extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||||
|
// SAFETY: GWLP_USERDATA holds the `*const WinClip` stored right after window creation (0/null for
|
||||||
|
// the WM_(NC)CREATE messages that fire before that — handled by the null check below).
|
||||||
|
let ptr = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) } as *const WinClip;
|
||||||
|
if ptr.is_null() {
|
||||||
|
// SAFETY: default processing before our state pointer is attached.
|
||||||
|
return unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) };
|
||||||
|
}
|
||||||
|
// SAFETY: `ptr` is the live Box<WinClip> leaked in pump_thread, owned by this (the only) message
|
||||||
|
// thread and freed only after the pump exits; the WndProc is not re-entered for this window, so
|
||||||
|
// `&*ptr` is a valid shared borrow.
|
||||||
|
let st = unsafe { &*ptr };
|
||||||
|
match msg {
|
||||||
|
WM_CLIPBOARDUPDATE => {
|
||||||
|
st.on_clipboard_update(hwnd);
|
||||||
|
LRESULT(0)
|
||||||
|
}
|
||||||
|
WM_RENDERFORMAT => {
|
||||||
|
st.on_render_format(wparam.0 as u32);
|
||||||
|
LRESULT(0)
|
||||||
|
}
|
||||||
|
WM_APP_CMD => {
|
||||||
|
st.drain_commands(hwnd);
|
||||||
|
LRESULT(0)
|
||||||
|
}
|
||||||
|
WM_DESTROY => {
|
||||||
|
// SAFETY: ends the GetMessageW pump by posting WM_QUIT to this thread's queue.
|
||||||
|
unsafe {
|
||||||
|
PostQuitMessage(0);
|
||||||
|
}
|
||||||
|
LRESULT(0)
|
||||||
|
}
|
||||||
|
// SAFETY: default handling for every other message.
|
||||||
|
_ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
//! Pure byte conversions between the Win32 clipboard formats and the portable wire MIMEs
|
||||||
|
//! (`design/clipboard-and-file-transfer.md` §3.5). Kept free of any `windows`-crate dependency so it
|
||||||
|
//! compiles on every host and its unit tests exercise the fiddly bits (CF_HTML offset math, UTF-16
|
||||||
|
//! (de)serialization) without a Windows box. The [`super::windows`] backend is the only production
|
||||||
|
//! consumer; it wraps these with the actual `GetClipboardData`/`SetClipboardData` calls.
|
||||||
|
//!
|
||||||
|
//! Format map (Win32 ↔ wire):
|
||||||
|
//! * `CF_UNICODETEXT` (UTF-16LE + NUL) ↔ `text/plain;charset=utf-8`
|
||||||
|
//! * `"HTML Format"` (CF_HTML, UTF-8 + ASCII header) ↔ `text/html`
|
||||||
|
//! * `"Rich Text Format"` (raw RTF) ↔ `text/rtf`
|
||||||
|
//! * `"PNG"` (raw PNG) ↔ `image/png` — identity, handled inline by the backend.
|
||||||
|
|
||||||
|
// ---- CF_UNICODETEXT ↔ text/plain;charset=utf-8 -----------------------------------------------
|
||||||
|
|
||||||
|
/// `CF_UNICODETEXT` HGLOBAL bytes → UTF-8 wire bytes. `raw` is the exact `GlobalSize`-length buffer;
|
||||||
|
/// it holds little-endian UTF-16 code units terminated by a single `0x0000`.
|
||||||
|
pub fn text_from_utf16(raw: &[u8]) -> Vec<u8> {
|
||||||
|
// Reinterpret each LE 2-byte pair as a UTF-16 code unit; a stray odd trailing byte (never present
|
||||||
|
// in valid data) is dropped by `chunks_exact`.
|
||||||
|
let mut units: Vec<u16> = raw
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||||
|
.collect();
|
||||||
|
// Strip exactly one trailing NUL terminator if present (guard against eating a real code unit).
|
||||||
|
if units.last() == Some(&0) {
|
||||||
|
units.pop();
|
||||||
|
}
|
||||||
|
String::from_utf16_lossy(&units).into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UTF-8 wire bytes → `CF_UNICODETEXT` HGLOBAL bytes (UTF-16LE + a required `0x0000` terminator).
|
||||||
|
pub fn text_to_utf16(wire: &[u8]) -> Vec<u8> {
|
||||||
|
let s = String::from_utf8_lossy(wire);
|
||||||
|
let mut out = Vec::with_capacity(wire.len() * 2 + 2);
|
||||||
|
for u in s.encode_utf16() {
|
||||||
|
out.extend_from_slice(&u.to_le_bytes());
|
||||||
|
}
|
||||||
|
out.extend_from_slice(&0u16.to_le_bytes()); // REQUIRED NUL terminator for CF_UNICODETEXT
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- "HTML Format" (CF_HTML) ↔ text/html -----------------------------------------------------
|
||||||
|
//
|
||||||
|
// CF_HTML is UTF-8: an ASCII `Key:Value\r\n` header carrying byte offsets, then the HTML with
|
||||||
|
// `<!--StartFragment-->`/`<!--EndFragment-->` markers. Offsets are byte counts from buffer start;
|
||||||
|
// the offsets live *inside* the header, so their digit-width feeds back into the header length. The
|
||||||
|
// spec-blessed fix (Chromium/Firefox/LibreOffice) is fixed-width 10-digit zero-padded offsets, which
|
||||||
|
// makes the header a compile-time constant and every offset a one-pass computation.
|
||||||
|
|
||||||
|
const CF_HTML_HEADER: &str = "Version:0.9\r\n\
|
||||||
|
StartHTML:0000000000\r\n\
|
||||||
|
EndHTML:0000000000\r\n\
|
||||||
|
StartFragment:0000000000\r\n\
|
||||||
|
EndFragment:0000000000\r\n";
|
||||||
|
const CF_HTML_PREFIX: &str = "<html><body>\r\n<!--StartFragment-->";
|
||||||
|
const CF_HTML_SUFFIX: &str = "<!--EndFragment-->\r\n</body></html>";
|
||||||
|
|
||||||
|
/// UTF-8 HTML fragment (wire bytes) → a `CF_HTML` buffer, NUL-terminated. The trailing NUL is the
|
||||||
|
/// conventional CF_HTML expectation (§4); `EndHTML` still points at content end, before the NUL.
|
||||||
|
pub fn html_to_cf(wire: &[u8]) -> Vec<u8> {
|
||||||
|
let fragment = String::from_utf8_lossy(wire);
|
||||||
|
let start_html = CF_HTML_HEADER.len(); // 105
|
||||||
|
let start_fragment = start_html + CF_HTML_PREFIX.len(); // 139
|
||||||
|
let end_fragment = start_fragment + fragment.len(); // byte length — fragment may be multibyte
|
||||||
|
let end_html = end_fragment + CF_HTML_SUFFIX.len();
|
||||||
|
|
||||||
|
let mut buf = Vec::with_capacity(end_html + 1);
|
||||||
|
buf.extend_from_slice(CF_HTML_HEADER.as_bytes());
|
||||||
|
buf.extend_from_slice(CF_HTML_PREFIX.as_bytes());
|
||||||
|
buf.extend_from_slice(fragment.as_bytes());
|
||||||
|
buf.extend_from_slice(CF_HTML_SUFFIX.as_bytes());
|
||||||
|
|
||||||
|
// Overwrite the four zero-padded fields in place, restricting the search to the header region so a
|
||||||
|
// fragment that happens to contain "StartHTML:" can't fool the patcher.
|
||||||
|
patch_offset(&mut buf[..start_html], b"StartHTML:", start_html);
|
||||||
|
patch_offset(&mut buf[..start_html], b"EndHTML:", end_html);
|
||||||
|
patch_offset(&mut buf[..start_html], b"StartFragment:", start_fragment);
|
||||||
|
patch_offset(&mut buf[..start_html], b"EndFragment:", end_fragment);
|
||||||
|
|
||||||
|
buf.push(0); // conventional NUL terminator
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `CF_HTML` buffer → the UTF-8 HTML fragment (wire bytes). Uses the `StartFragment`/`EndFragment`
|
||||||
|
/// offsets; falls back to `StartHTML`/`EndHTML`, then to the whole buffer, if the markers are absent.
|
||||||
|
pub fn html_from_cf(raw: &[u8]) -> Vec<u8> {
|
||||||
|
let range = header_range(raw, b"StartFragment:", b"EndFragment:")
|
||||||
|
.or_else(|| header_range(raw, b"StartHTML:", b"EndHTML:"));
|
||||||
|
match range {
|
||||||
|
// Content is UTF-8 per spec; return the exact slice (drop any trailing NUL for cleanliness).
|
||||||
|
Some((start, end)) => {
|
||||||
|
let slice = &raw[start..end];
|
||||||
|
strip_trailing_nul(slice).to_vec()
|
||||||
|
}
|
||||||
|
None => strip_trailing_nul(raw).to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve `[start_label .. end_label]` into a validated byte range within `raw`.
|
||||||
|
fn header_range(raw: &[u8], start_label: &[u8], end_label: &[u8]) -> Option<(usize, usize)> {
|
||||||
|
let start = read_header_offset(raw, start_label)?;
|
||||||
|
let end = read_header_offset(raw, end_label)?;
|
||||||
|
if start <= end && end <= raw.len() {
|
||||||
|
Some((start, end))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overwrite the 10 ASCII digits following `label` in `header` with `value`, zero-padded.
|
||||||
|
fn patch_offset(header: &mut [u8], label: &[u8], value: usize) {
|
||||||
|
if let Some(pos) = find(header, label) {
|
||||||
|
let at = pos + label.len();
|
||||||
|
if at + 10 <= header.len() {
|
||||||
|
let digits = format!("{value:010}");
|
||||||
|
header[at..at + 10].copy_from_slice(digits.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the decimal integer following `label:` in the ASCII header. The colon-suffixed labels only
|
||||||
|
/// match in the header, never the marker comments (`<!--StartFragment-->`) or fragment text.
|
||||||
|
fn read_header_offset(raw: &[u8], label: &[u8]) -> Option<usize> {
|
||||||
|
let mut at = find(raw, label)? + label.len();
|
||||||
|
let mut n: usize = 0;
|
||||||
|
let mut any = false;
|
||||||
|
while let Some(&b) = raw.get(at) {
|
||||||
|
if b.is_ascii_digit() {
|
||||||
|
n = n.checked_mul(10)?.checked_add((b - b'0') as usize)?;
|
||||||
|
any = true;
|
||||||
|
at += 1;
|
||||||
|
} else {
|
||||||
|
break; // stops at '\r'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
any.then_some(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
||||||
|
if needle.is_empty() || needle.len() > hay.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
hay.windows(needle.len()).position(|w| w == needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- "Rich Text Format" ↔ text/rtf -----------------------------------------------------------
|
||||||
|
|
||||||
|
/// `"Rich Text Format"` HGLOBAL bytes → RTF wire bytes. RTF is `{ }`-delimited; some producers append
|
||||||
|
/// a NUL past the final `}`, so strip a single trailing NUL to keep the wire payload byte-clean.
|
||||||
|
pub fn rtf_from_cf(raw: &[u8]) -> Vec<u8> {
|
||||||
|
strip_trailing_nul(raw).to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_trailing_nul(b: &[u8]) -> &[u8] {
|
||||||
|
match b.last() {
|
||||||
|
Some(0) => &b[..b.len() - 1],
|
||||||
|
_ => b,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_round_trips_and_handles_terminator() {
|
||||||
|
// UTF-8 → UTF-16LE+NUL → UTF-8.
|
||||||
|
let wire = "héllo 🌍".as_bytes();
|
||||||
|
let cf = text_to_utf16(wire);
|
||||||
|
// Ends with a single 0x0000 terminator.
|
||||||
|
assert_eq!(&cf[cf.len() - 2..], &[0, 0]);
|
||||||
|
assert_eq!(text_from_utf16(&cf), wire);
|
||||||
|
|
||||||
|
// A CF buffer *without* a terminator still decodes (no code unit eaten).
|
||||||
|
let no_term: Vec<u8> = "hi".encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||||
|
assert_eq!(text_from_utf16(&no_term), b"hi");
|
||||||
|
|
||||||
|
// Empty text → just the terminator → empty wire.
|
||||||
|
assert_eq!(text_to_utf16(b""), vec![0, 0]);
|
||||||
|
assert_eq!(text_from_utf16(&[0, 0]), b"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cf_html_matches_the_spec_offsets() {
|
||||||
|
// The worked example from the format reference: fragment "Hello".
|
||||||
|
let cf = html_to_cf(b"Hello");
|
||||||
|
let s = String::from_utf8(cf.clone()).unwrap();
|
||||||
|
assert!(s.contains("StartHTML:0000000105"), "{s}");
|
||||||
|
assert!(s.contains("EndHTML:0000000178"), "{s}");
|
||||||
|
assert!(s.contains("StartFragment:0000000139"), "{s}");
|
||||||
|
assert!(s.contains("EndFragment:0000000144"), "{s}");
|
||||||
|
// The declared fragment range must slice back to exactly "Hello".
|
||||||
|
let start = read_header_offset(&cf, b"StartFragment:").unwrap();
|
||||||
|
let end = read_header_offset(&cf, b"EndFragment:").unwrap();
|
||||||
|
assert_eq!(&cf[start..end], b"Hello");
|
||||||
|
// Trailing NUL present, and EndHTML points *before* it.
|
||||||
|
assert_eq!(*cf.last().unwrap(), 0);
|
||||||
|
assert_eq!(read_header_offset(&cf, b"EndHTML:").unwrap(), cf.len() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cf_html_round_trips_including_multibyte() {
|
||||||
|
for frag in [
|
||||||
|
"Hello",
|
||||||
|
"<b>bold</b> & <i>ital</i>",
|
||||||
|
"café ☕ <span>x</span>",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
let cf = html_to_cf(frag.as_bytes());
|
||||||
|
assert_eq!(html_from_cf(&cf), frag.as_bytes(), "fragment {frag:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cf_html_extract_tolerates_foreign_producers() {
|
||||||
|
// A producer that adds SourceURL and uses Version 1.0 — offsets must still drive extraction,
|
||||||
|
// never a hardcoded 105-byte header.
|
||||||
|
let fragment = "picked";
|
||||||
|
let prefix = "<html><body><!--StartFragment-->";
|
||||||
|
let header_body = format!(
|
||||||
|
"Version:1.0\r\nStartHTML:{sh:010}\r\nEndHTML:{eh:010}\r\n\
|
||||||
|
StartFragment:{sf:010}\r\nEndFragment:{ef:010}\r\nSourceURL:https://x/\r\n",
|
||||||
|
sh = 0,
|
||||||
|
eh = 0,
|
||||||
|
sf = 0,
|
||||||
|
ef = 0,
|
||||||
|
);
|
||||||
|
// Compute real offsets against this ad-hoc layout.
|
||||||
|
let start_html = header_body.len();
|
||||||
|
let start_fragment = start_html + prefix.len();
|
||||||
|
let end_fragment = start_fragment + fragment.len();
|
||||||
|
let end_html = end_fragment + "<!--EndFragment--></body></html>".len();
|
||||||
|
let full = format!(
|
||||||
|
"Version:1.0\r\nStartHTML:{start_html:010}\r\nEndHTML:{end_html:010}\r\n\
|
||||||
|
StartFragment:{start_fragment:010}\r\nEndFragment:{end_fragment:010}\r\nSourceURL:https://x/\r\n\
|
||||||
|
{prefix}{fragment}<!--EndFragment--></body></html>"
|
||||||
|
);
|
||||||
|
assert_eq!(html_from_cf(full.as_bytes()), fragment.as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cf_html_extract_falls_back_without_markers() {
|
||||||
|
// No fragment markers at all → whole buffer (minus any NUL).
|
||||||
|
let mut b = b"<p>no markers</p>".to_vec();
|
||||||
|
assert_eq!(html_from_cf(&b), b"<p>no markers</p>");
|
||||||
|
b.push(0);
|
||||||
|
assert_eq!(html_from_cf(&b), b"<p>no markers</p>");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rtf_strips_one_trailing_nul() {
|
||||||
|
assert_eq!(rtf_from_cf(br"{\rtf1 hi}"), br"{\rtf1 hi}");
|
||||||
|
assert_eq!(rtf_from_cf(b"{\\rtf1 hi}\0"), br"{\rtf1 hi}");
|
||||||
|
// Only one NUL is stripped.
|
||||||
|
assert_eq!(rtf_from_cf(b"x\0\0"), b"x\0");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,11 @@
|
|||||||
|
|
||||||
mod audio;
|
mod audio;
|
||||||
mod capture;
|
mod capture;
|
||||||
|
/// Host-side shared-clipboard backend. The wire protocol + client live in `punktfunk-core`; this
|
||||||
|
/// drives the host session's real clipboard (`design/clipboard-and-file-transfer.md` §4). Linux uses
|
||||||
|
/// Wayland data-control / Mutter; Windows uses the Win32 clipboard (delayed rendering).
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
|
mod clipboard;
|
||||||
mod config;
|
mod config;
|
||||||
mod discovery;
|
mod discovery;
|
||||||
mod wol;
|
mod wol;
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ use punktfunk_core::config::{
|
|||||||
use punktfunk_core::input::{InputEvent, InputKind};
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
|
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
|
||||||
use punktfunk_core::quic::{
|
use punktfunk_core::quic::{
|
||||||
endpoint, io, BitrateChanged, ClockEcho, ClockProbe, ColorInfo, Hello, LossReport,
|
endpoint, io, BitrateChanged, ClipControl, ClipFetchHdr, ClipOffer, ClipState, ClockEcho,
|
||||||
PairChallenge, PairProof, PairRequest, PairResult, ProbeRequest, ProbeResult, Reconfigure,
|
ClockProbe, ColorInfo, Hello, LossReport, PairChallenge, PairProof, PairRequest, PairResult,
|
||||||
Reconfigured, RequestKeyframe, RfiRequest, SetBitrate, Start, Welcome,
|
ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, RfiRequest, SetBitrate,
|
||||||
|
Start, Welcome,
|
||||||
};
|
};
|
||||||
use punktfunk_core::transport::UdpTransport;
|
use punktfunk_core::transport::UdpTransport;
|
||||||
use punktfunk_core::Session;
|
use punktfunk_core::Session;
|
||||||
@@ -476,6 +477,86 @@ fn fec_static_override() -> Option<u8> {
|
|||||||
.map(|p| p.min(90))
|
.map(|p| p.min(90))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Operator clipboard policy from `PUNKTFUNK_CLIPBOARD` (`design/clipboard-and-file-transfer.md`
|
||||||
|
/// §4.2): `off` (default — the whole feature is dark), `on` / `1` (text + files), `text-only` /
|
||||||
|
/// `no-files` (text/RTF/HTML/image only). Returns `None` when clipboard is off (the host neither
|
||||||
|
/// advertises the cap nor accepts fetch streams); otherwise the permitted-format
|
||||||
|
/// [`punktfunk_core::quic::CLIP_POLICY_TEXT`] / `CLIP_POLICY_FILES` bitfield.
|
||||||
|
///
|
||||||
|
/// The policy gates the advertised capability and whether the [`crate::clipboard::session`]
|
||||||
|
/// coordinator (Linux data-control backend) starts. `off` keeps the whole feature dark.
|
||||||
|
fn clipboard_policy() -> Option<u8> {
|
||||||
|
use punktfunk_core::quic::{CLIP_POLICY_FILES, CLIP_POLICY_TEXT};
|
||||||
|
match std::env::var("PUNKTFUNK_CLIPBOARD")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"" | "0" | "off" | "false" => None,
|
||||||
|
"text-only" | "no-files" | "text" => Some(CLIP_POLICY_TEXT),
|
||||||
|
_ => Some(CLIP_POLICY_TEXT | CLIP_POLICY_FILES), // "on" / "1" / anything truthy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the shared clipboard is enabled at all for this host (policy not `off`).
|
||||||
|
fn clipboard_enabled() -> bool {
|
||||||
|
clipboard_policy().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command from the session control loop into the host clipboard coordinator
|
||||||
|
/// (`crate::clipboard::session`, Linux data-control). Defined here — portable — so the control loop
|
||||||
|
/// compiles on every host platform; the coordinator that consumes it is Linux-only.
|
||||||
|
pub(crate) enum ClipCoordCmd {
|
||||||
|
/// The client toggled sync. When enabled, the coordinator (re)announces the current host
|
||||||
|
/// clipboard; when disabled, it drops any selection it owns and stops forwarding host copies.
|
||||||
|
SetEnabled(bool),
|
||||||
|
/// The client copied: install its offered wire MIMEs as a lazy host selection (empty = clear).
|
||||||
|
RemoteOffer { seq: u32, mimes: Vec<String> },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle to the host clipboard coordinator, held by the session control loop.
|
||||||
|
struct ClipCoord {
|
||||||
|
/// Whether a real backend is live. `false` on gamescope / older GNOME / non-Linux; the control
|
||||||
|
/// loop then answers an enable request with `CLIP_REASON_BACKEND_UNAVAILABLE` and a defensive
|
||||||
|
/// decline loop handles any stray fetch stream.
|
||||||
|
available: bool,
|
||||||
|
cmd_tx: tokio::sync::mpsc::UnboundedSender<ClipCoordCmd>,
|
||||||
|
/// Host-copy announcements from the coordinator → control loop → client.
|
||||||
|
offer_rx: tokio::sync::mpsc::UnboundedReceiver<ClipOffer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the host clipboard backend (when the operator policy allows it, this session mirrors a real
|
||||||
|
/// compositor, and the platform has a backend) and spawn its coordinator, returning a handle.
|
||||||
|
/// Otherwise the handle is inert (`available = false`, channels dropped) so the caller's control loop
|
||||||
|
/// stays platform-agnostic. `has_compositor` is false for the synthetic protocol-test source, which
|
||||||
|
/// has no display/clipboard to share — keeping it out of the real session clipboard.
|
||||||
|
async fn start_clip_coord(
|
||||||
|
conn: quinn::Connection,
|
||||||
|
clip_enabled: Arc<AtomicBool>,
|
||||||
|
has_compositor: bool,
|
||||||
|
) -> ClipCoord {
|
||||||
|
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let (offer_tx, offer_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
|
let available = if has_compositor && clipboard_enabled() {
|
||||||
|
crate::clipboard::session::start(conn, clip_enabled, cmd_rx, offer_tx).await
|
||||||
|
} else {
|
||||||
|
drop((conn, clip_enabled, cmd_rx, offer_tx));
|
||||||
|
false
|
||||||
|
};
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
|
let available = {
|
||||||
|
let _ = (conn, clip_enabled, cmd_rx, offer_tx, has_compositor);
|
||||||
|
false
|
||||||
|
};
|
||||||
|
ClipCoord {
|
||||||
|
available,
|
||||||
|
cmd_tx,
|
||||||
|
offer_rx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Adaptive-FEC band + starting point. Every recovery shard is extra wire bytes AND an extra
|
/// Adaptive-FEC band + starting point. Every recovery shard is extra wire bytes AND an extra
|
||||||
/// packet, so on a clean link FEC decays toward [`FEC_MIN`] (fewer packets — the win for a
|
/// packet, so on a clean link FEC decays toward [`FEC_MIN`] (fewer packets — the win for a
|
||||||
/// packet-rate-bound uplink like the Steam Deck's WiFi tx); loss ramps it toward [`FEC_MAX`].
|
/// packet-rate-bound uplink like the Steam Deck's WiFi tx); loss ramps it toward [`FEC_MAX`].
|
||||||
@@ -1069,8 +1150,19 @@ async fn serve_session(
|
|||||||
// assuming HEVC.
|
// assuming HEVC.
|
||||||
codec: codec_bit,
|
codec: codec_bit,
|
||||||
// This host applies sequence-gated gamepad-state snapshots (InputKind::GamepadState),
|
// This host applies sequence-gated gamepad-state snapshots (InputKind::GamepadState),
|
||||||
// so capable clients send those instead of the loss-fragile per-transition events.
|
// so capable clients send those instead of the loss-fragile per-transition events. The
|
||||||
host_caps: punktfunk_core::quic::HOST_CAP_GAMEPAD_STATE,
|
// clipboard bit is advertised only when the operator policy enables it (design
|
||||||
|
// clipboard-and-file-transfer.md §3.1) AND this platform has a backend (Linux
|
||||||
|
// data-control / Mutter, or the Win32 clipboard) — the client greys the toggle out
|
||||||
|
// otherwise. A Linux host whose compositor lacks data-control still advertises it and
|
||||||
|
// answers a later enable with BACKEND_UNAVAILABLE, so the client can surface *why* it's
|
||||||
|
// unavailable.
|
||||||
|
host_caps: punktfunk_core::quic::HOST_CAP_GAMEPAD_STATE
|
||||||
|
| if clipboard_enabled() && cfg!(any(target_os = "linux", target_os = "windows")) {
|
||||||
|
punktfunk_core::quic::HOST_CAP_CLIPBOARD
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
};
|
};
|
||||||
io::write_msg(&mut send, &welcome.encode()).await?;
|
io::write_msg(&mut send, &welcome.encode()).await?;
|
||||||
|
|
||||||
@@ -1145,8 +1237,26 @@ async fn serve_session(
|
|||||||
let adaptive_fec = fec_static_override().is_none();
|
let adaptive_fec = fec_static_override().is_none();
|
||||||
let fec_target = Arc::new(AtomicU8::new(welcome.fec.fec_percent));
|
let fec_target = Arc::new(AtomicU8::new(welcome.fec.fec_percent));
|
||||||
let fec_target_ctl = fec_target.clone();
|
let fec_target_ctl = fec_target.clone();
|
||||||
|
// Shared-clipboard enable state (client `ClipControl` → host). The coordinator reads it to decide
|
||||||
|
// whether to forward host copies; the control loop flips it on each `ClipControl`.
|
||||||
|
let clip_enabled = Arc::new(AtomicBool::new(false));
|
||||||
|
let clip_enabled_ctl = clip_enabled.clone();
|
||||||
|
// Start the host clipboard coordinator (Linux data-control backend). On success it watches the
|
||||||
|
// session clipboard, forwards host copies as `ClipOffer`s (`clip_offer_rx` → this control loop →
|
||||||
|
// client), installs client offers as a lazy source, and owns the fetch-stream accept loop.
|
||||||
|
// `available` is false when there's no backend (gamescope / older GNOME / non-Linux) — the
|
||||||
|
// control loop then answers `ClipControl` with `BACKEND_UNAVAILABLE` and the defensive decline
|
||||||
|
// loop below handles stray fetch streams.
|
||||||
|
let ClipCoord {
|
||||||
|
available: clip_available,
|
||||||
|
cmd_tx: clip_cmd_tx,
|
||||||
|
offer_rx: mut clip_offer_rx,
|
||||||
|
} = start_clip_coord(conn.clone(), clip_enabled.clone(), compositor.is_some()).await;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut active = hello.mode;
|
let mut active = hello.mode;
|
||||||
|
// Set once `clip_offer_rx` closes (coordinator gone / inert handle) so its `select!` branch
|
||||||
|
// stops firing on a perpetually-ready `None`.
|
||||||
|
let mut clip_offer_closed = false;
|
||||||
// Host-side switch rate limit (a backstop against a hostile/broken client spamming
|
// Host-side switch rate limit (a backstop against a hostile/broken client spamming
|
||||||
// Reconfigure into pipeline-rebuild churn — the drain-to-newest in the data plane already
|
// Reconfigure into pipeline-rebuild churn — the drain-to-newest in the data plane already
|
||||||
// coalesces a well-behaved resize drag; compliant clients self-limit to ≥ 1 s).
|
// coalesces a well-behaved resize drag; compliant clients self-limit to ≥ 1 s).
|
||||||
@@ -1285,6 +1395,44 @@ async fn serve_session(
|
|||||||
if io::write_msg(&mut ctrl_send, &echo.encode()).await.is_err() {
|
if io::write_msg(&mut ctrl_send, &echo.encode()).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
} else if let Ok(ctl) = ClipControl::decode(&msg) {
|
||||||
|
// Shared clipboard enable/disable (design/clipboard-and-file-transfer.md
|
||||||
|
// §3.1). Reply with the resolved state; the operator policy is authoritative
|
||||||
|
// over the client's request. When the policy allows it but no backend bound
|
||||||
|
// (gamescope / older GNOME), enable is refused with BACKEND_UNAVAILABLE so the
|
||||||
|
// client can say *why*. The resolved `enabled` gates the coordinator.
|
||||||
|
let policy = clipboard_policy();
|
||||||
|
let (enabled, resolved_policy, reason) = match policy {
|
||||||
|
None => (false, 0, punktfunk_core::quic::CLIP_REASON_POLICY_DISABLED),
|
||||||
|
Some(p) if ctl.enabled && !clip_available => {
|
||||||
|
(false, p, punktfunk_core::quic::CLIP_REASON_BACKEND_UNAVAILABLE)
|
||||||
|
}
|
||||||
|
Some(p) => {
|
||||||
|
let files_ok = p & punktfunk_core::quic::CLIP_POLICY_FILES != 0;
|
||||||
|
let wants_files = ctl.flags & punktfunk_core::quic::CLIP_FLAG_FILES != 0;
|
||||||
|
let reason = if wants_files && !files_ok {
|
||||||
|
punktfunk_core::quic::CLIP_REASON_NO_FILES
|
||||||
|
} else {
|
||||||
|
punktfunk_core::quic::CLIP_REASON_OK
|
||||||
|
};
|
||||||
|
(ctl.enabled, p, reason)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
clip_enabled_ctl.store(enabled, Ordering::SeqCst);
|
||||||
|
// Drive the coordinator: enable re-announces the current host clipboard,
|
||||||
|
// disable drops any selection we own. A dropped send (inert handle) is fine.
|
||||||
|
let _ = clip_cmd_tx.send(ClipCoordCmd::SetEnabled(enabled));
|
||||||
|
tracing::info!(enabled, files = enabled && resolved_policy & punktfunk_core::quic::CLIP_POLICY_FILES != 0, "clipboard control");
|
||||||
|
let state = ClipState { enabled, policy: resolved_policy, reason };
|
||||||
|
if io::write_msg(&mut ctrl_send, &state.encode()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if let Ok(offer) = ClipOffer::decode(&msg) {
|
||||||
|
// The client copied: hand its lazy format list to the coordinator, which
|
||||||
|
// installs a host-side source that fetches from the client on host paste.
|
||||||
|
tracing::debug!(seq = offer.seq, kinds = offer.kinds.len(), "clipboard offer from client");
|
||||||
|
let mimes = offer.kinds.iter().map(|k| k.mime.clone()).collect();
|
||||||
|
let _ = clip_cmd_tx.send(ClipCoordCmd::RemoteOffer { seq: offer.seq, mimes });
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!("unknown control message — ignoring");
|
tracing::warn!("unknown control message — ignoring");
|
||||||
}
|
}
|
||||||
@@ -1295,6 +1443,21 @@ async fn serve_session(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
offer = clip_offer_rx.recv(), if !clip_offer_closed => {
|
||||||
|
// Host copied → the coordinator minted a `ClipOffer`; forward it to the client
|
||||||
|
// (only while sync is on — a race with a just-received disable would otherwise
|
||||||
|
// leak a stale offer). `None` = coordinator gone; disable this branch.
|
||||||
|
match offer {
|
||||||
|
Some(offer) => {
|
||||||
|
if clip_enabled_ctl.load(Ordering::SeqCst)
|
||||||
|
&& io::write_msg(&mut ctrl_send, &offer.encode()).await.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => clip_offer_closed = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
correction = reconfig_result_rx.recv() => {
|
correction = reconfig_result_rx.recv() => {
|
||||||
// H2 rollback/correction ack: the data plane reports the mode ACTUALLY live
|
// H2 rollback/correction ack: the data plane reports the mode ACTUALLY live
|
||||||
// after a rebuild that failed (stayed at the old mode) or that the backend
|
// after a rebuild that failed (stayed at the old mode) or that the backend
|
||||||
@@ -1376,6 +1539,43 @@ async fn serve_session(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clipboard fetch-stream accept loop (design/clipboard-and-file-transfer.md §3.3, §4.2). When a
|
||||||
|
// backend is live the coordinator (spawned above) owns `accept_bi` and serves real host
|
||||||
|
// clipboard bytes. This is the *fallback*: the operator allowed the cap but no backend bound
|
||||||
|
// (gamescope / older GNOME / a not-yet-implemented platform), so a stray or hostile fetch stream
|
||||||
|
// is answered `CLIP_FETCH_UNAVAILABLE` instead of hanging. Exactly one `accept_bi` consumer runs
|
||||||
|
// (this OR the coordinator). The control stream is the FIRST bi-stream (already accepted at the
|
||||||
|
// handshake), so this loop only ever sees clipboard fetch streams; it dies with the connection.
|
||||||
|
if !clip_available && clipboard_enabled() {
|
||||||
|
let clip_conn = conn.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
use punktfunk_core::quic::clipstream;
|
||||||
|
while let Ok((mut send, mut recv)) = clip_conn.accept_bi().await {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Validate the stream header + request; a malformed/unknown stream is dropped.
|
||||||
|
match clipstream::read_stream_header(&mut recv).await {
|
||||||
|
Ok(k) if k == clipstream::CLIP_STREAM_KIND_FETCH => {}
|
||||||
|
_ => {
|
||||||
|
let _ = send.reset(clipstream::cancelled_code());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if clipstream::read_fetch(&mut recv).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _ = clipstream::write_fetch_hdr(
|
||||||
|
&mut send,
|
||||||
|
&ClipFetchHdr {
|
||||||
|
status: punktfunk_core::quic::CLIP_FETCH_UNAVAILABLE,
|
||||||
|
total_size: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Stop signal: stream duration elapsed or the client went away.
|
// Stop signal: stream duration elapsed or the client went away.
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
// Deliberate-quit signal: set (before `stop`, so the display lease reads it on teardown) when the
|
// Deliberate-quit signal: set (before `stop`, so the display lease reads it on teardown) when the
|
||||||
@@ -5359,6 +5559,138 @@ mod tests {
|
|||||||
host.join().unwrap().unwrap();
|
host.join().unwrap().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shared clipboard end to end over a real synthetic session
|
||||||
|
/// (`design/clipboard-and-file-transfer.md`): with the operator policy enabled, the host
|
||||||
|
/// advertises the capability, acknowledges an enable with a `ClipState`, and — a synthetic
|
||||||
|
/// session mirrors no compositor, so no data-control backend binds — declines a fetch with an
|
||||||
|
/// `Error` the client surfaces. Exercises the whole 0x40-0x44 control+fetch path across two real
|
||||||
|
/// endpoints (client `NativeClient` ↔ host `serve_session`). The live-backend paths (a real
|
||||||
|
/// compositor) are covered by the on-glass test against GNOME/Hyprland.
|
||||||
|
#[test]
|
||||||
|
fn clipboard_control_and_fetch_decline_over_session() {
|
||||||
|
let _serial = SESSION_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
use punktfunk_core::client::NativeClient;
|
||||||
|
use punktfunk_core::clipboard::ClipEventCore;
|
||||||
|
use punktfunk_core::quic::{
|
||||||
|
CLIP_FILE_INDEX_NONE, CLIP_FLAG_FILES, CLIP_POLICY_FILES, HOST_CAP_CLIPBOARD,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Restore the env even on a panicking assert (the poisoned lock is recovered above, so a
|
||||||
|
// leaked var could otherwise reach the next session test).
|
||||||
|
struct EnvGuard(&'static str);
|
||||||
|
impl Drop for EnvGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
std::env::remove_var(self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _env = EnvGuard("PUNKTFUNK_CLIPBOARD");
|
||||||
|
// Operator policy on. Session tests serialize on SESSION_TEST_LOCK, and only serve_session
|
||||||
|
// (a session test) reads this env, so the mutation is race-free here.
|
||||||
|
std::env::set_var("PUNKTFUNK_CLIPBOARD", "1");
|
||||||
|
|
||||||
|
let host = std::thread::spawn(|| {
|
||||||
|
run(Punktfunk1Options {
|
||||||
|
port: 19781,
|
||||||
|
source: Punktfunk1Source::Synthetic,
|
||||||
|
seconds: 0,
|
||||||
|
frames: 600, // keep the session alive well past the control exchange
|
||||||
|
max_sessions: 1,
|
||||||
|
max_concurrent: 1,
|
||||||
|
require_pairing: false,
|
||||||
|
allow_pairing: false,
|
||||||
|
pairing_pin: None,
|
||||||
|
paired_store: None,
|
||||||
|
data_port: None,
|
||||||
|
idle_timeout: None,
|
||||||
|
mdns: false,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
|
||||||
|
let mode = punktfunk_core::Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 60,
|
||||||
|
};
|
||||||
|
let client = NativeClient::connect(
|
||||||
|
"127.0.0.1",
|
||||||
|
19781,
|
||||||
|
mode,
|
||||||
|
CompositorPref::Auto,
|
||||||
|
GamepadPref::Auto,
|
||||||
|
0, // bitrate_kbps
|
||||||
|
0, // video_caps
|
||||||
|
2, // audio_channels
|
||||||
|
0, // video_codecs (HEVC-only)
|
||||||
|
0, // preferred_codec
|
||||||
|
None, // display_hdr
|
||||||
|
None, // launch
|
||||||
|
None, // pin (TOFU)
|
||||||
|
None, // identity (host doesn't require pairing)
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
)
|
||||||
|
.expect("client connects to synthetic host");
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
client.host_caps() & HOST_CAP_CLIPBOARD,
|
||||||
|
0,
|
||||||
|
"an enabled host advertises HOST_CAP_CLIPBOARD"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A bounded poll over the clipboard event plane.
|
||||||
|
let poll = |pred: &dyn Fn(&ClipEventCore) -> bool| -> Option<ClipEventCore> {
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||||
|
while std::time::Instant::now() < deadline {
|
||||||
|
match client.next_clip(std::time::Duration::from_millis(200)) {
|
||||||
|
Ok(ev) if pred(&ev) => return Some(ev),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(punktfunk_core::PunktfunkError::NoFrame) => {}
|
||||||
|
Err(_) => break, // session closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enable sync (requesting files) → the host acks with a ClipState. A synthetic session
|
||||||
|
// mirrors no compositor, so no data-control backend binds: the host refuses the enable with
|
||||||
|
// `BACKEND_UNAVAILABLE` while still reporting the operator policy (files permitted).
|
||||||
|
client.clip_control(true, CLIP_FLAG_FILES).unwrap();
|
||||||
|
let state = poll(&|e| matches!(e, ClipEventCore::State { .. }))
|
||||||
|
.expect("host replies with a ClipState ack");
|
||||||
|
match state {
|
||||||
|
ClipEventCore::State {
|
||||||
|
enabled,
|
||||||
|
policy,
|
||||||
|
reason,
|
||||||
|
} => {
|
||||||
|
assert!(!enabled, "no backend for a synthetic session → not enabled");
|
||||||
|
assert_eq!(
|
||||||
|
reason,
|
||||||
|
punktfunk_core::quic::CLIP_REASON_BACKEND_UNAVAILABLE,
|
||||||
|
"the refusal reason is BACKEND_UNAVAILABLE"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
policy & CLIP_POLICY_FILES,
|
||||||
|
0,
|
||||||
|
"PUNKTFUNK_CLIPBOARD=1 permits files"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the host clipboard: a synthetic session has no backend, so the host declines and
|
||||||
|
// the client surfaces an Error for that transfer id.
|
||||||
|
let xfer = client
|
||||||
|
.clip_fetch(1, "text/plain;charset=utf-8".into(), CLIP_FILE_INDEX_NONE)
|
||||||
|
.unwrap();
|
||||||
|
let err = poll(&|e| matches!(e, ClipEventCore::Error { id, .. } if *id == xfer))
|
||||||
|
.expect("host declines the fetch (no backend) → Error event");
|
||||||
|
assert!(matches!(err, ClipEventCore::Error { .. }));
|
||||||
|
|
||||||
|
drop(client);
|
||||||
|
host.join().unwrap().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
fn test_paired_path() -> std::path::PathBuf {
|
fn test_paired_path() -> std::path::PathBuf {
|
||||||
std::env::temp_dir().join(format!("punktfunk-paired-test-{}.json", std::process::id()))
|
std::env::temp_dir().join(format!("punktfunk-paired-test-{}.json", std::process::id()))
|
||||||
}
|
}
|
||||||
|
|||||||
+327
-1
@@ -25,7 +25,11 @@
|
|||||||
// TTL of a v2 envelope; `punktfunk_connection_next_rumble` is unchanged and drops it). Additive —
|
// TTL of a v2 envelope; `punktfunk_connection_next_rumble` is unchanged and drops it). Additive —
|
||||||
// the wire is backward-compatible (the envelope is a length-tolerant tail on 0xCA), so
|
// the wire is backward-compatible (the envelope is a length-tolerant tail on 0xCA), so
|
||||||
// [`WIRE_VERSION`] is unchanged.
|
// [`WIRE_VERSION`] is unchanged.
|
||||||
#define ABI_VERSION 5
|
// v6: added the shared-clipboard client surface — `punktfunk_connection_host_caps` and
|
||||||
|
// `punktfunk_connection_clipboard_{control,offer,fetch,serve,cancel}` +
|
||||||
|
// `punktfunk_connection_next_clipboard`. Additive; the wire grows only backward-compatible control
|
||||||
|
// messages (0x40-0x44) and a new `Welcome::host_caps` bit, so [`WIRE_VERSION`] is unchanged.
|
||||||
|
#define ABI_VERSION 6
|
||||||
|
|
||||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||||
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||||
@@ -159,11 +163,60 @@
|
|||||||
// Codec bit: AV1. (Mirrors `quic::CODEC_AV1`.)
|
// Codec bit: AV1. (Mirrors `quic::CODEC_AV1`.)
|
||||||
#define PUNKTFUNK_CODEC_AV1 4
|
#define PUNKTFUNK_CODEC_AV1 4
|
||||||
|
|
||||||
|
// Host-capability bit in [`punktfunk_connection_host_caps`]: the host applies gamepad-state
|
||||||
|
// snapshots (a capable client sends full-state snapshots instead of per-transition events).
|
||||||
|
// (Mirrors `quic::HOST_CAP_GAMEPAD_STATE`.)
|
||||||
|
#define PUNKTFUNK_HOST_CAP_GAMEPAD_STATE 1
|
||||||
|
|
||||||
|
// Host-capability bit in [`punktfunk_connection_host_caps`]: the host supports the shared
|
||||||
|
// clipboard, so a client may offer the toggle. (Mirrors `quic::HOST_CAP_CLIPBOARD`.)
|
||||||
|
#define PUNKTFUNK_HOST_CAP_CLIPBOARD 2
|
||||||
|
|
||||||
// `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble
|
// `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble
|
||||||
// datagram — an old host that sent no self-termination lease. The client then falls back to its
|
// datagram — an old host that sent no self-termination lease. The client then falls back to its
|
||||||
// own staleness heuristic for that update instead of a host-supplied deadline.
|
// own staleness heuristic for that update instead of a host-supplied deadline.
|
||||||
#define PUNKTFUNK_RUMBLE_NO_TTL 4294967295
|
#define PUNKTFUNK_RUMBLE_NO_TTL 4294967295
|
||||||
|
|
||||||
|
// [`PunktfunkClipEvent::kind`] — the host announced clipboard content is available
|
||||||
|
// (`transfer_id` = the offer `seq`; `data`/`len` = a `\n`-separated `"<mime>\t<size_hint>"`
|
||||||
|
// format list). Fetch it lazily (only on a local paste) via
|
||||||
|
// [`punktfunk_connection_clipboard_fetch`].
|
||||||
|
#define PUNKTFUNK_CLIP_REMOTE_OFFER 1
|
||||||
|
|
||||||
|
// [`PunktfunkClipEvent::kind`] — host ack / policy / backend update (`enabled`/`policy`/`reason`
|
||||||
|
// valid). Reflect it in the toggle UI.
|
||||||
|
#define PUNKTFUNK_CLIP_STATE 2
|
||||||
|
|
||||||
|
// [`PunktfunkClipEvent::kind`] — the host is pasting our offered data: answer with
|
||||||
|
// [`punktfunk_connection_clipboard_serve`] (`transfer_id` = `req_id`; `seq`/`file_index` valid;
|
||||||
|
// `data`/`len` = the requested MIME).
|
||||||
|
#define PUNKTFUNK_CLIP_FETCH_REQUEST 3
|
||||||
|
|
||||||
|
// [`PunktfunkClipEvent::kind`] — bytes for a fetch we started (`transfer_id` = `xfer_id`;
|
||||||
|
// `data`/`len` = the payload, borrowed until the next `next_clipboard`; `last` = final chunk).
|
||||||
|
#define PUNKTFUNK_CLIP_DATA 4
|
||||||
|
|
||||||
|
// [`PunktfunkClipEvent::kind`] — a transfer was cancelled (`transfer_id` = the id).
|
||||||
|
#define PUNKTFUNK_CLIP_CANCELLED 5
|
||||||
|
|
||||||
|
// [`PunktfunkClipEvent::kind`] — a transfer failed (`transfer_id` = the id; `status` = a
|
||||||
|
// `PunktfunkStatus` code).
|
||||||
|
#define PUNKTFUNK_CLIP_ERROR 6
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Per-fetch requester-side size cap (bytes). A holder that streams more than this is treated as a
|
||||||
|
// cap breach and the fetch fails rather than buffering unboundedly (§7). Phase 0 uses one fixed
|
||||||
|
// value; a future host-policy `PUNKTFUNK_CLIP_MAX_MB` tightens it per session.
|
||||||
|
#define CLIP_FETCH_CAP (64 << 20)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Inbound-serve `req_id`s carry this high bit so they never collide with the client-assigned
|
||||||
|
// outbound-fetch `xfer_id`s (which count up from 1). A single [`ClipCommand::Cancel`] `id` can
|
||||||
|
// then be routed to the right table.
|
||||||
|
#define INBOUND_REQ_FLAG 2147483648
|
||||||
|
#endif
|
||||||
|
|
||||||
// 16-byte AEAD authentication tag appended by GCM.
|
// 16-byte AEAD authentication tag appended by GCM.
|
||||||
#define TAG_LEN 16
|
#define TAG_LEN 16
|
||||||
|
|
||||||
@@ -434,6 +487,16 @@
|
|||||||
#define HOST_CAP_GAMEPAD_STATE 1
|
#define HOST_CAP_GAMEPAD_STATE 1
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`Welcome::host_caps`] bit: the host has a shared-clipboard service (a working OS backend)
|
||||||
|
// **and** its operator policy does not hard-disable it, so the client may offer the clipboard
|
||||||
|
// toggle. Absent (an older host, or `PUNKTFUNK_CLIPBOARD` off) ⇒ the client greys the toggle
|
||||||
|
// out. Purely additive: nothing clipboard-related happens until a [`ClipControl`]`{ enabled:
|
||||||
|
// true }` crosses (see `design/clipboard-and-file-transfer.md` §3.1). Packs into the existing
|
||||||
|
// trailing `host_caps` byte — no wire-layout change.
|
||||||
|
#define HOST_CAP_CLIPBOARD 2
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||||
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||||
@@ -530,6 +593,119 @@
|
|||||||
#define MSG_CLOCK_ECHO 49
|
#define MSG_CLOCK_ECHO 49
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`ClipControl`] (client → host): enable/disable the shared clipboard for this
|
||||||
|
// session. Idempotent; opt-in is enforced here, not just in UI.
|
||||||
|
#define MSG_CLIP_CONTROL 64
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`ClipState`] (host → client): ack + unsolicited policy/backend updates.
|
||||||
|
#define MSG_CLIP_STATE 65
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`ClipOffer`] (symmetric): the lazy announcement — format list only, no bytes.
|
||||||
|
#define MSG_CLIP_OFFER 66
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`ClipFetch`] (requester → holder, **fetch stream only**): pull one format of the
|
||||||
|
// current offer.
|
||||||
|
#define MSG_CLIP_FETCH 67
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`ClipFetchHdr`] (holder → requester, **fetch stream only**): the fetch response
|
||||||
|
// header that precedes the data chunks.
|
||||||
|
#define MSG_CLIP_FETCH_HDR 68
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`ClipControl::flags`] bit: the client permits file kinds to be offered/fetched this session.
|
||||||
|
// Absent ⇒ files are filtered out of offers in both directions (text/rich/image only).
|
||||||
|
#define CLIP_FLAG_FILES 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`ClipState::policy`] bit: the host permits non-file formats (text/RTF/HTML/image). Always set
|
||||||
|
// while enabled unless a future direction limit clears it.
|
||||||
|
#define CLIP_POLICY_TEXT 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`ClipState::policy`] bit: the host permits file formats. Cleared by the operator `no-files`
|
||||||
|
// / `text-only` policy so the client can grey out "Include files".
|
||||||
|
#define CLIP_POLICY_FILES 2
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`ClipState::reason`]: normal ack, nothing exceptional.
|
||||||
|
#define CLIP_REASON_OK 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`ClipState::reason`]: this session type has no working clipboard backend (e.g. a gamescope
|
||||||
|
// session with no data-control global) — the client shows "not supported in this session type".
|
||||||
|
#define CLIP_REASON_BACKEND_UNAVAILABLE 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`ClipState::reason`]: another client took over the single per-desktop clipboard binding; this
|
||||||
|
// one was disabled (last `ClipControl{enabled}` wins).
|
||||||
|
#define CLIP_REASON_TAKEN_OVER 2
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`ClipState::reason`]: the host operator policy (`PUNKTFUNK_CLIPBOARD=off`) disables clipboard.
|
||||||
|
#define CLIP_REASON_POLICY_DISABLED 3
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`ClipState::reason`]: enabled, but the host policy forbids file transfer (`no-files` /
|
||||||
|
// `text-only`) — surfaced so the client greys "Include files" with a footnote.
|
||||||
|
#define CLIP_REASON_NO_FILES 4
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`ClipFetchHdr::status`]: the requested format is being served; data chunks follow until FIN.
|
||||||
|
#define CLIP_FETCH_OK 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`ClipFetchHdr::status`]: the fetch named a `seq` that is no longer the holder's current offer;
|
||||||
|
// the requester degrades the paste to "nothing inserted" rather than wrong data. No chunks follow.
|
||||||
|
#define CLIP_FETCH_STALE 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`ClipFetchHdr::status`]: the format/index is not available (no backend, or it vanished). No
|
||||||
|
// chunks follow.
|
||||||
|
#define CLIP_FETCH_UNAVAILABLE 2
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`ClipFetchHdr::status`]: policy/cap denies this fetch (e.g. a file fetch under `no-files`). No
|
||||||
|
// chunks follow.
|
||||||
|
#define CLIP_FETCH_DENIED 3
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Maximum number of [`ClipKind`] entries in one [`ClipOffer`] (resource cap, §7).
|
||||||
|
#define CLIP_MAX_KINDS 16
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Maximum length in bytes of a [`ClipKind::mime`] string (resource cap, §7).
|
||||||
|
#define CLIP_MAX_MIME 128
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`ClipFetch::file_index`] sentinel meaning "not a file fetch" (a whole non-file format, or the
|
||||||
|
// file *manifest* itself). Real file fetches use `0..n`.
|
||||||
|
#define CLIP_FILE_INDEX_NONE UINT32_MAX
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Type byte of [`PairRequest`].
|
// Type byte of [`PairRequest`].
|
||||||
#define MSG_PAIR_REQUEST 16
|
#define MSG_PAIR_REQUEST 16
|
||||||
@@ -586,6 +762,25 @@
|
|||||||
#define ColorInfo_MC_BT2020_NCL 9
|
#define ColorInfo_MC_BT2020_NCL 9
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Stream-kind byte: a clipboard fetch (request/response of one format). Future stream kinds
|
||||||
|
// (e.g. a bulk file-content push) mux under the same [`STREAM_MAGIC`] with a different byte.
|
||||||
|
#define CLIP_STREAM_KIND_FETCH 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// QUIC application error code used to `reset`/`stop` a clipboard fetch stream on cancel — sync
|
||||||
|
// disabled mid-transfer, paste timed out, size cap exceeded, teardown. Distinct from the
|
||||||
|
// connection close codes ([`super::QUIT_CLOSE_CODE`] `0x51` / [`super::APP_EXITED_CLOSE_CODE`]
|
||||||
|
// `0x52`) and the connection reject code `0x42`.
|
||||||
|
#define CLIP_CANCELLED_CODE 96
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Chunk size for streaming fetch data (64 KiB writes — matches the control-frame bound).
|
||||||
|
#define CLIP_CHUNK (64 * 1024)
|
||||||
|
#endif
|
||||||
|
|
||||||
// Stable C ABI status codes. `Ok` is 0; all errors are negative so callers can
|
// Stable C ABI status codes. `Ok` is 0; all errors are negative so callers can
|
||||||
// test `rc < 0`. Do not renumber existing variants — only append.
|
// test `rc < 0`. Do not renumber existing variants — only append.
|
||||||
enum PunktfunkStatus
|
enum PunktfunkStatus
|
||||||
@@ -915,6 +1110,47 @@ typedef struct {
|
|||||||
} PunktfunkRichInputEx;
|
} PunktfunkRichInputEx;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// One advertised clipboard format passed to [`punktfunk_connection_clipboard_offer`].
|
||||||
|
typedef struct {
|
||||||
|
// NUL-terminated UTF-8 wire MIME (e.g. `text/plain;charset=utf-8`). ≤ 128 bytes on the wire.
|
||||||
|
const char *mime;
|
||||||
|
// Best-effort size in bytes; `0` = unknown.
|
||||||
|
uint64_t size_hint;
|
||||||
|
} PunktfunkClipKind;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// A shared-clipboard event, filled by [`punktfunk_connection_next_clipboard`]. A flat tagged
|
||||||
|
// struct (like `PunktfunkHidOutput`): read the fields named in the `kind`'s doc; the rest are 0.
|
||||||
|
typedef struct {
|
||||||
|
// One of `PUNKTFUNK_CLIP_*`.
|
||||||
|
uint8_t kind;
|
||||||
|
// `State`: 1 = enabled, 0 = disabled.
|
||||||
|
uint8_t enabled;
|
||||||
|
// `State`: bitfield of `quic::CLIP_POLICY_*` — what the host currently permits.
|
||||||
|
uint8_t policy;
|
||||||
|
// `State`: one of `quic::CLIP_REASON_*`.
|
||||||
|
uint8_t reason;
|
||||||
|
// `Data`: 1 = final chunk of this transfer.
|
||||||
|
uint8_t last;
|
||||||
|
// Per-transfer id: the offer `seq` (RemoteOffer), the `req_id` (FetchRequest), or the
|
||||||
|
// `xfer_id` (Data/Cancelled/Error).
|
||||||
|
uint32_t transfer_id;
|
||||||
|
// `FetchRequest`: the offer `seq` the request is against.
|
||||||
|
uint32_t seq;
|
||||||
|
// `FetchRequest`: file index, or `quic::CLIP_FILE_INDEX_NONE`.
|
||||||
|
uint32_t file_index;
|
||||||
|
// `Error`: a `PunktfunkStatus` code (negative); 0 otherwise.
|
||||||
|
int32_t status;
|
||||||
|
// RemoteOffer/FetchRequest/Data: a pointer into a per-connection slot, valid until the next
|
||||||
|
// `next_clipboard` call; NULL for the other kinds.
|
||||||
|
const uint8_t *data;
|
||||||
|
// Byte length of `data` (0 when `data` is NULL).
|
||||||
|
uintptr_t len;
|
||||||
|
} PunktfunkClipEvent;
|
||||||
|
#endif
|
||||||
|
|
||||||
// A speed-test measurement, filled by [`punktfunk_connection_probe_result`]. `done` is 0 until
|
// A speed-test measurement, filled by [`punktfunk_connection_probe_result`]. `done` is 0 until
|
||||||
// the host's end-of-burst report lands, then 1 (the numbers are final). `throughput_kbps` is the
|
// the host's end-of-burst report lands, then 1 (the numbers are final). `throughput_kbps` is the
|
||||||
// delivered wire throughput to drive a bitrate choice from; `loss_pct` is the link loss and
|
// delivered wire throughput to drive a bitrate choice from; `loss_pct` is the link loss and
|
||||||
@@ -1554,6 +1790,96 @@ PunktfunkStatus punktfunk_connection_mode(const PunktfunkConnection *c,
|
|||||||
PunktfunkStatus punktfunk_connection_gamepad(const PunktfunkConnection *c, uint32_t *gamepad);
|
PunktfunkStatus punktfunk_connection_gamepad(const PunktfunkConnection *c, uint32_t *gamepad);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// The host capability bitfield the session's `Welcome` carried — a bitfield of
|
||||||
|
// `PUNKTFUNK_HOST_CAP_GAMEPAD_STATE` / `PUNKTFUNK_HOST_CAP_CLIPBOARD`. A client tests
|
||||||
|
// `caps & PUNKTFUNK_HOST_CAP_CLIPBOARD` to decide whether to offer the shared-clipboard toggle.
|
||||||
|
// Safe any time after connect.
|
||||||
|
//
|
||||||
|
// # Safety
|
||||||
|
// `c` is a valid connection handle; `caps` is writable (NULL is skipped).
|
||||||
|
PunktfunkStatus punktfunk_connection_host_caps(const PunktfunkConnection *c, uint8_t *caps);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Enable or disable the shared clipboard for this session (`design` §3.1). Opt-in: nothing is
|
||||||
|
// announced or served until this is called with `enabled = true`. `flags` carries
|
||||||
|
// `quic::CLIP_FLAG_FILES` (allow file transfer). The host replies with a `State` event.
|
||||||
|
//
|
||||||
|
// # Safety
|
||||||
|
// `c` is a valid connection handle.
|
||||||
|
PunktfunkStatus punktfunk_connection_clipboard_control(const PunktfunkConnection *c,
|
||||||
|
bool enabled,
|
||||||
|
uint8_t flags);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Announce that the local clipboard changed — the lazy format-list offer. `seq` is a monotonic
|
||||||
|
// per-sender counter (newest wins); `kinds`/`n` is the advertised formats (≤ 16). The bytes cross
|
||||||
|
// only if the host later fetches.
|
||||||
|
//
|
||||||
|
// # Safety
|
||||||
|
// `c` is a valid connection handle; `kinds` points to `n` `PunktfunkClipKind`s (NULL allowed only
|
||||||
|
// when `n == 0`), each `mime` a valid NUL-terminated UTF-8 string.
|
||||||
|
PunktfunkStatus punktfunk_connection_clipboard_offer(const PunktfunkConnection *c,
|
||||||
|
uint32_t seq,
|
||||||
|
const PunktfunkClipKind *kinds,
|
||||||
|
uintptr_t n);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Start pulling one format (`mime`) of the host's current offer `seq` — lazily, on a local paste.
|
||||||
|
// `file_index` selects a file for a file transfer, or `quic::CLIP_FILE_INDEX_NONE` for a non-file
|
||||||
|
// format. Writes the transfer id (echoed on the resulting `Data`/`Error`/`Cancelled` event) to
|
||||||
|
// `xfer_id_out`.
|
||||||
|
//
|
||||||
|
// # Safety
|
||||||
|
// `c` is a valid connection handle; `mime` is a valid NUL-terminated UTF-8 string; `xfer_id_out`
|
||||||
|
// is writable (NULL is skipped).
|
||||||
|
PunktfunkStatus punktfunk_connection_clipboard_fetch(const PunktfunkConnection *c,
|
||||||
|
uint32_t seq,
|
||||||
|
const char *mime,
|
||||||
|
uint32_t file_index,
|
||||||
|
uint32_t *xfer_id_out);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Provide bytes answering a `FetchRequest` event (the host is pasting our offered data). Call
|
||||||
|
// repeatedly to stream a large payload; `last = true` completes it. `data` may be NULL only when
|
||||||
|
// `len == 0` (e.g. a final empty chunk). `punktfunk_connection_clipboard_cancel(req_id)` aborts.
|
||||||
|
//
|
||||||
|
// # Safety
|
||||||
|
// `c` is a valid connection handle; `data` points to `len` bytes (NULL allowed only when
|
||||||
|
// `len == 0`).
|
||||||
|
PunktfunkStatus punktfunk_connection_clipboard_serve(const PunktfunkConnection *c,
|
||||||
|
uint32_t req_id,
|
||||||
|
const uint8_t *data,
|
||||||
|
uintptr_t len,
|
||||||
|
bool last);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Cancel a clipboard transfer by id — either an outbound fetch (`xfer_id` from
|
||||||
|
// [`punktfunk_connection_clipboard_fetch`]) or an inbound serve (`req_id` from a `FetchRequest`).
|
||||||
|
//
|
||||||
|
// # Safety
|
||||||
|
// `c` is a valid connection handle.
|
||||||
|
PunktfunkStatus punktfunk_connection_clipboard_cancel(const PunktfunkConnection *c, uint32_t id);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Pull the next shared-clipboard event into `*out`. [`PunktfunkStatus::NoFrame`] on timeout,
|
||||||
|
// [`PunktfunkStatus::Closed`] once the session ended. A native client drains this on its own
|
||||||
|
// thread and drives the OS pasteboard from it. The `data`/`len` pointer (when non-NULL) borrows a
|
||||||
|
// per-connection buffer valid until the next `next_clipboard` call on this handle.
|
||||||
|
//
|
||||||
|
// # Safety
|
||||||
|
// `c` is a valid connection handle; `out` is writable for one `PunktfunkClipEvent`.
|
||||||
|
PunktfunkStatus punktfunk_connection_next_clipboard(PunktfunkConnection *c,
|
||||||
|
PunktfunkClipEvent *out,
|
||||||
|
uint32_t timeout_ms);
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// The compositor backend the host actually resolved for this session (one of the
|
// The compositor backend the host actually resolved for this session (one of the
|
||||||
// `PUNKTFUNK_COMPOSITOR_*` values; the `Welcome`'s echo of the [`punktfunk_connect_ex`]
|
// `PUNKTFUNK_COMPOSITOR_*` values; the `Welcome`'s echo of the [`punktfunk_connect_ex`]
|
||||||
|
|||||||
Reference in New Issue
Block a user