feat(clipboard): Linux + Windows host clipboard backends as the pf-clipboard crate (Phase 1 host + Phase 3)
ci / web (pull_request) Successful in 1m9s
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Has been skipped
ci / docs-site (pull_request) Successful in 1m30s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 5m16s
ci / bench (pull_request) Successful in 6m8s
ci / rust (pull_request) Failing after 7m6s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 6m33s
android / android (pull_request) Successful in 12m39s
ci / web (pull_request) Successful in 1m9s
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Has been skipped
ci / docs-site (pull_request) Successful in 1m30s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 5m16s
ci / bench (pull_request) Successful in 6m8s
ci / rust (pull_request) Failing after 7m6s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 6m33s
android / android (pull_request) Successful in 12m39s
The host half of the shared clipboard (design/clipboard-and-file-transfer.md §4),
ported from feat/shared-clipboard (6bd8c18b) into the post-W6 crate shape: the
backends land as a pf-clipboard subsystem crate (the pf-inject/pf-capture
pattern) instead of growing punktfunk-host back out, and the ~340-line
punktfunk1.rs integration is re-implemented against the native.rs/control.rs
split that replaced it.
pf-clipboard:
- host::wayland — ext-data-control-v1 (KWin / wlroots / Sway / Hyprland).
- host::mutter — GNOME via Mutter's *direct* org.gnome.Mutter.RemoteDesktop
clipboard (no data-control at any GNOME version; the xdg portal needs an
interactive grant a headless host can't answer).
- host::windows + host::winfmt — Win32 clipboard on a hidden message-loop
window: WM_CLIPBOARDUPDATE listener + OLE delayed rendering (WM_RENDERFORMAT)
for text / CF_HTML / RTF / PNG.
- host::session — the backend-agnostic coordinator bridging HostClipboard to
the QUIC clipboard plane (offers, fetch accept-loop, remote offers, pastes).
- A portable facade (policy / enabled / cap_advertised / ClipCoordCmd / start /
spawn_decline_loop) so the orchestrator compiles cfg-free on every platform;
ClipCoordCmd moves into the crate (it was host-owned before).
punktfunk-host glue:
- handshake.rs advertises HOST_CAP_CLIPBOARD via pf_clipboard::cap_advertised.
- serve_session starts the coordinator (gated on a real compositor — the
synthetic source stays out of the session clipboard) and spawns the
CLIP_FETCH_UNAVAILABLE decline loop when the policy is on but no backend bound.
- control.rs gains the ClipControl/ClipOffer arms + the host-offer forward
branch, and the e2e session test (cap advertise → ClipState ack with
BACKEND_UNAVAILABLE → fetch decline) rides in native.rs's tests.
Still opt-in default OFF (PUNKTFUNK_CLIPBOARD). Remaining: the macOS client
(design §5) — then this becomes user-visible.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 the host's native control loop
|
||||
//! 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::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;
|
||||
}
|
||||
Reference in New Issue
Block a user