feat(clipboard): Linux + Windows host clipboard backends (Phase 1 host + Phase 3)

Host-side real-session-clipboard integration, reached through the portable
ClipCoordCmd seam so punktfunk1.rs stays platform-agnostic (design
clipboard-and-file-transfer.md §4). Advertised + started only when the operator
policy (PUNKTFUNK_CLIPBOARD, default off) allows it and the session mirrors a
live compositor.

clipboard/ module (gated cfg(any(linux, windows))):
- mod.rs — the unified HostClipboard enum + §3.5 wire<->platform normalization
  and the shared ClipEvent / PasteResponder.
- wayland.rs — ext-data-control-v1 (KWin / wlroots / Sway / Hyprland), the
  preferred Linux backend. On-glass verified on Hyprland.
- mutter.rs — GNOME. Mutter ships no wl/ext data-control at any version, so this
  talks to its DIRECT org.gnome.Mutter.RemoteDesktop clipboard (the xdg portal
  needs an interactive grant a headless host can't answer). On-glass on GNOME.
- windows.rs — the Win32 clipboard on a dedicated message-loop window:
  AddClipboardFormatListener -> WM_CLIPBOARDUPDATE for host copies, OLE delayed
  rendering (WM_RENDERFORMAT) for host pastes, per-window state via GWLP_USERDATA.
- winfmt.rs — pure Win32<->wire byte conversions (CF_HTML offset math, UTF-16
  text, RTF NUL trim), unit-tested on any host (cfg(any(windows, test))).
- session.rs — the backend-agnostic per-session coordinator owning all four data
  paths (host copy->client, client fetch, client copy->host, host paste).

Cargo.toml: windows crate gains Win32_System_DataExchange + Win32_System_Ole.

Verified: Linux builds + clippy + 282 host tests + fmt clean; the full host crate
compiles native on x86_64-pc-windows-msvc (checked on the RTX box).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 19:08:36 +02:00
parent af3a7d8cd5
commit 6bd8c18b4d
9 changed files with 3060 additions and 9 deletions
@@ -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");
}
}