From 768366da1fc3012185c821c59713b47859129f6d Mon Sep 17 00:00:00 2001 From: AKolenda Date: Sat, 26 Sep 2026 00:05:48 -0600 Subject: [PATCH 01/11] feat(cliprdr-native): add a Linux clipboard backend `ironrdp-cliprdr-native` only had a Windows backend, so clipboard redirection on Linux fell back to the stub and did nothing. `LinuxClipboard` bridges CLIPRDR to the desktop clipboard through `arboard`: the Wayland data-control protocol when the compositor offers it, the X11 clipboard (through XWayland on Wayland desktops) otherwise. Plain text (CF_UNICODETEXT) and images (CF_DIB and CF_DIBV5, converted through PNG with `ironrdp-cliprdr-format`) travel in both directions. Files and HTML are not supported. The OS clipboard is driven from a dedicated thread, so the CLIPRDR callbacks, which arrive on the session task, never wait on it: - Neither clipboard protocol notifies a non-owner of selection changes, so the local clipboard is polled every 750 ms, and only after the server requested the initial format list. - A remote copy is fetched as soon as it is announced and written to the OS clipboard, since neither protocol lets a client offer data it only produces on demand. Reading that write back is not mistaken for a local copy. - A local copy supersedes an outstanding remote response, and a newer remote copy waits for the outstanding one, or replaces it after 5 s. The client uses it for `ClipboardType::Enable` on Linux. A desktop without a usable clipboard logs a warning and uses the stub backend instead of failing the connection. The worker's state machine is tested against a fake clipboard (`cargo test -p ironrdp-cliprdr-native --lib`). --- crates/ironrdp-client/src/lib.rs | 2 +- crates/ironrdp-client/src/rdp.rs | 44 +- crates/ironrdp-cliprdr-native/Cargo.toml | 3 + crates/ironrdp-cliprdr-native/README.md | 2 +- crates/ironrdp-cliprdr-native/src/lib.rs | 4 + .../ironrdp-cliprdr-native/src/linux/image.rs | 169 ++++++ .../ironrdp-cliprdr-native/src/linux/mod.rs | 208 +++++++ crates/ironrdp-cliprdr-native/src/linux/os.rs | 86 +++ .../src/linux/worker.rs | 565 ++++++++++++++++++ 9 files changed, 1078 insertions(+), 5 deletions(-) create mode 100644 crates/ironrdp-cliprdr-native/src/linux/image.rs create mode 100644 crates/ironrdp-cliprdr-native/src/linux/mod.rs create mode 100644 crates/ironrdp-cliprdr-native/src/linux/os.rs create mode 100644 crates/ironrdp-cliprdr-native/src/linux/worker.rs diff --git a/crates/ironrdp-client/src/lib.rs b/crates/ironrdp-client/src/lib.rs index fe4b112898..34cf5be4e3 100644 --- a/crates/ironrdp-client/src/lib.rs +++ b/crates/ironrdp-client/src/lib.rs @@ -12,7 +12,7 @@ pub mod output_channel; pub mod rail; pub mod rdp; -#[cfg(all(windows, feature = "clipboard"))] +#[cfg(all(any(windows, target_os = "linux"), feature = "clipboard"))] mod clipboard; mod ws; diff --git a/crates/ironrdp-client/src/rdp.rs b/crates/ironrdp-client/src/rdp.rs index 6e81254c76..bf589c1e16 100644 --- a/crates/ironrdp-client/src/rdp.rs +++ b/crates/ironrdp-client/src/rdp.rs @@ -790,14 +790,21 @@ impl RdpClient { // ── Clipboard initialisation (compile-time gated) ───────────────────── // // On Windows the WinClipboard object must outlive the entire connection loop, so we - // keep it alive via `_win_clipboard`. On non-Windows a StubClipboard backend is used - // and its ownership can be released immediately after the factory is extracted. + // keep it alive via `_win_clipboard`; the same goes for LinuxClipboard and + // `_linux_clipboard`. Elsewhere a StubClipboard backend is used and its ownership can be + // released immediately after the factory is extracted. #[cfg(all(windows, feature = "clipboard"))] #[expect( clippy::collection_is_never_read, reason = "binding owns the Windows clipboard so it stays alive for the connection's lifetime" )] let _win_clipboard; + #[cfg(all(target_os = "linux", feature = "clipboard"))] + #[expect( + clippy::collection_is_never_read, + reason = "binding owns the Linux clipboard so it stays alive for the connection's lifetime" + )] + let _linux_clipboard; #[cfg(feature = "clipboard")] let cliprdr_factory: Option>; @@ -812,6 +819,10 @@ impl RdpClient { { _win_clipboard = None; } + #[cfg(target_os = "linux")] + { + _linux_clipboard = None; + } } (ClipboardType::Disable, _) => { cliprdr_factory = None; @@ -819,6 +830,10 @@ impl RdpClient { { _win_clipboard = None; } + #[cfg(target_os = "linux")] + { + _linux_clipboard = None; + } } (ClipboardType::Stub, _) => { use ironrdp_cliprdr_native::StubClipboard; @@ -828,6 +843,10 @@ impl RdpClient { { _win_clipboard = None; } + #[cfg(target_os = "linux")] + { + _linux_clipboard = None; + } } (ClipboardType::Enable, None) => { #[cfg(windows)] @@ -852,7 +871,26 @@ impl RdpClient { } } - #[cfg(not(windows))] + #[cfg(target_os = "linux")] + { + use crate::clipboard::ClientClipboardMessageProxy; + use ironrdp_cliprdr_native::{LinuxClipboard, StubClipboard}; + match LinuxClipboard::new(ClientClipboardMessageProxy::new(self.input_event_sender.clone())) { + Ok(clipboard) => { + cliprdr_factory = Some(clipboard.backend_factory()); + _linux_clipboard = Some(clipboard); + } + Err(error) => { + // Without a desktop clipboard there is nothing to bridge, and the + // session is still useful, so this is not a connection failure. + warn!(%error, "OS clipboard unavailable; clipboard redirection is off for this session"); + cliprdr_factory = Some(StubClipboard::new().backend_factory()); + _linux_clipboard = None; + } + } + } + + #[cfg(not(any(windows, target_os = "linux")))] { use ironrdp_cliprdr_native::StubClipboard; let stub = StubClipboard::new(); diff --git a/crates/ironrdp-cliprdr-native/Cargo.toml b/crates/ironrdp-cliprdr-native/Cargo.toml index c6b1c89bc1..185e03e438 100644 --- a/crates/ironrdp-cliprdr-native/Cargo.toml +++ b/crates/ironrdp-cliprdr-native/Cargo.toml @@ -32,6 +32,9 @@ visibility = { version = "0.1", optional = true } wayland-client = "0.31" wayland-protocols = { version = "0.32", features = ["client", "staging"] } wayland-protocols-wlr = { version = "0.3", features = ["client"] } +arboard = { version = "3.6", default-features = false, features = ["image-data", "wayland-data-control"] } +ironrdp-cliprdr-format = { path = "../ironrdp-cliprdr-format", version = "0.2" } +png = "0.18" [target.'cfg(windows)'.dependencies] windows = { version = "0.62", features = [ diff --git a/crates/ironrdp-cliprdr-native/README.md b/crates/ironrdp-cliprdr-native/README.md index 22212e02b1..e0cc1fba0b 100644 --- a/crates/ironrdp-cliprdr-native/README.md +++ b/crates/ironrdp-cliprdr-native/README.md @@ -1,6 +1,6 @@ # IronRDP CLIPRDR native backends -Native CLIPRDR backend implementations. Windows has a full backend; Linux has a Wayland clipboard client that a backend can be built on. +Native CLIPRDR backend implementations: Windows (`WinClipboard`) and Linux desktops over X11 and Wayland (`LinuxClipboard`, text and images). This crate is part of the [IronRDP] project. diff --git a/crates/ironrdp-cliprdr-native/src/lib.rs b/crates/ironrdp-cliprdr-native/src/lib.rs index 4ce1515840..bbf605a192 100644 --- a/crates/ironrdp-cliprdr-native/src/lib.rs +++ b/crates/ironrdp-cliprdr-native/src/lib.rs @@ -17,6 +17,10 @@ pub use crate::windows::{HWND, WinClipboard, WinCliprdrError, WinCliprdrResult}; #[cfg(target_os = "linux")] pub mod data_control; +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use crate::linux::{LinuxClipboard, LinuxCliprdrBackend, LinuxCliprdrError}; mod stub; use std::sync::OnceLock; diff --git a/crates/ironrdp-cliprdr-native/src/linux/image.rs b/crates/ironrdp-cliprdr-native/src/linux/image.rs new file mode 100644 index 0000000000..1d86223917 --- /dev/null +++ b/crates/ironrdp-cliprdr-native/src/linux/image.rs @@ -0,0 +1,169 @@ +//! PNG <-> RGBA conversion for the OS clipboard. +//! +//! `arboard` hands images over as tightly packed 8-bit RGBA, while +//! `ironrdp-cliprdr-format` converts between `CF_DIB`/`CF_DIBV5` and PNG, so +//! PNG is the meeting point between the two. + +use core::fmt; + +/// A tightly packed 8-bit RGBA image, row-major, no padding. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Rgba { + pub(super) width: u32, + pub(super) height: u32, + pub(super) bytes: Vec, +} + +#[derive(Debug)] +pub(super) enum ImageError { + Decode(png::DecodingError), + Encode(png::EncodingError), + /// The image dimensions and the pixel buffer disagree, or the image is too large to hold. + Size, + UnsupportedColorType(png::ColorType), +} + +impl fmt::Display for ImageError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Decode(error) => write!(f, "PNG decoding failed: {error}"), + Self::Encode(error) => write!(f, "PNG encoding failed: {error}"), + Self::Size => f.write_str("image dimensions do not match the pixel buffer"), + Self::UnsupportedColorType(color_type) => write!(f, "unsupported PNG color type {color_type:?}"), + } + } +} + +impl core::error::Error for ImageError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + match self { + Self::Decode(error) => Some(error), + Self::Encode(error) => Some(error), + Self::Size | Self::UnsupportedColorType(_) => None, + } + } +} + +impl From for ImageError { + fn from(error: png::DecodingError) -> Self { + Self::Decode(error) + } +} + +impl From for ImageError { + fn from(error: png::EncodingError) -> Self { + Self::Encode(error) + } +} + +/// Decodes a PNG into 8-bit RGBA, expanding palettes, grayscale and 16-bit +/// samples along the way. +pub(super) fn png_to_rgba(png: &[u8]) -> Result { + let mut decoder = png::Decoder::new(std::io::Cursor::new(png)); + decoder.set_transformations(png::Transformations::normalize_to_color8()); + let mut reader = decoder.read_info()?; + let size = reader.output_buffer_size().ok_or(ImageError::Size)?; + let mut buffer = vec![0; size]; + let info = reader.next_frame(&mut buffer)?; + buffer.truncate(info.buffer_size()); + + let bytes = match info.color_type { + png::ColorType::Rgba => buffer, + png::ColorType::Rgb => buffer + .chunks_exact(3) + .flat_map(|px| [px[0], px[1], px[2], 0xFF]) + .collect::>(), + png::ColorType::Grayscale => buffer.iter().flat_map(|&g| [g, g, g, 0xFF]).collect::>(), + png::ColorType::GrayscaleAlpha => buffer + .chunks_exact(2) + .flat_map(|px| [px[0], px[0], px[0], px[1]]) + .collect::>(), + other @ png::ColorType::Indexed => return Err(ImageError::UnsupportedColorType(other)), + }; + + let expected = usize::try_from(info.width) + .ok() + .and_then(|w| w.checked_mul(usize::try_from(info.height).ok()?)) + .and_then(|px| px.checked_mul(4)) + .ok_or(ImageError::Size)?; + if bytes.len() != expected { + return Err(ImageError::Size); + } + + Ok(Rgba { + width: info.width, + height: info.height, + bytes, + }) +} + +/// Encodes 8-bit RGBA as a PNG. +pub(super) fn rgba_to_png(image: &Rgba) -> Result, ImageError> { + let expected = usize::try_from(image.width) + .ok() + .and_then(|w| w.checked_mul(usize::try_from(image.height).ok()?)) + .and_then(|px| px.checked_mul(4)) + .ok_or(ImageError::Size)?; + if image.bytes.len() != expected || image.width == 0 || image.height == 0 { + return Err(ImageError::Size); + } + + let mut out = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut out, image.width, image.height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header()?; + writer.write_image_data(&image.bytes)?; + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Rgba { + Rgba { + width: 2, + height: 2, + bytes: vec![ + 0xFF, 0x00, 0x00, 0xFF, // red + 0x00, 0xFF, 0x00, 0x80, // half-transparent green + 0x00, 0x00, 0xFF, 0xFF, // blue + 0x10, 0x20, 0x30, 0x00, // transparent + ], + } + } + + #[test] + fn rgba_survives_a_png_round_trip() { + let image = sample(); + let png = rgba_to_png(&image).expect("encode"); + assert_eq!(png_to_rgba(&png).expect("decode"), image); + } + + #[test] + fn rgb_png_gets_an_opaque_alpha_channel() { + let mut out = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut out, 1, 1); + encoder.set_color(png::ColorType::Rgb); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().expect("header"); + writer.write_image_data(&[1, 2, 3]).expect("data"); + } + let image = png_to_rgba(&out).expect("decode"); + assert_eq!(image.bytes, vec![1, 2, 3, 0xFF]); + } + + #[test] + fn mismatched_dimensions_are_rejected() { + let image = Rgba { + width: 3, + height: 1, + bytes: vec![0; 8], + }; + assert!(matches!(rgba_to_png(&image), Err(ImageError::Size))); + } +} diff --git a/crates/ironrdp-cliprdr-native/src/linux/mod.rs b/crates/ironrdp-cliprdr-native/src/linux/mod.rs new file mode 100644 index 0000000000..c038e1d3a5 --- /dev/null +++ b/crates/ironrdp-cliprdr-native/src/linux/mod.rs @@ -0,0 +1,208 @@ +//! `CLIPRDR` backend for Linux desktops, over the X11 and Wayland clipboards. +//! +//! Built on [`arboard`]. On Wayland it uses the data-control protocol when the +//! compositor offers it (Sway, KDE Plasma, GNOME 48 and later) and otherwise +//! the X11 clipboard through XWayland, which GNOME and KDE keep in sync with +//! the Wayland selection. Plain text (`CF_UNICODETEXT`) and images +//! (`CF_DIB`/`CF_DIBV5`) travel in both directions; files and HTML do not. +//! +//! Remote content is fetched as soon as the remote announces a copy and written +//! to the OS clipboard, because neither clipboard protocol used here lets a +//! client offer content it will only produce on demand. Local changes are +//! found by polling; see [`worker::POLL_INTERVAL`]. + +mod image; +mod os; +mod worker; + +use core::fmt; +use std::sync::mpsc::{self, Sender}; +use std::thread::JoinHandle; + +use ironrdp_cliprdr::backend::{ClipboardMessageProxy, CliprdrBackend, CliprdrBackendFactory}; +use ironrdp_cliprdr::pdu::{ + ClipboardFormat, ClipboardGeneralCapabilityFlags, FileContentsRequest, FileContentsResponse, FormatDataRequest, + FormatDataResponse, LockDataId, +}; +use ironrdp_core::impl_as_any; +use tracing::{debug, warn}; + +use self::worker::{Command, Worker}; + +#[derive(Debug)] +pub enum LinuxCliprdrError { + /// No usable clipboard: neither a Wayland data-control clipboard nor an X11 + /// display could be opened. + Clipboard(arboard::Error), + Spawn(std::io::Error), + /// The clipboard thread stopped before reporting whether it could start. + WorkerStopped, +} + +impl fmt::Display for LinuxCliprdrError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Clipboard(error) => write!(f, "cannot open the OS clipboard: {error}"), + Self::Spawn(error) => write!(f, "cannot start the clipboard thread: {error}"), + Self::WorkerStopped => f.write_str("the clipboard thread stopped unexpectedly"), + } + } +} + +impl core::error::Error for LinuxCliprdrError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + match self { + Self::Clipboard(error) => Some(error), + Self::Spawn(error) => Some(error), + Self::WorkerStopped => None, + } + } +} + +/// The Linux clipboard bridge. Owns the thread that talks to the OS clipboard; +/// keep it alive for as long as the connection it serves, and build one +/// [`CliprdrBackend`] per channel initialization through +/// [`backend_factory`](Self::backend_factory). +pub struct LinuxClipboard { + commands: Sender, + thread: Option>, +} + +impl fmt::Debug for LinuxClipboard { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("LinuxClipboard").finish_non_exhaustive() + } +} + +impl LinuxClipboard { + /// Opens the OS clipboard and starts the clipboard thread. + /// + /// `message_proxy` delivers the resulting `CLIPRDR` messages to the RDP + /// session; it is called from the clipboard thread. + pub fn new(message_proxy: impl ClipboardMessageProxy + 'static) -> Result { + let (commands, receiver) = mpsc::channel(); + let (ready_sender, ready) = mpsc::channel(); + + let thread = std::thread::Builder::new() + .name("cliprdr-linux".to_owned()) + .spawn(move || { + let os = match os::ArboardClipboard::open() { + Ok(os) => os, + Err(error) => { + let _ = ready_sender.send(Err(error)); + return; + } + }; + let _ = ready_sender.send(Ok(())); + Worker::new(os, message_proxy).run(&receiver); + debug!("Clipboard thread stopped"); + }) + .map_err(LinuxCliprdrError::Spawn)?; + + match ready.recv() { + Ok(Ok(())) => Ok(Self { + commands, + thread: Some(thread), + }), + Ok(Err(error)) => Err(LinuxCliprdrError::Clipboard(error)), + Err(_) => Err(LinuxCliprdrError::WorkerStopped), + } + } + + pub fn backend_factory(&self) -> Box { + Box::new(LinuxCliprdrBackendFactory { + commands: self.commands.clone(), + }) + } +} + +impl Drop for LinuxClipboard { + fn drop(&mut self) { + let _ = self.commands.send(Command::Shutdown); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +struct LinuxCliprdrBackendFactory { + commands: Sender, +} + +impl CliprdrBackendFactory for LinuxCliprdrBackendFactory { + fn build_cliprdr_backend(&self) -> Box { + Box::new(LinuxCliprdrBackend { + commands: self.commands.clone(), + }) + } +} + +/// Forwards the `CLIPRDR` callbacks to the clipboard thread. +#[derive(Debug)] +pub struct LinuxCliprdrBackend { + commands: Sender, +} + +impl_as_any!(LinuxCliprdrBackend); + +impl LinuxCliprdrBackend { + fn send(&self, command: Command) { + if self.commands.send(command).is_err() { + warn!("The clipboard thread is gone; dropping a CLIPRDR event"); + } + } +} + +impl CliprdrBackend for LinuxCliprdrBackend { + fn temporary_directory(&self) -> &str { + ".cliprdr" + } + + fn client_capabilities(&self) -> ClipboardGeneralCapabilityFlags { + // No file transfer: no STREAM_FILECLIP_ENABLED, no CAN_LOCK_CLIPDATA. + ClipboardGeneralCapabilityFlags::empty() + } + + fn on_ready(&mut self) {} + + fn on_request_format_list(&mut self) { + self.send(Command::AdvertiseLocal); + } + + fn on_process_negotiated_capabilities(&mut self, capabilities: ClipboardGeneralCapabilityFlags) { + debug!(?capabilities, "CLIPRDR capabilities negotiated"); + } + + fn on_remote_copy(&mut self, available_formats: &[ClipboardFormat]) { + self.send(Command::RemoteCopy(available_formats.to_vec())); + } + + fn on_format_data_request(&mut self, request: FormatDataRequest) { + self.send(Command::RenderLocal(request.format)); + } + + fn on_format_data_response(&mut self, response: FormatDataResponse<'_>) { + let data = (!response.is_error()).then(|| response.data().to_vec()); + self.send(Command::RemoteData(data)); + } + + fn on_file_contents_request(&mut self, request: FileContentsRequest) { + debug!(?request, "File transfer is not supported"); + } + + fn on_file_contents_response(&mut self, response: FileContentsResponse<'_>) { + debug!(?response, "File transfer is not supported"); + } + + fn on_lock(&mut self, data_id: LockDataId) { + debug!(?data_id); + } + + fn on_unlock(&mut self, data_id: LockDataId) { + debug!(?data_id); + } + + fn now_ms(&self) -> u64 { + crate::native_now_ms() + } +} diff --git a/crates/ironrdp-cliprdr-native/src/linux/os.rs b/crates/ironrdp-cliprdr-native/src/linux/os.rs new file mode 100644 index 0000000000..2a43eb7aa9 --- /dev/null +++ b/crates/ironrdp-cliprdr-native/src/linux/os.rs @@ -0,0 +1,86 @@ +//! The OS clipboard as seen by the worker: a trait so the state machine can be +//! tested without a display, and its `arboard` implementation. + +use std::borrow::Cow; + +use tracing::trace; + +use super::image::Rgba; + +/// One logical clipboard item. Text wins over an image when both are offered, +/// which is what a paste into a terminal or an editor expects. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Content { + Text(String), + Image(Rgba), +} + +pub(super) trait OsClipboard { + /// The current clipboard content, or `None` when it is empty or holds + /// nothing this backend can carry. + fn read(&mut self) -> Option; + + /// Replaces the clipboard content. + fn write(&mut self, content: &Content) -> Result<(), String>; +} + +/// The real clipboard through `arboard`: Wayland data-control when the +/// compositor offers it, the X11 clipboard (through XWayland on Wayland +/// desktops) otherwise. +pub(super) struct ArboardClipboard { + inner: arboard::Clipboard, +} + +impl ArboardClipboard { + pub(super) fn open() -> Result { + Ok(Self { + inner: arboard::Clipboard::new()?, + }) + } +} + +impl OsClipboard for ArboardClipboard { + fn read(&mut self) -> Option { + match self.inner.get_text() { + Ok(text) if !text.is_empty() => return Some(Content::Text(text)), + Ok(_) | Err(arboard::Error::ContentNotAvailable) => {} + Err(error) => { + trace!(%error, "Could not read text from the OS clipboard"); + return None; + } + } + + match self.inner.get_image() { + Ok(image) => { + let width = u32::try_from(image.width).ok()?; + let height = u32::try_from(image.height).ok()?; + Some(Content::Image(Rgba { + width, + height, + bytes: image.bytes.into_owned(), + })) + } + Err(arboard::Error::ContentNotAvailable) => None, + Err(error) => { + trace!(%error, "Could not read an image from the OS clipboard"); + None + } + } + } + + fn write(&mut self, content: &Content) -> Result<(), String> { + let result = match content { + Content::Text(text) => self.inner.set_text(text.as_str()), + Content::Image(image) => { + let width = usize::try_from(image.width).map_err(|error| error.to_string())?; + let height = usize::try_from(image.height).map_err(|error| error.to_string())?; + self.inner.set_image(arboard::ImageData { + width, + height, + bytes: Cow::Borrowed(&image.bytes), + }) + } + }; + result.map_err(|error| error.to_string()) + } +} diff --git a/crates/ironrdp-cliprdr-native/src/linux/worker.rs b/crates/ironrdp-cliprdr-native/src/linux/worker.rs new file mode 100644 index 0000000000..a10d3d24df --- /dev/null +++ b/crates/ironrdp-cliprdr-native/src/linux/worker.rs @@ -0,0 +1,565 @@ +//! The state machine behind [`LinuxClipboard`](super::LinuxClipboard). +//! +//! Runs on its own thread so the `CLIPRDR` callbacks, which arrive on the RDP +//! session task, never wait on the OS clipboard. The backend forwards each +//! callback as a [`Command`]; the worker owns the [`OsClipboard`], the +//! [`ClipboardMessageProxy`] and every piece of state. + +use core::time::Duration; +use std::sync::mpsc::{Receiver, RecvTimeoutError}; +use std::time::Instant; + +use ironrdp_cliprdr::backend::{ClipboardMessage, ClipboardMessageProxy}; +use ironrdp_cliprdr::pdu::{ClipboardFormat, ClipboardFormatId, FormatDataResponse, OwnedFormatDataResponse}; +use ironrdp_cliprdr_format::bitmap; +use ironrdp_core::IntoOwned as _; +use tracing::{debug, warn}; + +use super::image; +use super::os::{Content, OsClipboard}; + +/// How often the OS clipboard is re-read for changes. Neither X11 nor the +/// Wayland data-control protocol tells a non-owner when the selection changes, +/// so the clipboard is polled; text is read first and an image only when there +/// is no text, which keeps the common case cheap. +pub(super) const POLL_INTERVAL: Duration = Duration::from_millis(750); + +/// How long an outstanding paste request is waited for before a newer remote +/// copy replaces it. The remote may never answer if the application that owned +/// its clipboard went away. +const PENDING_PASTE_TIMEOUT: Duration = Duration::from_secs(5); + +/// A `CLIPRDR` callback, forwarded from the backend. +#[derive(Debug)] +pub(super) enum Command { + /// The channel is up: advertise whatever the OS clipboard holds. + AdvertiseLocal, + /// The remote copied something and offers these formats. + RemoteCopy(Vec), + /// The remote wants our clipboard in this format. + RenderLocal(ClipboardFormatId), + /// The remote answered our paste request: the bytes, or `None` on failure. + RemoteData(Option>), + Shutdown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PendingPaste { + Text, + Image { dibv5: bool }, +} + +pub(super) struct Worker { + os: C, + proxy: P, + /// What the remote has been told our clipboard holds and what + /// [`Command::RenderLocal`] serves. + local: Option, + /// Polling must wait for the server to request the initial format list. + initialized: bool, + /// A local copy or newer remote offer superseded the outstanding response. + discard_pending: bool, + /// The last thing we wrote to the OS clipboard on the remote's behalf, so + /// reading it back is not mistaken for a local copy. + last_written: Option, + pending: Option<(PendingPaste, Instant)>, + /// A remote copy that arrived while `pending` was outstanding. + desired: Option<(PendingPaste, ClipboardFormatId)>, +} + +impl Worker { + pub(super) fn new(os: C, proxy: P) -> Self { + Self { + os, + proxy, + local: None, + initialized: false, + discard_pending: false, + last_written: None, + pending: None, + desired: None, + } + } + + pub(super) fn run(mut self, commands: &Receiver) { + loop { + match commands.recv_timeout(POLL_INTERVAL) { + Ok(Command::Shutdown) | Err(RecvTimeoutError::Disconnected) => break, + Ok(command) => self.handle(command), + Err(RecvTimeoutError::Timeout) => self.poll(), + } + } + } + + pub(super) fn handle(&mut self, command: Command) { + match command { + Command::AdvertiseLocal => { + self.initialized = true; + self.local = self.os.read(); + self.advertise(); + } + Command::RemoteCopy(formats) => self.remote_copy(&formats), + Command::RenderLocal(format) => self.render_local(format), + Command::RemoteData(data) => self.remote_data(data), + Command::Shutdown => {} + } + } + + /// Re-reads the OS clipboard and advertises it if it changed. + pub(super) fn poll(&mut self) { + if !self.initialized { + return; + } + // A stalled owner must not strand a newer copy indefinitely. Check on + // the timer, rather than requiring a third copy to trigger recovery. + if self + .pending + .is_some_and(|(_, since)| since.elapsed() >= PENDING_PASTE_TIMEOUT) + { + if let Some(next) = self.desired.take() { + debug!("Pending paste timed out; requesting the newer remote copy"); + self.issue_paste(next); + } + } + let Some(content) = self.os.read() else { + return; + }; + if self.last_written.as_ref() == Some(&content) || self.local.as_ref() == Some(&content) { + return; + } + // Something new was copied locally, so the echo guard is stale. + self.last_written = None; + self.discard_pending = self.pending.is_some(); + self.desired = None; + self.local = Some(content); + self.advertise(); + } + + fn advertise(&self) { + let formats = self.local.as_ref().map_or_else(Vec::new, advertised_formats); + self.proxy + .send_clipboard_message(ClipboardMessage::SendInitiateCopy(formats)); + } + + fn remote_copy(&mut self, formats: &[ClipboardFormat]) { + let Some(target) = paste_target(formats) else { + self.pending = None; + self.desired = None; + return; + }; + + if let Some((_, since)) = self.pending { + if since.elapsed() < PENDING_PASTE_TIMEOUT { + self.desired = Some(target); + return; + } + debug!("Pending paste timed out; abandoning it for the newer remote copy"); + } + + self.issue_paste(target); + } + + fn issue_paste(&mut self, (paste, format): (PendingPaste, ClipboardFormatId)) { + self.pending = Some((paste, Instant::now())); + self.discard_pending = false; + self.desired = None; + self.proxy + .send_clipboard_message(ClipboardMessage::SendInitiatePaste(format)); + } + + fn remote_data(&mut self, data: Option>) { + let superseded = self.discard_pending || self.desired.is_some(); + self.discard_pending = false; + match (self.pending.take(), data) { + (Some(_), _) if superseded => debug!("Ignoring superseded remote clipboard content"), + (Some((paste, _)), Some(bytes)) => match decode_remote(paste, &bytes) { + Ok(content) => self.store_remote(content), + Err(error) => warn!(%error, "Could not decode the remote clipboard content"), + }, + (Some(_), None) => debug!("The remote could not provide the requested clipboard format"), + (None, _) => debug!("Unexpected format data response"), + } + + if let Some(next) = self.desired.take() { + self.issue_paste(next); + } + } + + fn store_remote(&mut self, content: Content) { + match self.os.write(&content) { + Ok(()) => { + self.last_written = Some(content.clone()); + self.local = Some(content); + } + Err(error) => warn!( + error, + "Could not write the remote clipboard content to the OS clipboard" + ), + } + } + + fn render_local(&self, format: ClipboardFormatId) { + let response = match (format, self.local.as_ref()) { + (ClipboardFormatId::CF_UNICODETEXT, Some(Content::Text(text))) => { + FormatDataResponse::new_unicode_string(text).into_owned() + } + (ClipboardFormatId::CF_DIB, Some(Content::Image(image))) => { + render_image(image, bitmap::png_to_cf_dib, "CF_DIB") + } + (ClipboardFormatId::CF_DIBV5, Some(Content::Image(image))) => { + render_image(image, bitmap::png_to_cf_dibv5, "CF_DIBV5") + } + _ => FormatDataResponse::new_error(), + }; + self.proxy + .send_clipboard_message(ClipboardMessage::SendFormatData(response)); + } +} + +fn render_image( + image: &image::Rgba, + to_dib: fn(&[u8]) -> Result, bitmap::BitmapError>, + name: &str, +) -> OwnedFormatDataResponse { + let dib = image::rgba_to_png(image) + .map_err(|error| error.to_string()) + .and_then(|png| to_dib(&png).map_err(|error| error.to_string())); + match dib { + Ok(dib) => FormatDataResponse::new_data(dib).into_owned(), + Err(error) => { + warn!(%error, format = name, "Could not convert the local image for the remote"); + FormatDataResponse::new_error() + } + } +} + +/// The formats advertised for one piece of local content. +fn advertised_formats(content: &Content) -> Vec { + match content { + Content::Text(_) => vec![ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT)], + // DIBV5 carries alpha; DIB is for peers that do not understand it. + Content::Image(_) => vec![ + ClipboardFormat::new(ClipboardFormatId::CF_DIB), + ClipboardFormat::new(ClipboardFormatId::CF_DIBV5), + ], + } +} + +/// Which remote format to fetch: text first, then the richest image. +fn paste_target(formats: &[ClipboardFormat]) -> Option<(PendingPaste, ClipboardFormatId)> { + let has = |id: ClipboardFormatId| formats.iter().any(|format| format.id() == id); + + if has(ClipboardFormatId::CF_UNICODETEXT) { + Some((PendingPaste::Text, ClipboardFormatId::CF_UNICODETEXT)) + } else if has(ClipboardFormatId::CF_DIBV5) { + Some((PendingPaste::Image { dibv5: true }, ClipboardFormatId::CF_DIBV5)) + } else if has(ClipboardFormatId::CF_DIB) { + Some((PendingPaste::Image { dibv5: false }, ClipboardFormatId::CF_DIB)) + } else { + None + } +} + +fn decode_remote(paste: PendingPaste, bytes: &[u8]) -> Result { + match paste { + PendingPaste::Text => FormatDataResponse::new_data(bytes) + .to_unicode_string() + .map(Content::Text) + .map_err(|error| error.to_string()), + PendingPaste::Image { dibv5 } => { + let png = if dibv5 { + bitmap::dibv5_to_png(bytes) + } else { + bitmap::dib_to_png(bytes) + } + .map_err(|error| error.to_string())?; + image::png_to_rgba(&png) + .map(Content::Image) + .map_err(|error| error.to_string()) + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::*; + use crate::linux::image::Rgba; + + #[derive(Default)] + struct FakeOs { + content: Option, + written: Vec, + } + + impl OsClipboard for Arc> { + fn read(&mut self) -> Option { + self.lock().expect("fake os").content.clone() + } + + fn write(&mut self, content: &Content) -> Result<(), String> { + let mut os = self.lock().expect("fake os"); + os.content = Some(content.clone()); + os.written.push(content.clone()); + Ok(()) + } + } + + #[derive(Debug, Default, Clone)] + struct Recorder(Arc>>); + + impl ClipboardMessageProxy for Recorder { + fn send_clipboard_message(&self, message: ClipboardMessage) { + self.0.lock().expect("recorder").push(message); + } + } + + impl Recorder { + fn take(&self) -> Vec { + core::mem::take(&mut *self.0.lock().expect("recorder")) + } + } + + type TestWorker = Worker>, Recorder>; + + fn worker() -> (TestWorker, Arc>, Recorder) { + let os = Arc::new(Mutex::new(FakeOs::default())); + let recorder = Recorder::default(); + (Worker::new(Arc::clone(&os), recorder.clone()), os, recorder) + } + + fn set_local(os: &Arc>, content: Content) { + os.lock().expect("fake os").content = Some(content); + } + + fn format_ids(messages: &[ClipboardMessage]) -> Vec> { + messages + .iter() + .filter_map(|message| match message { + ClipboardMessage::SendInitiateCopy(formats) => Some(formats.iter().map(|f| f.id()).collect()), + _ => None, + }) + .collect() + } + + fn sample_image() -> Rgba { + Rgba { + width: 2, + height: 1, + bytes: vec![0xFF, 0x00, 0x00, 0xFF, 0x00, 0x00, 0xFF, 0xFF], + } + } + + #[test] + fn polling_before_monitor_ready_does_not_send_clipboard_pdus() { + let (mut worker, os, recorder) = worker(); + set_local(&os, Content::Text("preexisting local text".to_owned())); + worker.poll(); + assert!(recorder.take().is_empty()); + worker.handle(Command::AdvertiseLocal); + assert_eq!( + format_ids(&recorder.take()), + vec![vec![ClipboardFormatId::CF_UNICODETEXT]] + ); + } + + #[test] + fn a_newer_remote_copy_resumes_on_timeout_without_a_third_copy() { + let (mut worker, _os, recorder) = worker(); + worker.handle(Command::AdvertiseLocal); + worker.handle(Command::RemoteCopy(vec![ClipboardFormat::new( + ClipboardFormatId::CF_UNICODETEXT, + )])); + worker.handle(Command::RemoteCopy(vec![ClipboardFormat::new( + ClipboardFormatId::CF_DIB, + )])); + recorder.take(); + worker.pending.as_mut().expect("pending paste").1 = Instant::now() - PENDING_PASTE_TIMEOUT; + worker.poll(); + assert!(matches!( + recorder.take().as_slice(), + [ClipboardMessage::SendInitiatePaste(ClipboardFormatId::CF_DIB)] + )); + } + + #[test] + fn a_local_copy_is_not_overwritten_by_an_older_remote_response() { + let (mut worker, os, recorder) = worker(); + worker.handle(Command::AdvertiseLocal); + worker.handle(Command::RemoteCopy(vec![ClipboardFormat::new( + ClipboardFormatId::CF_UNICODETEXT, + )])); + set_local(&os, Content::Text("new local copy".to_owned())); + worker.poll(); + recorder.take(); + let old = FormatDataResponse::new_unicode_string("old remote copy").into_owned(); + worker.handle(Command::RemoteData(Some(old.data().to_vec()))); + assert!(os.lock().expect("fake os").written.is_empty()); + assert_eq!( + os.lock().expect("fake os").content, + Some(Content::Text("new local copy".to_owned())) + ); + } + + #[test] + fn channel_ready_advertises_the_current_clipboard() { + let (mut worker, os, recorder) = worker(); + set_local(&os, Content::Text("hello".to_owned())); + + worker.handle(Command::AdvertiseLocal); + + assert_eq!( + format_ids(&recorder.take()), + vec![vec![ClipboardFormatId::CF_UNICODETEXT]] + ); + } + + #[test] + fn channel_ready_with_an_empty_clipboard_advertises_nothing() { + let (mut worker, _os, recorder) = worker(); + + worker.handle(Command::AdvertiseLocal); + + assert_eq!(format_ids(&recorder.take()), vec![Vec::::new()]); + } + + #[test] + fn a_local_change_is_advertised_once() { + let (mut worker, os, recorder) = worker(); + worker.handle(Command::AdvertiseLocal); + recorder.take(); + + set_local(&os, Content::Text("copied locally".to_owned())); + worker.poll(); + worker.poll(); + + assert_eq!( + format_ids(&recorder.take()), + vec![vec![ClipboardFormatId::CF_UNICODETEXT]] + ); + + set_local(&os, Content::Image(sample_image())); + worker.poll(); + assert_eq!( + format_ids(&recorder.take()), + vec![vec![ClipboardFormatId::CF_DIB, ClipboardFormatId::CF_DIBV5]] + ); + } + + #[test] + fn remote_text_lands_on_the_os_clipboard_without_echoing_back() { + let (mut worker, os, recorder) = worker(); + + worker.handle(Command::RemoteCopy(vec![ + ClipboardFormat::new(ClipboardFormatId::CF_DIB), + ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT), + ])); + let messages = recorder.take(); + assert!( + matches!( + messages.as_slice(), + [ClipboardMessage::SendInitiatePaste(ClipboardFormatId::CF_UNICODETEXT)] + ), + "text is preferred over an image: {messages:?}" + ); + + let wire = FormatDataResponse::new_unicode_string("from the remote").into_owned(); + worker.handle(Command::RemoteData(Some(wire.data().to_vec()))); + + assert_eq!( + os.lock().expect("fake os").written, + vec![Content::Text("from the remote".to_owned())] + ); + + // Reading our own write back must not re-advertise it to the remote. + worker.poll(); + assert!(recorder.take().is_empty()); + } + + #[test] + fn remote_image_round_trips_through_dib() { + let (mut worker, os, recorder) = worker(); + let image = sample_image(); + + worker.handle(Command::RemoteCopy(vec![ClipboardFormat::new( + ClipboardFormatId::CF_DIB, + )])); + assert!(matches!( + recorder.take().as_slice(), + [ClipboardMessage::SendInitiatePaste(ClipboardFormatId::CF_DIB)] + )); + + let png = image::rgba_to_png(&image).expect("png"); + let dib = bitmap::png_to_cf_dib(&png).expect("dib"); + worker.handle(Command::RemoteData(Some(dib))); + + assert_eq!(os.lock().expect("fake os").written, vec![Content::Image(image)]); + } + + #[test] + fn a_failed_remote_paste_leaves_the_os_clipboard_alone() { + let (mut worker, os, _recorder) = worker(); + worker.handle(Command::RemoteCopy(vec![ClipboardFormat::new( + ClipboardFormatId::CF_UNICODETEXT, + )])); + worker.handle(Command::RemoteData(None)); + assert!(os.lock().expect("fake os").written.is_empty()); + } + + #[test] + fn a_second_remote_copy_waits_for_the_outstanding_paste() { + let (mut worker, os, recorder) = worker(); + worker.handle(Command::RemoteCopy(vec![ClipboardFormat::new( + ClipboardFormatId::CF_UNICODETEXT, + )])); + worker.handle(Command::RemoteCopy(vec![ClipboardFormat::new( + ClipboardFormatId::CF_DIB, + )])); + assert_eq!(recorder.take().len(), 1, "only one paste request in flight"); + + let wire = FormatDataResponse::new_unicode_string("first").into_owned(); + worker.handle(Command::RemoteData(Some(wire.data().to_vec()))); + assert!( + os.lock().expect("fake os").written.is_empty(), + "superseded response must not replace the clipboard" + ); + assert!(matches!( + recorder.take().as_slice(), + [ClipboardMessage::SendInitiatePaste(ClipboardFormatId::CF_DIB)] + )); + } + + #[test] + fn local_content_is_rendered_in_the_requested_format() { + let (mut worker, os, recorder) = worker(); + set_local(&os, Content::Text("render me".to_owned())); + worker.handle(Command::AdvertiseLocal); + recorder.take(); + + worker.handle(Command::RenderLocal(ClipboardFormatId::CF_UNICODETEXT)); + let messages = recorder.take(); + let [ClipboardMessage::SendFormatData(response)] = messages.as_slice() else { + panic!("expected one format data response, got {messages:?}"); + }; + assert_eq!(response.to_unicode_string().expect("utf-16"), "render me"); + + worker.handle(Command::RenderLocal(ClipboardFormatId::CF_DIB)); + let messages = recorder.take(); + let [ClipboardMessage::SendFormatData(response)] = messages.as_slice() else { + panic!("expected one format data response, got {messages:?}"); + }; + assert!(response.is_error(), "text cannot be rendered as an image"); + + set_local(&os, Content::Image(sample_image())); + worker.poll(); + recorder.take(); + worker.handle(Command::RenderLocal(ClipboardFormatId::CF_DIBV5)); + let messages = recorder.take(); + let [ClipboardMessage::SendFormatData(response)] = messages.as_slice() else { + panic!("expected one format data response, got {messages:?}"); + }; + assert!(!response.is_error()); + bitmap::validate_dibv5(response.data()).expect("a well-formed DIBV5"); + } +} From 25b7f0157bdf92b47b9f398ae5c19a2df1995365 Mon Sep 17 00:00:00 2001 From: AKolenda Date: Fri, 2 Oct 2026 00:48:45 -0600 Subject: [PATCH 02/11] fix(cliprdr-native): render Linux clipboard data on demand --- Cargo.lock | 2 + crates/ironrdp-client/src/rdp.rs | 15 +- crates/ironrdp-cliprdr-native/Cargo.toml | 5 +- crates/ironrdp-cliprdr-native/README.md | 28 +- crates/ironrdp-cliprdr-native/src/lib.rs | 10 +- .../ironrdp-cliprdr-native/src/linux/image.rs | 169 ---- .../ironrdp-cliprdr-native/src/linux/mod.rs | 34 +- crates/ironrdp-cliprdr-native/src/linux/os.rs | 203 +++-- .../src/linux/worker.rs | 749 ++++++----------- .../ironrdp-cliprdr-native/src/linux/x11.rs | 763 ++++++++++++++++++ .../tests/cliprdr_native/linux.rs | 558 +++++++++++++ .../tests/cliprdr_native/mod.rs | 1 + 12 files changed, 1777 insertions(+), 760 deletions(-) delete mode 100644 crates/ironrdp-cliprdr-native/src/linux/image.rs create mode 100644 crates/ironrdp-cliprdr-native/src/linux/x11.rs create mode 100644 crates/ironrdp-testsuite-extra/tests/cliprdr_native/linux.rs diff --git a/Cargo.lock b/Cargo.lock index bcd384dddc..babaf15329 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2823,6 +2823,7 @@ name = "ironrdp-cliprdr-native" version = "0.7.0" dependencies = [ "ironrdp-cliprdr", + "ironrdp-cliprdr-format", "ironrdp-core 0.2.1", "nix", "tracing", @@ -2831,6 +2832,7 @@ dependencies = [ "wayland-protocols", "wayland-protocols-wlr", "windows", + "x11rb", ] [[package]] diff --git a/crates/ironrdp-client/src/rdp.rs b/crates/ironrdp-client/src/rdp.rs index bf589c1e16..9c5e9e240a 100644 --- a/crates/ironrdp-client/src/rdp.rs +++ b/crates/ironrdp-client/src/rdp.rs @@ -804,7 +804,7 @@ impl RdpClient { clippy::collection_is_never_read, reason = "binding owns the Linux clipboard so it stays alive for the connection's lifetime" )] - let _linux_clipboard; + let mut _linux_clipboard = None; #[cfg(feature = "clipboard")] let cliprdr_factory: Option>; @@ -819,10 +819,6 @@ impl RdpClient { { _win_clipboard = None; } - #[cfg(target_os = "linux")] - { - _linux_clipboard = None; - } } (ClipboardType::Disable, _) => { cliprdr_factory = None; @@ -830,10 +826,6 @@ impl RdpClient { { _win_clipboard = None; } - #[cfg(target_os = "linux")] - { - _linux_clipboard = None; - } } (ClipboardType::Stub, _) => { use ironrdp_cliprdr_native::StubClipboard; @@ -843,10 +835,6 @@ impl RdpClient { { _win_clipboard = None; } - #[cfg(target_os = "linux")] - { - _linux_clipboard = None; - } } (ClipboardType::Enable, None) => { #[cfg(windows)] @@ -885,7 +873,6 @@ impl RdpClient { // session is still useful, so this is not a connection failure. warn!(%error, "OS clipboard unavailable; clipboard redirection is off for this session"); cliprdr_factory = Some(StubClipboard::new().backend_factory()); - _linux_clipboard = None; } } } diff --git a/crates/ironrdp-cliprdr-native/Cargo.toml b/crates/ironrdp-cliprdr-native/Cargo.toml index 185e03e438..c13e0e03b4 100644 --- a/crates/ironrdp-cliprdr-native/Cargo.toml +++ b/crates/ironrdp-cliprdr-native/Cargo.toml @@ -27,14 +27,13 @@ ironrdp-core = { path = "../ironrdp-core", version = "0.2" } tracing = { version = "0.1", features = ["log"] } [target.'cfg(target_os = "linux")'.dependencies] -nix = { version = "0.31", features = ["poll"] } +nix = { version = "0.31", features = ["fs", "poll"] } visibility = { version = "0.1", optional = true } wayland-client = "0.31" wayland-protocols = { version = "0.32", features = ["client", "staging"] } wayland-protocols-wlr = { version = "0.3", features = ["client"] } -arboard = { version = "3.6", default-features = false, features = ["image-data", "wayland-data-control"] } +x11rb = { version = "0.13", features = ["xfixes"] } ironrdp-cliprdr-format = { path = "../ironrdp-cliprdr-format", version = "0.2" } -png = "0.18" [target.'cfg(windows)'.dependencies] windows = { version = "0.62", features = [ diff --git a/crates/ironrdp-cliprdr-native/README.md b/crates/ironrdp-cliprdr-native/README.md index e0cc1fba0b..5ca9694fe4 100644 --- a/crates/ironrdp-cliprdr-native/README.md +++ b/crates/ironrdp-cliprdr-native/README.md @@ -8,8 +8,28 @@ This crate is part of the [IronRDP] project. ## Linux -The `data_control` module is a clipboard client for Wayland compositors that offer [`ext-data-control-v1`] or [`wlr-data-control-unstable-v1`]. -It reads and sets the clipboard without a window and supports delayed rendering, so the data for a paste can be produced when the paste happens. +`LinuxClipboard` uses the shared `data_control` client for Wayland compositors offering `ext-data-control-v1` or `wlr-data-control-unstable-v1`. +When data-control is unavailable, including on GNOME, it falls back to X11/XWayland through XFixes and selection ownership. +A desktop without either clipboard service uses the client's stub backend. -[`ext-data-control-v1`]: https://gitlab.freedesktop.org/wayland/wayland-protocols/-/blob/main/staging/ext-data-control/ext-data-control-v1.xml -[`wlr-data-control-unstable-v1`]: https://gitlab.freedesktop.org/wlroots/wlr-protocols/-/blob/master/unstable/wlr-data-control-unstable-v1.xml +Both backends react to selection changes and advertise remote formats without fetching their contents. +Text (`CF_UNICODETEXT`) and images (`CF_DIB`/`CF_DIBV5`, converted to/from PNG by `ironrdp-cliprdr-format`) are requested only when an application pastes. +Local clipboard content is read only when the peer requests it. +File clipboard transfer and HTML are not supported. + +Only one Format Data Request is outstanding at a time, because responses do not identify their request. +A paste times out after five seconds, but its outstanding request is retained until the late response is drained. +If the peer never replies, new remote pastes fail until the clipboard channel is reinitialized; this prevents stale content from being mistaken for a newer copy. +Selection changes cancel older paste waiters, and the existing `ironrdp_cliprdr::loop_detector` detects content echoed by clipboard managers. + +The clipboard workers bound data transfers and pending pastes. +X11 transfers use INCR for large content and separate requestor windows so late selection responses cannot complete newer reads. + +State-machine tests run in the normal CI suite with `cargo test -p ironrdp-testsuite-extra cliprdr_native`. +Three additional integration tests require a dedicated X11 display; the interoperability test also requires `xclip`: + +```sh +DISPLAY=:N cargo test -p ironrdp-testsuite-extra cliprdr_native::linux::x11_ -- --ignored --test-threads=1 +``` + +Use a private display such as Xvfb for these tests, because they replace its clipboard selection. diff --git a/crates/ironrdp-cliprdr-native/src/lib.rs b/crates/ironrdp-cliprdr-native/src/lib.rs index bbf605a192..331cf3c38a 100644 --- a/crates/ironrdp-cliprdr-native/src/lib.rs +++ b/crates/ironrdp-cliprdr-native/src/lib.rs @@ -17,8 +17,14 @@ pub use crate::windows::{HWND, WinClipboard, WinCliprdrError, WinCliprdrResult}; #[cfg(target_os = "linux")] pub mod data_control; -#[cfg(target_os = "linux")] -mod linux; +#[cfg(all(target_os = "linux", feature = "__test"))] +pub mod linux; +#[cfg(all(target_os = "linux", not(feature = "__test")))] +#[expect( + unreachable_pub, + reason = "the __test feature exposes internals to the shared test suite" +)] +pub(crate) mod linux; #[cfg(target_os = "linux")] pub use crate::linux::{LinuxClipboard, LinuxCliprdrBackend, LinuxCliprdrError}; diff --git a/crates/ironrdp-cliprdr-native/src/linux/image.rs b/crates/ironrdp-cliprdr-native/src/linux/image.rs deleted file mode 100644 index 1d86223917..0000000000 --- a/crates/ironrdp-cliprdr-native/src/linux/image.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! PNG <-> RGBA conversion for the OS clipboard. -//! -//! `arboard` hands images over as tightly packed 8-bit RGBA, while -//! `ironrdp-cliprdr-format` converts between `CF_DIB`/`CF_DIBV5` and PNG, so -//! PNG is the meeting point between the two. - -use core::fmt; - -/// A tightly packed 8-bit RGBA image, row-major, no padding. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct Rgba { - pub(super) width: u32, - pub(super) height: u32, - pub(super) bytes: Vec, -} - -#[derive(Debug)] -pub(super) enum ImageError { - Decode(png::DecodingError), - Encode(png::EncodingError), - /// The image dimensions and the pixel buffer disagree, or the image is too large to hold. - Size, - UnsupportedColorType(png::ColorType), -} - -impl fmt::Display for ImageError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Decode(error) => write!(f, "PNG decoding failed: {error}"), - Self::Encode(error) => write!(f, "PNG encoding failed: {error}"), - Self::Size => f.write_str("image dimensions do not match the pixel buffer"), - Self::UnsupportedColorType(color_type) => write!(f, "unsupported PNG color type {color_type:?}"), - } - } -} - -impl core::error::Error for ImageError { - fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { - match self { - Self::Decode(error) => Some(error), - Self::Encode(error) => Some(error), - Self::Size | Self::UnsupportedColorType(_) => None, - } - } -} - -impl From for ImageError { - fn from(error: png::DecodingError) -> Self { - Self::Decode(error) - } -} - -impl From for ImageError { - fn from(error: png::EncodingError) -> Self { - Self::Encode(error) - } -} - -/// Decodes a PNG into 8-bit RGBA, expanding palettes, grayscale and 16-bit -/// samples along the way. -pub(super) fn png_to_rgba(png: &[u8]) -> Result { - let mut decoder = png::Decoder::new(std::io::Cursor::new(png)); - decoder.set_transformations(png::Transformations::normalize_to_color8()); - let mut reader = decoder.read_info()?; - let size = reader.output_buffer_size().ok_or(ImageError::Size)?; - let mut buffer = vec![0; size]; - let info = reader.next_frame(&mut buffer)?; - buffer.truncate(info.buffer_size()); - - let bytes = match info.color_type { - png::ColorType::Rgba => buffer, - png::ColorType::Rgb => buffer - .chunks_exact(3) - .flat_map(|px| [px[0], px[1], px[2], 0xFF]) - .collect::>(), - png::ColorType::Grayscale => buffer.iter().flat_map(|&g| [g, g, g, 0xFF]).collect::>(), - png::ColorType::GrayscaleAlpha => buffer - .chunks_exact(2) - .flat_map(|px| [px[0], px[0], px[0], px[1]]) - .collect::>(), - other @ png::ColorType::Indexed => return Err(ImageError::UnsupportedColorType(other)), - }; - - let expected = usize::try_from(info.width) - .ok() - .and_then(|w| w.checked_mul(usize::try_from(info.height).ok()?)) - .and_then(|px| px.checked_mul(4)) - .ok_or(ImageError::Size)?; - if bytes.len() != expected { - return Err(ImageError::Size); - } - - Ok(Rgba { - width: info.width, - height: info.height, - bytes, - }) -} - -/// Encodes 8-bit RGBA as a PNG. -pub(super) fn rgba_to_png(image: &Rgba) -> Result, ImageError> { - let expected = usize::try_from(image.width) - .ok() - .and_then(|w| w.checked_mul(usize::try_from(image.height).ok()?)) - .and_then(|px| px.checked_mul(4)) - .ok_or(ImageError::Size)?; - if image.bytes.len() != expected || image.width == 0 || image.height == 0 { - return Err(ImageError::Size); - } - - let mut out = Vec::new(); - { - let mut encoder = png::Encoder::new(&mut out, image.width, image.height); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - let mut writer = encoder.write_header()?; - writer.write_image_data(&image.bytes)?; - } - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample() -> Rgba { - Rgba { - width: 2, - height: 2, - bytes: vec![ - 0xFF, 0x00, 0x00, 0xFF, // red - 0x00, 0xFF, 0x00, 0x80, // half-transparent green - 0x00, 0x00, 0xFF, 0xFF, // blue - 0x10, 0x20, 0x30, 0x00, // transparent - ], - } - } - - #[test] - fn rgba_survives_a_png_round_trip() { - let image = sample(); - let png = rgba_to_png(&image).expect("encode"); - assert_eq!(png_to_rgba(&png).expect("decode"), image); - } - - #[test] - fn rgb_png_gets_an_opaque_alpha_channel() { - let mut out = Vec::new(); - { - let mut encoder = png::Encoder::new(&mut out, 1, 1); - encoder.set_color(png::ColorType::Rgb); - encoder.set_depth(png::BitDepth::Eight); - let mut writer = encoder.write_header().expect("header"); - writer.write_image_data(&[1, 2, 3]).expect("data"); - } - let image = png_to_rgba(&out).expect("decode"); - assert_eq!(image.bytes, vec![1, 2, 3, 0xFF]); - } - - #[test] - fn mismatched_dimensions_are_rejected() { - let image = Rgba { - width: 3, - height: 1, - bytes: vec![0; 8], - }; - assert!(matches!(rgba_to_png(&image), Err(ImageError::Size))); - } -} diff --git a/crates/ironrdp-cliprdr-native/src/linux/mod.rs b/crates/ironrdp-cliprdr-native/src/linux/mod.rs index c038e1d3a5..5ce75ecca6 100644 --- a/crates/ironrdp-cliprdr-native/src/linux/mod.rs +++ b/crates/ironrdp-cliprdr-native/src/linux/mod.rs @@ -1,19 +1,14 @@ -//! `CLIPRDR` backend for Linux desktops, over the X11 and Wayland clipboards. +//! Linux CLIPRDR backend with clipboard change events and delayed rendering. //! -//! Built on [`arboard`]. On Wayland it uses the data-control protocol when the -//! compositor offers it (Sway, KDE Plasma, GNOME 48 and later) and otherwise -//! the X11 clipboard through XWayland, which GNOME and KDE keep in sync with -//! the Wayland selection. Plain text (`CF_UNICODETEXT`) and images -//! (`CF_DIB`/`CF_DIBV5`) travel in both directions; files and HTML do not. -//! -//! Remote content is fetched as soon as the remote announces a copy and written -//! to the OS clipboard, because neither clipboard protocol used here lets a -//! client offer content it will only produce on demand. Local changes are -//! found by polling; see [`worker::POLL_INTERVAL`]. +//! Wayland uses the shared [`crate::data_control`] client. X11/XWayland uses +//! XFixes selection notifications and selection ownership. Both advertise +//! remote formats immediately and request data only when an application pastes. +//! Plain text (`CF_UNICODETEXT`) and PNG images (`CF_DIB`/`CF_DIBV5`) travel in +//! both directions; file clipboard transfer and HTML are not supported. -mod image; -mod os; -mod worker; +pub mod os; +pub mod worker; +pub mod x11; use core::fmt; use std::sync::mpsc::{self, Sender}; @@ -33,7 +28,7 @@ use self::worker::{Command, Worker}; pub enum LinuxCliprdrError { /// No usable clipboard: neither a Wayland data-control clipboard nor an X11 /// display could be opened. - Clipboard(arboard::Error), + Clipboard(String), Spawn(std::io::Error), /// The clipboard thread stopped before reporting whether it could start. WorkerStopped, @@ -52,7 +47,7 @@ impl fmt::Display for LinuxCliprdrError { impl core::error::Error for LinuxCliprdrError { fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { match self { - Self::Clipboard(error) => Some(error), + Self::Clipboard(_) => None, Self::Spawn(error) => Some(error), Self::WorkerStopped => None, } @@ -83,10 +78,11 @@ impl LinuxClipboard { let (commands, receiver) = mpsc::channel(); let (ready_sender, ready) = mpsc::channel(); + let events = commands.clone(); let thread = std::thread::Builder::new() .name("cliprdr-linux".to_owned()) .spawn(move || { - let os = match os::ArboardClipboard::open() { + let os = match os::NativeClipboard::open(events) { Ok(os) => os, Err(error) => { let _ = ready_sender.send(Err(error)); @@ -131,6 +127,7 @@ struct LinuxCliprdrBackendFactory { impl CliprdrBackendFactory for LinuxCliprdrBackendFactory { fn build_cliprdr_backend(&self) -> Box { + let _ = self.commands.send(Command::Reset); Box::new(LinuxCliprdrBackend { commands: self.commands.clone(), }) @@ -182,7 +179,8 @@ impl CliprdrBackend for LinuxCliprdrBackend { } fn on_format_data_response(&mut self, response: FormatDataResponse<'_>) { - let data = (!response.is_error()).then(|| response.data().to_vec()); + let data = (!response.is_error() && response.data().len() <= worker::MAX_TRANSFER_BYTES) + .then(|| response.data().to_vec()); self.send(Command::RemoteData(data)); } diff --git a/crates/ironrdp-cliprdr-native/src/linux/os.rs b/crates/ironrdp-cliprdr-native/src/linux/os.rs index 2a43eb7aa9..5043042353 100644 --- a/crates/ironrdp-cliprdr-native/src/linux/os.rs +++ b/crates/ironrdp-cliprdr-native/src/linux/os.rs @@ -1,86 +1,163 @@ -//! The OS clipboard as seen by the worker: a trait so the state machine can be -//! tested without a display, and its `arboard` implementation. +//! The clipboard interface used by the protocol worker. -use std::borrow::Cow; +use std::sync::mpsc::Sender; -use tracing::trace; +use super::worker::Command; +use super::x11::X11Clipboard; +use crate::data_control::{Content, DataControl}; -use super::image::Rgba; +pub const TEXT: &str = "text/plain;charset=utf-8"; +pub const PNG: &str = "image/png"; -/// One logical clipboard item. Text wins over an image when both are offered, -/// which is what a paste into a terminal or an editor expects. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) enum Content { - Text(String), - Image(Rgba), -} +type CompletePaste = Box>) + Send>; -pub(super) trait OsClipboard { - /// The current clipboard content, or `None` when it is empty or holds - /// nothing this backend can carry. - fn read(&mut self) -> Option; +/// A delayed OS paste. Every completion, including failure, closes its waiter. +pub struct Transfer { + mime: String, + generation: u64, + complete: Option, +} - /// Replaces the clipboard content. - fn write(&mut self, content: &Content) -> Result<(), String>; +impl core::fmt::Debug for Transfer { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("Transfer") + .field("mime", &self.mime) + .field("generation", &self.generation) + .finish_non_exhaustive() + } } -/// The real clipboard through `arboard`: Wayland data-control when the -/// compositor offers it, the X11 clipboard (through XWayland on Wayland -/// desktops) otherwise. -pub(super) struct ArboardClipboard { - inner: arboard::Clipboard, +impl Transfer { + pub fn new(mime: String, generation: u64, complete: impl FnOnce(Option>) + Send + 'static) -> Self { + Self { + mime, + generation, + complete: Some(Box::new(complete)), + } + } + + pub fn mime_type(&self) -> &str { + &self.mime + } + + pub fn generation(&self) -> u64 { + self.generation + } + + pub fn finish(mut self, data: Option>) { + if let Some(complete) = self.complete.take() { + complete(data); + } + } } -impl ArboardClipboard { - pub(super) fn open() -> Result { - Ok(Self { - inner: arboard::Clipboard::new()?, - }) +impl Drop for Transfer { + fn drop(&mut self) { + if let Some(complete) = self.complete.take() { + complete(None); + } } } -impl OsClipboard for ArboardClipboard { - fn read(&mut self) -> Option { - match self.inner.get_text() { - Ok(text) if !text.is_empty() => return Some(Content::Text(text)), - Ok(_) | Err(arboard::Error::ContentNotAvailable) => {} - Err(error) => { - trace!(%error, "Could not read text from the OS clipboard"); - return None; +pub trait OsClipboard { + fn is_owner(&self) -> bool; + fn mime_types(&self) -> Vec; + fn read(&mut self, mime: &str) -> Result>, String>; + fn offer(&mut self, mimes: &[String], generation: u64) -> Result<(), String>; + fn clear(&mut self) -> Result<(), String>; +} + +pub enum NativeClipboard { + Wayland { + clipboard: DataControl, + events: Sender, + }, + X11(X11Clipboard), +} + +impl NativeClipboard { + pub fn open(events: Sender) -> Result { + match DataControl::connect() { + Ok(clipboard) => { + let changes = events.clone(); + clipboard.on_change(move |mimes| { + let _ = changes.send(Command::LocalChanged(mimes)); + }); + Ok(Self::Wayland { clipboard, events }) + } + Err(wayland_error) => { + tracing::debug!(%wayland_error, "Wayland data-control unavailable; trying X11"); + X11Clipboard::open(events) + .map(Self::X11) + .map_err(|x11_error| format!("Wayland: {wayland_error}; X11: {x11_error}")) } } + } +} + +impl OsClipboard for NativeClipboard { + fn is_owner(&self) -> bool { + match self { + Self::Wayland { clipboard, .. } => clipboard.owns_selection(), + Self::X11(clipboard) => clipboard.is_owner(), + } + } - match self.inner.get_image() { - Ok(image) => { - let width = u32::try_from(image.width).ok()?; - let height = u32::try_from(image.height).ok()?; - Some(Content::Image(Rgba { - width, - height, - bytes: image.bytes.into_owned(), - })) - } - Err(arboard::Error::ContentNotAvailable) => None, - Err(error) => { - trace!(%error, "Could not read an image from the OS clipboard"); - None + fn mime_types(&self) -> Vec { + match self { + Self::Wayland { clipboard, .. } => clipboard.selection_mime_types(), + Self::X11(clipboard) => clipboard.mime_types(), + } + } + + fn read(&mut self, mime: &str) -> Result>, String> { + match self { + Self::Wayland { clipboard, .. } => { + let serial = clipboard.serial(); + let data = clipboard.read(mime).map_err(|error| error.to_string())?; + if serial != clipboard.serial() { + return Err("clipboard selection changed while reading".into()); + } + Ok(data) } + Self::X11(clipboard) => clipboard.read(mime), } } - fn write(&mut self, content: &Content) -> Result<(), String> { - let result = match content { - Content::Text(text) => self.inner.set_text(text.as_str()), - Content::Image(image) => { - let width = usize::try_from(image.width).map_err(|error| error.to_string())?; - let height = usize::try_from(image.height).map_err(|error| error.to_string())?; - self.inner.set_image(arboard::ImageData { - width, - height, - bytes: Cow::Borrowed(&image.bytes), - }) + fn offer(&mut self, mimes: &[String], generation: u64) -> Result<(), String> { + match self { + Self::Wayland { clipboard, events } => { + let events = events.clone(); + clipboard.on_transfer(move |request| { + let transfer = Transfer::new(request.mime_type().to_owned(), generation, move |data| { + let result = match data { + Some(data) => request.complete(data), + None => request.fail(), + }; + if let Err(error) = result { + tracing::debug!(%error, "Could not finish a Wayland paste"); + } + }); + let _ = events.send(Command::Paste(transfer)); + }); + let content = mimes + .iter() + .fold(Content::new(), |content, mime| content.advertise(mime)); + clipboard.set_selection(content).map_err(|error| error.to_string())?; + clipboard.synchronize().map_err(|error| error.to_string())?; + if !clipboard.owns_selection() { + return Err("clipboard ownership was not acquired".into()); + } + Ok(()) } - }; - result.map_err(|error| error.to_string()) + Self::X11(clipboard) => clipboard.offer(mimes, generation), + } + } + + fn clear(&mut self) -> Result<(), String> { + match self { + Self::Wayland { clipboard, .. } => clipboard.clear_selection().map_err(|error| error.to_string()), + Self::X11(clipboard) => clipboard.clear(), + } } } diff --git a/crates/ironrdp-cliprdr-native/src/linux/worker.rs b/crates/ironrdp-cliprdr-native/src/linux/worker.rs index a10d3d24df..0227feb28c 100644 --- a/crates/ironrdp-cliprdr-native/src/linux/worker.rs +++ b/crates/ironrdp-cliprdr-native/src/linux/worker.rs @@ -1,565 +1,340 @@ -//! The state machine behind [`LinuxClipboard`](super::LinuxClipboard). -//! -//! Runs on its own thread so the `CLIPRDR` callbacks, which arrive on the RDP -//! session task, never wait on the OS clipboard. The backend forwards each -//! callback as a [`Command`]; the worker owns the [`OsClipboard`], the -//! [`ClipboardMessageProxy`] and every piece of state. +//! Serializes the CLIPRDR request/response stream and OS clipboard events. use core::time::Duration; +use std::collections::{HashMap, VecDeque}; use std::sync::mpsc::{Receiver, RecvTimeoutError}; use std::time::Instant; use ironrdp_cliprdr::backend::{ClipboardMessage, ClipboardMessageProxy}; -use ironrdp_cliprdr::pdu::{ClipboardFormat, ClipboardFormatId, FormatDataResponse, OwnedFormatDataResponse}; +use ironrdp_cliprdr::loop_detector::{ClipboardSource, LoopDetector}; +use ironrdp_cliprdr::pdu::{ClipboardFormat, ClipboardFormatId, FormatDataResponse}; use ironrdp_cliprdr_format::bitmap; use ironrdp_core::IntoOwned as _; use tracing::{debug, warn}; -use super::image; -use super::os::{Content, OsClipboard}; +use super::os::{OsClipboard, PNG, TEXT, Transfer}; +use crate::data_control::find_mime_match; -/// How often the OS clipboard is re-read for changes. Neither X11 nor the -/// Wayland data-control protocol tells a non-owner when the selection changes, -/// so the clipboard is polled; text is read first and an image only when there -/// is no text, which keeps the common case cheap. -pub(super) const POLL_INTERVAL: Duration = Duration::from_millis(750); +pub const PASTE_TIMEOUT: Duration = Duration::from_secs(5); +pub const MAX_TRANSFER_BYTES: usize = 100 * 1024 * 1024; +const MAX_WAITERS: usize = 16; -/// How long an outstanding paste request is waited for before a newer remote -/// copy replaces it. The remote may never answer if the application that owned -/// its clipboard went away. -const PENDING_PASTE_TIMEOUT: Duration = Duration::from_secs(5); - -/// A `CLIPRDR` callback, forwarded from the backend. #[derive(Debug)] -pub(super) enum Command { - /// The channel is up: advertise whatever the OS clipboard holds. +pub enum Command { AdvertiseLocal, - /// The remote copied something and offers these formats. + LocalChanged(Vec), RemoteCopy(Vec), - /// The remote wants our clipboard in this format. - RenderLocal(ClipboardFormatId), - /// The remote answered our paste request: the bytes, or `None` on failure. RemoteData(Option>), + RenderLocal(ClipboardFormatId), + Paste(Transfer), + Reset, Shutdown, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PendingPaste { - Text, - Image { dibv5: bool }, +struct Pending { + format: ClipboardFormatId, + generation: u64, + deadline: Instant, + expired: bool, + waiters: Vec, +} + +struct Queued { + deadline: Instant, + transfer: Transfer, } -pub(super) struct Worker { - os: C, +pub struct Worker { + os: O, proxy: P, - /// What the remote has been told our clipboard holds and what - /// [`Command::RenderLocal`] serves. - local: Option, - /// Polling must wait for the server to request the initial format list. - initialized: bool, - /// A local copy or newer remote offer superseded the outstanding response. - discard_pending: bool, - /// The last thing we wrote to the OS clipboard on the remote's behalf, so - /// reading it back is not mistaken for a local copy. - last_written: Option, - pending: Option<(PendingPaste, Instant)>, - /// A remote copy that arrived while `pending` was outstanding. - desired: Option<(PendingPaste, ClipboardFormatId)>, + ready: bool, + local_mimes: Vec, + remote: Vec, + owns_remote: bool, + generation: u64, + pending: Option, + queue: VecDeque, + cache: HashMap>, + loops: LoopDetector, } -impl Worker { - pub(super) fn new(os: C, proxy: P) -> Self { +impl Worker { + pub fn new(os: O, proxy: P) -> Self { + let local_mimes = os.mime_types(); Self { os, proxy, - local: None, - initialized: false, - discard_pending: false, - last_written: None, + local_mimes, + ready: false, + remote: Vec::new(), + owns_remote: false, + generation: 0, pending: None, - desired: None, + queue: VecDeque::new(), + cache: HashMap::new(), + loops: LoopDetector::new(), } } - pub(super) fn run(mut self, commands: &Receiver) { + pub fn run(mut self, receiver: &Receiver) { loop { - match commands.recv_timeout(POLL_INTERVAL) { + match receiver.recv_timeout(Duration::from_millis(100)) { Ok(Command::Shutdown) | Err(RecvTimeoutError::Disconnected) => break, Ok(command) => self.handle(command), - Err(RecvTimeoutError::Timeout) => self.poll(), + Err(RecvTimeoutError::Timeout) => {} } + self.expire(Instant::now()); } + let _ = self.os.clear(); } - pub(super) fn handle(&mut self, command: Command) { + pub fn handle(&mut self, command: Command) { match command { Command::AdvertiseLocal => { - self.initialized = true; - self.local = self.os.read(); - self.advertise(); + self.ready = true; + if !self.owns_remote { + self.local_mimes = self.os.mime_types(); + self.advertise_local(); + } } - Command::RemoteCopy(formats) => self.remote_copy(&formats), - Command::RenderLocal(format) => self.render_local(format), + Command::LocalChanged(_) => { + // An OS event queued before offer() acknowledged our ownership + // must not invalidate the selection that was just installed. + if self.os.is_owner() { + return; + } + self.invalidate(); + self.owns_remote = false; + self.remote.clear(); + self.local_mimes = self.os.mime_types(); + if self.ready { + self.advertise_local(); + } + } + Command::RemoteCopy(formats) => { + self.invalidate(); + self.remote = formats; + self.owns_remote = true; + let mut mimes = Vec::new(); + if self.remote.iter().any(|f| f.id() == ClipboardFormatId::CF_UNICODETEXT) { + mimes.extend([TEXT.to_owned(), "text/plain".to_owned()]); + } + if self + .remote + .iter() + .any(|f| matches!(f.id(), ClipboardFormatId::CF_DIB | ClipboardFormatId::CF_DIBV5)) + { + mimes.push(PNG.to_owned()); + } + if let Err(error) = self.os.offer(&mimes, self.generation) { + warn!(%error, "Could not advertise the remote clipboard"); + let _ = self.os.clear(); + self.owns_remote = false; + self.remote.clear(); + self.local_mimes = self.os.mime_types(); + if self.ready { + self.advertise_local(); + } + } + } + Command::Paste(transfer) => self.paste(transfer), Command::RemoteData(data) => self.remote_data(data), + Command::RenderLocal(format) => self.render_local(format), + Command::Reset => { + self.invalidate(); + self.pending = None; + self.ready = false; + self.remote.clear(); + if self.owns_remote { + let _ = self.os.clear(); + } + self.owns_remote = false; + self.loops.clear(); + } Command::Shutdown => {} } } - /// Re-reads the OS clipboard and advertises it if it changed. - pub(super) fn poll(&mut self) { - if !self.initialized { - return; - } - // A stalled owner must not strand a newer copy indefinitely. Check on - // the timer, rather than requiring a third copy to trigger recovery. - if self - .pending - .is_some_and(|(_, since)| since.elapsed() >= PENDING_PASTE_TIMEOUT) - { - if let Some(next) = self.desired.take() { - debug!("Pending paste timed out; requesting the newer remote copy"); - self.issue_paste(next); - } + fn invalidate(&mut self) { + self.generation = self.generation.wrapping_add(1); + self.queue.clear(); + self.cache.clear(); + if let Some(pending) = &mut self.pending { + pending.waiters.clear(); } - let Some(content) = self.os.read() else { - return; - }; - if self.last_written.as_ref() == Some(&content) || self.local.as_ref() == Some(&content) { - return; - } - // Something new was copied locally, so the echo guard is stale. - self.last_written = None; - self.discard_pending = self.pending.is_some(); - self.desired = None; - self.local = Some(content); - self.advertise(); } - fn advertise(&self) { - let formats = self.local.as_ref().map_or_else(Vec::new, advertised_formats); + fn advertise_local(&self) { + let mut formats = Vec::new(); + if find_mime_match(TEXT, &self.local_mimes).is_some() { + formats.push(ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT)); + } + if find_mime_match(PNG, &self.local_mimes).is_some() { + formats.extend([ + ClipboardFormat::new(ClipboardFormatId::CF_DIB), + ClipboardFormat::new(ClipboardFormatId::CF_DIBV5), + ]); + } self.proxy .send_clipboard_message(ClipboardMessage::SendInitiateCopy(formats)); } - fn remote_copy(&mut self, formats: &[ClipboardFormat]) { - let Some(target) = paste_target(formats) else { - self.pending = None; - self.desired = None; + fn paste(&mut self, transfer: Transfer) { + if !self.owns_remote || transfer.generation() != self.generation { return; - }; - - if let Some((_, since)) = self.pending { - if since.elapsed() < PENDING_PASTE_TIMEOUT { - self.desired = Some(target); + } + let has = |id| self.remote.iter().any(|f| f.id() == id); + let format = + if transfer.mime_type().split(';').next() == Some("text/plain") && has(ClipboardFormatId::CF_UNICODETEXT) { + ClipboardFormatId::CF_UNICODETEXT + } else if transfer.mime_type() == PNG && has(ClipboardFormatId::CF_DIBV5) { + ClipboardFormatId::CF_DIBV5 + } else if transfer.mime_type() == PNG && has(ClipboardFormatId::CF_DIB) { + ClipboardFormatId::CF_DIB + } else { + return; + }; + if let Some(data) = self.cache.get(&format) { + transfer.finish(Some(data.clone())); + return; + } + if let Some(pending) = &mut self.pending { + if pending.expired || MAX_WAITERS <= pending.waiters.len() + self.queue.len() { return; } - debug!("Pending paste timed out; abandoning it for the newer remote copy"); + if pending.generation == self.generation && pending.format == format { + pending.waiters.push(transfer); + } else { + self.queue.push_back(Queued { + deadline: Instant::now() + PASTE_TIMEOUT, + transfer, + }); + } + } else { + self.start(format, transfer); } - - self.issue_paste(target); } - fn issue_paste(&mut self, (paste, format): (PendingPaste, ClipboardFormatId)) { - self.pending = Some((paste, Instant::now())); - self.discard_pending = false; - self.desired = None; + fn start(&mut self, format: ClipboardFormatId, transfer: Transfer) { + self.pending = Some(Pending { + format, + generation: self.generation, + deadline: Instant::now() + PASTE_TIMEOUT, + expired: false, + waiters: vec![transfer], + }); self.proxy .send_clipboard_message(ClipboardMessage::SendInitiatePaste(format)); } - fn remote_data(&mut self, data: Option>) { - let superseded = self.discard_pending || self.desired.is_some(); - self.discard_pending = false; - match (self.pending.take(), data) { - (Some(_), _) if superseded => debug!("Ignoring superseded remote clipboard content"), - (Some((paste, _)), Some(bytes)) => match decode_remote(paste, &bytes) { - Ok(content) => self.store_remote(content), - Err(error) => warn!(%error, "Could not decode the remote clipboard content"), - }, - (Some(_), None) => debug!("The remote could not provide the requested clipboard format"), - (None, _) => debug!("Unexpected format data response"), - } - - if let Some(next) = self.desired.take() { - self.issue_paste(next); + /// Expire OS waiters, but keep the outstanding wire request until its reply arrives. + /// [MS-RDPECLIP 2.2.5.2] has no response identifier, so starting another request + /// after a timeout would misinterpret the late reply as the new clipboard content. + /// + /// [MS-RDPECLIP 2.2.5.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeclip/28c193b8-4cec-413e-a07b-9235e5e15f6b + pub fn expire(&mut self, now: Instant) { + if let Some(pending) = &mut self.pending { + if pending.deadline <= now { + pending.expired = true; + pending.waiters.clear(); + } } + self.queue.retain(|queued| now < queued.deadline); } - fn store_remote(&mut self, content: Content) { - match self.os.write(&content) { - Ok(()) => { - self.last_written = Some(content.clone()); - self.local = Some(content); + fn remote_data(&mut self, data: Option>) { + let Some(pending) = self.pending.take() else { + debug!("Discarding an unsolicited clipboard response"); + return; + }; + if !pending.expired + && Instant::now() < pending.deadline + && pending.generation == self.generation + && self.owns_remote + { + let decoded = data.filter(|data| data.len() <= MAX_TRANSFER_BYTES).and_then(|data| { + let result = match pending.format { + ClipboardFormatId::CF_UNICODETEXT => FormatDataResponse::new_data(data.as_slice()) + .to_unicode_string() + .map(String::into_bytes) + .map_err(|error| error.to_string()), + ClipboardFormatId::CF_DIB => bitmap::dib_to_png(&data).map_err(|error| error.to_string()), + ClipboardFormatId::CF_DIBV5 => bitmap::dibv5_to_png(&data).map_err(|error| error.to_string()), + _ => return None, + }; + match result { + Ok(data) => Some(data), + Err(error) => { + warn!(%error, "Could not decode the remote clipboard"); + None + } + } + }); + if let Some(data) = &decoded { + self.loops + .record_content(data, ClipboardSource::Remote, crate::native_now_ms()); + self.cache.insert(pending.format, data.clone()); + } + for transfer in pending.waiters { + transfer.finish(decoded.clone()); + } + } + while self.pending.is_none() { + let Some(queued) = self.queue.pop_front() else { + break; + }; + if Instant::now() < queued.deadline { + self.paste(queued.transfer); } - Err(error) => warn!( - error, - "Could not write the remote clipboard content to the OS clipboard" - ), } } - fn render_local(&self, format: ClipboardFormatId) { - let response = match (format, self.local.as_ref()) { - (ClipboardFormatId::CF_UNICODETEXT, Some(Content::Text(text))) => { - FormatDataResponse::new_unicode_string(text).into_owned() + fn render_local(&mut self, format: ClipboardFormatId) { + let result = (|| -> Result, String> { + if self.owns_remote { + return Err("local clipboard selection was replaced".into()); } - (ClipboardFormatId::CF_DIB, Some(Content::Image(image))) => { - render_image(image, bitmap::png_to_cf_dib, "CF_DIB") + let mime = match format { + ClipboardFormatId::CF_UNICODETEXT => TEXT, + ClipboardFormatId::CF_DIB | ClipboardFormatId::CF_DIBV5 => PNG, + _ => return Err("unsupported clipboard format".into()), + }; + let data = self.os.read(mime)?.ok_or("clipboard format is no longer available")?; + if MAX_TRANSFER_BYTES < data.len() { + return Err("clipboard data exceeds the size limit".into()); } - (ClipboardFormatId::CF_DIBV5, Some(Content::Image(image))) => { - render_image(image, bitmap::png_to_cf_dibv5, "CF_DIBV5") + // Native APIs suppress our own selection events. This also stops a + // clipboard manager that republishes recently received content. + if self + .loops + .would_cause_content_loop(&data, ClipboardSource::Local, crate::native_now_ms()) + { + return Err("clipboard content would echo a recent remote paste".into()); + } + self.loops + .record_content(&data, ClipboardSource::Local, crate::native_now_ms()); + match format { + ClipboardFormatId::CF_UNICODETEXT => { + let text = core::str::from_utf8(&data).map_err(|error| error.to_string())?; + let units = text.encode_utf16().count().saturating_add(1); + if MAX_TRANSFER_BYTES / 2 < units { + return Err("encoded clipboard text exceeds the size limit".into()); + } + Ok(FormatDataResponse::new_unicode_string(text).into_data().into_owned()) + } + ClipboardFormatId::CF_DIB => bitmap::png_to_cf_dib(&data).map_err(|error| error.to_string()), + ClipboardFormatId::CF_DIBV5 => bitmap::png_to_cf_dibv5(&data).map_err(|error| error.to_string()), + _ => Err("unsupported clipboard format".into()), + } + })(); + let response = match result { + Ok(data) => FormatDataResponse::new_data(data).into_owned(), + Err(error) => { + debug!(%error, "Could not render the local clipboard"); + FormatDataResponse::new_error() } - _ => FormatDataResponse::new_error(), }; self.proxy .send_clipboard_message(ClipboardMessage::SendFormatData(response)); } } - -fn render_image( - image: &image::Rgba, - to_dib: fn(&[u8]) -> Result, bitmap::BitmapError>, - name: &str, -) -> OwnedFormatDataResponse { - let dib = image::rgba_to_png(image) - .map_err(|error| error.to_string()) - .and_then(|png| to_dib(&png).map_err(|error| error.to_string())); - match dib { - Ok(dib) => FormatDataResponse::new_data(dib).into_owned(), - Err(error) => { - warn!(%error, format = name, "Could not convert the local image for the remote"); - FormatDataResponse::new_error() - } - } -} - -/// The formats advertised for one piece of local content. -fn advertised_formats(content: &Content) -> Vec { - match content { - Content::Text(_) => vec![ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT)], - // DIBV5 carries alpha; DIB is for peers that do not understand it. - Content::Image(_) => vec![ - ClipboardFormat::new(ClipboardFormatId::CF_DIB), - ClipboardFormat::new(ClipboardFormatId::CF_DIBV5), - ], - } -} - -/// Which remote format to fetch: text first, then the richest image. -fn paste_target(formats: &[ClipboardFormat]) -> Option<(PendingPaste, ClipboardFormatId)> { - let has = |id: ClipboardFormatId| formats.iter().any(|format| format.id() == id); - - if has(ClipboardFormatId::CF_UNICODETEXT) { - Some((PendingPaste::Text, ClipboardFormatId::CF_UNICODETEXT)) - } else if has(ClipboardFormatId::CF_DIBV5) { - Some((PendingPaste::Image { dibv5: true }, ClipboardFormatId::CF_DIBV5)) - } else if has(ClipboardFormatId::CF_DIB) { - Some((PendingPaste::Image { dibv5: false }, ClipboardFormatId::CF_DIB)) - } else { - None - } -} - -fn decode_remote(paste: PendingPaste, bytes: &[u8]) -> Result { - match paste { - PendingPaste::Text => FormatDataResponse::new_data(bytes) - .to_unicode_string() - .map(Content::Text) - .map_err(|error| error.to_string()), - PendingPaste::Image { dibv5 } => { - let png = if dibv5 { - bitmap::dibv5_to_png(bytes) - } else { - bitmap::dib_to_png(bytes) - } - .map_err(|error| error.to_string())?; - image::png_to_rgba(&png) - .map(Content::Image) - .map_err(|error| error.to_string()) - } - } -} - -#[cfg(test)] -mod tests { - use std::sync::{Arc, Mutex}; - - use super::*; - use crate::linux::image::Rgba; - - #[derive(Default)] - struct FakeOs { - content: Option, - written: Vec, - } - - impl OsClipboard for Arc> { - fn read(&mut self) -> Option { - self.lock().expect("fake os").content.clone() - } - - fn write(&mut self, content: &Content) -> Result<(), String> { - let mut os = self.lock().expect("fake os"); - os.content = Some(content.clone()); - os.written.push(content.clone()); - Ok(()) - } - } - - #[derive(Debug, Default, Clone)] - struct Recorder(Arc>>); - - impl ClipboardMessageProxy for Recorder { - fn send_clipboard_message(&self, message: ClipboardMessage) { - self.0.lock().expect("recorder").push(message); - } - } - - impl Recorder { - fn take(&self) -> Vec { - core::mem::take(&mut *self.0.lock().expect("recorder")) - } - } - - type TestWorker = Worker>, Recorder>; - - fn worker() -> (TestWorker, Arc>, Recorder) { - let os = Arc::new(Mutex::new(FakeOs::default())); - let recorder = Recorder::default(); - (Worker::new(Arc::clone(&os), recorder.clone()), os, recorder) - } - - fn set_local(os: &Arc>, content: Content) { - os.lock().expect("fake os").content = Some(content); - } - - fn format_ids(messages: &[ClipboardMessage]) -> Vec> { - messages - .iter() - .filter_map(|message| match message { - ClipboardMessage::SendInitiateCopy(formats) => Some(formats.iter().map(|f| f.id()).collect()), - _ => None, - }) - .collect() - } - - fn sample_image() -> Rgba { - Rgba { - width: 2, - height: 1, - bytes: vec![0xFF, 0x00, 0x00, 0xFF, 0x00, 0x00, 0xFF, 0xFF], - } - } - - #[test] - fn polling_before_monitor_ready_does_not_send_clipboard_pdus() { - let (mut worker, os, recorder) = worker(); - set_local(&os, Content::Text("preexisting local text".to_owned())); - worker.poll(); - assert!(recorder.take().is_empty()); - worker.handle(Command::AdvertiseLocal); - assert_eq!( - format_ids(&recorder.take()), - vec![vec![ClipboardFormatId::CF_UNICODETEXT]] - ); - } - - #[test] - fn a_newer_remote_copy_resumes_on_timeout_without_a_third_copy() { - let (mut worker, _os, recorder) = worker(); - worker.handle(Command::AdvertiseLocal); - worker.handle(Command::RemoteCopy(vec![ClipboardFormat::new( - ClipboardFormatId::CF_UNICODETEXT, - )])); - worker.handle(Command::RemoteCopy(vec![ClipboardFormat::new( - ClipboardFormatId::CF_DIB, - )])); - recorder.take(); - worker.pending.as_mut().expect("pending paste").1 = Instant::now() - PENDING_PASTE_TIMEOUT; - worker.poll(); - assert!(matches!( - recorder.take().as_slice(), - [ClipboardMessage::SendInitiatePaste(ClipboardFormatId::CF_DIB)] - )); - } - - #[test] - fn a_local_copy_is_not_overwritten_by_an_older_remote_response() { - let (mut worker, os, recorder) = worker(); - worker.handle(Command::AdvertiseLocal); - worker.handle(Command::RemoteCopy(vec![ClipboardFormat::new( - ClipboardFormatId::CF_UNICODETEXT, - )])); - set_local(&os, Content::Text("new local copy".to_owned())); - worker.poll(); - recorder.take(); - let old = FormatDataResponse::new_unicode_string("old remote copy").into_owned(); - worker.handle(Command::RemoteData(Some(old.data().to_vec()))); - assert!(os.lock().expect("fake os").written.is_empty()); - assert_eq!( - os.lock().expect("fake os").content, - Some(Content::Text("new local copy".to_owned())) - ); - } - - #[test] - fn channel_ready_advertises_the_current_clipboard() { - let (mut worker, os, recorder) = worker(); - set_local(&os, Content::Text("hello".to_owned())); - - worker.handle(Command::AdvertiseLocal); - - assert_eq!( - format_ids(&recorder.take()), - vec![vec![ClipboardFormatId::CF_UNICODETEXT]] - ); - } - - #[test] - fn channel_ready_with_an_empty_clipboard_advertises_nothing() { - let (mut worker, _os, recorder) = worker(); - - worker.handle(Command::AdvertiseLocal); - - assert_eq!(format_ids(&recorder.take()), vec![Vec::::new()]); - } - - #[test] - fn a_local_change_is_advertised_once() { - let (mut worker, os, recorder) = worker(); - worker.handle(Command::AdvertiseLocal); - recorder.take(); - - set_local(&os, Content::Text("copied locally".to_owned())); - worker.poll(); - worker.poll(); - - assert_eq!( - format_ids(&recorder.take()), - vec![vec![ClipboardFormatId::CF_UNICODETEXT]] - ); - - set_local(&os, Content::Image(sample_image())); - worker.poll(); - assert_eq!( - format_ids(&recorder.take()), - vec![vec![ClipboardFormatId::CF_DIB, ClipboardFormatId::CF_DIBV5]] - ); - } - - #[test] - fn remote_text_lands_on_the_os_clipboard_without_echoing_back() { - let (mut worker, os, recorder) = worker(); - - worker.handle(Command::RemoteCopy(vec![ - ClipboardFormat::new(ClipboardFormatId::CF_DIB), - ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT), - ])); - let messages = recorder.take(); - assert!( - matches!( - messages.as_slice(), - [ClipboardMessage::SendInitiatePaste(ClipboardFormatId::CF_UNICODETEXT)] - ), - "text is preferred over an image: {messages:?}" - ); - - let wire = FormatDataResponse::new_unicode_string("from the remote").into_owned(); - worker.handle(Command::RemoteData(Some(wire.data().to_vec()))); - - assert_eq!( - os.lock().expect("fake os").written, - vec![Content::Text("from the remote".to_owned())] - ); - - // Reading our own write back must not re-advertise it to the remote. - worker.poll(); - assert!(recorder.take().is_empty()); - } - - #[test] - fn remote_image_round_trips_through_dib() { - let (mut worker, os, recorder) = worker(); - let image = sample_image(); - - worker.handle(Command::RemoteCopy(vec![ClipboardFormat::new( - ClipboardFormatId::CF_DIB, - )])); - assert!(matches!( - recorder.take().as_slice(), - [ClipboardMessage::SendInitiatePaste(ClipboardFormatId::CF_DIB)] - )); - - let png = image::rgba_to_png(&image).expect("png"); - let dib = bitmap::png_to_cf_dib(&png).expect("dib"); - worker.handle(Command::RemoteData(Some(dib))); - - assert_eq!(os.lock().expect("fake os").written, vec![Content::Image(image)]); - } - - #[test] - fn a_failed_remote_paste_leaves_the_os_clipboard_alone() { - let (mut worker, os, _recorder) = worker(); - worker.handle(Command::RemoteCopy(vec![ClipboardFormat::new( - ClipboardFormatId::CF_UNICODETEXT, - )])); - worker.handle(Command::RemoteData(None)); - assert!(os.lock().expect("fake os").written.is_empty()); - } - - #[test] - fn a_second_remote_copy_waits_for_the_outstanding_paste() { - let (mut worker, os, recorder) = worker(); - worker.handle(Command::RemoteCopy(vec![ClipboardFormat::new( - ClipboardFormatId::CF_UNICODETEXT, - )])); - worker.handle(Command::RemoteCopy(vec![ClipboardFormat::new( - ClipboardFormatId::CF_DIB, - )])); - assert_eq!(recorder.take().len(), 1, "only one paste request in flight"); - - let wire = FormatDataResponse::new_unicode_string("first").into_owned(); - worker.handle(Command::RemoteData(Some(wire.data().to_vec()))); - assert!( - os.lock().expect("fake os").written.is_empty(), - "superseded response must not replace the clipboard" - ); - assert!(matches!( - recorder.take().as_slice(), - [ClipboardMessage::SendInitiatePaste(ClipboardFormatId::CF_DIB)] - )); - } - - #[test] - fn local_content_is_rendered_in_the_requested_format() { - let (mut worker, os, recorder) = worker(); - set_local(&os, Content::Text("render me".to_owned())); - worker.handle(Command::AdvertiseLocal); - recorder.take(); - - worker.handle(Command::RenderLocal(ClipboardFormatId::CF_UNICODETEXT)); - let messages = recorder.take(); - let [ClipboardMessage::SendFormatData(response)] = messages.as_slice() else { - panic!("expected one format data response, got {messages:?}"); - }; - assert_eq!(response.to_unicode_string().expect("utf-16"), "render me"); - - worker.handle(Command::RenderLocal(ClipboardFormatId::CF_DIB)); - let messages = recorder.take(); - let [ClipboardMessage::SendFormatData(response)] = messages.as_slice() else { - panic!("expected one format data response, got {messages:?}"); - }; - assert!(response.is_error(), "text cannot be rendered as an image"); - - set_local(&os, Content::Image(sample_image())); - worker.poll(); - recorder.take(); - worker.handle(Command::RenderLocal(ClipboardFormatId::CF_DIBV5)); - let messages = recorder.take(); - let [ClipboardMessage::SendFormatData(response)] = messages.as_slice() else { - panic!("expected one format data response, got {messages:?}"); - }; - assert!(!response.is_error()); - bitmap::validate_dibv5(response.data()).expect("a well-formed DIBV5"); - } -} diff --git a/crates/ironrdp-cliprdr-native/src/linux/x11.rs b/crates/ironrdp-cliprdr-native/src/linux/x11.rs new file mode 100644 index 0000000000..f12e8c75f3 --- /dev/null +++ b/crates/ironrdp-cliprdr-native/src/linux/x11.rs @@ -0,0 +1,763 @@ +//! XFixes selection notifications and ICCCM selection transfers. +//! +//! Every conversion uses its own requestor window, so a timed-out owner's late +//! reply cannot satisfy a newer request. Large selections use the INCR protocol. +//! +//! See [ICCCM section 2.7.2, INCR Properties]. +//! +//! [ICCCM section 2.7.2, INCR Properties]: https://www.x.org/releases/X11R7.7/doc/xorg-docs/icccm/icccm.html + +use core::time::Duration; +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex, mpsc}; +use std::thread::JoinHandle; +use std::time::Instant; + +use x11rb::connection::Connection as _; +use x11rb::protocol::Event; +use x11rb::protocol::xfixes::{ConnectionExt as _, SelectionEventMask}; +use x11rb::protocol::xproto::{ + Atom, AtomEnum, ChangeWindowAttributesAux, ConnectionExt as _, CreateWindowAux, EventMask, PropMode, Property, + SelectionNotifyEvent, SelectionRequestEvent, Window, WindowClass, +}; +use x11rb::rust_connection::RustConnection; +use x11rb::wrapper::ConnectionExt as _; +use x11rb::{COPY_DEPTH_FROM_PARENT, CURRENT_TIME, NONE}; + +use super::os::{OsClipboard, PNG, TEXT, Transfer}; +use super::worker::{Command, MAX_TRANSFER_BYTES, PASTE_TIMEOUT}; + +// Fits the X11 core request size even without BIG-REQUESTS. +const CHUNK_BYTES: usize = 64 * 1024; +const MAX_TRANSFERS: usize = 16; +type ReadReply = mpsc::Sender>, String>>; +type XResult = Result>; + +x11rb::atom_manager! { + Atoms: AtomsCookie { + CLIPBOARD, TARGETS, TIMESTAMP, INCR, UTF8_STRING, + TEXT_UTF8: b"text/plain;charset=utf-8", + TEXT_PLAIN: b"text/plain", + PNG: b"image/png", + TRANSFER: b"IRONRDP_CLIPBOARD_TRANSFER", + } +} + +enum Request { + Read(String, ReadReply), + Offer(Vec, u64, mpsc::Sender>), + IsOwner(mpsc::Sender), + Clear, + Complete(SelectionRequestEvent, u64, Option>), + Shutdown, +} + +pub struct X11Clipboard { + commands: mpsc::Sender, + mime_types: Arc>>, + thread: Option>, +} + +impl X11Clipboard { + pub fn open(events: mpsc::Sender) -> Result { + let (commands, receiver) = mpsc::channel(); + let mime_types = Arc::new(Mutex::new(Vec::new())); + let state = + State::connect(events, commands.clone(), Arc::clone(&mime_types)).map_err(|error| error.to_string())?; + let thread = std::thread::Builder::new() + .name("cliprdr-x11".into()) + .spawn(move || { + if let Err(error) = state.run(receiver) { + tracing::warn!(%error, "X11 clipboard worker stopped"); + } + }) + .map_err(|error| error.to_string())?; + Ok(Self { + commands, + mime_types, + thread: Some(thread), + }) + } +} + +impl OsClipboard for X11Clipboard { + fn is_owner(&self) -> bool { + let (sender, receiver) = mpsc::channel(); + if self.commands.send(Request::IsOwner(sender)).is_err() { + return false; + } + receiver.recv_timeout(PASTE_TIMEOUT).unwrap_or(false) + } + + fn mime_types(&self) -> Vec { + self.mime_types + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + fn read(&mut self, mime: &str) -> Result>, String> { + let (sender, receiver) = mpsc::channel(); + self.commands + .send(Request::Read(mime.to_owned(), sender)) + .map_err(|error| error.to_string())?; + receiver + .recv_timeout(PASTE_TIMEOUT + Duration::from_secs(1)) + .map_err(|error| error.to_string())? + } + + fn offer(&mut self, mimes: &[String], generation: u64) -> Result<(), String> { + let (sender, receiver) = mpsc::channel(); + self.commands + .send(Request::Offer(mimes.to_vec(), generation, sender)) + .map_err(|error| error.to_string())?; + receiver + .recv_timeout(PASTE_TIMEOUT) + .map_err(|error| error.to_string())? + } + + fn clear(&mut self) -> Result<(), String> { + self.commands.send(Request::Clear).map_err(|error| error.to_string()) + } +} + +impl Drop for X11Clipboard { + fn drop(&mut self) { + let _ = self.commands.send(Request::Shutdown); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +enum ReadKind { + Targets, + Data(ReadReply), +} + +struct Incoming { + target: Atom, + kind: ReadKind, + bytes: Vec, + incremental: bool, + deadline: Instant, +} + +struct PendingPaste { + request: SelectionRequestEvent, + deadline: Instant, + expired: bool, +} + +struct Outgoing { + target: Atom, + data: Vec, + offset: usize, + deadline: Instant, +} + +struct State { + connection: RustConnection, + window: Window, + root: Window, + atoms: Atoms, + events: mpsc::Sender, + commands: mpsc::Sender, + mime_types: Arc>>, + targets: Vec, + owner: Window, + owns: bool, + generation: u64, + timestamp: u32, + incoming: HashMap, + outgoing: HashMap<(Window, Atom), Outgoing>, + pending_pastes: HashMap<(Window, Atom), PendingPaste>, + deferred: VecDeque, +} + +impl State { + fn connect( + events: mpsc::Sender, + commands: mpsc::Sender, + mime_types: Arc>>, + ) -> XResult { + let (connection, screen) = x11rb::connect(None)?; + connection.xfixes_query_version(5, 0)?.reply()?; + let root = connection.setup().roots[screen].root; + let window = connection.generate_id()?; + connection + .create_window( + COPY_DEPTH_FROM_PARENT, + window, + root, + 0, + 0, + 1, + 1, + 0, + WindowClass::INPUT_OUTPUT, + 0, + &CreateWindowAux::new().event_mask(EventMask::PROPERTY_CHANGE), + )? + .check()?; + let atoms = Atoms::new(&connection)?.reply()?; + connection + .xfixes_select_selection_input( + window, + atoms.CLIPBOARD, + SelectionEventMask::SET_SELECTION_OWNER + | SelectionEventMask::SELECTION_WINDOW_DESTROY + | SelectionEventMask::SELECTION_CLIENT_CLOSE, + )? + .check()?; + let owner = connection.get_selection_owner(atoms.CLIPBOARD)?.reply()?.owner; + let mut state = Self { + connection, + window, + root, + atoms, + events, + commands, + mime_types, + targets: Vec::new(), + owner, + owns: false, + generation: 0, + timestamp: CURRENT_TIME, + incoming: HashMap::new(), + outgoing: HashMap::new(), + pending_pastes: HashMap::new(), + deferred: VecDeque::new(), + }; + state.timestamp = state.server_timestamp()?; + if owner != NONE { + state.begin_read(state.atoms.TARGETS, ReadKind::Targets)?; + } + state.connection.flush()?; + Ok(state) + } + + fn run(mut self, receiver: mpsc::Receiver) -> XResult<()> { + loop { + for _ in 0..64 { + let event = match self.deferred.pop_front() { + Some(event) => Some(event), + None => self.connection.poll_for_event()?, + }; + let Some(event) = event else { + break; + }; + // BadWindow from an application that closed mid-paste is local to + // that transfer; it must not stop clipboard synchronization. + if let Err(error) = self.event(event) { + tracing::debug!(%error, "X11 clipboard event failed"); + } + } + match receiver.recv_timeout(Duration::from_millis(10)) { + Ok(Request::Shutdown) | Err(mpsc::RecvTimeoutError::Disconnected) => break, + Ok(request) => { + if let Err(error) = self.request(request) { + tracing::debug!(%error, "X11 clipboard request failed"); + } + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + let now = Instant::now(); + let expired: Vec<_> = self + .incoming + .iter() + .filter(|(_, read)| read.deadline <= now) + .map(|(window, _)| *window) + .collect(); + for window in expired { + self.finish_read(window, Err("X11 clipboard read timed out".into()))?; + } + self.outgoing.retain(|_, write| now < write.deadline); + let mut refused = Vec::new(); + for paste in self.pending_pastes.values_mut() { + if !paste.expired && paste.deadline <= now { + paste.expired = true; + refused.push(paste.request); + } + } + for request in refused { + let _ = self.notify(request, NONE); + } + // Keep expired pending slots until Complete is consumed, so cached + // responses already queued on the channel remain within the budget. + self.connection.flush()?; + } + self.clear()?; + Ok(()) + } + + fn request(&mut self, request: Request) -> XResult<()> { + match request { + Request::Read(mime, sender) => { + let target = if mime == PNG { + self.targets.contains(&self.atoms.PNG).then_some(self.atoms.PNG) + } else { + [ + self.atoms.UTF8_STRING, + self.atoms.TEXT_UTF8, + self.atoms.TEXT_PLAIN, + AtomEnum::STRING.into(), + ] + .into_iter() + .find(|target| self.targets.contains(target)) + }; + if let Some(target) = target.filter(|_| !self.owns && self.owner != NONE) { + self.begin_read(target, ReadKind::Data(sender))?; + } else { + let _ = sender.send(Ok(None)); + } + } + Request::IsOwner(sender) => { + let owner = self + .connection + .get_selection_owner(self.atoms.CLIPBOARD)? + .reply()? + .owner; + let _ = sender.send(owner == self.window); + } + Request::Offer(mimes, generation, sender) => { + let result = (|| -> XResult<()> { + self.generation = generation; + self.targets.clear(); + for mime in mimes { + if mime == PNG { + self.targets.push(self.atoms.PNG); + } + if mime == TEXT { + self.targets + .extend([self.atoms.UTF8_STRING, self.atoms.TEXT_UTF8, self.atoms.TEXT_PLAIN]); + } + } + self.timestamp = self.server_timestamp()?; + self.connection + .set_selection_owner(self.window, self.atoms.CLIPBOARD, self.timestamp)? + .check()?; + self.owner = self + .connection + .get_selection_owner(self.atoms.CLIPBOARD)? + .reply()? + .owner; + self.owns = self.owner == self.window; + let readers: Vec<_> = self.incoming.keys().copied().collect(); + for window in readers { + self.finish_read(window, Err("clipboard selection changed while reading".into()))?; + } + if !self.owns { + return Err("clipboard ownership was not acquired".into()); + } + Ok(()) + })(); + let _ = sender.send(result.map_err(|error| error.to_string())); + } + Request::Clear => self.clear()?, + Request::Complete(request, generation, data) => { + let property = if request.property == NONE { + request.target + } else { + request.property + }; + let Some(pending) = self.pending_pastes.remove(&(request.requestor, property)) else { + return Ok(()); + }; + if pending.expired { + return Ok(()); + } + if generation != self.generation || !self.owns { + self.notify(request, NONE)?; + } else if let Some(data) = data.filter(|d| d.len() <= MAX_TRANSFER_BYTES) { + let property = if request.property == NONE { + request.target + } else { + request.property + }; + if data.len() <= CHUNK_BYTES { + self.connection + .change_property8(PropMode::REPLACE, request.requestor, property, request.target, &data)? + .check()?; + self.notify(request, property)?; + } else if self.outgoing.len() < MAX_TRANSFERS { + self.connection + .change_window_attributes( + request.requestor, + &ChangeWindowAttributesAux::new().event_mask(EventMask::PROPERTY_CHANGE), + )? + .check()?; + let length = u32::try_from(data.len())?; + self.connection + .change_property32( + PropMode::REPLACE, + request.requestor, + property, + self.atoms.INCR, + &[length], + )? + .check()?; + self.outgoing.insert( + (request.requestor, property), + Outgoing { + target: request.target, + data, + offset: 0, + deadline: Instant::now() + PASTE_TIMEOUT, + }, + ); + self.notify(request, property)?; + } else { + self.notify(request, NONE)?; + } + } else { + self.notify(request, NONE)?; + } + } + Request::Shutdown => {} + } + Ok(()) + } + + // ICCCM uses the event timestamp that triggered ownership. RDP commands + // have no X timestamp, so obtain one from PropertyNotify and retain all + // other events for normal dispatch. + fn server_timestamp(&mut self) -> XResult { + self.connection + .change_property8( + PropMode::APPEND, + self.window, + self.atoms.TIMESTAMP, + AtomEnum::INTEGER, + &[], + )? + .check()?; + self.connection.flush()?; + loop { + let event = self.connection.wait_for_event()?; + if let Event::PropertyNotify(property) = &event { + if property.window == self.window && property.atom == self.atoms.TIMESTAMP { + return Ok(property.time); + } + } + self.deferred.push_back(event); + } + } + + fn clear(&mut self) -> XResult<()> { + if self + .connection + .get_selection_owner(self.atoms.CLIPBOARD)? + .reply()? + .owner + == self.window + { + self.connection + .set_selection_owner(NONE, self.atoms.CLIPBOARD, self.timestamp)? + .check()?; + } + self.owns = false; + Ok(()) + } + + fn begin_read(&mut self, target: Atom, kind: ReadKind) -> XResult<()> { + let window = self.connection.generate_id()?; + self.connection + .create_window( + COPY_DEPTH_FROM_PARENT, + window, + self.root, + 0, + 0, + 1, + 1, + 0, + WindowClass::INPUT_OUTPUT, + 0, + &CreateWindowAux::new().event_mask(EventMask::PROPERTY_CHANGE), + )? + .check()?; + self.connection + .convert_selection( + window, + self.atoms.CLIPBOARD, + target, + self.atoms.TRANSFER, + self.timestamp, + )? + .check()?; + self.incoming.insert( + window, + Incoming { + target, + kind, + bytes: Vec::new(), + incremental: false, + deadline: Instant::now() + PASTE_TIMEOUT, + }, + ); + Ok(()) + } + + fn event(&mut self, event: Event) -> XResult<()> { + match event { + Event::XfixesSelectionNotify(event) if event.selection == self.atoms.CLIPBOARD => { + if self + .connection + .get_selection_owner(self.atoms.CLIPBOARD)? + .reply()? + .owner + != event.owner + { + return Ok(()); + } + self.owner = event.owner; + if event.owner == self.window { + self.owns = true; + return Ok(()); + } + self.timestamp = event.selection_timestamp; + self.owns = false; + self.targets.clear(); + let readers: Vec<_> = self.incoming.keys().copied().collect(); + for window in readers { + self.finish_read(window, Err("clipboard selection changed while reading".into()))?; + } + if event.owner != NONE { + self.begin_read(self.atoms.TARGETS, ReadKind::Targets)?; + } else { + self.mime_types + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + let _ = self.events.send(Command::LocalChanged(Vec::new())); + } + } + Event::SelectionNotify(event) + if event.selection == self.atoms.CLIPBOARD + && self + .incoming + .get(&event.requestor) + .is_some_and(|read| read.target == event.target) => + { + if event.property != self.atoms.TRANSFER { + self.finish_read(event.requestor, Err("clipboard conversion refused".into()))?; + } else { + self.read_property(event.requestor)?; + } + } + Event::PropertyNotify(event) => { + if event.state == Property::NEW_VALUE && self.incoming.get(&event.window).is_some_and(|r| r.incremental) + { + self.read_property(event.window)?; + } + if event.state == Property::DELETE { + if let Some(mut write) = self.outgoing.remove(&(event.window, event.atom)) { + let end = write.data.len().min(write.offset.saturating_add(CHUNK_BYTES)); + let chunk = &write.data[write.offset..end]; + self.connection + .change_property8(PropMode::REPLACE, event.window, event.atom, write.target, chunk)? + .check()?; + if !chunk.is_empty() { + write.offset = end; + write.deadline = Instant::now() + PASTE_TIMEOUT; + self.outgoing.insert((event.window, event.atom), write); + } + } + } + } + Event::SelectionRequest(request) => self.selection_request(request)?, + Event::SelectionClear(event) if event.selection == self.atoms.CLIPBOARD => { + // A clear from an earlier owner can be queued while Offer is + // obtaining its timestamp. Consult the server before applying + // it to a selection that has since been reacquired. + self.owns = self + .connection + .get_selection_owner(self.atoms.CLIPBOARD)? + .reply()? + .owner + == self.window; + } + _ => {} + } + Ok(()) + } + + fn read_property(&mut self, window: Window) -> XResult<()> { + let reply = self + .connection + .get_property( + true, + window, + self.atoms.TRANSFER, + AtomEnum::ANY, + 0, + u32::try_from(MAX_TRANSFER_BYTES / 4 + 1)?, + )? + .reply()?; + let Some(read) = self.incoming.get_mut(&window) else { + return Ok(()); + }; + if reply.type_ == self.atoms.INCR { + let size = reply.value32().and_then(|mut values| values.next()).unwrap_or(u32::MAX); + if read.incremental || MAX_TRANSFER_BYTES < usize::try_from(size)? { + self.finish_read(window, Err("X11 clipboard data exceeds the size limit".into()))?; + } else { + read.incremental = true; + } + return Ok(()); + } + if reply.bytes_after != 0 || MAX_TRANSFER_BYTES < read.bytes.len().saturating_add(reply.value.len()) { + self.finish_read(window, Err("X11 clipboard data exceeds the size limit".into()))?; + return Ok(()); + } + let expected_type = if read.target == self.atoms.TARGETS { + AtomEnum::ATOM.into() + } else { + read.target + }; + let expected_format = if read.target == self.atoms.TARGETS { 32 } else { 8 }; + if reply.type_ != expected_type || reply.format != expected_format { + self.finish_read(window, Err("invalid X11 clipboard property type".into()))?; + return Ok(()); + } + let finished = !read.incremental || reply.value.is_empty(); + read.bytes.extend_from_slice(&reply.value); + if finished { + let bytes = core::mem::take(&mut read.bytes); + self.finish_read(window, Ok(bytes))?; + } + Ok(()) + } + + fn finish_read(&mut self, window: Window, result: Result, String>) -> XResult<()> { + let Some(read) = self.incoming.remove(&window) else { + return Ok(()); + }; + self.connection.destroy_window(window)?.check()?; + match read.kind { + ReadKind::Data(sender) => { + let result = result.map(|bytes| { + if read.target == AtomEnum::STRING.into() { + bytes.into_iter().map(char::from).collect::().into_bytes() + } else { + bytes + } + }); + let _ = sender.send(result.map(Some)); + } + ReadKind::Targets => { + if self.owns { + return Ok(()); + } + self.targets = result + .unwrap_or_default() + .chunks_exact(4) + .map(|chunk| u32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) + .collect(); + let mut mimes = Vec::new(); + if [ + self.atoms.UTF8_STRING, + self.atoms.TEXT_UTF8, + self.atoms.TEXT_PLAIN, + AtomEnum::STRING.into(), + ] + .iter() + .any(|target| self.targets.contains(target)) + { + mimes.push(TEXT.to_owned()); + } + if self.targets.contains(&self.atoms.PNG) { + mimes.push(PNG.to_owned()); + } + *self + .mime_types + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = mimes.clone(); + let _ = self.events.send(Command::LocalChanged(mimes)); + } + } + Ok(()) + } + + fn selection_request(&mut self, request: SelectionRequestEvent) -> XResult<()> { + if request.selection != self.atoms.CLIPBOARD || !self.owns { + return self.notify(request, NONE); + } + // X timestamps wrap at 32 bits. We cannot render a selection from + // before our current ownership interval, including queued old requests. + if request.time != CURRENT_TIME && 0x7fff_ffff < request.time.wrapping_sub(self.timestamp) { + return self.notify(request, NONE); + } + let property = if request.property == NONE { + request.target + } else { + request.property + }; + if request.target == self.atoms.TARGETS { + let mut targets = self.targets.clone(); + targets.extend([self.atoms.TARGETS, self.atoms.TIMESTAMP]); + self.connection + .change_property32(PropMode::REPLACE, request.requestor, property, AtomEnum::ATOM, &targets)? + .check()?; + return self.notify(request, property); + } + if request.target == self.atoms.TIMESTAMP { + self.connection + .change_property32( + PropMode::REPLACE, + request.requestor, + property, + AtomEnum::INTEGER, + &[self.timestamp], + )? + .check()?; + return self.notify(request, property); + } + if !self.targets.contains(&request.target) { + return self.notify(request, NONE); + } + // Reserve before notifying the protocol worker: cached replies can + // otherwise enqueue unbounded full clipboard buffers before we consume + // their Complete commands. A slot covers waiting, queued and INCR data. + let key = (request.requestor, property); + if MAX_TRANSFERS <= self.pending_pastes.len() + self.outgoing.len() + || self.pending_pastes.contains_key(&key) + || self.outgoing.contains_key(&key) + { + return self.notify(request, NONE); + } + self.pending_pastes.insert( + key, + PendingPaste { + request, + deadline: Instant::now() + PASTE_TIMEOUT, + expired: false, + }, + ); + let mime = if request.target == self.atoms.PNG { PNG } else { TEXT }; + let commands = self.commands.clone(); + let generation = self.generation; + let transfer = Transfer::new(mime.to_owned(), generation, move |data| { + let _ = commands.send(Request::Complete(request, generation, data)); + }); + let _ = self.events.send(Command::Paste(transfer)); + Ok(()) + } + + fn notify(&self, request: SelectionRequestEvent, property: Atom) -> XResult<()> { + let notify = SelectionNotifyEvent { + response_type: x11rb::protocol::xproto::SELECTION_NOTIFY_EVENT, + sequence: 0, + time: request.time, + requestor: request.requestor, + selection: request.selection, + target: request.target, + property, + }; + self.connection + .send_event(false, request.requestor, EventMask::NO_EVENT, notify)? + .check()?; + Ok(()) + } +} diff --git a/crates/ironrdp-testsuite-extra/tests/cliprdr_native/linux.rs b/crates/ironrdp-testsuite-extra/tests/cliprdr_native/linux.rs new file mode 100644 index 0000000000..03110a685a --- /dev/null +++ b/crates/ironrdp-testsuite-extra/tests/cliprdr_native/linux.rs @@ -0,0 +1,558 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex, mpsc}; +use std::time::Instant; + +use ironrdp_cliprdr::backend::{ClipboardMessage, ClipboardMessageProxy}; +use ironrdp_cliprdr::pdu::{ClipboardFormat, ClipboardFormatId, FormatDataResponse}; +use ironrdp_cliprdr_format::bitmap; +use ironrdp_cliprdr_native::linux::os::{OsClipboard, PNG, TEXT, Transfer}; +use ironrdp_cliprdr_native::linux::worker::{Command, PASTE_TIMEOUT, Worker}; + +#[derive(Default)] +struct FakeOs { + data: HashMap>, + offers: Vec<(Vec, u64)>, + reads: usize, + clears: usize, + owns: bool, +} + +struct FakeClipboard(Arc>); + +impl OsClipboard for FakeClipboard { + fn is_owner(&self) -> bool { + self.0.lock().unwrap().owns + } + fn mime_types(&self) -> Vec { + self.0.lock().unwrap().data.keys().cloned().collect() + } + fn read(&mut self, mime: &str) -> Result>, String> { + let mut os = self.0.lock().unwrap(); + os.reads += 1; + Ok(os.data.get(mime).cloned()) + } + fn offer(&mut self, mimes: &[String], generation: u64) -> Result<(), String> { + self.0.lock().unwrap().offers.push((mimes.to_vec(), generation)); + Ok(()) + } + fn clear(&mut self) -> Result<(), String> { + self.0.lock().unwrap().clears += 1; + Ok(()) + } +} + +#[derive(Default, Debug, Clone)] +struct Recorder(Arc>>); + +impl ClipboardMessageProxy for Recorder { + fn send_clipboard_message(&self, message: ClipboardMessage) { + self.0.lock().unwrap().push(message); + } +} + +impl Recorder { + fn take(&self) -> Vec { + core::mem::take(&mut *self.0.lock().unwrap()) + } +} + +type TestWorker = Worker; +fn worker() -> (TestWorker, Arc>, Recorder) { + let os = Arc::new(Mutex::new(FakeOs::default())); + let recorder = Recorder::default(); + ( + Worker::new(FakeClipboard(Arc::clone(&os)), recorder.clone()), + os, + recorder, + ) +} + +fn remote(worker: &mut TestWorker, formats: &[ClipboardFormatId]) { + worker.handle(Command::RemoteCopy( + formats.iter().copied().map(ClipboardFormat::new).collect(), + )); +} + +fn paste(worker: &mut TestWorker, os: &Arc>, mime: &str) -> mpsc::Receiver>> { + let generation = os.lock().unwrap().offers.last().unwrap().1; + let (sender, receiver) = mpsc::channel(); + worker.handle(Command::Paste(Transfer::new( + mime.to_owned(), + generation, + move |data| { + sender.send(data).unwrap(); + }, + ))); + receiver +} + +fn text_response(worker: &mut TestWorker, text: &str) { + worker.handle(Command::RemoteData(Some( + FormatDataResponse::new_unicode_string(text).data().to_vec(), + ))); +} + +fn png() -> Vec { + let mut bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut bytes, 1, 1); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder + .write_header() + .unwrap() + .write_image_data(&[0x22, 0x44, 0x66, 0xff]) + .unwrap(); + } + bytes +} + +#[test] +fn selection_events_wait_for_monitor_ready_and_do_not_read_content() { + let (mut worker, os, recorder) = worker(); + os.lock().unwrap().data.insert(TEXT.into(), b"hello".to_vec()); + worker.handle(Command::LocalChanged(vec![TEXT.into()])); + assert!(recorder.take().is_empty()); + worker.handle(Command::AdvertiseLocal); + assert!( + matches!(recorder.take().as_slice(), [ClipboardMessage::SendInitiateCopy(formats)] if formats.len() == 1 && formats[0].id() == ClipboardFormatId::CF_UNICODETEXT) + ); + assert_eq!(os.lock().unwrap().reads, 0); +} + +#[test] +fn a_new_local_copy_with_the_same_formats_is_announced() { + let (mut worker, _, recorder) = worker(); + worker.handle(Command::AdvertiseLocal); + recorder.take(); + worker.handle(Command::LocalChanged(vec![TEXT.into()])); + worker.handle(Command::LocalChanged(vec![TEXT.into()])); + assert_eq!(recorder.take().len(), 2); +} + +#[test] +fn remote_formats_are_advertised_without_fetching_data() { + let (mut worker, os, recorder) = worker(); + remote( + &mut worker, + &[ClipboardFormatId::CF_UNICODETEXT, ClipboardFormatId::CF_DIBV5], + ); + assert!(recorder.take().is_empty()); + assert_eq!(os.lock().unwrap().offers[0].0, [TEXT, "text/plain", PNG]); + let response = paste(&mut worker, &os, TEXT); + assert!(matches!( + recorder.take().as_slice(), + [ClipboardMessage::SendInitiatePaste(ClipboardFormatId::CF_UNICODETEXT)] + )); + text_response(&mut worker, "only when pasted"); + assert_eq!(response.recv().unwrap(), Some(b"only when pasted".to_vec())); +} + +#[test] +fn same_format_waiters_share_one_wire_request_and_cache() { + let (mut worker, os, recorder) = worker(); + remote(&mut worker, &[ClipboardFormatId::CF_UNICODETEXT]); + let first = paste(&mut worker, &os, TEXT); + let second = paste(&mut worker, &os, "text/plain"); + assert_eq!(recorder.take().len(), 1); + text_response(&mut worker, "shared"); + assert_eq!(first.recv().unwrap(), Some(b"shared".to_vec())); + assert_eq!(second.recv().unwrap(), Some(b"shared".to_vec())); + let cached = paste(&mut worker, &os, TEXT); + assert_eq!(cached.recv().unwrap(), Some(b"shared".to_vec())); + assert!(recorder.take().is_empty()); +} + +#[test] +fn different_formats_are_requested_one_at_a_time() { + let (mut worker, os, recorder) = worker(); + remote( + &mut worker, + &[ + ClipboardFormatId::CF_UNICODETEXT, + ClipboardFormatId::CF_DIB, + ClipboardFormatId::CF_DIBV5, + ], + ); + let text = paste(&mut worker, &os, TEXT); + let image = paste(&mut worker, &os, PNG); + assert_eq!(recorder.take().len(), 1); + text_response(&mut worker, "text"); + assert_eq!(text.recv().unwrap(), Some(b"text".to_vec())); + assert!(matches!( + recorder.take().as_slice(), + [ClipboardMessage::SendInitiatePaste(ClipboardFormatId::CF_DIBV5)] + )); + worker.handle(Command::RemoteData(Some(bitmap::png_to_cf_dibv5(&png()).unwrap()))); + let decoded = image.recv().unwrap().unwrap(); + assert_eq!( + bitmap::png_to_cf_dibv5(&decoded).unwrap(), + bitmap::png_to_cf_dibv5(&png()).unwrap() + ); +} + +#[test] +fn a_timed_out_request_is_drained_before_another_can_start() { + let (mut worker, os, recorder) = worker(); + remote(&mut worker, &[ClipboardFormatId::CF_UNICODETEXT]); + let old = paste(&mut worker, &os, TEXT); + recorder.take(); + worker.expire(Instant::now() + PASTE_TIMEOUT); + assert_eq!(old.recv().unwrap(), None); + remote(&mut worker, &[ClipboardFormatId::CF_DIB]); + let blocked = paste(&mut worker, &os, PNG); + assert_eq!(blocked.recv().unwrap(), None); + assert!( + recorder.take().is_empty(), + "timeout must not start an ambiguous second request" + ); + text_response(&mut worker, "late old response"); + let current = paste(&mut worker, &os, PNG); + assert!(matches!( + recorder.take().as_slice(), + [ClipboardMessage::SendInitiatePaste(ClipboardFormatId::CF_DIB)] + )); + worker.handle(Command::RemoteData(Some(bitmap::png_to_cf_dib(&png()).unwrap()))); + assert!(current.recv().unwrap().is_some()); +} + +#[test] +fn replaced_remote_selection_discards_the_old_response() { + let (mut worker, os, recorder) = worker(); + remote(&mut worker, &[ClipboardFormatId::CF_UNICODETEXT]); + let old = paste(&mut worker, &os, TEXT); + remote(&mut worker, &[ClipboardFormatId::CF_UNICODETEXT]); + assert_eq!(old.recv().unwrap(), None); + let new = paste(&mut worker, &os, TEXT); + assert_eq!(recorder.take().len(), 1); + text_response(&mut worker, "old"); + assert_eq!( + recorder.take().len(), + 1, + "new request starts only after old reply is drained" + ); + text_response(&mut worker, "new"); + assert_eq!(new.recv().unwrap(), Some(b"new".to_vec())); +} + +#[test] +fn local_selection_change_cancels_waiters_and_discards_remote_data() { + let (mut worker, os, recorder) = worker(); + remote(&mut worker, &[ClipboardFormatId::CF_UNICODETEXT]); + let response = paste(&mut worker, &os, TEXT); + worker.handle(Command::LocalChanged(vec![TEXT.into()])); + assert_eq!(response.recv().unwrap(), None); + recorder.take(); + text_response(&mut worker, "stale"); + assert!(recorder.take().is_empty()); + assert_eq!( + os.lock().unwrap().offers.len(), + 1, + "a response cannot reclaim the clipboard" + ); +} + +#[test] +fn stale_os_paste_and_unadvertised_formats_are_refused() { + let (mut worker, os, recorder) = worker(); + remote(&mut worker, &[ClipboardFormatId::CF_UNICODETEXT]); + let generation = os.lock().unwrap().offers[0].1; + remote(&mut worker, &[ClipboardFormatId::CF_UNICODETEXT]); + let (sender, receiver) = mpsc::channel(); + worker.handle(Command::Paste(Transfer::new(TEXT.into(), generation, move |data| { + sender.send(data).unwrap(); + }))); + assert_eq!(receiver.recv().unwrap(), None); + assert_eq!(paste(&mut worker, &os, PNG).recv().unwrap(), None); + assert!(recorder.take().is_empty()); +} + +#[test] +fn failed_remote_response_closes_the_os_paste() { + let (mut worker, os, _) = worker(); + remote(&mut worker, &[ClipboardFormatId::CF_UNICODETEXT]); + let response = paste(&mut worker, &os, TEXT); + worker.handle(Command::RemoteData(None)); + assert_eq!(response.recv().unwrap(), None); +} + +#[test] +fn pending_os_pastes_are_bounded() { + let (mut worker, os, _) = worker(); + remote(&mut worker, &[ClipboardFormatId::CF_UNICODETEXT]); + let waiters: Vec<_> = core::iter::repeat_with(|| paste(&mut worker, &os, TEXT)) + .take(16) + .collect(); + assert_eq!(paste(&mut worker, &os, TEXT).recv().unwrap(), None); + text_response(&mut worker, "bounded"); + for waiter in waiters { + assert_eq!(waiter.recv().unwrap(), Some(b"bounded".to_vec())); + } +} + +#[test] +fn image_dimensions_are_checked_by_the_shared_bitmap_converter() { + let (mut worker, os, _) = worker(); + remote(&mut worker, &[ClipboardFormatId::CF_DIB]); + let response = paste(&mut worker, &os, PNG); + let mut header = vec![0u8; 40]; + header[0..4].copy_from_slice(&40u32.to_le_bytes()); + header[4..8].copy_from_slice(&100_000i32.to_le_bytes()); + header[8..12].copy_from_slice(&100_000i32.to_le_bytes()); + header[12..14].copy_from_slice(&1u16.to_le_bytes()); + header[14..16].copy_from_slice(&32u16.to_le_bytes()); + worker.handle(Command::RemoteData(Some(header))); + assert_eq!(response.recv().unwrap(), None); +} + +#[test] +fn local_content_is_read_only_when_the_peer_requests_it() { + let (mut worker, os, recorder) = worker(); + os.lock() + .unwrap() + .data + .insert(TEXT.into(), "local \u{03bb}".as_bytes().to_vec()); + os.lock().unwrap().data.insert(PNG.into(), png()); + worker.handle(Command::AdvertiseLocal); + recorder.take(); + assert_eq!(os.lock().unwrap().reads, 0); + worker.handle(Command::RenderLocal(ClipboardFormatId::CF_UNICODETEXT)); + assert!( + matches!(recorder.take().as_slice(), [ClipboardMessage::SendFormatData(response)] if response.to_unicode_string().unwrap() == "local \u{03bb}") + ); + worker.handle(Command::RenderLocal(ClipboardFormatId::CF_DIBV5)); + assert!( + matches!(recorder.take().as_slice(), [ClipboardMessage::SendFormatData(response)] if bitmap::validate_dibv5(response.data()).is_ok()) + ); + assert_eq!(os.lock().unwrap().reads, 2); +} + +#[test] +fn shared_loop_detector_rejects_a_clipboard_manager_echo() { + let (mut worker, os, recorder) = worker(); + remote(&mut worker, &[ClipboardFormatId::CF_UNICODETEXT]); + let response = paste(&mut worker, &os, TEXT); + text_response(&mut worker, "echoed"); + response.recv().unwrap(); + os.lock().unwrap().data.insert(TEXT.into(), b"echoed".to_vec()); + worker.handle(Command::LocalChanged(vec![TEXT.into()])); + recorder.take(); + worker.handle(Command::RenderLocal(ClipboardFormatId::CF_UNICODETEXT)); + assert!(matches!(recorder.take().as_slice(), [ClipboardMessage::SendFormatData(response)] if response.is_error())); + os.lock().unwrap().data.insert(TEXT.into(), b"different".to_vec()); + worker.handle(Command::RenderLocal(ClipboardFormatId::CF_UNICODETEXT)); + assert!(matches!(recorder.take().as_slice(), [ClipboardMessage::SendFormatData(response)] if !response.is_error())); +} + +#[test] +fn channel_reset_cancels_pending_pastes_and_releases_ownership() { + let (mut worker, os, recorder) = worker(); + remote(&mut worker, &[ClipboardFormatId::CF_UNICODETEXT]); + let response = paste(&mut worker, &os, TEXT); + worker.handle(Command::Reset); + assert_eq!(response.recv().unwrap(), None); + assert_eq!(os.lock().unwrap().clears, 1); + recorder.take(); + worker.handle(Command::LocalChanged(vec![TEXT.into()])); + assert!(recorder.take().is_empty()); +} + +/// Run against a private X server: DISPLAY=:N cargo test -p ironrdp-testsuite-core +/// x11_delayed_pastes -- --ignored --test-threads=1. +#[test] +#[ignore = "requires a dedicated X11 display"] +fn x11_delayed_pastes_use_notifications_and_incremental_transfers() { + use core::time::Duration; + + use ironrdp_cliprdr_native::linux::x11::X11Clipboard; + + let (owner_events, owner_receiver) = mpsc::channel(); + let mut owner = X11Clipboard::open(owner_events).unwrap(); + let (reader_events, reader_receiver) = mpsc::channel(); + let reader = X11Clipboard::open(reader_events).unwrap(); + owner.offer(&[TEXT.into(), PNG.into()], 42).unwrap(); + loop { + if let Command::LocalChanged(mimes) = reader_receiver.recv_timeout(Duration::from_secs(5)).unwrap() { + if mimes.contains(&TEXT.to_owned()) && mimes.contains(&PNG.to_owned()) { + break; + } + } + } + assert!( + !owner_receiver + .try_iter() + .any(|event| matches!(event, Command::Paste(_))) + ); + let thread = std::thread::spawn(move || { + let mut reader = reader; + let result = reader.read(TEXT); + (reader, result) + }); + let transfer = loop { + if let Command::Paste(transfer) = owner_receiver.recv_timeout(Duration::from_secs(5)).unwrap() { + break transfer; + } + }; + assert_eq!(transfer.generation(), 42); + // Exceeds the core X11 request length and must use INCR in both directions. + let bytes = "clipboard \u{03bb}\n".repeat(30_000).into_bytes(); + transfer.finish(Some(bytes.clone())); + let (mut reader, received) = thread.join().unwrap(); + assert_eq!(received.unwrap(), Some(bytes)); + + // Becoming the owner must not report our own selection back as a local copy. + assert!( + !owner_receiver + .try_iter() + .any(|event| matches!(event, Command::LocalChanged(_))) + ); + reader.offer(&[TEXT.into()], 43).unwrap(); + loop { + if let Command::LocalChanged(mimes) = owner_receiver.recv_timeout(Duration::from_secs(5)).unwrap() { + if mimes.contains(&TEXT.to_owned()) { + break; + } + } + } + assert_eq!(owner.mime_types(), [TEXT]); +} + +#[test] +#[ignore = "requires a dedicated X11 display and xclip"] +fn x11_clipboard_interoperates_with_xclip_in_both_directions() { + use core::time::Duration; + use std::io::Write as _; + use std::process::{Command as ProcessCommand, Stdio}; + + use ironrdp_cliprdr_native::linux::x11::X11Clipboard; + + let (events, receiver) = mpsc::channel(); + let mut clipboard = X11Clipboard::open(events).unwrap(); + clipboard.offer(&[TEXT.into()], 1).unwrap(); + // The read is processed after the ownership command on the X11 worker. + // Our own selection is not read back, but this waits for the claim to finish. + assert!(clipboard.read(TEXT).unwrap().is_none()); + let reader = std::thread::spawn(|| { + ProcessCommand::new("xclip") + .args(["-selection", "clipboard", "-out"]) + .output() + .unwrap() + }); + let transfer = loop { + if let Command::Paste(transfer) = receiver.recv_timeout(Duration::from_secs(5)).unwrap() { + break transfer; + } + }; + let data = "remote unicode \u{03bb}\n".repeat(20_000).into_bytes(); + transfer.finish(Some(data.clone())); + // ICCCM requires an already-started INCR transfer to finish even after + // the owner relinquishes the selection. + clipboard.clear().unwrap(); + let output = reader.join().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert_eq!(output.stdout, data); + + let mut writer = ProcessCommand::new("xclip") + .args(["-selection", "clipboard", "-in", "-quiet"]) + .stdin(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut stdin = writer.stdin.take().unwrap(); + stdin.write_all(&data).unwrap(); + drop(stdin); + loop { + if let Command::LocalChanged(mimes) = receiver.recv_timeout(Duration::from_secs(5)).unwrap() { + if mimes.contains(&TEXT.to_owned()) { + break; + } + } + } + let result = clipboard.read(TEXT); + let _ = writer.kill(); + let _ = writer.wait(); + assert_eq!(result.unwrap(), Some(data)); +} + +#[test] +fn queued_local_event_does_not_cancel_an_acknowledged_remote_selection() { + let (mut worker, os, recorder) = worker(); + remote(&mut worker, &[ClipboardFormatId::CF_UNICODETEXT]); + os.lock().unwrap().owns = true; + worker.handle(Command::LocalChanged(vec![PNG.into()])); + let response = paste(&mut worker, &os, TEXT); + assert!(matches!( + recorder.take().as_slice(), + [ClipboardMessage::SendInitiatePaste(ClipboardFormatId::CF_UNICODETEXT)] + )); + text_response(&mut worker, "current remote"); + assert_eq!(response.recv().unwrap(), Some(b"current remote".to_vec())); +} + +#[test] +fn local_notifications_refresh_the_current_selection_instead_of_stale_event_formats() { + let (mut worker, os, recorder) = worker(); + worker.handle(Command::AdvertiseLocal); + recorder.take(); + os.lock() + .unwrap() + .data + .insert(TEXT.into(), b"newest selection".to_vec()); + worker.handle(Command::LocalChanged(vec![PNG.into()])); + assert!( + matches!(recorder.take().as_slice(), [ClipboardMessage::SendInitiateCopy(formats)] if formats.len() == 1 && formats[0].id() == ClipboardFormatId::CF_UNICODETEXT) + ); +} + +#[test] +#[ignore = "requires a dedicated X11 display"] +fn x11_bounds_requests_before_queuing_clipboard_data() { + use core::time::Duration; + use ironrdp_cliprdr_native::linux::x11::X11Clipboard; + + let (owner_events, owner_receiver) = mpsc::channel(); + let mut owner = X11Clipboard::open(owner_events).unwrap(); + owner.offer(&[TEXT.into()], 1).unwrap(); + let (results, received) = mpsc::channel(); + let readers: Vec<_> = core::iter::repeat_with(|| { + let results = results.clone(); + std::thread::spawn(move || { + let (events, notifications) = mpsc::channel(); + let mut reader = X11Clipboard::open(events).unwrap(); + loop { + if let Command::LocalChanged(mimes) = notifications.recv_timeout(Duration::from_secs(5)).unwrap() { + if mimes.contains(&TEXT.to_owned()) { + break; + } + } + } + results.send(reader.read(TEXT)).unwrap(); + }) + }) + .take(17) + .collect(); + let mut held = Vec::new(); + while held.len() < 16 { + if let Command::Paste(transfer) = owner_receiver.recv_timeout(Duration::from_secs(5)).unwrap() { + held.push(transfer); + } + } + assert!( + received.recv_timeout(Duration::from_secs(2)).unwrap().is_err(), + "the seventeenth read is refused before a Paste is queued" + ); + assert!( + !owner_receiver + .try_iter() + .any(|event| matches!(event, Command::Paste(_))) + ); + for transfer in held { + transfer.finish(None); + } + for _ in 0..16 { + assert!(received.recv_timeout(Duration::from_secs(5)).unwrap().is_err()); + } + for reader in readers { + reader.join().unwrap(); + } +} diff --git a/crates/ironrdp-testsuite-extra/tests/cliprdr_native/mod.rs b/crates/ironrdp-testsuite-extra/tests/cliprdr_native/mod.rs index 8f40d71418..e6be8b30d7 100644 --- a/crates/ironrdp-testsuite-extra/tests/cliprdr_native/mod.rs +++ b/crates/ironrdp-testsuite-extra/tests/cliprdr_native/mod.rs @@ -1,2 +1,3 @@ mod data_control; +mod linux; mod live; From 7e2a5c538bbeafebe7747a7c8c2814221053089a Mon Sep 17 00:00:00 2001 From: AKolenda Date: Fri, 2 Oct 2026 00:56:08 -0600 Subject: [PATCH 03/11] fix(cliprdr-native): keep production feature builds lint-clean --- crates/ironrdp-cliprdr-native/src/linux/os.rs | 4 +-- .../src/linux/worker.rs | 4 +-- .../ironrdp-cliprdr-native/src/linux/x11.rs | 6 ++-- .../tests/cliprdr_native/linux.rs | 35 ++++++++++--------- 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/crates/ironrdp-cliprdr-native/src/linux/os.rs b/crates/ironrdp-cliprdr-native/src/linux/os.rs index 5043042353..567df10bfd 100644 --- a/crates/ironrdp-cliprdr-native/src/linux/os.rs +++ b/crates/ironrdp-cliprdr-native/src/linux/os.rs @@ -80,8 +80,8 @@ impl NativeClipboard { match DataControl::connect() { Ok(clipboard) => { let changes = events.clone(); - clipboard.on_change(move |mimes| { - let _ = changes.send(Command::LocalChanged(mimes)); + clipboard.on_change(move |_| { + let _ = changes.send(Command::LocalChanged); }); Ok(Self::Wayland { clipboard, events }) } diff --git a/crates/ironrdp-cliprdr-native/src/linux/worker.rs b/crates/ironrdp-cliprdr-native/src/linux/worker.rs index 0227feb28c..66905f8b55 100644 --- a/crates/ironrdp-cliprdr-native/src/linux/worker.rs +++ b/crates/ironrdp-cliprdr-native/src/linux/worker.rs @@ -22,7 +22,7 @@ const MAX_WAITERS: usize = 16; #[derive(Debug)] pub enum Command { AdvertiseLocal, - LocalChanged(Vec), + LocalChanged, RemoteCopy(Vec), RemoteData(Option>), RenderLocal(ClipboardFormatId), @@ -97,7 +97,7 @@ impl Worker { self.advertise_local(); } } - Command::LocalChanged(_) => { + Command::LocalChanged => { // An OS event queued before offer() acknowledged our ownership // must not invalidate the selection that was just installed. if self.os.is_owner() { diff --git a/crates/ironrdp-cliprdr-native/src/linux/x11.rs b/crates/ironrdp-cliprdr-native/src/linux/x11.rs index f12e8c75f3..908df457db 100644 --- a/crates/ironrdp-cliprdr-native/src/linux/x11.rs +++ b/crates/ironrdp-cliprdr-native/src/linux/x11.rs @@ -530,7 +530,7 @@ impl State { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clear(); - let _ = self.events.send(Command::LocalChanged(Vec::new())); + let _ = self.events.send(Command::LocalChanged); } } Event::SelectionNotify(event) @@ -673,8 +673,8 @@ impl State { *self .mime_types .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = mimes.clone(); - let _ = self.events.send(Command::LocalChanged(mimes)); + .unwrap_or_else(std::sync::PoisonError::into_inner) = mimes; + let _ = self.events.send(Command::LocalChanged); } } Ok(()) diff --git a/crates/ironrdp-testsuite-extra/tests/cliprdr_native/linux.rs b/crates/ironrdp-testsuite-extra/tests/cliprdr_native/linux.rs index 03110a685a..e0042d1cc0 100644 --- a/crates/ironrdp-testsuite-extra/tests/cliprdr_native/linux.rs +++ b/crates/ironrdp-testsuite-extra/tests/cliprdr_native/linux.rs @@ -111,7 +111,7 @@ fn png() -> Vec { fn selection_events_wait_for_monitor_ready_and_do_not_read_content() { let (mut worker, os, recorder) = worker(); os.lock().unwrap().data.insert(TEXT.into(), b"hello".to_vec()); - worker.handle(Command::LocalChanged(vec![TEXT.into()])); + worker.handle(Command::LocalChanged); assert!(recorder.take().is_empty()); worker.handle(Command::AdvertiseLocal); assert!( @@ -125,8 +125,8 @@ fn a_new_local_copy_with_the_same_formats_is_announced() { let (mut worker, _, recorder) = worker(); worker.handle(Command::AdvertiseLocal); recorder.take(); - worker.handle(Command::LocalChanged(vec![TEXT.into()])); - worker.handle(Command::LocalChanged(vec![TEXT.into()])); + worker.handle(Command::LocalChanged); + worker.handle(Command::LocalChanged); assert_eq!(recorder.take().len(), 2); } @@ -240,7 +240,7 @@ fn local_selection_change_cancels_waiters_and_discards_remote_data() { let (mut worker, os, recorder) = worker(); remote(&mut worker, &[ClipboardFormatId::CF_UNICODETEXT]); let response = paste(&mut worker, &os, TEXT); - worker.handle(Command::LocalChanged(vec![TEXT.into()])); + worker.handle(Command::LocalChanged); assert_eq!(response.recv().unwrap(), None); recorder.take(); text_response(&mut worker, "stale"); @@ -335,7 +335,7 @@ fn shared_loop_detector_rejects_a_clipboard_manager_echo() { text_response(&mut worker, "echoed"); response.recv().unwrap(); os.lock().unwrap().data.insert(TEXT.into(), b"echoed".to_vec()); - worker.handle(Command::LocalChanged(vec![TEXT.into()])); + worker.handle(Command::LocalChanged); recorder.take(); worker.handle(Command::RenderLocal(ClipboardFormatId::CF_UNICODETEXT)); assert!(matches!(recorder.take().as_slice(), [ClipboardMessage::SendFormatData(response)] if response.is_error())); @@ -353,7 +353,7 @@ fn channel_reset_cancels_pending_pastes_and_releases_ownership() { assert_eq!(response.recv().unwrap(), None); assert_eq!(os.lock().unwrap().clears, 1); recorder.take(); - worker.handle(Command::LocalChanged(vec![TEXT.into()])); + worker.handle(Command::LocalChanged); assert!(recorder.take().is_empty()); } @@ -372,7 +372,8 @@ fn x11_delayed_pastes_use_notifications_and_incremental_transfers() { let reader = X11Clipboard::open(reader_events).unwrap(); owner.offer(&[TEXT.into(), PNG.into()], 42).unwrap(); loop { - if let Command::LocalChanged(mimes) = reader_receiver.recv_timeout(Duration::from_secs(5)).unwrap() { + if let Command::LocalChanged = reader_receiver.recv_timeout(Duration::from_secs(5)).unwrap() { + let mimes = reader.mime_types(); if mimes.contains(&TEXT.to_owned()) && mimes.contains(&PNG.to_owned()) { break; } @@ -404,12 +405,12 @@ fn x11_delayed_pastes_use_notifications_and_incremental_transfers() { assert!( !owner_receiver .try_iter() - .any(|event| matches!(event, Command::LocalChanged(_))) + .any(|event| matches!(event, Command::LocalChanged)) ); reader.offer(&[TEXT.into()], 43).unwrap(); loop { - if let Command::LocalChanged(mimes) = owner_receiver.recv_timeout(Duration::from_secs(5)).unwrap() { - if mimes.contains(&TEXT.to_owned()) { + if let Command::LocalChanged = owner_receiver.recv_timeout(Duration::from_secs(5)).unwrap() { + if owner.mime_types().contains(&TEXT.to_owned()) { break; } } @@ -462,8 +463,8 @@ fn x11_clipboard_interoperates_with_xclip_in_both_directions() { stdin.write_all(&data).unwrap(); drop(stdin); loop { - if let Command::LocalChanged(mimes) = receiver.recv_timeout(Duration::from_secs(5)).unwrap() { - if mimes.contains(&TEXT.to_owned()) { + if let Command::LocalChanged = receiver.recv_timeout(Duration::from_secs(5)).unwrap() { + if clipboard.mime_types().contains(&TEXT.to_owned()) { break; } } @@ -479,7 +480,7 @@ fn queued_local_event_does_not_cancel_an_acknowledged_remote_selection() { let (mut worker, os, recorder) = worker(); remote(&mut worker, &[ClipboardFormatId::CF_UNICODETEXT]); os.lock().unwrap().owns = true; - worker.handle(Command::LocalChanged(vec![PNG.into()])); + worker.handle(Command::LocalChanged); let response = paste(&mut worker, &os, TEXT); assert!(matches!( recorder.take().as_slice(), @@ -490,7 +491,7 @@ fn queued_local_event_does_not_cancel_an_acknowledged_remote_selection() { } #[test] -fn local_notifications_refresh_the_current_selection_instead_of_stale_event_formats() { +fn local_notifications_refresh_the_current_selection_formats() { let (mut worker, os, recorder) = worker(); worker.handle(Command::AdvertiseLocal); recorder.take(); @@ -498,7 +499,7 @@ fn local_notifications_refresh_the_current_selection_instead_of_stale_event_form .unwrap() .data .insert(TEXT.into(), b"newest selection".to_vec()); - worker.handle(Command::LocalChanged(vec![PNG.into()])); + worker.handle(Command::LocalChanged); assert!( matches!(recorder.take().as_slice(), [ClipboardMessage::SendInitiateCopy(formats)] if formats.len() == 1 && formats[0].id() == ClipboardFormatId::CF_UNICODETEXT) ); @@ -520,8 +521,8 @@ fn x11_bounds_requests_before_queuing_clipboard_data() { let (events, notifications) = mpsc::channel(); let mut reader = X11Clipboard::open(events).unwrap(); loop { - if let Command::LocalChanged(mimes) = notifications.recv_timeout(Duration::from_secs(5)).unwrap() { - if mimes.contains(&TEXT.to_owned()) { + if let Command::LocalChanged = notifications.recv_timeout(Duration::from_secs(5)).unwrap() { + if reader.mime_types().contains(&TEXT.to_owned()) { break; } } From d05397b7e5f0f9f4caecf9e297656b6b7dba85d5 Mon Sep 17 00:00:00 2001 From: AKolenda Date: Fri, 9 Oct 2026 23:19:42 -0600 Subject: [PATCH 04/11] feat(cliprdr-native): let the data-control client wait for the compositor The Linux CLIPRDR backend needs to know whether set_selection actually took the selection before it treats the remote formats as mirrored. owns_selection only reflects what the worker has seen so far, so add a crate-internal synchronize() that issues wl_display.sync and returns once the compositor has answered, after the selection events caused by earlier requests have been dispatched. --- .../src/data_control/client.rs | 13 +++++++++++ .../src/data_control/state.rs | 5 ++++ .../src/data_control/worker.rs | 23 +++++++++++++++++-- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/ironrdp-cliprdr-native/src/data_control/client.rs b/crates/ironrdp-cliprdr-native/src/data_control/client.rs index 9d331aaf41..acab5ea91e 100644 --- a/crates/ironrdp-cliprdr-native/src/data_control/client.rs +++ b/crates/ironrdp-cliprdr-native/src/data_control/client.rs @@ -260,6 +260,19 @@ impl DataControl { }) } + /// Wait until the compositor has processed the requests sent so far. + /// + /// Afterwards [`owns_selection`](Self::owns_selection) reflects the outcome of an earlier + /// [`set_selection`](Self::set_selection), including a copy by another client that won. + pub(crate) fn synchronize(&self) -> Result<()> { + let (sender, receiver) = mpsc::channel(); + self.link.send(Command::Synchronize(sender))?; + receiver.recv_timeout(READ_IDLE_TIMEOUT).map_err(|error| match error { + mpsc::RecvTimeoutError::Timeout => Error::Timeout, + mpsc::RecvTimeoutError::Disconnected => Error::Stopped, + }) + } + /// The protocol the compositor is speaking. #[must_use] pub fn protocol(&self) -> Protocol { diff --git a/crates/ironrdp-cliprdr-native/src/data_control/state.rs b/crates/ironrdp-cliprdr-native/src/data_control/state.rs index 069845a86c..194d374e27 100644 --- a/crates/ironrdp-cliprdr-native/src/data_control/state.rs +++ b/crates/ironrdp-cliprdr-native/src/data_control/state.rs @@ -137,6 +137,11 @@ impl DataControlSource { #[derive(Debug)] #[cfg_attr(feature = "__test", visibility::make(pub))] pub(crate) enum Command { + /// Signal `sender` once the compositor has processed every earlier request. + /// + /// The selection events those requests caused are dispatched before the + /// signal, so [`Shared`] reflects them when it arrives. + Synchronize(std::sync::mpsc::Sender<()>), /// Set the clipboard selection on the compositor. /// /// Creates a data control source with the offered MIME types and diff --git a/crates/ironrdp-cliprdr-native/src/data_control/worker.rs b/crates/ironrdp-cliprdr-native/src/data_control/worker.rs index 934c0f7515..2447686676 100644 --- a/crates/ironrdp-cliprdr-native/src/data_control/worker.rs +++ b/crates/ironrdp-cliprdr-native/src/data_control/worker.rs @@ -20,10 +20,10 @@ use nix::{ poll::{PollFd, PollFlags, PollTimeout, poll}, }; use wayland_client::{ - Connection, EventQueue, + Connection, Dispatch, EventQueue, backend::WaylandError, globals::{GlobalList, registry_queue_init}, - protocol::wl_seat::WlSeat, + protocol::{wl_callback::WlCallback, wl_seat::WlSeat}, }; use wayland_protocols::ext::data_control::v1::client::ext_data_control_manager_v1::ExtDataControlManagerV1; use wayland_protocols_wlr::data_control::v1::client::zwlr_data_control_manager_v1::ZwlrDataControlManagerV1; @@ -233,6 +233,11 @@ impl Worker { let queue_handle = self.queue.handle(); while let Ok(command) = self.commands.try_recv() { match command { + Command::Synchronize(sender) => { + // The callback fires after the compositor has handled the + // requests sent before it; the event loop keeps running meanwhile. + self.connection.display().sync(&queue_handle, sender); + } Command::SetSelection { mime_types, data } => { self.client.data_control.set_selection(&mime_types, data, &queue_handle); } @@ -266,6 +271,20 @@ impl Worker { } } +impl Dispatch> for Client { + fn event( + _state: &mut Self, + _proxy: &WlCallback, + _event: ::Event, + data: &mpsc::Sender<()>, + _conn: &Connection, + _qh: &wayland_client::QueueHandle, + ) { + // The waiter may have timed out and gone away. + let _ = data.send(()); + } +} + fn readable(fd: &PollFd<'_>) -> bool { fd.revents() .is_some_and(|events| events.intersects(PollFlags::POLLIN | PollFlags::POLLHUP | PollFlags::POLLERR)) From 3ed0191121d6db4e4ba133ca3d593a8c3526eeb7 Mon Sep 17 00:00:00 2001 From: AKolenda Date: Fri, 9 Oct 2026 23:44:05 -0600 Subject: [PATCH 05/11] fix(cliprdr-native): build the Linux backend on the merged data-control client The data-control module now comes from master (#2055), so drop what only the earlier copy needed: the nix fs feature, and the serial check around Wayland reads, since DataControl::read already fails with SelectionChanged when another copy lands during the read. The Linux tests now live in ironrdp-testsuite-extra next to the data-control tests, which needs the cliprdr, cliprdr-format and png dev-dependencies there. --- Cargo.lock | 3 +++ crates/ironrdp-cliprdr-native/Cargo.toml | 2 +- crates/ironrdp-cliprdr-native/src/linux/os.rs | 10 ++-------- crates/ironrdp-testsuite-extra/Cargo.toml | 3 +++ 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index babaf15329..1a0a0596d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3466,6 +3466,8 @@ dependencies = [ "ironrdp-bulk", "ironrdp-cfg", "ironrdp-client", + "ironrdp-cliprdr", + "ironrdp-cliprdr-format", "ironrdp-cliprdr-native", "ironrdp-core 0.2.1", "ironrdp-daemon", @@ -3491,6 +3493,7 @@ dependencies = [ "ironrdp-tokio", "ironrdp-viewer", "ironrdp-vmconnect", + "png", "rcgen", "semver", "tokio", diff --git a/crates/ironrdp-cliprdr-native/Cargo.toml b/crates/ironrdp-cliprdr-native/Cargo.toml index c13e0e03b4..7818c1b597 100644 --- a/crates/ironrdp-cliprdr-native/Cargo.toml +++ b/crates/ironrdp-cliprdr-native/Cargo.toml @@ -27,7 +27,7 @@ ironrdp-core = { path = "../ironrdp-core", version = "0.2" } tracing = { version = "0.1", features = ["log"] } [target.'cfg(target_os = "linux")'.dependencies] -nix = { version = "0.31", features = ["fs", "poll"] } +nix = { version = "0.31", features = ["poll"] } visibility = { version = "0.1", optional = true } wayland-client = "0.31" wayland-protocols = { version = "0.32", features = ["client", "staging"] } diff --git a/crates/ironrdp-cliprdr-native/src/linux/os.rs b/crates/ironrdp-cliprdr-native/src/linux/os.rs index 567df10bfd..1c9092f56a 100644 --- a/crates/ironrdp-cliprdr-native/src/linux/os.rs +++ b/crates/ironrdp-cliprdr-native/src/linux/os.rs @@ -112,14 +112,8 @@ impl OsClipboard for NativeClipboard { fn read(&mut self, mime: &str) -> Result>, String> { match self { - Self::Wayland { clipboard, .. } => { - let serial = clipboard.serial(); - let data = clipboard.read(mime).map_err(|error| error.to_string())?; - if serial != clipboard.serial() { - return Err("clipboard selection changed while reading".into()); - } - Ok(data) - } + // Fails with `SelectionChanged` if another copy lands during the read. + Self::Wayland { clipboard, .. } => clipboard.read(mime).map_err(|error| error.to_string()), Self::X11(clipboard) => clipboard.read(mime), } } diff --git a/crates/ironrdp-testsuite-extra/Cargo.toml b/crates/ironrdp-testsuite-extra/Cargo.toml index bb5d8cb085..49b860b113 100644 --- a/crates/ironrdp-testsuite-extra/Cargo.toml +++ b/crates/ironrdp-testsuite-extra/Cargo.toml @@ -67,7 +67,10 @@ tokio-rustls = { version = "0.26", default-features = false } uuid = { version = "1", features = ["v4"] } [target.'cfg(target_os = "linux")'.dev-dependencies] +ironrdp-cliprdr.path = "../ironrdp-cliprdr" +ironrdp-cliprdr-format.path = "../ironrdp-cliprdr-format" ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", features = ["__test"] } +png = "0.18" [target.'cfg(windows)'.dev-dependencies] ironrdp-rdpdr-native.path = "../ironrdp-rdpdr-native" From 06d46edeef99961ee6642ee7d032d7d3ed0ce032 Mon Sep 17 00:00:00 2001 From: AKolenda Date: Fri, 9 Oct 2026 23:44:05 -0600 Subject: [PATCH 06/11] refactor(cliprdr-native): deduplicate Linux backend helpers Share the sequence that drops the remote formats and re-announces the local clipboard between the local-change and failed-mirror paths, and compute the effective X11 selection-request property in one helper. --- .../src/linux/worker.rs | 27 ++++++++++--------- .../ironrdp-cliprdr-native/src/linux/x11.rs | 27 +++++++++---------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/crates/ironrdp-cliprdr-native/src/linux/worker.rs b/crates/ironrdp-cliprdr-native/src/linux/worker.rs index 66905f8b55..116b6b8a51 100644 --- a/crates/ironrdp-cliprdr-native/src/linux/worker.rs +++ b/crates/ironrdp-cliprdr-native/src/linux/worker.rs @@ -104,12 +104,7 @@ impl Worker { return; } self.invalidate(); - self.owns_remote = false; - self.remote.clear(); - self.local_mimes = self.os.mime_types(); - if self.ready { - self.advertise_local(); - } + self.revert_to_local(); } Command::RemoteCopy(formats) => { self.invalidate(); @@ -129,12 +124,10 @@ impl Worker { if let Err(error) = self.os.offer(&mimes, self.generation) { warn!(%error, "Could not advertise the remote clipboard"); let _ = self.os.clear(); - self.owns_remote = false; - self.remote.clear(); - self.local_mimes = self.os.mime_types(); - if self.ready { - self.advertise_local(); - } + // The peer's Format List was already acknowledged. Announcing the + // local formats supersedes it, so the peer stops treating its own + // content as available here. + self.revert_to_local(); } } Command::Paste(transfer) => self.paste(transfer), @@ -164,6 +157,16 @@ impl Worker { } } + /// Drop the remote formats and announce what the local clipboard holds. + fn revert_to_local(&mut self) { + self.owns_remote = false; + self.remote.clear(); + self.local_mimes = self.os.mime_types(); + if self.ready { + self.advertise_local(); + } + } + fn advertise_local(&self) { let mut formats = Vec::new(); if find_mime_match(TEXT, &self.local_mimes).is_some() { diff --git a/crates/ironrdp-cliprdr-native/src/linux/x11.rs b/crates/ironrdp-cliprdr-native/src/linux/x11.rs index 908df457db..4299eb5eb3 100644 --- a/crates/ironrdp-cliprdr-native/src/linux/x11.rs +++ b/crates/ironrdp-cliprdr-native/src/linux/x11.rs @@ -130,6 +130,16 @@ impl Drop for X11Clipboard { } } +/// The property a selection request is answered in. ICCCM obsolete requestors +/// pass `None` and expect the target atom to be used instead. +fn effective_property(request: &SelectionRequestEvent) -> Atom { + if request.property == NONE { + request.target + } else { + request.property + } +} + enum ReadKind { Targets, Data(ReadReply), @@ -356,11 +366,7 @@ impl State { } Request::Clear => self.clear()?, Request::Complete(request, generation, data) => { - let property = if request.property == NONE { - request.target - } else { - request.property - }; + let property = effective_property(&request); let Some(pending) = self.pending_pastes.remove(&(request.requestor, property)) else { return Ok(()); }; @@ -370,11 +376,6 @@ impl State { if generation != self.generation || !self.owns { self.notify(request, NONE)?; } else if let Some(data) = data.filter(|d| d.len() <= MAX_TRANSFER_BYTES) { - let property = if request.property == NONE { - request.target - } else { - request.property - }; if data.len() <= CHUNK_BYTES { self.connection .change_property8(PropMode::REPLACE, request.requestor, property, request.target, &data)? @@ -689,11 +690,7 @@ impl State { if request.time != CURRENT_TIME && 0x7fff_ffff < request.time.wrapping_sub(self.timestamp) { return self.notify(request, NONE); } - let property = if request.property == NONE { - request.target - } else { - request.property - }; + let property = effective_property(&request); if request.target == self.atoms.TARGETS { let mut targets = self.targets.clone(); targets.extend([self.atoms.TARGETS, self.atoms.TIMESTAMP]); From de0ec536e06d7f07e1f013c1d027001a70b57d31 Mon Sep 17 00:00:00 2001 From: AKolenda Date: Fri, 9 Oct 2026 23:44:18 -0600 Subject: [PATCH 07/11] docs(cliprdr-native): keep the data-control overview in the README The rebase replaced master's description of the data_control module with the backend text. Keep both, and point the test commands at ironrdp-testsuite-extra where the Linux tests now live. --- crates/ironrdp-cliprdr-native/README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/ironrdp-cliprdr-native/README.md b/crates/ironrdp-cliprdr-native/README.md index 5ca9694fe4..fa19c3251f 100644 --- a/crates/ironrdp-cliprdr-native/README.md +++ b/crates/ironrdp-cliprdr-native/README.md @@ -8,7 +8,10 @@ This crate is part of the [IronRDP] project. ## Linux -`LinuxClipboard` uses the shared `data_control` client for Wayland compositors offering `ext-data-control-v1` or `wlr-data-control-unstable-v1`. +The `data_control` module is a clipboard client for Wayland compositors that offer [`ext-data-control-v1`] or [`wlr-data-control-unstable-v1`]. +It reads and sets the clipboard without a window and supports delayed rendering, so the data for a paste can be produced when the paste happens. + +`LinuxClipboard` is a CLIPRDR backend built on that client. When data-control is unavailable, including on GNOME, it falls back to X11/XWayland through XFixes and selection ownership. A desktop without either clipboard service uses the client's stub backend. @@ -33,3 +36,6 @@ DISPLAY=:N cargo test -p ironrdp-testsuite-extra cliprdr_native::linux::x11_ -- ``` Use a private display such as Xvfb for these tests, because they replace its clipboard selection. + +[`ext-data-control-v1`]: https://gitlab.freedesktop.org/wayland/wayland-protocols/-/blob/main/staging/ext-data-control/ext-data-control-v1.xml +[`wlr-data-control-unstable-v1`]: https://gitlab.freedesktop.org/wlroots/wlr-protocols/-/blob/master/unstable/wlr-data-control-unstable-v1.xml From d4ab352f6465fd00e2c439c55f91502aff223298 Mon Sep 17 00:00:00 2001 From: AKolenda Date: Sun, 11 Oct 2026 15:14:34 -0600 Subject: [PATCH 08/11] fix(cliprdr-native): bound the wait for the Linux clipboard to open LinuxClipboard::new waited without a limit for the clipboard thread to open the OS clipboard. A stale SSH-forwarded DISPLAY or an unresponsive compositor could block it forever, and with it the session setup that calls it. Give up after 10 seconds with LinuxCliprdrError::TimedOut, so the client falls back to the stub clipboard as it does for other open failures. If the OS call returns later, the thread exits without starting the bridge. --- .../ironrdp-cliprdr-native/src/linux/mod.rs | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/ironrdp-cliprdr-native/src/linux/mod.rs b/crates/ironrdp-cliprdr-native/src/linux/mod.rs index 5ce75ecca6..2ba24d94c1 100644 --- a/crates/ironrdp-cliprdr-native/src/linux/mod.rs +++ b/crates/ironrdp-cliprdr-native/src/linux/mod.rs @@ -11,7 +11,8 @@ pub mod worker; pub mod x11; use core::fmt; -use std::sync::mpsc::{self, Sender}; +use core::time::Duration; +use std::sync::mpsc::{self, RecvTimeoutError, Sender}; use std::thread::JoinHandle; use ironrdp_cliprdr::backend::{ClipboardMessageProxy, CliprdrBackend, CliprdrBackendFactory}; @@ -24,6 +25,10 @@ use tracing::{debug, warn}; use self::worker::{Command, Worker}; +/// How long [`LinuxClipboard::new`] waits for the OS clipboard to open. A stale +/// `DISPLAY` or an unresponsive compositor must not stall the session setup. +const STARTUP_TIMEOUT: Duration = Duration::from_secs(10); + #[derive(Debug)] pub enum LinuxCliprdrError { /// No usable clipboard: neither a Wayland data-control clipboard nor an X11 @@ -32,6 +37,8 @@ pub enum LinuxCliprdrError { Spawn(std::io::Error), /// The clipboard thread stopped before reporting whether it could start. WorkerStopped, + /// The OS clipboard did not open within the startup timeout. + TimedOut, } impl fmt::Display for LinuxCliprdrError { @@ -40,6 +47,7 @@ impl fmt::Display for LinuxCliprdrError { Self::Clipboard(error) => write!(f, "cannot open the OS clipboard: {error}"), Self::Spawn(error) => write!(f, "cannot start the clipboard thread: {error}"), Self::WorkerStopped => f.write_str("the clipboard thread stopped unexpectedly"), + Self::TimedOut => f.write_str("the OS clipboard did not open in time"), } } } @@ -49,7 +57,7 @@ impl core::error::Error for LinuxCliprdrError { match self { Self::Clipboard(_) => None, Self::Spawn(error) => Some(error), - Self::WorkerStopped => None, + Self::WorkerStopped | Self::TimedOut => None, } } } @@ -74,6 +82,10 @@ impl LinuxClipboard { /// /// `message_proxy` delivers the resulting `CLIPRDR` messages to the RDP /// session; it is called from the clipboard thread. + /// + /// Gives up with [`LinuxCliprdrError::TimedOut`] if the OS clipboard does not + /// open within 10 seconds. A thread still blocked in the OS call is left + /// behind and exits without starting the bridge if the call later returns. pub fn new(message_proxy: impl ClipboardMessageProxy + 'static) -> Result { let (commands, receiver) = mpsc::channel(); let (ready_sender, ready) = mpsc::channel(); @@ -89,19 +101,23 @@ impl LinuxClipboard { return; } }; - let _ = ready_sender.send(Ok(())); + if ready_sender.send(Ok(())).is_err() { + // `new` timed out and nobody holds the bridge. + return; + } Worker::new(os, message_proxy).run(&receiver); debug!("Clipboard thread stopped"); }) .map_err(LinuxCliprdrError::Spawn)?; - match ready.recv() { + match ready.recv_timeout(STARTUP_TIMEOUT) { Ok(Ok(())) => Ok(Self { commands, thread: Some(thread), }), Ok(Err(error)) => Err(LinuxCliprdrError::Clipboard(error)), - Err(_) => Err(LinuxCliprdrError::WorkerStopped), + Err(RecvTimeoutError::Timeout) => Err(LinuxCliprdrError::TimedOut), + Err(RecvTimeoutError::Disconnected) => Err(LinuxCliprdrError::WorkerStopped), } } From 7a2306de40dffe39a9698e02ccde2a201afe6fa3 Mon Sep 17 00:00:00 2001 From: AKolenda Date: Sun, 11 Oct 2026 15:14:34 -0600 Subject: [PATCH 09/11] refactor(cliprdr-native): drop the cached Linux local MIME list Worker::local_mimes was refreshed right before every read, so advertise_local can ask the OS clipboard directly. --- crates/ironrdp-cliprdr-native/src/linux/worker.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/ironrdp-cliprdr-native/src/linux/worker.rs b/crates/ironrdp-cliprdr-native/src/linux/worker.rs index 116b6b8a51..2b011ae83e 100644 --- a/crates/ironrdp-cliprdr-native/src/linux/worker.rs +++ b/crates/ironrdp-cliprdr-native/src/linux/worker.rs @@ -48,7 +48,6 @@ pub struct Worker { os: O, proxy: P, ready: bool, - local_mimes: Vec, remote: Vec, owns_remote: bool, generation: u64, @@ -60,11 +59,9 @@ pub struct Worker { impl Worker { pub fn new(os: O, proxy: P) -> Self { - let local_mimes = os.mime_types(); Self { os, proxy, - local_mimes, ready: false, remote: Vec::new(), owns_remote: false, @@ -93,7 +90,6 @@ impl Worker { Command::AdvertiseLocal => { self.ready = true; if !self.owns_remote { - self.local_mimes = self.os.mime_types(); self.advertise_local(); } } @@ -161,18 +157,18 @@ impl Worker { fn revert_to_local(&mut self) { self.owns_remote = false; self.remote.clear(); - self.local_mimes = self.os.mime_types(); if self.ready { self.advertise_local(); } } fn advertise_local(&self) { + let local_mimes = self.os.mime_types(); let mut formats = Vec::new(); - if find_mime_match(TEXT, &self.local_mimes).is_some() { + if find_mime_match(TEXT, &local_mimes).is_some() { formats.push(ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT)); } - if find_mime_match(PNG, &self.local_mimes).is_some() { + if find_mime_match(PNG, &local_mimes).is_some() { formats.extend([ ClipboardFormat::new(ClipboardFormatId::CF_DIB), ClipboardFormat::new(ClipboardFormatId::CF_DIBV5), From 7775a4265ce5553e7cb27bb5d07e1b0be0cdc6be Mon Sep 17 00:00:00 2001 From: AKolenda Date: Sun, 11 Oct 2026 15:15:00 -0600 Subject: [PATCH 10/11] fix(cliprdr-native): keep the local clipboard for unsupported remote copies A remote Format List without CF_UNICODETEXT, CF_DIB or CF_DIBV5 (HTML or files only, for example) still took the OS selection with an empty offer, which replaced the user's local clipboard with an owner that serves nothing. Leave the local clipboard alone in that case. If an earlier remote copy is still on offer, the empty offer still withdraws its formats. --- .../ironrdp-cliprdr-native/src/linux/worker.rs | 17 ++++++++++++----- .../tests/cliprdr_native/linux.rs | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/ironrdp-cliprdr-native/src/linux/worker.rs b/crates/ironrdp-cliprdr-native/src/linux/worker.rs index 2b011ae83e..6ed5db6c62 100644 --- a/crates/ironrdp-cliprdr-native/src/linux/worker.rs +++ b/crates/ironrdp-cliprdr-native/src/linux/worker.rs @@ -104,19 +104,26 @@ impl Worker { } Command::RemoteCopy(formats) => { self.invalidate(); - self.remote = formats; - self.owns_remote = true; let mut mimes = Vec::new(); - if self.remote.iter().any(|f| f.id() == ClipboardFormatId::CF_UNICODETEXT) { + if formats.iter().any(|f| f.id() == ClipboardFormatId::CF_UNICODETEXT) { mimes.extend([TEXT.to_owned(), "text/plain".to_owned()]); } - if self - .remote + if formats .iter() .any(|f| matches!(f.id(), ClipboardFormatId::CF_DIB | ClipboardFormatId::CF_DIBV5)) { mimes.push(PNG.to_owned()); } + if mimes.is_empty() && !self.owns_remote { + // Nothing here can be rendered locally. Keep the user's local + // clipboard rather than replacing it with an owner that serves + // nothing. When an earlier remote copy is still on offer, the + // empty offer below withdraws its formats. + debug!("No supported format in the remote clipboard"); + return; + } + self.remote = formats; + self.owns_remote = true; if let Err(error) = self.os.offer(&mimes, self.generation) { warn!(%error, "Could not advertise the remote clipboard"); let _ = self.os.clear(); diff --git a/crates/ironrdp-testsuite-extra/tests/cliprdr_native/linux.rs b/crates/ironrdp-testsuite-extra/tests/cliprdr_native/linux.rs index e0042d1cc0..c560964c0d 100644 --- a/crates/ironrdp-testsuite-extra/tests/cliprdr_native/linux.rs +++ b/crates/ironrdp-testsuite-extra/tests/cliprdr_native/linux.rs @@ -148,6 +148,24 @@ fn remote_formats_are_advertised_without_fetching_data() { assert_eq!(response.recv().unwrap(), Some(b"only when pasted".to_vec())); } +#[test] +fn unsupported_remote_formats_keep_the_local_clipboard() { + let (mut worker, os, recorder) = worker(); + os.lock().unwrap().data.insert(TEXT.into(), b"local".to_vec()); + worker.handle(Command::AdvertiseLocal); + recorder.take(); + remote(&mut worker, &[ClipboardFormatId::CF_HDROP]); + assert!(os.lock().unwrap().offers.is_empty()); + assert!(recorder.take().is_empty()); + + // An earlier remote copy is withdrawn instead. + remote(&mut worker, &[ClipboardFormatId::CF_UNICODETEXT]); + remote(&mut worker, &[ClipboardFormatId::CF_HDROP]); + let os = os.lock().unwrap(); + assert_eq!(os.offers.len(), 2); + assert!(os.offers[1].0.is_empty()); +} + #[test] fn same_format_waiters_share_one_wire_request_and_cache() { let (mut worker, os, recorder) = worker(); From 66ea1f178cc69cd3a2cc551ddc5899631068ec08 Mon Sep 17 00:00:00 2001 From: AKolenda Date: Sun, 11 Oct 2026 15:18:06 -0600 Subject: [PATCH 11/11] refactor(cliprdr-native): share X11 selection ownership with the worker X11Clipboard::is_owner sent a request to the X11 thread and waited up to 5 seconds for a GetSelectionOwner round trip. The X11 thread already tracks ownership and updates it before it reports a selection change, so share that flag through an AtomicBool and drop Request::IsOwner. --- .../ironrdp-cliprdr-native/src/linux/x11.rs | 62 ++++++++++--------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/crates/ironrdp-cliprdr-native/src/linux/x11.rs b/crates/ironrdp-cliprdr-native/src/linux/x11.rs index 4299eb5eb3..7ca4095d08 100644 --- a/crates/ironrdp-cliprdr-native/src/linux/x11.rs +++ b/crates/ironrdp-cliprdr-native/src/linux/x11.rs @@ -7,6 +7,7 @@ //! //! [ICCCM section 2.7.2, INCR Properties]: https://www.x.org/releases/X11R7.7/doc/xorg-docs/icccm/icccm.html +use core::sync::atomic::{AtomicBool, Ordering}; use core::time::Duration; use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex, mpsc}; @@ -46,7 +47,6 @@ x11rb::atom_manager! { enum Request { Read(String, ReadReply), Offer(Vec, u64, mpsc::Sender>), - IsOwner(mpsc::Sender), Clear, Complete(SelectionRequestEvent, u64, Option>), Shutdown, @@ -55,6 +55,7 @@ enum Request { pub struct X11Clipboard { commands: mpsc::Sender, mime_types: Arc>>, + owns: Arc, thread: Option>, } @@ -62,8 +63,9 @@ impl X11Clipboard { pub fn open(events: mpsc::Sender) -> Result { let (commands, receiver) = mpsc::channel(); let mime_types = Arc::new(Mutex::new(Vec::new())); - let state = - State::connect(events, commands.clone(), Arc::clone(&mime_types)).map_err(|error| error.to_string())?; + let owns = Arc::new(AtomicBool::new(false)); + let state = State::connect(events, commands.clone(), Arc::clone(&mime_types), Arc::clone(&owns)) + .map_err(|error| error.to_string())?; let thread = std::thread::Builder::new() .name("cliprdr-x11".into()) .spawn(move || { @@ -75,6 +77,7 @@ impl X11Clipboard { Ok(Self { commands, mime_types, + owns, thread: Some(thread), }) } @@ -82,11 +85,8 @@ impl X11Clipboard { impl OsClipboard for X11Clipboard { fn is_owner(&self) -> bool { - let (sender, receiver) = mpsc::channel(); - if self.commands.send(Request::IsOwner(sender)).is_err() { - return false; - } - receiver.recv_timeout(PASTE_TIMEOUT).unwrap_or(false) + // The X11 thread updates this before it reports any selection change. + self.owns.load(Ordering::Acquire) } fn mime_types(&self) -> Vec { @@ -176,7 +176,8 @@ struct State { mime_types: Arc>>, targets: Vec, owner: Window, - owns: bool, + /// Shared with `X11Clipboard::is_owner`; written only through `set_owns`. + owns: Arc, generation: u64, timestamp: u32, incoming: HashMap, @@ -190,6 +191,7 @@ impl State { events: mpsc::Sender, commands: mpsc::Sender, mime_types: Arc>>, + owns: Arc, ) -> XResult { let (connection, screen) = x11rb::connect(None)?; connection.xfixes_query_version(5, 0)?.reply()?; @@ -231,7 +233,7 @@ impl State { mime_types, targets: Vec::new(), owner, - owns: false, + owns, generation: 0, timestamp: CURRENT_TIME, incoming: HashMap::new(), @@ -316,20 +318,12 @@ impl State { .into_iter() .find(|target| self.targets.contains(target)) }; - if let Some(target) = target.filter(|_| !self.owns && self.owner != NONE) { + if let Some(target) = target.filter(|_| !self.owns() && self.owner != NONE) { self.begin_read(target, ReadKind::Data(sender))?; } else { let _ = sender.send(Ok(None)); } } - Request::IsOwner(sender) => { - let owner = self - .connection - .get_selection_owner(self.atoms.CLIPBOARD)? - .reply()? - .owner; - let _ = sender.send(owner == self.window); - } Request::Offer(mimes, generation, sender) => { let result = (|| -> XResult<()> { self.generation = generation; @@ -352,12 +346,12 @@ impl State { .get_selection_owner(self.atoms.CLIPBOARD)? .reply()? .owner; - self.owns = self.owner == self.window; + self.set_owns(self.owner == self.window); let readers: Vec<_> = self.incoming.keys().copied().collect(); for window in readers { self.finish_read(window, Err("clipboard selection changed while reading".into()))?; } - if !self.owns { + if !self.owns() { return Err("clipboard ownership was not acquired".into()); } Ok(()) @@ -373,7 +367,7 @@ impl State { if pending.expired { return Ok(()); } - if generation != self.generation || !self.owns { + if generation != self.generation || !self.owns() { self.notify(request, NONE)?; } else if let Some(data) = data.filter(|d| d.len() <= MAX_TRANSFER_BYTES) { if data.len() <= CHUNK_BYTES { @@ -420,6 +414,14 @@ impl State { Ok(()) } + fn owns(&self) -> bool { + self.owns.load(Ordering::Acquire) + } + + fn set_owns(&self, owns: bool) { + self.owns.store(owns, Ordering::Release); + } + // ICCCM uses the event timestamp that triggered ownership. RDP commands // have no X timestamp, so obtain one from PropertyNotify and retain all // other events for normal dispatch. @@ -457,7 +459,7 @@ impl State { .set_selection_owner(NONE, self.atoms.CLIPBOARD, self.timestamp)? .check()?; } - self.owns = false; + self.set_owns(false); Ok(()) } @@ -514,11 +516,11 @@ impl State { } self.owner = event.owner; if event.owner == self.window { - self.owns = true; + self.set_owns(true); return Ok(()); } self.timestamp = event.selection_timestamp; - self.owns = false; + self.set_owns(false); self.targets.clear(); let readers: Vec<_> = self.incoming.keys().copied().collect(); for window in readers { @@ -572,12 +574,12 @@ impl State { // A clear from an earlier owner can be queued while Offer is // obtaining its timestamp. Consult the server before applying // it to a selection that has since been reacquired. - self.owns = self + let owner = self .connection .get_selection_owner(self.atoms.CLIPBOARD)? .reply()? - .owner - == self.window; + .owner; + self.set_owns(owner == self.window); } _ => {} } @@ -648,7 +650,7 @@ impl State { let _ = sender.send(result.map(Some)); } ReadKind::Targets => { - if self.owns { + if self.owns() { return Ok(()); } self.targets = result @@ -682,7 +684,7 @@ impl State { } fn selection_request(&mut self, request: SelectionRequestEvent) -> XResult<()> { - if request.selection != self.atoms.CLIPBOARD || !self.owns { + if request.selection != self.atoms.CLIPBOARD || !self.owns() { return self.notify(request, NONE); } // X timestamps wrap at 32 bits. We cannot render a selection from