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