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
+409
View File
@@ -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);
}
}