feat(clipboard): Linux + Windows host clipboard backends as the pf-clipboard crate (Phase 1 host + Phase 3)
ci / web (pull_request) Successful in 1m9s
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Has been skipped
ci / docs-site (pull_request) Successful in 1m30s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 5m16s
ci / bench (pull_request) Successful in 6m8s
ci / rust (pull_request) Failing after 7m6s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 6m33s
android / android (pull_request) Successful in 12m39s

The host half of the shared clipboard (design/clipboard-and-file-transfer.md §4),
ported from feat/shared-clipboard (6bd8c18b) into the post-W6 crate shape: the
backends land as a pf-clipboard subsystem crate (the pf-inject/pf-capture
pattern) instead of growing punktfunk-host back out, and the ~340-line
punktfunk1.rs integration is re-implemented against the native.rs/control.rs
split that replaced it.

pf-clipboard:
- host::wayland — ext-data-control-v1 (KWin / wlroots / Sway / Hyprland).
- host::mutter — GNOME via Mutter's *direct* org.gnome.Mutter.RemoteDesktop
  clipboard (no data-control at any GNOME version; the xdg portal needs an
  interactive grant a headless host can't answer).
- host::windows + host::winfmt — Win32 clipboard on a hidden message-loop
  window: WM_CLIPBOARDUPDATE listener + OLE delayed rendering (WM_RENDERFORMAT)
  for text / CF_HTML / RTF / PNG.
- host::session — the backend-agnostic coordinator bridging HostClipboard to
  the QUIC clipboard plane (offers, fetch accept-loop, remote offers, pastes).
- A portable facade (policy / enabled / cap_advertised / ClipCoordCmd / start /
  spawn_decline_loop) so the orchestrator compiles cfg-free on every platform;
  ClipCoordCmd moves into the crate (it was host-owned before).

punktfunk-host glue:
- handshake.rs advertises HOST_CAP_CLIPBOARD via pf_clipboard::cap_advertised.
- serve_session starts the coordinator (gated on a real compositor — the
  synthetic source stays out of the session clipboard) and spawns the
  CLIP_FETCH_UNAVAILABLE decline loop when the policy is on but no backend bound.
- control.rs gains the ClipControl/ClipOffer arms + the host-offer forward
  branch, and the e2e session test (cap advertise → ClipState ack with
  BACKEND_UNAVAILABLE → fetch decline) rides in native.rs's tests.

Still opt-in default OFF (PUNKTFUNK_CLIPBOARD). Remaining: the macOS client
(design §5) — then this becomes user-visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 13:02:20 +02:00
parent 4ef90d586d
commit 391f8fb9f7
14 changed files with 3165 additions and 4 deletions
Generated
+17
View File
@@ -2778,6 +2778,22 @@ dependencies = [
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
]
[[package]]
name = "pf-clipboard"
version = "0.12.0"
dependencies = [
"anyhow",
"ashpd",
"futures-util",
"punktfunk-core",
"quinn",
"tokio",
"tracing",
"wayland-client",
"wayland-protocols",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "pf-console-ui"
version = "0.12.0"
@@ -3121,6 +3137,7 @@ dependencies = [
"openh264",
"opus",
"parking_lot",
"pf-clipboard",
"pf-driver-proto",
"pipewire",
"punktfunk-core",
+1
View File
@@ -6,6 +6,7 @@ members = [
"crates/punktfunk-host/vendor/usbip-sim",
"crates/punktfunk-tray",
"crates/pf-client-core",
"crates/pf-clipboard",
"crates/pf-presenter",
"crates/pf-console-ui",
"crates/pf-ffvk",
+44
View File
@@ -0,0 +1,44 @@
# Shared clipboard (plan §W6 shape, design/clipboard-and-file-transfer.md §4): the host-side
# session-clipboard backends — `ext-data-control-v1` (KWin/wlroots/Sway/Hyprland) and Mutter's
# *direct* `org.gnome.Mutter.RemoteDesktop.Session` clipboard on Linux, the Win32 clipboard
# (delayed rendering) on Windows — behind one `HostClipboard`, plus the backend-agnostic session
# coordinator bridging it to the QUIC clipboard plane. The wire protocol and the client half live
# in `punktfunk-core`; the orchestrator consumes only the portable facade (policy / `ClipCoordCmd` /
# `start`), so it stays free of platform cfg.
[package]
name = "pf-clipboard"
version = "0.12.0"
edition = "2021"
rust-version.workspace = true
license = "MIT OR Apache-2.0"
description = "punktfunk host shared clipboard: per-OS session-clipboard backends behind one HostClipboard + the QUIC clipboard-plane coordinator."
publish = false
[dependencies]
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
anyhow = "1"
tracing = "0.1"
quinn = "0.11"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
[target.'cfg(target_os = "linux")'.dependencies]
# Mutter's direct RemoteDesktop clipboard is raw D-Bus via `ashpd::zbus` — NOT the xdg
# `org.freedesktop.portal.Clipboard`, which needs an interactive grant a headless host can't
# answer. Reusing ashpd's zbus re-export keeps one zbus version across the workspace.
ashpd = "0.13"
futures-util = "0.3"
wayland-client = "0.31"
# `staging`: `ext_data_control_v1` (the session-clipboard protocol) ships in the staging set.
wayland-protocols = { version = "0.32", features = ["client", "staging"] }
[target.'cfg(target_os = "windows")'.dependencies]
# The Win32 clipboard on a hidden message-loop window: `WM_CLIPBOARDUPDATE` listener + OLE
# delayed rendering (`WM_RENDERFORMAT`) for text / CF_HTML / RTF / PNG.
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_System_DataExchange",
"Win32_System_LibraryLoader",
"Win32_System_Memory",
"Win32_System_Ole",
"Win32_UI_WindowsAndMessaging",
] }
+409
View File
@@ -0,0 +1,409 @@
//! Host-side shared-clipboard backend.
//!
//! The wire protocol and the client half live in `punktfunk-core`
//! (`punktfunk_core::quic` + `punktfunk_core::clipboard`); this module drives the **host's** real
//! session clipboard so it can offer what a host app copied and paste what the remote client
//! offered (`design/clipboard-and-file-transfer.md` §4).
//!
//! Concrete backends, selected at session start ([`HostClipboard::open`]) and presented as one
//! [`HostClipboard`] to the [`session`] coordinator:
//! * [`wayland`] (Linux) — `ext-data-control-v1` (KWin, wlroots / Sway, Hyprland). Preferred when present.
//! * [`mutter`] (Linux) — GNOME. Mutter implements **no** wlr/ext data-control, but its *direct*
//! `org.gnome.Mutter.RemoteDesktop.Session` D-Bus API carries the same clipboard operations (the
//! xdg `org.freedesktop.portal.Clipboard` would need an interactive grant a headless host can't
//! answer — so we skip it and talk to Mutter directly, as the input injector already does).
//! * [`windows`] — the Win32 clipboard: a hidden message-only window watches `WM_CLIPBOARDUPDATE`
//! and serves client content via OLE delayed rendering (`WM_RENDERFORMAT`).
//!
//! The `zwlr-data-control-unstable-v1` fallback (older wlroots/KWin) is a follow-up. The module
//! compiles on Linux and Windows; the [`session`] coordinator is backend-agnostic.
#[cfg(target_os = "linux")]
mod mutter;
#[cfg(target_os = "linux")]
mod wayland;
#[cfg(target_os = "windows")]
mod windows;
/// Pure Win32-clipboard ↔ wire byte conversions (CF_HTML offset math, UTF-16 text, RTF NUL
/// trimming). Free of any Win32 dependency, so it compiles — and its unit tests run — on any host
/// (`cfg(test)`); the Windows backend is the only production consumer.
#[cfg(any(target_os = "windows", test))]
mod winfmt;
pub mod session;
#[cfg(target_os = "linux")]
use std::io::Write as _;
#[cfg(target_os = "linux")]
use std::os::fd::OwnedFd;
use std::sync::Arc;
/// A clipboard event surfaced by a host backend to the [`session`] coordinator. Both the
/// data-control and Mutter backends emit this identical shape.
pub enum ClipEvent {
/// The host selection changed (a host app copied). `mimes` are the **wire** MIMEs offered (empty
/// = the clipboard was cleared). The coordinator forwards these as a `ClipOffer` to the client;
/// bytes cross only if the client later fetches.
Selection { mimes: Vec<String> },
/// A host app is pasting content the client offered. The coordinator fetches the wire-`mime`
/// bytes from the client and hands them to `responder`.
Paste {
mime: String,
responder: PasteResponder,
},
/// The backend ended (compositor / session gone).
Closed,
}
/// How a backend receives the bytes answering a [`ClipEvent::Paste`]. The two host clipboard
/// mechanisms complete a paste differently, so the coordinator stays agnostic by handing bytes to
/// whichever responder the backend attached.
pub enum PasteResponder {
/// data-control: the compositor handed us the destination pipe on the `send` event — write the
/// bytes and close it (EOF completes the paste).
#[cfg(target_os = "linux")]
Fd(OwnedFd),
/// Mutter: hand the bytes back to the backend actor, which owns the `SelectionWrite` fd and the
/// trailing `SelectionWriteDone` call that Mutter's transfer requires.
#[cfg(target_os = "linux")]
Channel(tokio::sync::oneshot::Sender<Vec<u8>>),
/// Windows: hand the bytes to the `WM_RENDERFORMAT` handler blocking the clipboard message-loop
/// thread, which then `SetClipboardData`s them for the pasting app (`std::sync::mpsc`, since that
/// thread waits synchronously — see [`windows`]).
#[cfg(target_os = "windows")]
Sync(std::sync::mpsc::Sender<Vec<u8>>),
}
impl PasteResponder {
/// Deliver the fetched bytes (empty on a failed fetch → an empty paste, never a hang).
pub async fn respond(self, bytes: Vec<u8>) {
match self {
#[cfg(target_os = "linux")]
PasteResponder::Fd(fd) => {
let _ = tokio::task::spawn_blocking(move || fulfill_paste(fd, &bytes)).await;
}
#[cfg(target_os = "linux")]
PasteResponder::Channel(tx) => {
let _ = tx.send(bytes);
}
#[cfg(target_os = "windows")]
PasteResponder::Sync(tx) => {
let _ = tx.send(bytes);
}
}
}
}
/// Write `bytes` into a paste pipe `fd` and close it (EOF signals the reader). Blocking — run off the
/// reactor for large payloads.
#[cfg(target_os = "linux")]
fn fulfill_paste(fd: OwnedFd, bytes: &[u8]) -> std::io::Result<()> {
let mut file = std::fs::File::from(fd);
file.write_all(bytes)?;
Ok(())
}
/// The active host clipboard backend, chosen per session: `ext-data-control`
/// (KWin/wlroots/Hyprland/Sway) or Mutter's direct RemoteDesktop clipboard (GNOME) on Linux, or the
/// Win32 clipboard on Windows. Presented as one type so the [`session`] coordinator is
/// backend-agnostic.
pub enum HostClipboard {
#[cfg(target_os = "linux")]
DataControl(wayland::ClipboardBackend),
#[cfg(target_os = "linux")]
Mutter(mutter::MutterClipboard),
#[cfg(target_os = "windows")]
Windows(windows::WindowsClipboard),
}
impl HostClipboard {
/// Open whichever backend this session supports. Linux tries data-control first
/// (KWin/wlroots/Hyprland/Sway) then Mutter's direct clipboard (GNOME); Windows opens the Win32
/// clipboard. Errors when none is available (gamescope, no live compositor) — the caller then
/// reports `BACKEND_UNAVAILABLE`.
pub async fn open() -> anyhow::Result<(
HostClipboard,
tokio::sync::mpsc::UnboundedReceiver<ClipEvent>,
)> {
#[cfg(target_os = "linux")]
{
// data-control's bind does blocking Wayland roundtrips — keep them off the reactor.
let dc = tokio::task::spawn_blocking(wayland::ClipboardBackend::open)
.await
.map_err(|e| anyhow::anyhow!("data-control open join: {e}"))?;
match dc {
Ok((b, rx)) => return Ok((HostClipboard::DataControl(b), rx)),
Err(e) => tracing::debug!(
error = format!("{e:#}"),
"no ext-data-control — trying Mutter direct clipboard"
),
}
let (m, rx) = mutter::MutterClipboard::open().await.map_err(|e| {
e.context("no clipboard backend (neither ext-data-control nor Mutter)")
})?;
Ok((HostClipboard::Mutter(m), rx))
}
#[cfg(target_os = "windows")]
{
let (b, rx) = windows::WindowsClipboard::open().await?;
Ok((HostClipboard::Windows(b), rx))
}
}
/// The current host selection's wire MIMEs (empty = nothing to offer).
pub fn current_wire_mimes(&self) -> Vec<String> {
match self {
#[cfg(target_os = "linux")]
HostClipboard::DataControl(b) => b.current_wire_mimes(),
#[cfg(target_os = "linux")]
HostClipboard::Mutter(m) => m.current_wire_mimes(),
#[cfg(target_os = "windows")]
HostClipboard::Windows(w) => w.current_wire_mimes(),
}
}
/// Install a client's offered formats as the host selection.
pub fn set_offer(&self, wire_mimes: &[String]) -> anyhow::Result<()> {
match self {
#[cfg(target_os = "linux")]
HostClipboard::DataControl(b) => b.set_offer(wire_mimes),
#[cfg(target_os = "linux")]
HostClipboard::Mutter(m) => {
m.set_offer(wire_mimes);
Ok(())
}
#[cfg(target_os = "windows")]
HostClipboard::Windows(w) => {
w.set_offer(wire_mimes);
Ok(())
}
}
}
/// Drop the host selection we own.
pub fn clear_offer(&self) -> anyhow::Result<()> {
match self {
#[cfg(target_os = "linux")]
HostClipboard::DataControl(b) => b.clear_offer(),
#[cfg(target_os = "linux")]
HostClipboard::Mutter(m) => {
m.clear_offer();
Ok(())
}
#[cfg(target_os = "windows")]
HostClipboard::Windows(w) => {
w.clear_offer();
Ok(())
}
}
}
/// Read one wire format of the current host selection (a client's fetch). Async: data-control
/// blocks on a pipe (offloaded), Mutter round-trips D-Bus + reads a pipe, Windows reads the
/// clipboard on a blocking thread.
pub async fn read_current(self: &Arc<Self>, wire_mime: &str) -> anyhow::Result<Vec<u8>> {
match &**self {
#[cfg(target_os = "linux")]
HostClipboard::DataControl(_) => {
let me = Arc::clone(self);
let wire = wire_mime.to_string();
tokio::task::spawn_blocking(move || match &*me {
HostClipboard::DataControl(b) => b.read_current(&wire),
_ => unreachable!("variant checked above"),
})
.await
.map_err(|e| anyhow::anyhow!("data-control read join: {e}"))?
}
#[cfg(target_os = "linux")]
HostClipboard::Mutter(m) => m.read_current(wire_mime).await,
#[cfg(target_os = "windows")]
HostClipboard::Windows(w) => w.read_current(wire_mime).await,
}
}
}
// ---- Format normalization (design/clipboard-and-file-transfer.md §3.5) ------------------------
//
// One portable vocabulary crosses the wire; each end maps to platform types at fetch time. Phase 1
// covers text / RTF / HTML / PNG (files are Phase 2). The wire MIMEs match the core's table.
/// Wire MIME for UTF-8 plain text.
pub const WIRE_TEXT: &str = "text/plain;charset=utf-8";
/// Wire MIME for HTML.
pub const WIRE_HTML: &str = "text/html";
/// Wire MIME for rich text.
pub const WIRE_RTF: &str = "text/rtf";
/// Wire MIME for a PNG image.
pub const WIRE_PNG: &str = "image/png";
/// Map a Wayland selection MIME to its canonical wire MIME, or `None` to drop it (internal targets
/// like `TARGETS`/`TIMESTAMP`/`SAVE_TARGETS`, and formats we don't sync in Phase 1). Aliases
/// collapse onto one canonical wire name so the offered list dedups cleanly.
#[cfg(target_os = "linux")]
pub fn wayland_to_wire(wl: &str) -> Option<&'static str> {
// Strip any parameter noise for the plain-text aliases (some apps send `text/plain;charset=...`
// with odd charsets, or bare `text/plain`).
let base = wl.split(';').next().unwrap_or(wl).trim();
match wl {
"text/html" => Some(WIRE_HTML),
"text/rtf" | "application/rtf" | "text/richtext" => Some(WIRE_RTF),
"image/png" => Some(WIRE_PNG),
_ => match base {
"text/plain" | "UTF8_STRING" | "STRING" | "TEXT" => Some(WIRE_TEXT),
_ => None,
},
}
}
/// The Wayland MIME candidates to request, in preference order, when a client fetches `wire` from
/// the host clipboard. The first one present in the current offer is used.
#[cfg(target_os = "linux")]
pub fn wayland_candidates(wire: &str) -> &'static [&'static str] {
match wire {
WIRE_TEXT => &[
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
"STRING",
"TEXT",
],
WIRE_HTML => &["text/html"],
WIRE_RTF => &["text/rtf", "application/rtf", "text/richtext"],
WIRE_PNG => &["image/png"],
_ => &[],
}
}
/// Pick the Wayland MIME to `receive()` for a wire fetch: the first [`wayland_candidates`] entry the
/// current selection actually advertises.
#[cfg(target_os = "linux")]
pub fn pick_wayland_mime(wire: &str, available: &[String]) -> Option<String> {
wayland_candidates(wire)
.iter()
.find(|c| available.iter().any(|a| a == *c))
.map(|c| c.to_string())
}
/// Normalize a raw Wayland offer's MIME list into the deduplicated wire MIME list announced to the
/// client (drops internal targets; collapses aliases; preserves a stable order).
#[cfg(target_os = "linux")]
pub fn offer_wire_mimes(raw: &[String]) -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new();
for m in raw {
if let Some(wire) = wayland_to_wire(m) {
if !out.contains(&wire) {
out.push(wire);
}
}
}
out
}
/// The Wayland MIMEs to advertise when installing a source for a client's offer. Each wire MIME
/// expands to its canonical Wayland name(s); a rich-text-only offer also advertises `text/plain`
/// so plain-text targets always paste (§3.5 synthesis — destination-side, one direction only).
#[cfg(target_os = "linux")]
pub fn wayland_offers_for(wire_mimes: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut push = |s: &str| {
if !out.iter().any(|o| o == s) {
out.push(s.to_string());
}
};
let mut has_plain = false;
let mut has_rich = false;
for w in wire_mimes {
match w.as_str() {
WIRE_TEXT => {
has_plain = true;
push("text/plain;charset=utf-8");
push("text/plain");
push("UTF8_STRING");
push("STRING");
}
WIRE_HTML => {
has_rich = true;
push("text/html");
}
WIRE_RTF => {
has_rich = true;
push("text/rtf");
}
WIRE_PNG => push("image/png"),
other => push(other),
}
}
// Synthesis: rich text without plain text → also advertise plain (the source derives it lazily).
if has_rich && !has_plain {
push("text/plain;charset=utf-8");
push("text/plain");
push("UTF8_STRING");
push("STRING");
}
out
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
#[test]
fn wayland_to_wire_canonicalizes_and_drops_targets() {
assert_eq!(wayland_to_wire("text/plain"), Some(WIRE_TEXT));
assert_eq!(wayland_to_wire("UTF8_STRING"), Some(WIRE_TEXT));
assert_eq!(wayland_to_wire("text/plain;charset=utf-8"), Some(WIRE_TEXT));
assert_eq!(wayland_to_wire("text/html"), Some(WIRE_HTML));
assert_eq!(wayland_to_wire("application/rtf"), Some(WIRE_RTF));
assert_eq!(wayland_to_wire("image/png"), Some(WIRE_PNG));
// Internal targets and unsupported formats are dropped.
assert_eq!(wayland_to_wire("TARGETS"), None);
assert_eq!(wayland_to_wire("TIMESTAMP"), None);
assert_eq!(wayland_to_wire("image/jpeg"), None);
}
#[test]
fn offer_wire_mimes_dedups_aliases() {
let raw = vec![
"TARGETS".to_string(),
"UTF8_STRING".to_string(),
"text/plain;charset=utf-8".to_string(),
"text/plain".to_string(),
"text/html".to_string(),
];
// text aliases collapse to one WIRE_TEXT; TARGETS dropped; html kept.
assert_eq!(offer_wire_mimes(&raw), vec![WIRE_TEXT, WIRE_HTML]);
}
#[test]
fn pick_wayland_mime_prefers_canonical() {
let avail = vec!["text/plain".to_string(), "UTF8_STRING".to_string()];
// Canonical charset form isn't present, so it falls to the next candidate.
assert_eq!(
pick_wayland_mime(WIRE_TEXT, &avail),
Some("text/plain".to_string())
);
let avail2 = vec![
"text/plain;charset=utf-8".to_string(),
"text/plain".to_string(),
];
assert_eq!(
pick_wayland_mime(WIRE_TEXT, &avail2),
Some("text/plain;charset=utf-8".to_string())
);
assert_eq!(pick_wayland_mime(WIRE_PNG, &avail2), None);
}
#[test]
fn wayland_offers_synthesizes_plain_for_rich_only() {
let offers = wayland_offers_for(&[WIRE_HTML.to_string()]);
assert!(offers.iter().any(|m| m == "text/html"));
assert!(
offers.iter().any(|m| m == "text/plain;charset=utf-8"),
"rich-only offer must synthesize plain text: {offers:?}"
);
// Plain already present → no duplicate synthesis, and text aliases included.
let offers2 = wayland_offers_for(&[WIRE_TEXT.to_string()]);
assert!(offers2.iter().any(|m| m == "UTF8_STRING"));
assert_eq!(offers2.iter().filter(|m| *m == "text/plain").count(), 1);
}
}
+524
View File
@@ -0,0 +1,524 @@
//! GNOME clipboard backend via Mutter's **direct** `org.gnome.Mutter.RemoteDesktop.Session` D-Bus
//! API (`design/clipboard-and-file-transfer.md` §4.1).
//!
//! Mutter implements no wlr/ext `data-control` (a deliberate privacy stance), so [`super::wayland`]
//! can't bind on GNOME. But Mutter's own RemoteDesktop session — the same interface the input
//! injector drives directly to dodge the xdg-portal approval dialog (`inject/linux/libei.rs`
//! `connect_mutter`) — carries the full clipboard surface: `EnableClipboard`, `SetSelection`,
//! `SelectionRead`/`SelectionWrite`/`SelectionWriteDone`, and the `SelectionOwnerChanged` /
//! `SelectionTransfer` signals. We open our **own** standalone session for it (it coexists with the
//! injector's input session; validated on GNOME 50), so this backend is self-contained just like the
//! data-control one.
//!
//! One actor task owns the zbus connection + session and multiplexes the two signals with a command
//! channel; the fds Mutter hands out are **non-blocking**, so reads/writes flip them to blocking and
//! run on a blocking thread. Option/signal dict keys are hyphenated: `mime-types`, `session-is-owner`.
use std::collections::HashMap;
use std::io::{Read as _, Write as _};
use std::os::fd::{AsRawFd, OwnedFd};
use std::sync::{Arc, Mutex};
use anyhow::{anyhow, Context, Result};
use ashpd::zbus::{
self,
zvariant::{OwnedObjectPath, OwnedValue, Value},
};
use futures_util::StreamExt;
use tokio::sync::{mpsc, oneshot};
use super::{ClipEvent, PasteResponder};
const RD_BUS: &str = "org.gnome.Mutter.RemoteDesktop";
const RD_PATH: &str = "/org/gnome/Mutter/RemoteDesktop";
const RD_IFACE: &str = "org.gnome.Mutter.RemoteDesktop";
const SESSION_IFACE: &str = "org.gnome.Mutter.RemoteDesktop.Session";
/// Upper bound on one `SelectionRead` (matches the wire clipboard cap, §7).
const CLIP_READ_CAP: u64 = 64 << 20;
/// Handle to the Mutter clipboard actor, held (inside a [`super::HostClipboard`]) by the session
/// coordinator.
pub struct MutterClipboard {
cmd_tx: mpsc::UnboundedSender<Cmd>,
/// Raw MIMEs the current host selection advertises (empty when we own it, or nothing is copied).
/// Written by the actor on `SelectionOwnerChanged`; read for `current_wire_mimes` / fetches.
current_raw: Arc<Mutex<Vec<String>>>,
}
enum Cmd {
SetOffer(Vec<String>),
ClearOffer,
ReadCurrent {
wire: String,
reply: oneshot::Sender<Result<Vec<u8>>>,
},
}
impl MutterClipboard {
/// Create a standalone Mutter RemoteDesktop session, `Start` + `EnableClipboard` it, and spawn
/// the actor. Errors when Mutter isn't running (not GNOME) — the caller falls through to
/// `BACKEND_UNAVAILABLE`.
pub async fn open() -> Result<(MutterClipboard, mpsc::UnboundedReceiver<ClipEvent>)> {
let conn = zbus::Connection::session()
.await
.context("session D-Bus (Mutter clipboard)")?;
let rd = zbus::Proxy::new(&conn, RD_BUS, RD_PATH, RD_IFACE)
.await
.context("Mutter RemoteDesktop proxy (is gnome-shell running?)")?;
let session_path: OwnedObjectPath = rd
.call("CreateSession", &())
.await
.context("Mutter RemoteDesktop.CreateSession (clipboard)")?;
let session = zbus::Proxy::new(&conn, RD_BUS, session_path, SESSION_IFACE)
.await
.context("Mutter RemoteDesktop.Session proxy")?;
session
.call_method("Start", &())
.await
.context("Mutter RemoteDesktop.Session.Start (clipboard)")?;
let empty: HashMap<&str, Value> = HashMap::new();
session
.call_method("EnableClipboard", &(empty,))
.await
.context("Mutter EnableClipboard")?;
let (event_tx, event_rx) = mpsc::unbounded_channel();
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let current_raw = Arc::new(Mutex::new(Vec::new()));
tokio::spawn(actor(conn, session, cmd_rx, event_tx, current_raw.clone()));
tracing::info!("clipboard backend bound (Mutter RemoteDesktop direct)");
Ok((
MutterClipboard {
cmd_tx,
current_raw,
},
event_rx,
))
}
/// Install a client's offered formats as the host selection (fire-and-forget on the actor).
pub fn set_offer(&self, wire_mimes: &[String]) {
let _ = self.cmd_tx.send(Cmd::SetOffer(wire_mimes.to_vec()));
}
/// Relinquish the selection we own.
pub fn clear_offer(&self) {
let _ = self.cmd_tx.send(Cmd::ClearOffer);
}
/// The current host selection's wire MIMEs (empty = nothing / we own it).
pub fn current_wire_mimes(&self) -> Vec<String> {
super::offer_wire_mimes(&self.current_raw.lock().unwrap())
.into_iter()
.map(str::to_string)
.collect()
}
/// Read one wire format of the current host selection (a client's fetch). Round-trips the actor
/// (SelectionRead + a blocking fd read).
pub async fn read_current(&self, wire: &str) -> Result<Vec<u8>> {
let (reply, rx) = oneshot::channel();
self.cmd_tx
.send(Cmd::ReadCurrent {
wire: wire.to_string(),
reply,
})
.map_err(|_| anyhow!("Mutter clipboard actor gone"))?;
rx.await
.map_err(|_| anyhow!("Mutter clipboard read dropped"))?
}
}
/// The actor: owns the connection + session, subscribes to the two clipboard signals, and serves
/// commands. Exits when the command channel closes (session ending) or a signal stream ends.
async fn actor(
conn: zbus::Connection,
session: zbus::Proxy<'static>,
mut cmd_rx: mpsc::UnboundedReceiver<Cmd>,
event_tx: mpsc::UnboundedSender<ClipEvent>,
current_raw: Arc<Mutex<Vec<String>>>,
) {
let (mut owner, mut transfer) = match (
session.receive_signal("SelectionOwnerChanged").await,
session.receive_signal("SelectionTransfer").await,
) {
(Ok(o), Ok(t)) => (o, t),
_ => {
tracing::warn!("Mutter clipboard: could not subscribe to selection signals");
let _ = event_tx.send(ClipEvent::Closed);
return;
}
};
loop {
tokio::select! {
sig = owner.next() => {
let Some(msg) = sig else { break };
let Ok((opts,)) = msg.body().deserialize::<(HashMap<String, OwnedValue>,)>() else {
continue;
};
let is_owner = dict_bool(&opts, "session-is-owner").unwrap_or(false);
let raw = dict_mimes(&opts, "mime-types");
if is_owner {
// Our own offer (the client's content) — not host clipboard; don't announce it,
// and clear `current_raw` so a fetch never reads our own source back.
current_raw.lock().unwrap().clear();
} else {
*current_raw.lock().unwrap() = raw.clone();
let wire = super::offer_wire_mimes(&raw)
.into_iter()
.map(str::to_string)
.collect();
if event_tx.send(ClipEvent::Selection { mimes: wire }).is_err() {
break;
}
}
}
sig = transfer.next() => {
let Some(msg) = sig else { break };
let Ok((mime, serial)) = msg.body().deserialize::<(String, u32)>() else {
continue;
};
match super::wayland_to_wire(&mime) {
Some(wire) => {
// A host app pastes our offer: hand the fetch to the coordinator, then serve
// the returned bytes into the SelectionWrite fd off-task. NB Mutter issues
// *two* transfers per read (a size probe + the real read), so the coordinator
// fetches from the client twice per paste — correct, just not deduplicated.
let (tx, rx) = oneshot::channel();
if event_tx
.send(ClipEvent::Paste {
mime: wire.to_string(),
responder: PasteResponder::Channel(tx),
})
.is_err()
{
break;
}
let session = session.clone();
tokio::spawn(async move {
let bytes = rx.await.unwrap_or_default();
serve_write(&session, serial, bytes).await;
});
}
// Format we don't sync — fail the transfer cleanly.
None => serve_write(&session, serial, Vec::new()).await,
}
}
cmd = cmd_rx.recv() => {
let Some(cmd) = cmd else { break }; // coordinator gone → session ending
match cmd {
Cmd::SetOffer(wire) => {
let wl = super::wayland_offers_for(&wire);
if let Err(e) = set_selection(&session, &wl).await {
tracing::debug!(error = %e, "Mutter SetSelection failed");
}
}
Cmd::ClearOffer => {
if let Err(e) = set_selection(&session, &[]).await {
tracing::debug!(error = %e, "Mutter clear selection failed");
}
}
Cmd::ReadCurrent { wire, reply } => {
let raw = current_raw.lock().unwrap().clone();
let _ = reply.send(read_selection(&session, &wire, &raw).await);
}
}
}
}
}
// Keep the connection owned for the actor's whole life (Mutter ties the session to it).
drop(conn);
let _ = event_tx.send(ClipEvent::Closed);
}
/// Offer `wl_mimes` as the host selection; an empty list relinquishes ownership.
async fn set_selection(session: &zbus::Proxy<'_>, wl_mimes: &[String]) -> Result<()> {
let mut opts: HashMap<&str, Value> = HashMap::new();
if !wl_mimes.is_empty() {
let refs: Vec<&str> = wl_mimes.iter().map(String::as_str).collect();
opts.insert("mime-types", Value::from(refs));
}
session
.call_method("SetSelection", &(opts,))
.await
.context("Mutter SetSelection")?;
Ok(())
}
/// Read the current selection's `wire` format: pick a concrete offered MIME, `SelectionRead` it, and
/// read the (non-blocking) fd to EOF on a blocking thread.
async fn read_selection(session: &zbus::Proxy<'_>, wire: &str, raw: &[String]) -> Result<Vec<u8>> {
let mime =
super::pick_wayland_mime(wire, raw).context("format not offered by the host clipboard")?;
let fd: zbus::zvariant::OwnedFd = session
.call("SelectionRead", &(mime.as_str(),))
.await
.context("Mutter SelectionRead")?;
let fd = OwnedFd::from(fd);
tokio::task::spawn_blocking(move || read_fd_to_end(fd))
.await
.map_err(|e| anyhow!("SelectionRead join: {e}"))?
}
/// Serve one `SelectionTransfer`: `SelectionWrite` → write `bytes` → `SelectionWriteDone`. Any write
/// failure still reports done (success=false) so Mutter completes the transfer.
async fn serve_write(session: &zbus::Proxy<'_>, serial: u32, bytes: Vec<u8>) {
let ok = match write_selection(session, serial, bytes).await {
Ok(()) => true,
Err(e) => {
tracing::debug!(error = %e, "Mutter SelectionWrite failed");
false
}
};
let _ = session
.call_method("SelectionWriteDone", &(serial, ok))
.await;
}
async fn write_selection(session: &zbus::Proxy<'_>, serial: u32, bytes: Vec<u8>) -> Result<()> {
let fd: zbus::zvariant::OwnedFd = session
.call("SelectionWrite", &(serial,))
.await
.context("Mutter SelectionWrite")?;
let fd = OwnedFd::from(fd);
tokio::task::spawn_blocking(move || write_fd(fd, &bytes))
.await
.map_err(|e| anyhow!("SelectionWrite join: {e}"))?
}
/// Read a Mutter clipboard fd to EOF (capped). The fd is `O_NONBLOCK`; flip it to blocking first.
fn read_fd_to_end(fd: OwnedFd) -> Result<Vec<u8>> {
set_blocking(&fd)?;
let file = std::fs::File::from(fd);
let mut buf = Vec::new();
file.take(CLIP_READ_CAP)
.read_to_end(&mut buf)
.context("read SelectionRead fd")?;
Ok(buf)
}
/// Write `bytes` into a Mutter clipboard fd and close it (EOF). Flip the `O_NONBLOCK` fd to blocking.
fn write_fd(fd: OwnedFd, bytes: &[u8]) -> Result<()> {
set_blocking(&fd)?;
let mut file = std::fs::File::from(fd);
file.write_all(bytes).context("write SelectionWrite fd")?;
Ok(())
}
/// Peel any `Value::Value` (variant) wrappers to the concrete value. The `a{sv}` dict values Mutter
/// sends arrive as variants, so a plain `TryFrom<OwnedValue>` (which matches the concrete type) never
/// sees through them — this strips the layer first.
fn peel<'a>(v: &'a Value<'a>) -> &'a Value<'a> {
let mut cur = v;
while let Value::Value(inner) = cur {
cur = inner;
}
cur
}
/// Extract a boolean dict entry (e.g. `session-is-owner`), unwrapping the variant.
fn dict_bool(opts: &HashMap<String, OwnedValue>, key: &str) -> Option<bool> {
match peel(opts.get(key)?) {
Value::Bool(b) => Some(*b),
_ => None,
}
}
/// Extract a string-array dict entry (e.g. `mime-types`), unwrapping the variant. Mutter wraps the
/// array in a single-field struct (`(as)`, seen on `SelectionOwnerChanged`), so unwrap that too.
fn dict_mimes(opts: &HashMap<String, OwnedValue>, key: &str) -> Vec<String> {
let Some(v) = opts.get(key) else {
return Vec::new();
};
let mut val = peel(v);
if let Value::Structure(s) = val {
match s.fields().first() {
Some(first) => val = peel(first),
None => return Vec::new(),
}
}
let Value::Array(arr) = val else {
return Vec::new();
};
arr.inner()
.iter()
.filter_map(|e| match peel(e) {
Value::Str(s) => Some(s.to_string()),
_ => None,
})
.collect()
}
/// Clear `O_NONBLOCK` on a Mutter clipboard fd so a blocking `spawn_blocking` read/write works.
fn set_blocking(fd: &OwnedFd) -> Result<()> {
let raw = fd.as_raw_fd();
// SAFETY: `raw` is a valid fd owned by `fd` for the duration of these fcntl calls.
let flags = unsafe { libc::fcntl(raw, libc::F_GETFL) };
if flags < 0 {
return Err(anyhow!(
"fcntl F_GETFL: {}",
std::io::Error::last_os_error()
));
}
// SAFETY: as above; clearing O_NONBLOCK on our own fd.
let rc = unsafe { libc::fcntl(raw, libc::F_SETFL, flags & !libc::O_NONBLOCK) };
if rc < 0 {
return Err(anyhow!(
"fcntl F_SETFL: {}",
std::io::Error::last_os_error()
));
}
Ok(())
}
/// On-glass tests against a **live GNOME/Mutter** session (`WAYLAND_DISPLAY=wayland-0`). `#[ignore]`d
/// — run explicitly under GNOME (Mutter has no `wl-clipboard`, so a second Mutter session plays the
/// "host app"):
///
/// ```text
/// cargo test -p punktfunk-host --bin punktfunk-host -- --ignored --nocapture clipboard::mutter::live
/// ```
///
/// Skips (does not fail) when Mutter isn't running, so `--ignored` off-GNOME is a clean no-op.
#[cfg(test)]
mod live {
use super::*;
use std::time::Duration;
/// A second Mutter session standing in for a host clipboard app.
struct Helper {
session: zbus::Proxy<'static>,
_conn: zbus::Connection,
}
impl Helper {
async fn open() -> Result<Helper> {
let conn = zbus::Connection::session().await?;
let rd = zbus::Proxy::new(&conn, RD_BUS, RD_PATH, RD_IFACE).await?;
let path: OwnedObjectPath = rd.call("CreateSession", &()).await?;
let session = zbus::Proxy::new(&conn, RD_BUS, path, SESSION_IFACE).await?;
session.call_method("Start", &()).await?;
let empty: HashMap<&str, Value> = HashMap::new();
session.call_method("EnableClipboard", &(empty,)).await?;
Ok(Helper {
session,
_conn: conn,
})
}
/// Own the selection offering plain text, serving `payload` on every transfer request.
async fn offer_text(&self, payload: &'static [u8]) {
let mut transfer = self
.session
.receive_signal("SelectionTransfer")
.await
.unwrap();
let session = self.session.clone();
tokio::spawn(async move {
while let Some(msg) = transfer.next().await {
if let Ok((_mime, serial)) = msg.body().deserialize::<(String, u32)>() {
serve_write(&session, serial, payload.to_vec()).await;
}
}
});
set_selection(
&self.session,
&[
"text/plain;charset=utf-8".to_string(),
"text/plain".to_string(),
],
)
.await
.unwrap();
}
/// Paste the current selection's plain text.
async fn read_text(&self) -> Vec<u8> {
let fd: zbus::zvariant::OwnedFd = self
.session
.call("SelectionRead", &("text/plain;charset=utf-8",))
.await
.unwrap();
let fd = OwnedFd::from(fd);
tokio::task::spawn_blocking(move || read_fd_to_end(fd))
.await
.unwrap()
.unwrap()
}
}
async fn next_selection(
rx: &mut mpsc::UnboundedReceiver<ClipEvent>,
timeout: Duration,
) -> Option<Vec<String>> {
tokio::time::timeout(timeout, async {
loop {
match rx.recv().await {
Some(ClipEvent::Selection { mimes }) if !mimes.is_empty() => {
return Some(mimes)
}
Some(_) => continue,
None => return None,
}
}
})
.await
.ok()
.flatten()
}
#[test]
#[ignore = "needs a live GNOME/Mutter session (WAYLAND_DISPLAY=wayland-0)"]
fn live_mutter_roundtrip() {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let (backend, mut rx) = match MutterClipboard::open().await {
Ok(v) => v,
Err(e) => {
eprintln!("SKIP: no Mutter clipboard (not GNOME?): {e:#}");
return;
}
};
let helper = Helper::open().await.expect("helper Mutter session");
// --- host copy → backend observes Selection + read_current returns the bytes ---
helper.offer_text(b"gnome-host-copied").await;
let mimes = next_selection(&mut rx, Duration::from_secs(3))
.await
.expect("Selection after the helper offered text");
assert!(
mimes.iter().any(|m| m == super::super::WIRE_TEXT),
"offer carries wire text: {mimes:?}"
);
let got = backend
.read_current(super::super::WIRE_TEXT)
.await
.expect("read_current text");
assert_eq!(got, b"gnome-host-copied");
// --- backend offers client content → the host app (helper) pastes it ---
backend.set_offer(&[super::super::WIRE_TEXT.to_string()]);
tokio::time::sleep(Duration::from_millis(500)).await; // let SetSelection take effect
// Answer every Paste request the host app (helper) triggers, until the read completes.
let paste_side = async {
while let Some(ev) = rx.recv().await {
if let ClipEvent::Paste { responder, .. } = ev {
responder.respond(b"punktfunk-served".to_vec()).await;
}
}
};
let read = tokio::select! {
r = helper.read_text() => r,
_ = paste_side => Vec::new(),
};
assert_eq!(read, b"punktfunk-served");
});
}
}
+254
View File
@@ -0,0 +1,254 @@
//! Host clipboard coordinator (`design/clipboard-and-file-transfer.md` §4.2).
//!
//! One async task per streaming session that bridges the real session clipboard (whichever
//! [`super::HostClipboard`] backend this platform opened) to the QUIC clipboard plane. It owns all
//! four data paths:
//!
//! * **host copy → client** — a backend [`ClipEvent::Selection`] becomes a [`ClipOffer`] pushed to the
//! control loop (`offer_tx`), which forwards it to the client.
//! * **client fetch of the host clipboard** — the fetch-stream `accept_bi` loop lives here; each
//! stream is answered by reading the current host selection ([`ClipboardBackend::read_current`]).
//! * **client copy → host** — a [`ClipCoordCmd::RemoteOffer`] installs the client's formats as a lazy
//! host selection ([`HostClipboard::set_offer`]).
//! * **host paste of client content** — a backend [`ClipEvent::Paste`] triggers an *outbound* fetch to
//! the client, whose bytes are handed to the backend's [`PasteResponder`].
//!
//! The coordinator is backend-agnostic (Linux data-control / Mutter, Windows Win32); the control loop
//! reaches it through the portable [`ClipCoordCmd`] channel so the host's native control loop
//! compiles on every host platform.
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
use punktfunk_core::clipboard::CLIP_FETCH_CAP;
use punktfunk_core::quic::{
clipstream, ClipFetch, ClipFetchHdr, ClipKind, ClipOffer, CLIP_FETCH_OK, CLIP_FETCH_STALE,
CLIP_FETCH_UNAVAILABLE, CLIP_FILE_INDEX_NONE,
};
use super::{ClipEvent, HostClipboard, PasteResponder};
use crate::ClipCoordCmd;
/// Upper bound on one outbound fetch (host pasting client content). A client that never answers must
/// not hang the pasting host app's pipe read (§3.4) — the paste falls back to empty instead.
const FETCH_TIMEOUT: Duration = Duration::from_secs(60);
/// Open whichever host clipboard backend this session supports (data-control, else Mutter direct) and
/// spawn the coordinator. Returns `true` when a live backend was bound (the caller's control loop
/// then serves real clipboard data); `false` when none is available (gamescope, no live compositor),
/// in which case the channels are dropped so the control loop reports `CLIP_REASON_BACKEND_UNAVAILABLE`
/// and declines fetches defensively.
pub async fn start(
conn: quinn::Connection,
clip_enabled: Arc<AtomicBool>,
cmd_rx: UnboundedReceiver<ClipCoordCmd>,
offer_tx: UnboundedSender<ClipOffer>,
) -> bool {
match HostClipboard::open().await {
Ok((backend, clip_rx)) => {
tokio::spawn(run(
conn,
Arc::new(backend),
clip_rx,
clip_enabled,
cmd_rx,
offer_tx,
));
true
}
Err(e) => {
tracing::info!(error = %format!("{e:#}"), "clipboard backend unavailable — fetches will be declined");
false
}
}
}
/// The coordinator loop. Multiplexes control-loop commands, backend clipboard events, and inbound
/// fetch streams; exits when any of the three peers goes away (session ending).
async fn run(
conn: quinn::Connection,
backend: Arc<HostClipboard>,
mut clip_rx: UnboundedReceiver<ClipEvent>,
clip_enabled: Arc<AtomicBool>,
mut cmd_rx: UnboundedReceiver<ClipCoordCmd>,
offer_tx: UnboundedSender<ClipOffer>,
) {
// Seq of the offer the host most recently announced; a client fetch naming a different seq is
// stale (the host clipboard moved on) and is declined.
let host_seq = Arc::new(AtomicU32::new(0));
let mut next_seq: u32 = 1;
// Seq of the client's most recent offer, echoed on the outbound fetch we open when a host app
// pastes client content (informational for the client's serve side).
let mut client_seq: u32 = 0;
loop {
tokio::select! {
cmd = cmd_rx.recv() => {
let Some(cmd) = cmd else { break }; // control loop gone → session ending
match cmd {
ClipCoordCmd::SetEnabled(true) => {
// A just-enabled client should see whatever the host already has copied.
let mimes = backend.current_wire_mimes();
if !mimes.is_empty() {
let _ = offer_tx.send(build_offer(&mut next_seq, &host_seq, mimes));
}
}
ClipCoordCmd::SetEnabled(false) => {
if let Err(e) = backend.clear_offer() {
tracing::debug!(error = %e, "clipboard clear_offer failed");
}
}
ClipCoordCmd::RemoteOffer { seq, mimes } => {
client_seq = seq;
let res = if mimes.is_empty() {
backend.clear_offer()
} else {
backend.set_offer(&mimes)
};
if let Err(e) = res {
tracing::debug!(error = %e, "clipboard apply remote offer failed");
}
}
}
}
ev = clip_rx.recv() => {
let Some(ev) = ev else { break }; // backend dispatch thread ended
match ev {
ClipEvent::Selection { mimes } => {
// Forward host copies (empty `mimes` = the clipboard was cleared) only while
// the client has sync on — the offer is metadata; bytes still cross lazily.
if clip_enabled.load(Ordering::SeqCst) {
let _ = offer_tx.send(build_offer(&mut next_seq, &host_seq, mimes));
}
}
ClipEvent::Paste { mime, responder } => {
// A host app is pasting the client's offered content: pull that format from
// the client and hand it to the backend's responder. Off-task so the loop
// keeps serving.
tokio::spawn(fetch_into_pipe(conn.clone(), client_seq, mime, responder));
}
ClipEvent::Closed => break,
}
}
accepted = conn.accept_bi() => {
let Ok((send, recv)) = accepted else { break }; // connection gone
// The control stream is already accepted at the handshake, so every stream here is a
// clipboard fetch. Serve it off-task (the read blocks on the source app's pipe).
tokio::spawn(serve_fetch(
send,
recv,
Arc::clone(&backend),
Arc::clone(&host_seq),
clip_enabled.load(Ordering::SeqCst),
));
}
}
}
// Session ending: don't leave our lazy source as the compositor's active selection.
let _ = backend.clear_offer();
}
/// Mint a [`ClipOffer`] for `mimes`, advancing the host offer seq (skipping 0, the "never offered"
/// sentinel) and publishing it as the current one for staleness checks.
fn build_offer(next_seq: &mut u32, host_seq: &AtomicU32, mimes: Vec<String>) -> ClipOffer {
let seq = *next_seq;
*next_seq = next_seq.wrapping_add(1);
if *next_seq == 0 {
*next_seq = 1;
}
host_seq.store(seq, Ordering::SeqCst);
let kinds = mimes
.into_iter()
.map(|mime| ClipKind { mime, size_hint: 0 })
.collect();
ClipOffer { seq, kinds }
}
/// Serve one inbound fetch stream (a client pulling the host clipboard): validate the header +
/// request, then answer with the current host selection's bytes for the requested wire MIME.
async fn serve_fetch(
mut send: quinn::SendStream,
mut recv: quinn::RecvStream,
backend: Arc<HostClipboard>,
host_seq: Arc<AtomicU32>,
enabled: bool,
) {
let _ = send.set_priority(-1);
match clipstream::read_stream_header(&mut recv).await {
Ok(k) if k == clipstream::CLIP_STREAM_KIND_FETCH => {}
_ => {
let _ = send.reset(clipstream::cancelled_code());
return;
}
}
let req = match clipstream::read_fetch(&mut recv).await {
Ok(r) => r,
Err(_) => return,
};
let decline = |status: u8| ClipFetchHdr {
status,
total_size: 0,
};
if !enabled {
let _ = clipstream::write_fetch_hdr(&mut send, &decline(CLIP_FETCH_UNAVAILABLE)).await;
return;
}
if req.seq != host_seq.load(Ordering::SeqCst) {
let _ = clipstream::write_fetch_hdr(&mut send, &decline(CLIP_FETCH_STALE)).await;
return;
}
// `read_current` reads the host selection (a blocking pipe read, offloaded by the backend).
match backend.read_current(&req.mime).await {
Ok(data) => {
let hdr = ClipFetchHdr {
status: CLIP_FETCH_OK,
total_size: data.len() as u64,
};
if clipstream::write_fetch_hdr(&mut send, &hdr).await.is_ok() {
let _ = clipstream::write_data(&mut send, &data).await;
}
}
// The format vanished (clipboard changed mid-fetch) or the read failed → nothing to send.
Err(_) => {
let _ = clipstream::write_fetch_hdr(&mut send, &decline(CLIP_FETCH_UNAVAILABLE)).await;
}
}
}
/// Pull `mime` of the client's current offer (`seq`) over an outbound fetch stream and hand the bytes
/// to the backend's paste `responder`. Any failure (timeout, decline, I/O) responds with empty bytes
/// so the pasting host app gets an empty paste instead of hanging.
async fn fetch_into_pipe(
conn: quinn::Connection,
seq: u32,
mime: String,
responder: PasteResponder,
) {
let req = ClipFetch {
seq,
file_index: CLIP_FILE_INDEX_NONE,
mime,
};
let fetched = tokio::time::timeout(FETCH_TIMEOUT, async {
let (send, mut recv) = clipstream::open_fetch(&conn, &req).await.ok()?;
let hdr = clipstream::read_fetch_hdr(&mut recv).await.ok()?;
if hdr.status != CLIP_FETCH_OK {
return None;
}
let data = clipstream::read_data(&mut recv, CLIP_FETCH_CAP)
.await
.ok()?;
drop(send); // clean close of our half
Some(data)
})
.await
.ok()
.flatten();
responder.respond(fetched.unwrap_or_default()).await;
}
+599
View File
@@ -0,0 +1,599 @@
//! `ext-data-control-v1` clipboard backend (`design/clipboard-and-file-transfer.md` §4.1).
//!
//! A dedicated thread owns the `wayland-client` [`EventQueue`] and runs a poll loop that dispatches
//! selection + paste events, emitting them over a channel. Everything else — installing a lazy
//! source (a client's offer) and `receive()`-ing the host selection (a client's fetch) — is issued
//! from the session thread on the shared, `Send + Sync` proxy handles; only *dispatch* is
//! single-threaded (per the wayland-client contract). Templated on `inject/linux/wlr.rs`.
//!
//! The `zwlr-data-control-unstable-v1` fallback for older wlroots/KWin is a mechanical parallel of
//! this file (the protocols are 1:1) — a follow-up.
use std::collections::HashMap;
use std::io::Read;
use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use anyhow::{anyhow, Context, Result};
use wayland_client::backend::ObjectId;
use wayland_client::protocol::wl_registry;
use wayland_client::protocol::wl_seat::WlSeat;
use wayland_client::{event_created_child, Connection, Dispatch, Proxy, QueueHandle};
use wayland_protocols::ext::data_control::v1::client::{
ext_data_control_device_v1::{self, ExtDataControlDeviceV1},
ext_data_control_manager_v1::ExtDataControlManagerV1,
ext_data_control_offer_v1::{self, ExtDataControlOfferV1},
ext_data_control_source_v1::{self, ExtDataControlSourceV1},
};
use super::{ClipEvent, PasteResponder};
/// Upper bound on bytes read from one `receive()` transfer (matches the wire clipboard cap, §7) so a
/// hostile host app can't stream unboundedly into our buffer.
const CLIP_READ_CAP: u64 = 64 << 20;
/// The current host selection, shared between the dispatch thread (writer) and the session thread
/// (reader, for `receive()`).
struct CurrentSelection {
offer: ExtDataControlOfferV1,
/// Raw Wayland MIMEs the offer advertises (what `receive()` accepts).
mimes: Vec<String>,
}
/// Dispatch-thread state. Also collects the manager + seat during the bind roundtrip.
struct State {
mgr: Option<ExtDataControlManagerV1>,
seat: Option<WlSeat>,
/// Offers accumulating their MIME list before the `selection` event promotes one.
pending: HashMap<ObjectId, Vec<String>>,
current: Arc<Mutex<Option<CurrentSelection>>>,
/// Pending count of our own `set_selection`s whose `selection` echo must be dropped rather than
/// announced back to the client (loop prevention, §3.4). Bumped by the session before each set;
/// each of our sets produces exactly one echo on wlroots/KWin, so one decrement per echo pairs
/// them up — a counter (not a bool) keeps rapid back-to-back offers from leaking a self-echo.
suppress_echoes: Arc<AtomicU32>,
tx: tokio::sync::mpsc::UnboundedSender<ClipEvent>,
}
impl Dispatch<wl_registry::WlRegistry, ()> for State {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_registry::Event::Global {
name,
interface,
version,
} = event
{
match interface.as_str() {
"ext_data_control_manager_v1" => {
state.mgr = Some(registry.bind(name, version.min(1), qh, ()));
}
"wl_seat" => {
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
}
_ => {}
}
}
}
}
// Manager + seat emit nothing we consume.
impl Dispatch<ExtDataControlManagerV1, ()> for State {
fn event(
_: &mut Self,
_: &ExtDataControlManagerV1,
_: <ExtDataControlManagerV1 as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<WlSeat, ()> for State {
fn event(
_: &mut Self,
_: &WlSeat,
_: <WlSeat as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<ExtDataControlDeviceV1, ()> for State {
fn event(
state: &mut Self,
_dev: &ExtDataControlDeviceV1,
event: ext_data_control_device_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
use ext_data_control_device_v1::Event;
match event {
// A new offer is being introduced; its `offer` events follow before `selection`.
Event::DataOffer { id } => {
state.pending.insert(id.id(), Vec::new());
}
// The active selection changed. `Some` = a new clipboard; `None` = cleared.
Event::Selection { id } => {
// Consume one pending self-echo if any (atomic vs. the session thread's bumps; the
// dispatch thread is the only decrementer). `Ok` = there was one → suppress.
let suppressed = state
.suppress_echoes
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |c| c.checked_sub(1))
.is_ok();
match id {
Some(offer) => {
let mimes = state.pending.remove(&offer.id()).unwrap_or_default();
if suppressed {
// Our own source's echo — don't store it as the host clipboard and
// don't announce it back to the client.
return;
}
let wire = super::offer_wire_mimes(&mimes)
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>();
*state.current.lock().unwrap() = Some(CurrentSelection { offer, mimes });
let _ = state.tx.send(ClipEvent::Selection { mimes: wire });
}
None => {
*state.current.lock().unwrap() = None;
if !suppressed {
let _ = state.tx.send(ClipEvent::Selection { mimes: Vec::new() });
}
}
}
}
Event::Finished => {
let _ = state.tx.send(ClipEvent::Closed);
}
// Primary selection is out of scope for the shared clipboard.
_ => {}
}
}
event_created_child!(State, ExtDataControlDeviceV1, [
ext_data_control_device_v1::EVT_DATA_OFFER_OPCODE => (ExtDataControlOfferV1, ()),
]);
}
impl Dispatch<ExtDataControlOfferV1, ()> for State {
fn event(
state: &mut Self,
offer: &ExtDataControlOfferV1,
event: ext_data_control_offer_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let ext_data_control_offer_v1::Event::Offer { mime_type } = event {
if let Some(list) = state.pending.get_mut(&offer.id()) {
list.push(mime_type);
}
}
}
}
impl Dispatch<ExtDataControlSourceV1, ()> for State {
fn event(
state: &mut Self,
_src: &ExtDataControlSourceV1,
event: ext_data_control_source_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
use ext_data_control_source_v1::Event;
match event {
// A host app pasted our (the client's) offered data.
Event::Send { mime_type, fd } => match super::wayland_to_wire(&mime_type) {
Some(wire) => {
let _ = state.tx.send(ClipEvent::Paste {
mime: wire.to_string(),
responder: PasteResponder::Fd(fd),
});
}
// We can't satisfy this format — closing the fd yields an empty paste.
None => drop(fd),
},
// Our source was superseded (a host app or another client set a new selection).
Event::Cancelled => {}
_ => {}
}
}
}
/// The host clipboard backend handle used by the session thread.
pub struct ClipboardBackend {
conn: Connection,
mgr: ExtDataControlManagerV1,
device: ExtDataControlDeviceV1,
qh: QueueHandle<State>,
current: Arc<Mutex<Option<CurrentSelection>>>,
suppress_echoes: Arc<AtomicU32>,
active_source: Mutex<Option<ExtDataControlSourceV1>>,
stop: Arc<AtomicBool>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl ClipboardBackend {
/// Connect to the active session's Wayland display (env already applied by
/// `vdisplay::apply_session_env`), bind `ext_data_control`, and start the dispatch thread.
/// Returns the handle plus the event stream. Errors if the compositor lacks the protocol
/// (caller reports `BackendUnavailable`).
pub fn open() -> Result<(
ClipboardBackend,
tokio::sync::mpsc::UnboundedReceiver<ClipEvent>,
)> {
let conn = Connection::connect_to_env()
.context("connect to Wayland for clipboard (WAYLAND_DISPLAY/XDG_RUNTIME_DIR set?)")?;
let mut queue = conn.new_event_queue();
let qh = queue.handle();
let _registry = conn.display().get_registry(&qh, ());
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let current = Arc::new(Mutex::new(None));
let suppress_echoes = Arc::new(AtomicU32::new(0));
let mut state = State {
mgr: None,
seat: None,
pending: HashMap::new(),
current: current.clone(),
suppress_echoes: suppress_echoes.clone(),
tx,
};
queue
.roundtrip(&mut state)
.context("Wayland registry roundtrip")?;
let mgr = state
.mgr
.clone()
.context("compositor lacks ext_data_control_manager_v1")?;
let seat = state
.seat
.clone()
.context("compositor advertised no wl_seat")?;
let device = mgr.get_data_device(&seat, &qh, ());
// Second roundtrip: the compositor sends the initial selection for the freshly-bound device
// (the current host clipboard), which the session announces to the client.
queue
.roundtrip(&mut state)
.context("Wayland get_data_device roundtrip")?;
let stop = Arc::new(AtomicBool::new(false));
let thread = {
let conn = conn.clone();
let stop = stop.clone();
std::thread::Builder::new()
.name("punktfunk-clipboard".into())
.spawn(move || dispatch_loop(conn, queue, state, stop))
.context("spawn clipboard dispatch thread")?
};
Ok((
ClipboardBackend {
conn,
mgr,
device,
qh,
current,
suppress_echoes,
active_source: Mutex::new(None),
stop,
thread: Some(thread),
},
rx,
))
}
/// Install a lazy source advertising a client's offered formats (wire MIMEs) as the host
/// selection. A later host-app paste fires a [`ClipEvent::Paste`]. Replaces any previous offer.
pub fn set_offer(&self, wire_mimes: &[String]) -> Result<()> {
let wl_mimes = super::wayland_offers_for(wire_mimes);
if wl_mimes.is_empty() {
return self.clear_offer();
}
let src = self.mgr.create_data_source(&self.qh, ());
for m in &wl_mimes {
src.offer(m.clone());
}
// Suppress the selection echo our own set triggers (loop prevention).
self.suppress_echoes.fetch_add(1, Ordering::SeqCst);
self.device.set_selection(Some(&src));
self.conn.flush().context("flush set_selection")?;
let mut slot = self.active_source.lock().unwrap();
if let Some(old) = slot.take() {
old.destroy();
}
*slot = Some(src);
Ok(())
}
/// Drop the host selection we own (client disabled sync / offered nothing).
pub fn clear_offer(&self) -> Result<()> {
let mut slot = self.active_source.lock().unwrap();
if let Some(old) = slot.take() {
self.suppress_echoes.fetch_add(1, Ordering::SeqCst);
self.device.set_selection(None);
old.destroy();
self.conn.flush().context("flush clear selection")?;
}
Ok(())
}
/// The current host selection's wire MIMEs (what a client offer announcement would carry), or
/// empty if the clipboard is empty. Used to answer an immediate query.
pub fn current_wire_mimes(&self) -> Vec<String> {
match self.current.lock().unwrap().as_ref() {
Some(sel) => super::offer_wire_mimes(&sel.mimes)
.into_iter()
.map(str::to_string)
.collect(),
None => Vec::new(),
}
}
/// Read one format (`wire_mime`) of the current host selection into a byte vector — a client's
/// lazy fetch. BLOCKS on the pipe until the source app finishes, so call from a blocking
/// context (e.g. `spawn_blocking`). Errors if there is no selection or the format isn't offered.
pub fn read_current(&self, wire_mime: &str) -> Result<Vec<u8>> {
let (offer, wl_mime) = {
let cur = self.current.lock().unwrap();
let sel = cur.as_ref().context("no current host selection")?;
let wl = super::pick_wayland_mime(wire_mime, &sel.mimes)
.context("format not offered by the host clipboard")?;
(sel.offer.clone(), wl)
};
let (read_fd, write_fd) = make_pipe()?;
offer.receive(wl_mime, write_fd.as_fd());
self.conn.flush().context("flush receive")?;
// Close our write end so the pipe reaches EOF once the source app closes its dup.
drop(write_fd);
let mut buf = Vec::new();
// `read_fd` is a fresh, uniquely-owned pipe read end; `File` takes sole ownership and closes
// it on drop.
let file = std::fs::File::from(read_fd);
file.take(CLIP_READ_CAP)
.read_to_end(&mut buf)
.context("read clipboard transfer")?;
Ok(buf)
}
}
impl Drop for ClipboardBackend {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
/// The dispatch thread: poll the Wayland socket with a short timeout so `stop` is honored promptly,
/// dispatching selection/paste events into `state`.
fn dispatch_loop(
conn: Connection,
mut queue: wayland_client::EventQueue<State>,
mut state: State,
stop: Arc<AtomicBool>,
) {
while !stop.load(Ordering::SeqCst) {
if queue.dispatch_pending(&mut state).is_err() {
break;
}
if conn.flush().is_err() {
break;
}
let Some(guard) = conn.prepare_read() else {
// Events are already queued; loop to dispatch them.
continue;
};
let raw_fd = guard.connection_fd().as_raw_fd();
let mut pfd = libc::pollfd {
fd: raw_fd,
events: libc::POLLIN,
revents: 0,
};
// SAFETY: `pfd` is a single valid pollfd; `poll` reads/writes exactly it for 200 ms.
let rc = unsafe { libc::poll(&mut pfd, 1, 200) };
if rc < 0 {
let err = std::io::Error::last_os_error();
drop(guard);
if err.kind() == std::io::ErrorKind::Interrupted {
continue; // EINTR — recheck stop, retry
}
break;
}
if rc == 0 {
drop(guard); // timeout — recheck stop
continue;
}
if pfd.revents & libc::POLLIN != 0 {
if guard.read().is_err() {
break;
}
} else {
drop(guard); // POLLHUP / POLLERR — connection gone
break;
}
}
let _ = state.tx.send(ClipEvent::Closed);
}
/// Create a `pipe2(O_CLOEXEC)`, returning `(read_end, write_end)` as owned fds.
fn make_pipe() -> Result<(OwnedFd, OwnedFd)> {
let mut fds = [0 as libc::c_int; 2];
// SAFETY: `pipe2` fully initializes the 2-element `fds` on success (returns 0); on failure (-1)
// we bail before reading it. Each returned fd is fresh and owned by exactly one `OwnedFd`.
let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
if rc < 0 {
return Err(anyhow!("pipe2 failed: {}", std::io::Error::last_os_error()));
}
// SAFETY: `fds[0]`/`fds[1]` are the fresh, uniquely-owned pipe ends from the checked `pipe2`.
let read_fd = unsafe { OwnedFd::from_raw_fd(fds[0]) };
// SAFETY: as above for the write end.
let write_fd = unsafe { OwnedFd::from_raw_fd(fds[1]) };
Ok((read_fd, write_fd))
}
/// On-glass tests against a **live** `data-control` compositor (Hyprland / Sway / KWin). `#[ignore]`d
/// — run explicitly under such a session with `wl-clipboard` present:
///
/// ```text
/// WAYLAND_DISPLAY=wayland-1 cargo test -p punktfunk-host --bin punktfunk-host \
/// -- --ignored --nocapture clipboard::wayland::live
/// ```
///
/// Each test skips (does not fail) when `open()` finds no backend — so `--ignored` on GNOME (no
/// data-control) or a headless CI runner is a clean no-op instead of a false failure.
#[cfg(test)]
mod live {
use super::*;
use std::io::Write as _;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// Poll the event channel (sync `try_recv`, no runtime) until `pred` matches or `timeout`.
fn wait_event(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<ClipEvent>,
timeout: Duration,
mut pred: impl FnMut(&ClipEvent) -> bool,
) -> Option<ClipEvent> {
let deadline = Instant::now() + timeout;
loop {
match rx.try_recv() {
Ok(ev) if pred(&ev) => return Some(ev),
Ok(_) => {}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
if Instant::now() >= deadline {
return None;
}
std::thread::sleep(Duration::from_millis(20));
}
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => return None,
}
}
}
/// Set the compositor selection from a "host app" (`wl-copy`, which forks a server that holds it).
fn wl_copy(bytes: &[u8], mime: &str) {
let mut child = Command::new("wl-copy")
.arg("--type")
.arg(mime)
.stdin(Stdio::piped())
.spawn()
.expect("spawn wl-copy");
child
.stdin
.take()
.unwrap()
.write_all(bytes)
.expect("write to wl-copy");
let _ = child.wait(); // foreground exits; the fork keeps serving
std::thread::sleep(Duration::from_millis(150));
}
fn open_or_skip() -> Option<(
ClipboardBackend,
tokio::sync::mpsc::UnboundedReceiver<ClipEvent>,
)> {
if Command::new("wl-copy").arg("--version").output().is_err() {
eprintln!("SKIP: wl-clipboard not installed");
return None;
}
match ClipboardBackend::open() {
Ok(v) => Some(v),
Err(e) => {
eprintln!("SKIP: no data-control backend on this compositor: {e:#}");
None
}
}
}
/// Host copy → we observe a `Selection` and can `read_current` the exact bytes back — both text
/// and PNG (§3.5 format normalization end to end).
#[test]
#[ignore = "needs a live data-control compositor (WAYLAND_DISPLAY)"]
fn live_host_copy_is_readable() {
let Some((backend, mut rx)) = open_or_skip() else {
return;
};
// Text.
wl_copy(b"hello-from-host-app", "text/plain;charset=utf-8");
let ev = wait_event(&mut rx, Duration::from_secs(3), |e| {
matches!(e, ClipEvent::Selection { mimes } if mimes.iter().any(|m| m == super::super::WIRE_TEXT))
})
.expect("Selection event carrying text after wl-copy");
assert!(matches!(ev, ClipEvent::Selection { .. }));
assert_eq!(
backend.read_current(super::super::WIRE_TEXT).unwrap(),
b"hello-from-host-app"
);
// PNG (arbitrary bytes tagged image/png — data-control is format-agnostic).
let png = b"\x89PNG\r\n\x1a\n-fake-but-tagged-image/png";
wl_copy(png, "image/png");
wait_event(&mut rx, Duration::from_secs(3), |e| {
matches!(e, ClipEvent::Selection { mimes } if mimes.iter().any(|m| m == super::super::WIRE_PNG))
})
.expect("Selection event carrying image/png");
assert_eq!(backend.read_current(super::super::WIRE_PNG).unwrap(), png);
}
/// We install a client's offer as the host selection; a host app (`wl-paste`) pasting it fires a
/// `Paste` event that we fulfill with bytes, and the host app receives exactly those bytes.
#[test]
#[ignore = "needs a live data-control compositor (WAYLAND_DISPLAY)"]
fn live_set_offer_is_pasteable() {
let Some((backend, mut rx)) = open_or_skip() else {
return;
};
backend
.set_offer(&[super::super::WIRE_TEXT.to_string()])
.expect("install offer");
// A host app pastes our offered selection.
let child = Command::new("wl-paste")
.arg("-n")
.stdout(Stdio::piped())
.spawn()
.expect("spawn wl-paste");
let paste = wait_event(&mut rx, Duration::from_secs(3), |e| {
matches!(e, ClipEvent::Paste { .. })
})
.expect("Paste event after wl-paste reads our offer");
match paste {
ClipEvent::Paste { mime, responder } => {
assert_eq!(
mime,
super::super::WIRE_TEXT,
"paste requested the text format"
);
match responder {
PasteResponder::Fd(fd) => {
super::super::fulfill_paste(fd, b"served-by-punktfunk").expect("fulfill");
}
PasteResponder::Channel(_) => panic!("data-control paste must carry an fd"),
}
}
_ => unreachable!(),
}
let out = child.wait_with_output().expect("wl-paste output");
assert_eq!(out.stdout, b"served-by-punktfunk");
}
}
+664
View File
@@ -0,0 +1,664 @@
//! Host-side shared-clipboard backend for Windows (`design/clipboard-and-file-transfer.md` §4, Phase
//! 3). The Win32 clipboard is thread-affine and message-driven, so the whole backend lives on one
//! dedicated **message-loop thread** owning a hidden message-only window:
//!
//! * **host copy → client** — `AddClipboardFormatListener` delivers `WM_CLIPBOARDUPDATE`; we map the
//! available formats to wire MIMEs, cache them, and emit [`ClipEvent::Selection`]. Our own offers
//! are suppressed by the owner-check (we never forward what we ourselves put on the clipboard).
//! * **client fetch of the host clipboard** — a `Cmd::Read` reads the requested format's HGLOBAL and
//! converts it to wire bytes ([`super::winfmt`]).
//! * **client copy → host** — a `Cmd::SetOffer` installs the client's formats via OLE **delayed
//! rendering**: `SetClipboardData(fmt, NULL)`, so no bytes cross until a host app actually pastes.
//! * **host paste of client content** — the paste triggers `WM_RENDERFORMAT`; the message-loop thread
//! blocks (bounded) while the coordinator fetches the bytes from the client, then `SetClipboardData`s
//! them for the pasting app.
//!
//! The async coordinator drives the thread through a [`Cmd`] channel woken by `PostMessage(WM_APP_CMD)`
//! (`PostMessage` is the documented thread-safe way to poke a message loop). Per-window state hangs
//! off `GWLP_USERDATA`, so multiple concurrent sessions each get their own window + state.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::cell::RefCell;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::Context as _;
use ::windows::core::{w, PCWSTR};
use ::windows::Win32::Foundation::{
GetLastError, GlobalFree, HANDLE, HGLOBAL, HINSTANCE, HWND, LPARAM, LRESULT, WPARAM,
};
use ::windows::Win32::System::DataExchange::{
AddClipboardFormatListener, CloseClipboard, EmptyClipboard, GetClipboardData,
GetClipboardOwner, IsClipboardFormatAvailable, OpenClipboard, RegisterClipboardFormatW,
SetClipboardData,
};
use ::windows::Win32::System::LibraryLoader::GetModuleHandleW;
use ::windows::Win32::System::Memory::{
GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE, GMEM_ZEROINIT,
};
use ::windows::Win32::System::Ole::CF_UNICODETEXT;
use ::windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetMessageW,
GetWindowLongPtrW, PostMessageW, PostQuitMessage, RegisterClassW, SetWindowLongPtrW,
TranslateMessage, GWLP_USERDATA, HWND_MESSAGE, MSG, WINDOW_EX_STYLE, WINDOW_STYLE, WM_APP,
WM_CLIPBOARDUPDATE, WM_DESTROY, WM_RENDERFORMAT, WNDCLASSW,
};
use super::winfmt;
use super::{ClipEvent, PasteResponder, WIRE_HTML, WIRE_PNG, WIRE_RTF, WIRE_TEXT};
/// Custom app message that wakes the pump to drain the [`Cmd`] channel.
const WM_APP_CMD: u32 = WM_APP + 1;
/// Upper bound the message-loop thread waits for the client's bytes during a `WM_RENDERFORMAT` paste.
/// The pasting app is frozen until we answer, so this caps how long a paste can hang; on expiry the
/// format is left unrendered (an empty paste) rather than blocking indefinitely.
const RENDER_TIMEOUT: Duration = Duration::from_secs(10);
/// `OpenClipboard` fails while another process transiently holds the clipboard (clipboard managers do
/// this constantly); retry briefly before giving up.
const OPEN_RETRIES: u32 = 20;
const OPEN_RETRY_DELAY: Duration = Duration::from_millis(5);
/// `RegisterClassW` returns this when the (process-global) class already exists — expected on the 2nd+
/// concurrent session, and not an error (we never unregister the class).
const ERROR_CLASS_ALREADY_EXISTS: u32 = 1410;
type ClipTx = tokio::sync::mpsc::UnboundedSender<ClipEvent>;
/// A command from the async coordinator into the message-loop thread. Delivered over a tokio channel
/// and drained on `WM_APP_CMD`.
enum Cmd {
/// Install the client's wire MIMEs as a delayed-render host selection (empty ⇒ clear).
SetOffer(Vec<String>),
/// Drop the selection we own.
Clear,
/// Read one wire format of the current host selection for a client fetch.
Read {
wire: String,
resp: tokio::sync::oneshot::Sender<anyhow::Result<Vec<u8>>>,
},
/// Tear the window + thread down.
Shutdown,
}
/// Message-loop-thread-owned state, reached from the `WndProc` via `GWLP_USERDATA`. Only the message
/// thread ever dereferences it, so the `RefCell`s are sound (no cross-thread sharing); the fields the
/// async handle also touches (`current_wire`) are behind their own `Arc<Mutex>`.
struct WinClip {
/// Backend → coordinator events.
clip_tx: ClipTx,
/// The current host selection's wire MIMEs, shared with the [`WindowsClipboard`] handle.
current_wire: Arc<Mutex<Vec<String>>>,
/// Coordinator → backend commands, drained on `WM_APP_CMD`.
cmd_rx: RefCell<tokio::sync::mpsc::UnboundedReceiver<Cmd>>,
/// Clipboard format ids we currently promise via delayed rendering (for `WM_RENDERFORMAT`).
offered: RefCell<Vec<u32>>,
fmt_html: u32,
fmt_rtf: u32,
fmt_png: u32,
/// Our own message window — used for the owner-check and clipboard opens.
own_hwnd: HWND,
}
impl WinClip {
/// `WM_CLIPBOARDUPDATE`: a host app copied (or the clipboard was cleared). Suppress our own
/// delayed-render echoes via the owner-check, else announce the new wire MIMEs.
fn on_clipboard_update(&self, hwnd: HWND) {
// SAFETY: GetClipboardOwner has no preconditions and needs no open clipboard.
let owner = unsafe { GetClipboardOwner() }.unwrap_or_default();
if owner.0 == hwnd.0 {
// Our own offer's echo (we own the clipboard) — not a host copy.
return;
}
let mimes = self.available_wire_mimes();
*self.current_wire.lock().unwrap() = mimes.clone();
let _ = self.clip_tx.send(ClipEvent::Selection { mimes });
}
/// The wire MIMEs the current clipboard advertises, in a stable order.
fn available_wire_mimes(&self) -> Vec<String> {
// SAFETY: IsClipboardFormatAvailable has no preconditions and needs no open clipboard.
let avail = |fmt: u32| unsafe { IsClipboardFormatAvailable(fmt) }.is_ok();
let mut out = Vec::new();
if avail(CF_UNICODETEXT.0 as u32) {
out.push(WIRE_TEXT.to_string());
}
if avail(self.fmt_html) {
out.push(WIRE_HTML.to_string());
}
if avail(self.fmt_rtf) {
out.push(WIRE_RTF.to_string());
}
if avail(self.fmt_png) {
out.push(WIRE_PNG.to_string());
}
out
}
/// `WM_APP_CMD`: run every queued coordinator command on this thread. Drained into a `Vec` first so
/// the `cmd_rx` borrow is released before any command runs (defensive against re-entry).
fn drain_commands(&self, hwnd: HWND) {
let mut cmds = Vec::new();
{
let mut rx = self.cmd_rx.borrow_mut();
while let Ok(c) = rx.try_recv() {
cmds.push(c);
}
}
for c in cmds {
match c {
Cmd::SetOffer(wire) => self.apply_offer(hwnd, &wire),
Cmd::Clear => self.clear(hwnd),
Cmd::Read { wire, resp } => {
let _ = resp.send(self.read(&wire));
}
Cmd::Shutdown => {
// Drop our offer first so no WM_RENDERALLFORMATS fires as the window dies (we do
// NOT want the client's content to outlive the session on the host clipboard).
self.clear(hwnd);
// SAFETY: our own live window; triggers WM_DESTROY → PostQuitMessage → pump exit.
unsafe {
let _ = DestroyWindow(hwnd);
}
return;
}
}
}
}
/// Install the client's offer as a delayed-render host selection.
fn apply_offer(&self, hwnd: HWND, wire: &[String]) {
let fmts = self.formats_for_offer(wire);
if fmts.is_empty() {
self.clear(hwnd);
return;
}
if open_clipboard_retry(hwnd).is_err() {
tracing::debug!("clipboard: OpenClipboard for set_offer failed");
return;
}
let _guard = ClipboardGuard;
// SAFETY: the clipboard is open (ClipboardGuard closes it); EmptyClipboard makes us the owner,
// then each SetClipboardData(_, None) registers a delayed-render promise for that format.
unsafe {
let _ = EmptyClipboard();
for &f in &fmts {
let _ = SetClipboardData(f, None);
}
}
*self.offered.borrow_mut() = fmts;
}
/// Drop the selection we own (empty the clipboard iff we're still its owner).
fn clear(&self, hwnd: HWND) {
let had = {
let mut o = self.offered.borrow_mut();
let was = !o.is_empty();
o.clear();
was
};
if !had {
return;
}
// SAFETY: GetClipboardOwner has no preconditions.
let owner = unsafe { GetClipboardOwner() }.unwrap_or_default();
if owner.0 != hwnd.0 {
return; // someone else took the clipboard already
}
if open_clipboard_retry(hwnd).is_err() {
return;
}
let _guard = ClipboardGuard;
// SAFETY: the clipboard is open (ClipboardGuard closes it); empty it to drop our promises.
unsafe {
let _ = EmptyClipboard();
}
}
/// Read one wire format of the current host selection (a client fetch).
fn read(&self, wire: &str) -> anyhow::Result<Vec<u8>> {
let fmt = self
.format_for_wire(wire)
.context("unsupported wire MIME")?;
// If we own the clipboard, its content is our own delayed-render offer (the client's copy),
// not a host selection — declining avoids GetClipboardData re-entering our own WM_RENDERFORMAT.
// SAFETY: GetClipboardOwner has no preconditions.
if unsafe { GetClipboardOwner() }.unwrap_or_default().0 == self.own_hwnd.0 {
anyhow::bail!("clipboard currently held by our own offer");
}
open_clipboard_retry(self.own_hwnd)?;
let _guard = ClipboardGuard;
// SAFETY: the clipboard is open (ClipboardGuard closes it). GetClipboardData hands back a
// clipboard-owned HGLOBAL (we must NOT free it); GlobalLock/Size/Unlock are balanced and we
// copy exactly GlobalSize bytes out before the lock is released.
let raw = unsafe {
let handle = GetClipboardData(fmt).context("GetClipboardData")?;
let hg = HGLOBAL(handle.0);
let p = GlobalLock(hg);
if p.is_null() {
anyhow::bail!("GlobalLock failed");
}
let n = GlobalSize(hg);
let mut buf = vec![0u8; n];
std::ptr::copy_nonoverlapping(p as *const u8, buf.as_mut_ptr(), n);
let _ = GlobalUnlock(hg);
buf
};
Ok(convert_from_win(wire, &raw))
}
/// `WM_RENDERFORMAT`: a host app is pasting a format we promised. Fetch the bytes from the client
/// (blocking this thread, bounded) and `SetClipboardData` them for the paster.
fn on_render_format(&self, fmt: u32) {
let Some(wire) = self.wire_for_format(fmt) else {
return;
};
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
let ev = ClipEvent::Paste {
mime: wire.to_string(),
responder: PasteResponder::Sync(tx),
};
if self.clip_tx.send(ev).is_err() {
return; // coordinator gone
}
let bytes = match rx.recv_timeout(RENDER_TIMEOUT) {
Ok(b) => b,
Err(_) => return, // timeout / dropped → leave the format unrendered (empty paste)
};
let win_bytes = convert_to_win(wire, &bytes);
let Ok(hg) = alloc_hglobal(&win_bytes) else {
return;
};
// Do NOT OpenClipboard here — the pasting app already holds it open across WM_RENDERFORMAT.
// SAFETY: `hg` is a freshly-filled moveable HGLOBAL. On success the clipboard takes ownership
// (we must not free it); on failure ownership stays with us, so we free it.
unsafe {
if SetClipboardData(fmt, Some(HANDLE(hg.0))).is_err() {
let _ = GlobalFree(Some(hg));
}
}
}
/// The Win32 clipboard format id for a wire MIME (`None` = unsupported).
fn format_for_wire(&self, wire: &str) -> Option<u32> {
match wire {
WIRE_TEXT => Some(CF_UNICODETEXT.0 as u32),
WIRE_HTML => Some(self.fmt_html),
WIRE_RTF => Some(self.fmt_rtf),
WIRE_PNG => Some(self.fmt_png),
_ => None,
}
}
/// The wire MIME for a Win32 clipboard format id (`None` = one we don't offer).
fn wire_for_format(&self, fmt: u32) -> Option<&'static str> {
if fmt == CF_UNICODETEXT.0 as u32 {
Some(WIRE_TEXT)
} else if fmt == self.fmt_html {
Some(WIRE_HTML)
} else if fmt == self.fmt_rtf {
Some(WIRE_RTF)
} else if fmt == self.fmt_png {
Some(WIRE_PNG)
} else {
None
}
}
/// The clipboard format ids to promise for a client offer (dedup, 1:1 with the wire MIMEs — the OS
/// auto-synthesizes CF_TEXT/CF_OEMTEXT from CF_UNICODETEXT, so no manual text fan-out is needed).
fn formats_for_offer(&self, wire: &[String]) -> Vec<u32> {
let mut out = Vec::new();
for w in wire {
if let Some(f) = self.format_for_wire(w) {
if !out.contains(&f) {
out.push(f);
}
}
}
out
}
}
/// The active Windows clipboard backend handle held by [`super::HostClipboard`]. All Win32 work runs
/// on the message-loop thread; this is just the async-side control surface.
pub struct WindowsClipboard {
cmd_tx: tokio::sync::mpsc::UnboundedSender<Cmd>,
/// The message window's `HWND` as an `isize` (so the handle stays `Send`/`Sync`); rebuilt for the
/// `PostMessage` wakeups, which are documented thread-safe.
hwnd: isize,
current_wire: Arc<Mutex<Vec<String>>>,
join: Option<std::thread::JoinHandle<()>>,
}
impl WindowsClipboard {
/// Spin up the message-loop thread + hidden window and return once it has bound (or failed).
pub async fn open() -> anyhow::Result<(
WindowsClipboard,
tokio::sync::mpsc::UnboundedReceiver<ClipEvent>,
)> {
let (clip_tx, clip_rx) = tokio::sync::mpsc::unbounded_channel::<ClipEvent>();
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<Cmd>();
let current_wire = Arc::new(Mutex::new(Vec::new()));
// Register the three custom formats up front — process-global and thread-agnostic, so this is
// fine off the message thread and lets bring-up fail cleanly if the atoms can't be created.
let fmt_html = register_format(w!("HTML Format"))?;
let fmt_rtf = register_format(w!("Rich Text Format"))?;
let fmt_png = register_format(w!("PNG"))?;
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<anyhow::Result<isize>>();
let cw = Arc::clone(&current_wire);
let join = std::thread::Builder::new()
.name("punktfunk-clipboard-win".into())
.spawn(move || pump_thread(clip_tx, cmd_rx, cw, fmt_html, fmt_rtf, fmt_png, ready_tx))
.context("spawn windows clipboard thread")?;
let hwnd = match tokio::time::timeout(Duration::from_secs(3), ready_rx).await {
Ok(Ok(Ok(h))) => h,
Ok(Ok(Err(e))) => return Err(e),
Ok(Err(_)) => anyhow::bail!("windows clipboard thread exited during bring-up"),
Err(_) => anyhow::bail!("windows clipboard bring-up timed out"),
};
Ok((
WindowsClipboard {
cmd_tx,
hwnd,
current_wire,
join: Some(join),
},
clip_rx,
))
}
/// The current host selection's wire MIMEs (empty = nothing to offer).
pub fn current_wire_mimes(&self) -> Vec<String> {
self.current_wire.lock().unwrap().clone()
}
/// Install a client's offered formats as the host selection (fire-and-forget onto the thread).
pub fn set_offer(&self, wire_mimes: &[String]) {
let _ = self.cmd_tx.send(Cmd::SetOffer(wire_mimes.to_vec()));
self.wake();
}
/// Drop the host selection we own (fire-and-forget onto the thread).
pub fn clear_offer(&self) {
let _ = self.cmd_tx.send(Cmd::Clear);
self.wake();
}
/// Read one wire format of the current host selection (a client's fetch).
pub async fn read_current(&self, wire_mime: &str) -> anyhow::Result<Vec<u8>> {
let (tx, rx) = tokio::sync::oneshot::channel();
self.cmd_tx
.send(Cmd::Read {
wire: wire_mime.to_string(),
resp: tx,
})
.map_err(|_| anyhow::anyhow!("clipboard thread gone"))?;
self.wake();
rx.await
.map_err(|_| anyhow::anyhow!("clipboard read dropped"))?
}
/// Poke the message loop so it drains the command channel.
fn wake(&self) {
// SAFETY: PostMessageW is documented thread-safe; `hwnd` is our message window (or already
// destroyed, in which case the post harmlessly fails and is ignored).
let _ = unsafe {
PostMessageW(
Some(HWND(self.hwnd as *mut core::ffi::c_void)),
WM_APP_CMD,
WPARAM(0),
LPARAM(0),
)
};
}
}
impl Drop for WindowsClipboard {
fn drop(&mut self) {
let _ = self.cmd_tx.send(Cmd::Shutdown);
self.wake();
if let Some(j) = self.join.take() {
let _ = j.join();
}
}
}
/// RAII `CloseClipboard` guard — pairs with a successful `open_clipboard_retry`, closing on scope exit
/// (including early `?`/`bail!` returns).
struct ClipboardGuard;
impl Drop for ClipboardGuard {
fn drop(&mut self) {
// SAFETY: constructed only after a successful OpenClipboard on this thread.
unsafe {
let _ = CloseClipboard();
}
}
}
/// Register (or resolve the existing id of) a custom clipboard format.
fn register_format(name: PCWSTR) -> anyhow::Result<u32> {
// SAFETY: RegisterClipboardFormatW is thread-agnostic and process-global; `name` is a static
// NUL-terminated wide literal.
let id = unsafe { RegisterClipboardFormatW(name) };
if id == 0 {
anyhow::bail!("RegisterClipboardFormatW failed");
}
Ok(id)
}
/// Allocate a moveable HGLOBAL holding `bytes` (zero-init so an empty payload is still a valid, locked
/// buffer). Ownership is transferred to the clipboard by a following `SetClipboardData`.
fn alloc_hglobal(bytes: &[u8]) -> anyhow::Result<HGLOBAL> {
// SAFETY: allocate at least one byte (GlobalLock of a 0-size block is unreliable), lock it, copy
// the payload in, unlock. Alloc/lock/unlock are balanced; on lock failure we free before erroring.
unsafe {
let hg = GlobalAlloc(GMEM_MOVEABLE | GMEM_ZEROINIT, bytes.len().max(1))
.context("GlobalAlloc")?;
let p = GlobalLock(hg);
if p.is_null() {
let _ = GlobalFree(Some(hg));
anyhow::bail!("GlobalLock failed");
}
std::ptr::copy_nonoverlapping(bytes.as_ptr(), p as *mut u8, bytes.len());
let _ = GlobalUnlock(hg);
Ok(hg)
}
}
/// `OpenClipboard(hwnd)` with a brief retry loop (another process often holds it transiently).
fn open_clipboard_retry(hwnd: HWND) -> anyhow::Result<()> {
for _ in 0..OPEN_RETRIES {
// SAFETY: OpenClipboard with our window as owner; balanced by ClipboardGuard/CloseClipboard.
if unsafe { OpenClipboard(Some(hwnd)) }.is_ok() {
return Ok(());
}
std::thread::sleep(OPEN_RETRY_DELAY);
}
anyhow::bail!("OpenClipboard failed after retries")
}
/// Convert a Win32 clipboard payload to wire bytes.
fn convert_from_win(wire: &str, raw: &[u8]) -> Vec<u8> {
match wire {
WIRE_TEXT => winfmt::text_from_utf16(raw),
WIRE_HTML => winfmt::html_from_cf(raw),
WIRE_RTF => winfmt::rtf_from_cf(raw),
_ => raw.to_vec(), // PNG + anything else: verbatim
}
}
/// Convert wire bytes to a Win32 clipboard payload.
fn convert_to_win(wire: &str, wire_bytes: &[u8]) -> Vec<u8> {
match wire {
WIRE_TEXT => winfmt::text_to_utf16(wire_bytes),
WIRE_HTML => winfmt::html_to_cf(wire_bytes),
_ => wire_bytes.to_vec(), // RTF + PNG + anything else: verbatim
}
}
/// Create the hidden message-only window (registering the class once, process-wide).
fn create_window() -> anyhow::Result<HWND> {
// SAFETY: standard window-class registration + message-only window creation; every argument is a
// valid handle / static literal, and `wndproc` matches the WNDPROC ABI.
unsafe {
let hinstance: HINSTANCE = GetModuleHandleW(PCWSTR::null())
.context("GetModuleHandleW")?
.into();
let class_name = w!("PunktfunkClipboardWindow");
let wc = WNDCLASSW {
lpfnWndProc: Some(wndproc),
hInstance: hinstance,
lpszClassName: class_name,
..Default::default()
};
if RegisterClassW(&wc) == 0 {
let code = GetLastError();
if code.0 != ERROR_CLASS_ALREADY_EXISTS {
anyhow::bail!("RegisterClassW failed: {code:?}");
}
}
let hwnd = CreateWindowExW(
WINDOW_EX_STYLE(0),
class_name,
w!(""),
WINDOW_STYLE(0),
0,
0,
0,
0,
Some(HWND_MESSAGE),
None,
Some(hinstance),
None,
)
.context("CreateWindowExW")?;
Ok(hwnd)
}
}
/// The message-loop thread body: build the window, wire up state, then pump until `WM_QUIT`.
fn pump_thread(
clip_tx: ClipTx,
cmd_rx: tokio::sync::mpsc::UnboundedReceiver<Cmd>,
current_wire: Arc<Mutex<Vec<String>>>,
fmt_html: u32,
fmt_rtf: u32,
fmt_png: u32,
ready_tx: tokio::sync::oneshot::Sender<anyhow::Result<isize>>,
) {
let hwnd = match create_window() {
Ok(h) => h,
Err(e) => {
let _ = ready_tx.send(Err(e));
return;
}
};
// A clone that outlives the boxed state, so we can announce Closed after the pump ends.
let closed_tx = clip_tx.clone();
let state = Box::new(WinClip {
clip_tx,
current_wire,
cmd_rx: RefCell::new(cmd_rx),
offered: RefCell::new(Vec::new()),
fmt_html,
fmt_rtf,
fmt_png,
own_hwnd: hwnd,
});
let ptr = Box::into_raw(state);
// SAFETY: stash the state pointer for the WndProc; the window was created on this thread and the
// pointer stays valid until we reclaim the Box after the pump exits.
unsafe {
SetWindowLongPtrW(hwnd, GWLP_USERDATA, ptr as isize);
}
// Snapshot whatever is already on the host clipboard, so the first client `enable` announces it
// (AddClipboardFormatListener only delivers *subsequent* changes).
{
// SAFETY: `ptr` is the live state we just stored; only this thread dereferences it.
let st = unsafe { &*ptr };
*st.current_wire.lock().unwrap() = st.available_wire_mimes();
}
// SAFETY: `hwnd` is our live window; start receiving WM_CLIPBOARDUPDATE.
if let Err(e) = unsafe { AddClipboardFormatListener(hwnd) } {
// SAFETY: tear down the half-built window and reclaim the leaked state box.
unsafe {
let _ = DestroyWindow(hwnd);
drop(Box::from_raw(ptr));
}
let _ = ready_tx.send(Err(
anyhow::Error::new(e).context("AddClipboardFormatListener")
));
return;
}
let _ = ready_tx.send(Ok(hwnd.0 as isize));
// SAFETY: the standard Win32 message pump. GetMessageW returns >0 for a message, 0 for WM_QUIT,
// and -1 on error — `.0 > 0` exits on both 0 and -1.
unsafe {
let mut msg = MSG::default();
while GetMessageW(&mut msg, None, 0, 0).0 > 0 {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
// Pump exited (window destroyed): reclaim the leaked state box. No WndProc runs after this point.
// SAFETY: `ptr` came from Box::into_raw above, is dereferenced only on this thread, and the
// message loop has ended so no further access occurs.
unsafe {
drop(Box::from_raw(ptr));
}
let _ = closed_tx.send(ClipEvent::Closed);
}
/// The window procedure. Reaches per-window state through `GWLP_USERDATA`; runs only on the message
/// thread. Registered as the class `WNDPROC` (a safe fn coerces to the `unsafe extern "system"` ABI).
extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
// SAFETY: GWLP_USERDATA holds the `*const WinClip` stored right after window creation (0/null for
// the WM_(NC)CREATE messages that fire before that — handled by the null check below).
let ptr = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) } as *const WinClip;
if ptr.is_null() {
// SAFETY: default processing before our state pointer is attached.
return unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) };
}
// SAFETY: `ptr` is the live Box<WinClip> leaked in pump_thread, owned by this (the only) message
// thread and freed only after the pump exits; the WndProc is not re-entered for this window, so
// `&*ptr` is a valid shared borrow.
let st = unsafe { &*ptr };
match msg {
WM_CLIPBOARDUPDATE => {
st.on_clipboard_update(hwnd);
LRESULT(0)
}
WM_RENDERFORMAT => {
st.on_render_format(wparam.0 as u32);
LRESULT(0)
}
WM_APP_CMD => {
st.drain_commands(hwnd);
LRESULT(0)
}
WM_DESTROY => {
// SAFETY: ends the GetMessageW pump by posting WM_QUIT to this thread's queue.
unsafe {
PostQuitMessage(0);
}
LRESULT(0)
}
// SAFETY: default handling for every other message.
_ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) },
}
}
+257
View File
@@ -0,0 +1,257 @@
//! Pure byte conversions between the Win32 clipboard formats and the portable wire MIMEs
//! (`design/clipboard-and-file-transfer.md` §3.5). Kept free of any `windows`-crate dependency so it
//! compiles on every host and its unit tests exercise the fiddly bits (CF_HTML offset math, UTF-16
//! (de)serialization) without a Windows box. The [`super::windows`] backend is the only production
//! consumer; it wraps these with the actual `GetClipboardData`/`SetClipboardData` calls.
//!
//! Format map (Win32 ↔ wire):
//! * `CF_UNICODETEXT` (UTF-16LE + NUL) ↔ `text/plain;charset=utf-8`
//! * `"HTML Format"` (CF_HTML, UTF-8 + ASCII header) ↔ `text/html`
//! * `"Rich Text Format"` (raw RTF) ↔ `text/rtf`
//! * `"PNG"` (raw PNG) ↔ `image/png` — identity, handled inline by the backend.
// ---- CF_UNICODETEXT ↔ text/plain;charset=utf-8 -----------------------------------------------
/// `CF_UNICODETEXT` HGLOBAL bytes → UTF-8 wire bytes. `raw` is the exact `GlobalSize`-length buffer;
/// it holds little-endian UTF-16 code units terminated by a single `0x0000`.
pub fn text_from_utf16(raw: &[u8]) -> Vec<u8> {
// Reinterpret each LE 2-byte pair as a UTF-16 code unit; a stray odd trailing byte (never present
// in valid data) is dropped by `chunks_exact`.
let mut units: Vec<u16> = raw
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
// Strip exactly one trailing NUL terminator if present (guard against eating a real code unit).
if units.last() == Some(&0) {
units.pop();
}
String::from_utf16_lossy(&units).into_bytes()
}
/// UTF-8 wire bytes → `CF_UNICODETEXT` HGLOBAL bytes (UTF-16LE + a required `0x0000` terminator).
pub fn text_to_utf16(wire: &[u8]) -> Vec<u8> {
let s = String::from_utf8_lossy(wire);
let mut out = Vec::with_capacity(wire.len() * 2 + 2);
for u in s.encode_utf16() {
out.extend_from_slice(&u.to_le_bytes());
}
out.extend_from_slice(&0u16.to_le_bytes()); // REQUIRED NUL terminator for CF_UNICODETEXT
out
}
// ---- "HTML Format" (CF_HTML) ↔ text/html -----------------------------------------------------
//
// CF_HTML is UTF-8: an ASCII `Key:Value\r\n` header carrying byte offsets, then the HTML with
// `<!--StartFragment-->`/`<!--EndFragment-->` markers. Offsets are byte counts from buffer start;
// the offsets live *inside* the header, so their digit-width feeds back into the header length. The
// spec-blessed fix (Chromium/Firefox/LibreOffice) is fixed-width 10-digit zero-padded offsets, which
// makes the header a compile-time constant and every offset a one-pass computation.
const CF_HTML_HEADER: &str = "Version:0.9\r\n\
StartHTML:0000000000\r\n\
EndHTML:0000000000\r\n\
StartFragment:0000000000\r\n\
EndFragment:0000000000\r\n";
const CF_HTML_PREFIX: &str = "<html><body>\r\n<!--StartFragment-->";
const CF_HTML_SUFFIX: &str = "<!--EndFragment-->\r\n</body></html>";
/// UTF-8 HTML fragment (wire bytes) → a `CF_HTML` buffer, NUL-terminated. The trailing NUL is the
/// conventional CF_HTML expectation (§4); `EndHTML` still points at content end, before the NUL.
pub fn html_to_cf(wire: &[u8]) -> Vec<u8> {
let fragment = String::from_utf8_lossy(wire);
let start_html = CF_HTML_HEADER.len(); // 105
let start_fragment = start_html + CF_HTML_PREFIX.len(); // 139
let end_fragment = start_fragment + fragment.len(); // byte length — fragment may be multibyte
let end_html = end_fragment + CF_HTML_SUFFIX.len();
let mut buf = Vec::with_capacity(end_html + 1);
buf.extend_from_slice(CF_HTML_HEADER.as_bytes());
buf.extend_from_slice(CF_HTML_PREFIX.as_bytes());
buf.extend_from_slice(fragment.as_bytes());
buf.extend_from_slice(CF_HTML_SUFFIX.as_bytes());
// Overwrite the four zero-padded fields in place, restricting the search to the header region so a
// fragment that happens to contain "StartHTML:" can't fool the patcher.
patch_offset(&mut buf[..start_html], b"StartHTML:", start_html);
patch_offset(&mut buf[..start_html], b"EndHTML:", end_html);
patch_offset(&mut buf[..start_html], b"StartFragment:", start_fragment);
patch_offset(&mut buf[..start_html], b"EndFragment:", end_fragment);
buf.push(0); // conventional NUL terminator
buf
}
/// A `CF_HTML` buffer → the UTF-8 HTML fragment (wire bytes). Uses the `StartFragment`/`EndFragment`
/// offsets; falls back to `StartHTML`/`EndHTML`, then to the whole buffer, if the markers are absent.
pub fn html_from_cf(raw: &[u8]) -> Vec<u8> {
let range = header_range(raw, b"StartFragment:", b"EndFragment:")
.or_else(|| header_range(raw, b"StartHTML:", b"EndHTML:"));
match range {
// Content is UTF-8 per spec; return the exact slice (drop any trailing NUL for cleanliness).
Some((start, end)) => {
let slice = &raw[start..end];
strip_trailing_nul(slice).to_vec()
}
None => strip_trailing_nul(raw).to_vec(),
}
}
/// Resolve `[start_label .. end_label]` into a validated byte range within `raw`.
fn header_range(raw: &[u8], start_label: &[u8], end_label: &[u8]) -> Option<(usize, usize)> {
let start = read_header_offset(raw, start_label)?;
let end = read_header_offset(raw, end_label)?;
if start <= end && end <= raw.len() {
Some((start, end))
} else {
None
}
}
/// Overwrite the 10 ASCII digits following `label` in `header` with `value`, zero-padded.
fn patch_offset(header: &mut [u8], label: &[u8], value: usize) {
if let Some(pos) = find(header, label) {
let at = pos + label.len();
if at + 10 <= header.len() {
let digits = format!("{value:010}");
header[at..at + 10].copy_from_slice(digits.as_bytes());
}
}
}
/// Read the decimal integer following `label:` in the ASCII header. The colon-suffixed labels only
/// match in the header, never the marker comments (`<!--StartFragment-->`) or fragment text.
fn read_header_offset(raw: &[u8], label: &[u8]) -> Option<usize> {
let mut at = find(raw, label)? + label.len();
let mut n: usize = 0;
let mut any = false;
while let Some(&b) = raw.get(at) {
if b.is_ascii_digit() {
n = n.checked_mul(10)?.checked_add((b - b'0') as usize)?;
any = true;
at += 1;
} else {
break; // stops at '\r'
}
}
any.then_some(n)
}
fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || needle.len() > hay.len() {
return None;
}
hay.windows(needle.len()).position(|w| w == needle)
}
// ---- "Rich Text Format" ↔ text/rtf -----------------------------------------------------------
/// `"Rich Text Format"` HGLOBAL bytes → RTF wire bytes. RTF is `{ }`-delimited; some producers append
/// a NUL past the final `}`, so strip a single trailing NUL to keep the wire payload byte-clean.
pub fn rtf_from_cf(raw: &[u8]) -> Vec<u8> {
strip_trailing_nul(raw).to_vec()
}
fn strip_trailing_nul(b: &[u8]) -> &[u8] {
match b.last() {
Some(0) => &b[..b.len() - 1],
_ => b,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_round_trips_and_handles_terminator() {
// UTF-8 → UTF-16LE+NUL → UTF-8.
let wire = "héllo 🌍".as_bytes();
let cf = text_to_utf16(wire);
// Ends with a single 0x0000 terminator.
assert_eq!(&cf[cf.len() - 2..], &[0, 0]);
assert_eq!(text_from_utf16(&cf), wire);
// A CF buffer *without* a terminator still decodes (no code unit eaten).
let no_term: Vec<u8> = "hi".encode_utf16().flat_map(u16::to_le_bytes).collect();
assert_eq!(text_from_utf16(&no_term), b"hi");
// Empty text → just the terminator → empty wire.
assert_eq!(text_to_utf16(b""), vec![0, 0]);
assert_eq!(text_from_utf16(&[0, 0]), b"");
}
#[test]
fn cf_html_matches_the_spec_offsets() {
// The worked example from the format reference: fragment "Hello".
let cf = html_to_cf(b"Hello");
let s = String::from_utf8(cf.clone()).unwrap();
assert!(s.contains("StartHTML:0000000105"), "{s}");
assert!(s.contains("EndHTML:0000000178"), "{s}");
assert!(s.contains("StartFragment:0000000139"), "{s}");
assert!(s.contains("EndFragment:0000000144"), "{s}");
// The declared fragment range must slice back to exactly "Hello".
let start = read_header_offset(&cf, b"StartFragment:").unwrap();
let end = read_header_offset(&cf, b"EndFragment:").unwrap();
assert_eq!(&cf[start..end], b"Hello");
// Trailing NUL present, and EndHTML points *before* it.
assert_eq!(*cf.last().unwrap(), 0);
assert_eq!(read_header_offset(&cf, b"EndHTML:").unwrap(), cf.len() - 1);
}
#[test]
fn cf_html_round_trips_including_multibyte() {
for frag in [
"Hello",
"<b>bold</b> & <i>ital</i>",
"café ☕ <span>x</span>",
"",
] {
let cf = html_to_cf(frag.as_bytes());
assert_eq!(html_from_cf(&cf), frag.as_bytes(), "fragment {frag:?}");
}
}
#[test]
fn cf_html_extract_tolerates_foreign_producers() {
// A producer that adds SourceURL and uses Version 1.0 — offsets must still drive extraction,
// never a hardcoded 105-byte header.
let fragment = "picked";
let prefix = "<html><body><!--StartFragment-->";
let header_body = format!(
"Version:1.0\r\nStartHTML:{sh:010}\r\nEndHTML:{eh:010}\r\n\
StartFragment:{sf:010}\r\nEndFragment:{ef:010}\r\nSourceURL:https://x/\r\n",
sh = 0,
eh = 0,
sf = 0,
ef = 0,
);
// Compute real offsets against this ad-hoc layout.
let start_html = header_body.len();
let start_fragment = start_html + prefix.len();
let end_fragment = start_fragment + fragment.len();
let end_html = end_fragment + "<!--EndFragment--></body></html>".len();
let full = format!(
"Version:1.0\r\nStartHTML:{start_html:010}\r\nEndHTML:{end_html:010}\r\n\
StartFragment:{start_fragment:010}\r\nEndFragment:{end_fragment:010}\r\nSourceURL:https://x/\r\n\
{prefix}{fragment}<!--EndFragment--></body></html>"
);
assert_eq!(html_from_cf(full.as_bytes()), fragment.as_bytes());
}
#[test]
fn cf_html_extract_falls_back_without_markers() {
// No fragment markers at all → whole buffer (minus any NUL).
let mut b = b"<p>no markers</p>".to_vec();
assert_eq!(html_from_cf(&b), b"<p>no markers</p>");
b.push(0);
assert_eq!(html_from_cf(&b), b"<p>no markers</p>");
}
#[test]
fn rtf_strips_one_trailing_nul() {
assert_eq!(rtf_from_cf(br"{\rtf1 hi}"), br"{\rtf1 hi}");
assert_eq!(rtf_from_cf(b"{\\rtf1 hi}\0"), br"{\rtf1 hi}");
// Only one NUL is stripped.
assert_eq!(rtf_from_cf(b"x\0\0"), b"x\0");
}
}
+147
View File
@@ -0,0 +1,147 @@
//! Shared clipboard, host side (plan §W6 shape; `design/clipboard-and-file-transfer.md` §4).
//!
//! The wire protocol and the client half live in `punktfunk-core` (`punktfunk_core::quic` +
//! `punktfunk_core::clipboard`); this crate drives the **host's** real session clipboard through
//! the per-OS backends in [`host`] and bridges it to the QUIC clipboard plane through the
//! [`host::session`] coordinator.
//!
//! The orchestrator consumes only this portable facade — [`policy`] / [`enabled`] /
//! [`cap_advertised`], the [`ClipCoordCmd`] channel vocabulary, [`start`], and
//! [`spawn_decline_loop`] — so its control loop compiles unchanged on every host platform; the
//! platform split lives entirely behind [`start`].
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use punktfunk_core::quic::ClipOffer;
/// The per-OS backends (`ext-data-control-v1` / Mutter direct / Win32) behind one
/// `HostClipboard`, plus the backend-agnostic [`host::session`] coordinator.
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub mod host;
/// 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 [`host::session`] coordinator
/// starts. `off` keeps the whole feature dark.
pub fn policy() -> Option<u8> {
use punktfunk_core::quic::{CLIP_POLICY_FILES, CLIP_POLICY_TEXT};
match std::env::var("PUNKTFUNK_CLIPBOARD")
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"" | "0" | "off" | "false" => None,
"text-only" | "no-files" | "text" => Some(CLIP_POLICY_TEXT),
_ => Some(CLIP_POLICY_TEXT | CLIP_POLICY_FILES), // "on" / "1" / anything truthy
}
}
/// Whether the shared clipboard is enabled at all for this host (policy not `off`).
pub fn enabled() -> bool {
policy().is_some()
}
/// Whether the host should advertise `HOST_CAP_CLIPBOARD` in the `Welcome`: the operator policy
/// enables it 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.
pub fn cap_advertised() -> bool {
enabled() && cfg!(any(target_os = "linux", target_os = "windows"))
}
/// A command from the session control loop into the host clipboard coordinator
/// ([`host::session`]). Defined here — portable — so the control loop compiles on every host
/// platform; the coordinator that consumes it exists only where a backend does.
pub enum ClipCoordCmd {
/// The client toggled sync. When enabled, the coordinator (re)announces the current host
/// clipboard; when disabled, it drops any selection it owns and stops forwarding host copies.
SetEnabled(bool),
/// The client copied: install its offered wire MIMEs as a lazy host selection (empty = clear).
RemoteOffer { seq: u32, mimes: Vec<String> },
}
/// Handle to the host clipboard coordinator, held by the session control loop.
pub struct ClipCoord {
/// Whether a real backend is live. `false` on gamescope / older GNOME / an unsupported
/// platform; the control loop then answers an enable request with
/// `CLIP_REASON_BACKEND_UNAVAILABLE` and [`spawn_decline_loop`] handles any stray fetch stream.
pub available: bool,
pub cmd_tx: tokio::sync::mpsc::UnboundedSender<ClipCoordCmd>,
/// Host-copy announcements from the coordinator → control loop → client.
pub offer_rx: tokio::sync::mpsc::UnboundedReceiver<ClipOffer>,
}
/// Open the host clipboard backend (when the operator policy allows it, this session mirrors a
/// real compositor, and the platform has a backend) and spawn its coordinator, returning a handle.
/// Otherwise the handle is inert (`available = false`, channels dropped) so the caller's control
/// loop stays platform-agnostic. `has_compositor` is false for the synthetic protocol-test source,
/// which has no display/clipboard to share — keeping it out of the real session clipboard.
pub async fn start(
conn: quinn::Connection,
clip_enabled: Arc<AtomicBool>,
has_compositor: bool,
) -> ClipCoord {
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
let (offer_tx, offer_rx) = tokio::sync::mpsc::unbounded_channel();
#[cfg(any(target_os = "linux", target_os = "windows"))]
let available = if has_compositor && enabled() {
host::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,
}
}
/// Clipboard fetch-stream accept loop, fallback flavor (`design/clipboard-and-file-transfer.md`
/// §3.3, §4.2). When a backend is live the coordinator (spawned by [`start`]) owns `accept_bi` and
/// serves real host clipboard bytes. This is for the other case: 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.
pub fn spawn_decline_loop(conn: quinn::Connection) {
tokio::spawn(async move {
use punktfunk_core::quic::{clipstream, ClipFetchHdr, CLIP_FETCH_UNAVAILABLE};
while let Ok((mut send, mut recv)) = 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: CLIP_FETCH_UNAVAILABLE,
total_size: 0,
},
)
.await;
});
}
});
}
+4
View File
@@ -10,6 +10,10 @@ repository.workspace = true
[dependencies]
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
# Shared clipboard (design/clipboard-and-file-transfer.md §4): the per-OS session-clipboard
# backends + coordinator. Portable dep — the crate's facade (policy / ClipCoordCmd / start)
# compiles everywhere; the backends are cfg-gated inside it.
pf-clipboard = { path = "../pf-clipboard" }
# M3 native control plane (the `punktfunk/1` QUIC handshake; data plane stays native-thread UDP).
quinn = "0.11"
anyhow = "1"
+150
View File
@@ -848,6 +848,17 @@ async fn serve_session(
let fec_target_ctl = fec_target.clone();
// The session's negotiated rate — the pin PyroWave retarget-refusals ack (§4.6).
let session_bitrate_kbps = welcome.bitrate_kbps;
// Shared-clipboard enable state (client `ClipControl` → host). The coordinator reads it to
// decide whether to forward host copies; the control task flips it on each `ClipControl`.
let clip_enabled = Arc::new(AtomicBool::new(false));
// Start the host clipboard coordinator. On success it watches the session clipboard, forwards
// host copies as `ClipOffer`s (`clip.offer_rx` → control task → 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 / an unsupported platform) — the control task
// then answers `ClipControl` with `BACKEND_UNAVAILABLE` and the decline loop below handles
// stray fetch streams.
let clip = pf_clipboard::start(conn.clone(), clip_enabled.clone(), compositor.is_some()).await;
let clip_available = clip.available;
tokio::spawn(control::run(
ctrl_send,
ctrl_recv,
@@ -864,7 +875,14 @@ async fn serve_session(
probe_tx,
probe_result_rx,
reconfig_result_rx,
clip_enabled,
clip,
));
// Fetch streams with no backend behind them are answered `CLIP_FETCH_UNAVAILABLE` instead of
// hanging (the coordinator owns `accept_bi` when a backend is live — exactly one consumer).
if !clip_available && pf_clipboard::enabled() {
pf_clipboard::spawn_decline_loop(conn.clone());
}
// Input plane: QUIC datagrams → channel → a native per-session thread. Pointer/keyboard
// events are forwarded to the host-lifetime [`InjectorService`] (`inj_tx`) so the portal
@@ -1730,6 +1748,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 clipboard 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 the session
// path (a session test) reads this env, so the mutation is race-free here.
std::env::set_var("PUNKTFUNK_CLIPBOARD", "1");
let host = std::thread::spawn(|| {
run(Punktfunk1Options {
port: 19781,
source: Punktfunk1Source::Synthetic,
seconds: 0,
frames: 600, // keep the session alive well past the control exchange
max_sessions: 1,
max_concurrent: 1,
require_pairing: false,
allow_pairing: false,
pairing_pin: None,
paired_store: None,
data_port: None,
idle_timeout: None,
mdns: false,
})
});
std::thread::sleep(std::time::Duration::from_millis(500));
let mode = punktfunk_core::Mode {
width: 1280,
height: 720,
refresh_hz: 60,
};
let client = NativeClient::connect(
"127.0.0.1",
19781,
mode,
CompositorPref::Auto,
GamepadPref::Auto,
0, // bitrate_kbps
0, // video_caps
2, // audio_channels
0, // video_codecs (HEVC-only)
0, // preferred_codec
None, // display_hdr
None, // launch
None, // pin (TOFU)
None, // identity (host doesn't require pairing)
std::time::Duration::from_secs(10),
)
.expect("client connects to synthetic host");
assert_ne!(
client.host_caps() & HOST_CAP_CLIPBOARD,
0,
"an enabled host advertises HOST_CAP_CLIPBOARD"
);
// A bounded poll over the clipboard event plane.
let poll = |pred: &dyn Fn(&ClipEventCore) -> bool| -> Option<ClipEventCore> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while std::time::Instant::now() < deadline {
match client.next_clip(std::time::Duration::from_millis(200)) {
Ok(ev) if pred(&ev) => return Some(ev),
Ok(_) => {}
Err(punktfunk_core::PunktfunkError::NoFrame) => {}
Err(_) => break, // session closed
}
}
None
};
// Enable sync (requesting files) → the host acks with a ClipState. A synthetic session
// mirrors no compositor, so no clipboard 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()))
}
+85 -2
View File
@@ -6,10 +6,13 @@
//! the data-plane thread over the session's mpsc bridges.
use super::*;
use pf_clipboard::ClipCoordCmd;
use punktfunk_core::quic::{ClipControl, ClipOffer, ClipState};
/// Run the control task for one live session. Owns the control streams (`serve_session` hands them
/// off after negotiation) plus every channel end that bridges to the data-plane thread. Returns
/// when the control stream closes or a data-plane channel drops.
/// off after negotiation) plus every channel end that bridges to the data-plane thread, and the
/// [`pf_clipboard::ClipCoord`] handle bridging to the clipboard coordinator. Returns when the
/// control stream closes or a data-plane channel drops.
#[allow(clippy::too_many_arguments)]
pub(super) async fn run(
mut ctrl_send: quinn::SendStream,
@@ -27,7 +30,17 @@ pub(super) async fn run(
probe_tx: std::sync::mpsc::Sender<ProbeRequest>,
mut probe_result_rx: tokio::sync::mpsc::UnboundedReceiver<ProbeResult>,
mut reconfig_result_rx: tokio::sync::mpsc::UnboundedReceiver<Reconfigured>,
clip_enabled: Arc<AtomicBool>,
clip: pf_clipboard::ClipCoord,
) {
let pf_clipboard::ClipCoord {
available: clip_available,
cmd_tx: clip_cmd_tx,
offer_rx: mut clip_offer_rx,
} = clip;
// 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;
let mut active = initial_mode;
// 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
@@ -180,6 +193,61 @@ pub(super) async fn run(
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 = pf_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.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");
}
@@ -190,6 +258,21 @@ pub(super) async fn run(
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.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
+10 -2
View File
@@ -365,8 +365,16 @@ pub(super) async fn negotiate(
// 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 — see
// `pf_clipboard::cap_advertised` for the deliberate compositor-lacks-data-control case.
host_caps: punktfunk_core::quic::HOST_CAP_GAMEPAD_STATE
| if pf_clipboard::cap_advertised() {
punktfunk_core::quic::HOST_CAP_CLIPBOARD
} else {
0
},
};
io::write_msg(send, &welcome.encode()).await?;