diff --git a/Cargo.lock b/Cargo.lock index bcd384dddc..1a0a0596d2 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]] @@ -3464,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", @@ -3489,6 +3493,7 @@ dependencies = [ "ironrdp-tokio", "ironrdp-viewer", "ironrdp-vmconnect", + "png", "rcgen", "semver", "tokio", 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..9c5e9e240a 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 mut _linux_clipboard = None; #[cfg(feature = "clipboard")] let cliprdr_factory: Option>; @@ -852,7 +859,25 @@ 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()); + } + } + } + + #[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..7818c1b597 100644 --- a/crates/ironrdp-cliprdr-native/Cargo.toml +++ b/crates/ironrdp-cliprdr-native/Cargo.toml @@ -32,6 +32,8 @@ 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"] } +x11rb = { version = "0.13", features = ["xfixes"] } +ironrdp-cliprdr-format = { path = "../ironrdp-cliprdr-format", version = "0.2" } [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..fa19c3251f 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. @@ -11,5 +11,31 @@ This crate is part of the [IronRDP] project. 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. + +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. + [`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 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)) diff --git a/crates/ironrdp-cliprdr-native/src/lib.rs b/crates/ironrdp-cliprdr-native/src/lib.rs index 4ce1515840..331cf3c38a 100644 --- a/crates/ironrdp-cliprdr-native/src/lib.rs +++ b/crates/ironrdp-cliprdr-native/src/lib.rs @@ -17,6 +17,16 @@ pub use crate::windows::{HWND, WinClipboard, WinCliprdrError, WinCliprdrResult}; #[cfg(target_os = "linux")] pub mod data_control; +#[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}; mod stub; use std::sync::OnceLock; 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..2ba24d94c1 --- /dev/null +++ b/crates/ironrdp-cliprdr-native/src/linux/mod.rs @@ -0,0 +1,222 @@ +//! Linux CLIPRDR backend with clipboard change events and delayed rendering. +//! +//! 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. + +pub mod os; +pub mod worker; +pub mod x11; + +use core::fmt; +use core::time::Duration; +use std::sync::mpsc::{self, RecvTimeoutError, 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}; + +/// 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 + /// display could be opened. + Clipboard(String), + 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 { + 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"), + Self::TimedOut => f.write_str("the OS clipboard did not open in time"), + } + } +} + +impl core::error::Error for LinuxCliprdrError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + match self { + Self::Clipboard(_) => None, + Self::Spawn(error) => Some(error), + Self::WorkerStopped | Self::TimedOut => 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. + /// + /// 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(); + + let events = commands.clone(); + let thread = std::thread::Builder::new() + .name("cliprdr-linux".to_owned()) + .spawn(move || { + let os = match os::NativeClipboard::open(events) { + Ok(os) => os, + Err(error) => { + let _ = ready_sender.send(Err(error)); + return; + } + }; + 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_timeout(STARTUP_TIMEOUT) { + Ok(Ok(())) => Ok(Self { + commands, + thread: Some(thread), + }), + Ok(Err(error)) => Err(LinuxCliprdrError::Clipboard(error)), + Err(RecvTimeoutError::Timeout) => Err(LinuxCliprdrError::TimedOut), + Err(RecvTimeoutError::Disconnected) => 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 { + let _ = self.commands.send(Command::Reset); + 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() && response.data().len() <= worker::MAX_TRANSFER_BYTES) + .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..1c9092f56a --- /dev/null +++ b/crates/ironrdp-cliprdr-native/src/linux/os.rs @@ -0,0 +1,157 @@ +//! The clipboard interface used by the protocol worker. + +use std::sync::mpsc::Sender; + +use super::worker::Command; +use super::x11::X11Clipboard; +use crate::data_control::{Content, DataControl}; + +pub const TEXT: &str = "text/plain;charset=utf-8"; +pub const PNG: &str = "image/png"; + +type CompletePaste = Box>) + Send>; + +/// A delayed OS paste. Every completion, including failure, closes its waiter. +pub struct Transfer { + mime: String, + generation: u64, + complete: Option, +} + +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() + } +} + +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 Drop for Transfer { + fn drop(&mut self) { + if let Some(complete) = self.complete.take() { + complete(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 |_| { + let _ = changes.send(Command::LocalChanged); + }); + 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(), + } + } + + 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 { + // 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), + } + } + + 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(()) + } + 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 new file mode 100644 index 0000000000..6ed5db6c62 --- /dev/null +++ b/crates/ironrdp-cliprdr-native/src/linux/worker.rs @@ -0,0 +1,346 @@ +//! 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::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::os::{OsClipboard, PNG, TEXT, Transfer}; +use crate::data_control::find_mime_match; + +pub const PASTE_TIMEOUT: Duration = Duration::from_secs(5); +pub const MAX_TRANSFER_BYTES: usize = 100 * 1024 * 1024; +const MAX_WAITERS: usize = 16; + +#[derive(Debug)] +pub enum Command { + AdvertiseLocal, + LocalChanged, + RemoteCopy(Vec), + RemoteData(Option>), + RenderLocal(ClipboardFormatId), + Paste(Transfer), + Reset, + Shutdown, +} + +struct Pending { + format: ClipboardFormatId, + generation: u64, + deadline: Instant, + expired: bool, + waiters: Vec, +} + +struct Queued { + deadline: Instant, + transfer: Transfer, +} + +pub struct Worker { + os: O, + proxy: P, + ready: bool, + remote: Vec, + owns_remote: bool, + generation: u64, + pending: Option, + queue: VecDeque, + cache: HashMap>, + loops: LoopDetector, +} + +impl Worker { + pub fn new(os: O, proxy: P) -> Self { + Self { + os, + proxy, + ready: false, + remote: Vec::new(), + owns_remote: false, + generation: 0, + pending: None, + queue: VecDeque::new(), + cache: HashMap::new(), + loops: LoopDetector::new(), + } + } + + pub fn run(mut self, receiver: &Receiver) { + loop { + match receiver.recv_timeout(Duration::from_millis(100)) { + Ok(Command::Shutdown) | Err(RecvTimeoutError::Disconnected) => break, + Ok(command) => self.handle(command), + Err(RecvTimeoutError::Timeout) => {} + } + self.expire(Instant::now()); + } + let _ = self.os.clear(); + } + + pub fn handle(&mut self, command: Command) { + match command { + Command::AdvertiseLocal => { + self.ready = true; + if !self.owns_remote { + self.advertise_local(); + } + } + 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.revert_to_local(); + } + Command::RemoteCopy(formats) => { + self.invalidate(); + let mut mimes = Vec::new(); + if formats.iter().any(|f| f.id() == ClipboardFormatId::CF_UNICODETEXT) { + mimes.extend([TEXT.to_owned(), "text/plain".to_owned()]); + } + 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(); + // 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), + 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 => {} + } + } + + 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(); + } + } + + /// Drop the remote formats and announce what the local clipboard holds. + fn revert_to_local(&mut self) { + self.owns_remote = false; + self.remote.clear(); + 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, &local_mimes).is_some() { + formats.push(ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT)); + } + if find_mime_match(PNG, &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 paste(&mut self, transfer: Transfer) { + if !self.owns_remote || transfer.generation() != self.generation { + return; + } + 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; + } + 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); + } + } + + 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)); + } + + /// 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 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); + } + } + } + + fn render_local(&mut self, format: ClipboardFormatId) { + let result = (|| -> Result, String> { + if self.owns_remote { + return Err("local clipboard selection was replaced".into()); + } + 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()); + } + // 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() + } + }; + self.proxy + .send_clipboard_message(ClipboardMessage::SendFormatData(response)); + } +} 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..7ca4095d08 --- /dev/null +++ b/crates/ironrdp-cliprdr-native/src/linux/x11.rs @@ -0,0 +1,762 @@ +//! 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::sync::atomic::{AtomicBool, Ordering}; +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>), + Clear, + Complete(SelectionRequestEvent, u64, Option>), + Shutdown, +} + +pub struct X11Clipboard { + commands: mpsc::Sender, + mime_types: Arc>>, + owns: 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 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 || { + 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, + owns, + thread: Some(thread), + }) + } +} + +impl OsClipboard for X11Clipboard { + fn is_owner(&self) -> bool { + // The X11 thread updates this before it reports any selection change. + self.owns.load(Ordering::Acquire) + } + + 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(); + } + } +} + +/// 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), +} + +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, + /// Shared with `X11Clipboard::is_owner`; written only through `set_owns`. + owns: Arc, + 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>>, + owns: 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, + 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::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.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() { + 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 = effective_property(&request); + 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) { + 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(()) + } + + 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. + 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.set_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.set_owns(true); + return Ok(()); + } + self.timestamp = event.selection_timestamp; + self.set_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); + } + } + 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. + let owner = self + .connection + .get_selection_owner(self.atoms.CLIPBOARD)? + .reply()? + .owner; + self.set_owns(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; + let _ = self.events.send(Command::LocalChanged); + } + } + 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 = effective_property(&request); + 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/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" 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..c560964c0d --- /dev/null +++ b/crates/ironrdp-testsuite-extra/tests/cliprdr_native/linux.rs @@ -0,0 +1,577 @@ +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); + 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); + worker.handle(Command::LocalChanged); + 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 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(); + 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); + 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); + 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); + 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 = 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; + } + } + } + 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 = owner_receiver.recv_timeout(Duration::from_secs(5)).unwrap() { + if owner.mime_types().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 = receiver.recv_timeout(Duration::from_secs(5)).unwrap() { + if clipboard.mime_types().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); + 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_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); + 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 = notifications.recv_timeout(Duration::from_secs(5)).unwrap() { + if reader.mime_types().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;