From 6bd8c18b4df73a242965bc4eb270aff55c3ce0a4 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 12 Jul 2026 19:08:36 +0200 Subject: [PATCH] feat(clipboard): Linux + Windows host clipboard backends (Phase 1 host + Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/punktfunk-host/Cargo.toml | 15 +- crates/punktfunk-host/src/clipboard/mod.rs | 409 +++++++++++ crates/punktfunk-host/src/clipboard/mutter.rs | 524 ++++++++++++++ .../punktfunk-host/src/clipboard/session.rs | 254 +++++++ .../punktfunk-host/src/clipboard/wayland.rs | 599 ++++++++++++++++ .../punktfunk-host/src/clipboard/windows.rs | 664 ++++++++++++++++++ crates/punktfunk-host/src/clipboard/winfmt.rs | 257 +++++++ crates/punktfunk-host/src/main.rs | 5 + crates/punktfunk-host/src/punktfunk1.rs | 342 ++++++++- 9 files changed, 3060 insertions(+), 9 deletions(-) create mode 100644 crates/punktfunk-host/src/clipboard/mod.rs create mode 100644 crates/punktfunk-host/src/clipboard/mutter.rs create mode 100644 crates/punktfunk-host/src/clipboard/session.rs create mode 100644 crates/punktfunk-host/src/clipboard/wayland.rs create mode 100644 crates/punktfunk-host/src/clipboard/windows.rs create mode 100644 crates/punktfunk-host/src/clipboard/winfmt.rs diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 0bb001b1..b1e8b12b 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -88,9 +88,11 @@ openh264 = "0.9" [target.'cfg(target_os = "linux")'.dependencies] # `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. -# `open_pipe_wire_remote` is unconditional, so ashpd's own `pipewire` feature is not -# needed — we drive PipeWire with the `pipewire` crate below. +# portal we use for libei input on KWin/GNOME; `tokio` is the default runtime. `open_pipe_wire_remote` +# is unconditional, so ashpd's own `pipewire` feature is not needed — we drive PipeWire with the +# `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"] } ffmpeg-next = "8" libc = "0.2" @@ -115,7 +117,7 @@ wayland-protocols-wlr = { 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 # 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 # 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. @@ -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 # crate / no C length-disassembler dep; a 12-byte absolute-jmp prologue patch suffices. "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 # uses; FP16/format-list, robust to overlay/format churn) requires the process to be DPI-aware. "Win32_UI_HiDpi", diff --git a/crates/punktfunk-host/src/clipboard/mod.rs b/crates/punktfunk-host/src/clipboard/mod.rs new file mode 100644 index 00000000..87f40649 --- /dev/null +++ b/crates/punktfunk-host/src/clipboard/mod.rs @@ -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 }, + /// 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>), + /// 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>), +} + +impl PasteResponder { + /// Deliver the fetched bytes (empty on a failed fetch → an empty paste, never a hang). + pub async fn respond(self, bytes: Vec) { + 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, + )> { + #[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 { + 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, wire_mime: &str) -> anyhow::Result> { + 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 { + 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 { + let mut out: Vec = 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); + } +} diff --git a/crates/punktfunk-host/src/clipboard/mutter.rs b/crates/punktfunk-host/src/clipboard/mutter.rs new file mode 100644 index 00000000..bc3143d8 --- /dev/null +++ b/crates/punktfunk-host/src/clipboard/mutter.rs @@ -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, + /// 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>>, +} + +enum Cmd { + SetOffer(Vec), + ClearOffer, + ReadCurrent { + wire: String, + reply: oneshot::Sender>>, + }, +} + +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)> { + 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 { + 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> { + 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, + event_tx: mpsc::UnboundedSender, + current_raw: Arc>>, +) { + 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,)>() 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> { + 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) { + 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) -> 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> { + 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` (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, key: &str) -> Option { + 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, key: &str) -> Vec { + 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 { + 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 { + 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, + timeout: Duration, + ) -> Option> { + 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"); + }); + } +} diff --git a/crates/punktfunk-host/src/clipboard/session.rs b/crates/punktfunk-host/src/clipboard/session.rs new file mode 100644 index 00000000..c5854bfc --- /dev/null +++ b/crates/punktfunk-host/src/clipboard/session.rs @@ -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, + cmd_rx: UnboundedReceiver, + offer_tx: UnboundedSender, +) -> 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, + mut clip_rx: UnboundedReceiver, + clip_enabled: Arc, + mut cmd_rx: UnboundedReceiver, + offer_tx: UnboundedSender, +) { + // 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) -> 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, + host_seq: Arc, + 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; +} diff --git a/crates/punktfunk-host/src/clipboard/wayland.rs b/crates/punktfunk-host/src/clipboard/wayland.rs new file mode 100644 index 00000000..4e53e366 --- /dev/null +++ b/crates/punktfunk-host/src/clipboard/wayland.rs @@ -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, +} + +/// Dispatch-thread state. Also collects the manager + seat during the bind roundtrip. +struct State { + mgr: Option, + seat: Option, + /// Offers accumulating their MIME list before the `selection` event promotes one. + pending: HashMap>, + current: Arc>>, + /// 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, + tx: tokio::sync::mpsc::UnboundedSender, +} + +impl Dispatch for State { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + 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 for State { + fn event( + _: &mut Self, + _: &ExtDataControlManagerV1, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} +impl Dispatch for State { + fn event( + _: &mut Self, + _: &WlSeat, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _dev: &ExtDataControlDeviceV1, + event: ext_data_control_device_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + 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::>(); + *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 for State { + fn event( + state: &mut Self, + offer: &ExtDataControlOfferV1, + event: ext_data_control_offer_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + 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 for State { + fn event( + state: &mut Self, + _src: &ExtDataControlSourceV1, + event: ext_data_control_source_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + 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, + current: Arc>>, + suppress_echoes: Arc, + active_source: Mutex>, + stop: Arc, + thread: Option>, +} + +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, + )> { + 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 { + 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> { + 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, + mut state: State, + stop: Arc, +) { + 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, + timeout: Duration, + mut pred: impl FnMut(&ClipEvent) -> bool, + ) -> Option { + 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, + )> { + 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"); + } +} diff --git a/crates/punktfunk-host/src/clipboard/windows.rs b/crates/punktfunk-host/src/clipboard/windows.rs new file mode 100644 index 00000000..503b53fc --- /dev/null +++ b/crates/punktfunk-host/src/clipboard/windows.rs @@ -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; + +/// 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), + /// 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>>, + }, + /// 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`. +struct WinClip { + /// Backend → coordinator events. + clip_tx: ClipTx, + /// The current host selection's wire MIMEs, shared with the [`WindowsClipboard`] handle. + current_wire: Arc>>, + /// Coordinator → backend commands, drained on `WM_APP_CMD`. + cmd_rx: RefCell>, + /// Clipboard format ids we currently promise via delayed rendering (for `WM_RENDERFORMAT`). + offered: RefCell>, + 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 { + // 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> { + 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::>(); + 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 { + 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 { + 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, + /// 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>>, + join: Option>, +} + +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, + )> { + let (clip_tx, clip_rx) = tokio::sync::mpsc::unbounded_channel::(); + let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::(); + 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::>(); + 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 { + 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> { + 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 { + // 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 { + // 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 { + 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 { + 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 { + // 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, + current_wire: Arc>>, + fmt_html: u32, + fmt_rtf: u32, + fmt_png: u32, + ready_tx: tokio::sync::oneshot::Sender>, +) { + 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 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) }, + } +} diff --git a/crates/punktfunk-host/src/clipboard/winfmt.rs b/crates/punktfunk-host/src/clipboard/winfmt.rs new file mode 100644 index 00000000..af78c2f2 --- /dev/null +++ b/crates/punktfunk-host/src/clipboard/winfmt.rs @@ -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 { + // 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 = 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 { + 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 +// ``/`` 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 = "\r\n"; +const CF_HTML_SUFFIX: &str = "\r\n"; + +/// 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 { + 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 { + 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 (``) or fragment text. +fn read_header_offset(raw: &[u8], label: &[u8]) -> Option { + 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 { + 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 { + 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 = "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", + "bold & ital", + "café ☕ x", + "", + ] { + 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 = ""; + 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 + "".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}" + ); + 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"

no markers

".to_vec(); + assert_eq!(html_from_cf(&b), b"

no markers

"); + b.push(0); + assert_eq!(html_from_cf(&b), b"

no markers

"); + } + + #[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"); + } +} diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 65255dd5..f2be293d 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -20,6 +20,11 @@ mod audio; 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 discovery; mod wol; diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index c7e42932..5f93c856 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -32,9 +32,10 @@ use punktfunk_core::config::{ use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF}; use punktfunk_core::quic::{ - endpoint, io, BitrateChanged, ClockEcho, ClockProbe, ColorInfo, Hello, LossReport, - PairChallenge, PairProof, PairRequest, PairResult, ProbeRequest, ProbeResult, Reconfigure, - Reconfigured, RequestKeyframe, RfiRequest, SetBitrate, Start, Welcome, + endpoint, io, BitrateChanged, ClipControl, ClipFetchHdr, ClipOffer, ClipState, ClockEcho, + ClockProbe, ColorInfo, Hello, LossReport, PairChallenge, PairProof, PairRequest, PairResult, + ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, RfiRequest, SetBitrate, + Start, Welcome, }; use punktfunk_core::transport::UdpTransport; use punktfunk_core::Session; @@ -476,6 +477,86 @@ fn fec_static_override() -> Option { .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 { + 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 }, +} + +/// 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, + /// Host-copy announcements from the coordinator → control loop → client. + offer_rx: tokio::sync::mpsc::UnboundedReceiver, +} + +/// 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, + 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 /// 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`]. @@ -1069,8 +1150,19 @@ async fn serve_session( // assuming HEVC. codec: codec_bit, // This host applies sequence-gated gamepad-state snapshots (InputKind::GamepadState), - // so capable clients send those instead of the loss-fragile per-transition events. - host_caps: punktfunk_core::quic::HOST_CAP_GAMEPAD_STATE, + // so capable clients send those instead of the loss-fragile per-transition events. The + // 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?; @@ -1145,8 +1237,26 @@ async fn serve_session( let adaptive_fec = fec_static_override().is_none(); let fec_target = Arc::new(AtomicU8::new(welcome.fec.fec_percent)); 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 { 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 // 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). @@ -1285,6 +1395,44 @@ async fn serve_session( if io::write_msg(&mut ctrl_send, &echo.encode()).await.is_err() { 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 { tracing::warn!("unknown control message — ignoring"); } @@ -1295,6 +1443,21 @@ async fn serve_session( 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() => { // 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 @@ -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. let stop = Arc::new(AtomicBool::new(false)); // 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(); } + /// 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 { + 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 { std::env::temp_dir().join(format!("punktfunk-paired-test-{}.json", std::process::id())) }