feat(cliprdr): add a Wayland data-control clipboard client - #2055
Greg Lamberson (glamberson) wants to merge 1 commit into
Conversation
Add the data_control module to ironrdp-cliprdr-native so a Linux CLIPRDR backend can read and set the clipboard without a window. It speaks ext-data-control-v1 and wlr-data-control-unstable-v1 and uses whichever the compositor offers. One thread owns the Wayland connection and DataControl is the handle to call from anywhere. A type advertised without data raises a TransferRequest when something pastes it, which maps onto the CLIPRDR Format Data Response. A selection the client set itself is not reported back as a local copy. Reads are capped at 100 MiB and time out when the source stalls for 5 seconds. The state tests live in ironrdp-testsuite-core behind the __test feature, because inline tests are not built for this crate.
04b9863 to
d57e221
Compare
|
This pull request may overlap with #2016. Both PRs add Linux clipboard support for ironrdp-cliprdr-native. This PR introduces a native Wayland data-control client (ext-data-control-v1 and wlr-data-control-unstable-v1) with delayed rendering; PR 2016 adds a LinuxClipboard backend using arboard over Wayland data-control or X11. The shared scope is the Linux clipboard backend for the cliprdr native crate, though the implementations differ. This notice is advisory only. Automated review continues as usual, and how these pull requests relate is for maintainers and authors to decide. Note LLM-assisted content (no human feedback). |
There was a problem hiding this comment.
The PR adds a self-contained Wayland data-control clipboard client (ext-data-control-v1 / wlr-data-control-unstable-v1) to ironrdp-cliprdr-native, with a worker-thread architecture, a public DataControl/Content/TransferRequest API, delayed rendering, MIME charset-tolerant matching, and state-machine tests behind a __test feature. Independent inspection confirms the specialists' code-level observations: Shared::own_source_live is written in four places and read nowhere; read() can return Ok(Some(empty)) when the offer races or disappears because the worker dispatches Wayland events before commands and EOF maps to success; source-side cached_data uses a one-directional MIME fallback weaker than find_mime_match; dispatch.rs mechanically duplicates four handlers per protocol; test-only accessors restate field access; and lock-poisoning is handled inconsistently (recovering in client.rs, silently skipped in state.rs). The unwired-public-API scope question is real but depends on #2016 timin…
| /// Whether our own source is still the compositor's selection source. | ||
| /// | ||
| /// While it is, data we cached ourselves can be read back without a | ||
| /// round trip; once another client takes the selection it must not be. | ||
| pub(crate) own_source_live: bool, |
There was a problem hiding this comment.
[skeptical] Shared::own_source_live is written but never read — low 🟡 — The field is set via set_own_source_live in set_selection, clear_selection, on_source_cancelled and on_device_finished (lines 551, 568, 591, 651) but nothing in the crate or testsuite reads it. Own-selection detection uses current_source.is_some() and read() always round-trips through the compositor, so the doc's claimed read-back short-circuit does not exist. Delete the field until a reader exists; its doc could mislead the future backend author.
| #[cfg(target_os = "linux")] | ||
| pub mod data_control; |
There was a problem hiding this comment.
[skeptical] Entire data_control module published with no in-repo consumer — medium 🟠 ❓ — DataControl, Content, TransferRequest, Options, Preference, Protocol, Error and find_mime_match become public API, but nothing in the workspace calls them; the consuming Linux CLIPRDR backend is deferred to #2016. Until a real consumer exercises the API, its shape (blocking read, callback signatures, preference knobs) is unvalidated and later changes become compatibility breaks in a published crate. Whether #2016 is imminent enough to justify landing the API first is missing context, so this stays a question.
| pub fn read(&self, mime_type: &str) -> Result<Option<Vec<u8>>> { | ||
| let available = self.selection_mime_types(); | ||
| let Some(matched) = find_mime_match(mime_type, &available) else { | ||
| return Ok(None); | ||
| }; | ||
|
|
||
| let (mut reader, writer) = std::io::pipe()?; | ||
| self.link.send(Command::ReceiveFromOffer { | ||
| mime_type: matched.to_owned(), | ||
| fd: OwnedFd::from(writer), | ||
| })?; | ||
|
|
||
| let mut data = Vec::new(); | ||
| let mut chunk = vec![0u8; 64 * 1024]; | ||
| loop { | ||
| let mut fds = [PollFd::new(reader.as_fd(), PollFlags::POLLIN)]; | ||
| let timeout = PollTimeout::try_from(READ_IDLE_TIMEOUT).unwrap_or(PollTimeout::NONE); | ||
| match poll(&mut fds, timeout) { | ||
| Ok(0) => return Err(Error::Timeout), | ||
| Ok(_) => {} | ||
| Err(nix::errno::Errno::EINTR) => continue, | ||
| Err(errno) => return Err(std::io::Error::from(errno).into()), | ||
| } | ||
| match reader.read(&mut chunk) { | ||
| // The writer closed: the source has sent everything. | ||
| Ok(0) => return Ok(Some(data)), | ||
| Ok(n) => { | ||
| if data.len() + n > self.max_read_bytes { | ||
| return Err(Error::TooLarge { | ||
| size: data.len() + n, | ||
| limit: self.max_read_bytes, | ||
| }); | ||
| } | ||
| data.extend_from_slice(&chunk[..n]); | ||
| } | ||
| Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} | ||
| Err(error) => return Err(error.into()), | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
[skeptical] read() validates against a stale MIME snapshot and silently returns empty data on a selection race — low 🟡 — read() snapshots selection_mime_types, finds a match, then queues ReceiveFromOffer. The worker dispatches Wayland events before commands (worker.rs run()), so by the time receive_from_offer runs, current_offer may be a different selection not offering the matched type, or gone entirely - in which case the fd is dropped (state.rs 662-673) and read() maps EOF (line 359) to Ok(Some(empty)), indistinguishable from genuinely empty clipboard content. A concurrent selection change should surface as an error rather than silent empty success.
| /// Data cached for `mime_type`, tolerating a charset parameter | ||
| /// (compositors commonly request `text/plain;charset=utf-8` for | ||
| /// `text/plain`). | ||
| fn cached_data(&self, mime_type: &str) -> Option<Arc<[u8]>> { | ||
| self.source_data | ||
| .get(mime_type) | ||
| .or_else(|| { | ||
| let base = mime_type.split(';').next()?.trim(); | ||
| self.source_data.get(base) | ||
| }) | ||
| .map(|data| Arc::from(data.as_slice())) | ||
| } |
There was a problem hiding this comment.
[skeptical] cached_data uses a weaker, one-directional MIME fallback than find_mime_match — low 🟡 — The read path deliberately tolerates charset parameters via find_mime_match, but the source-side cached_data only strips parameters from the requested type and never tries a charset-qualified cache entry: data cached under 'text/plain;charset=utf-8' is not found for a paste of 'text/plain', producing EOF or a spurious TransferRequest for exactly the mismatch find_mime_match exists to handle. Reusing find_mime_match over the source_data keys would remove the duplicated, inconsistent logic.
| impl Dispatch<ExtDataControlManagerV1, ()> for Client { | ||
| fn event( | ||
| _state: &mut Self, | ||
| _proxy: &ExtDataControlManagerV1, | ||
| _event: <ExtDataControlManagerV1 as wayland_client::Proxy>::Event, | ||
| _data: &(), | ||
| _conn: &Connection, | ||
| _qh: &QueueHandle<Self>, | ||
| ) { | ||
| // The manager has no events. | ||
| } | ||
| } | ||
|
|
||
| impl Dispatch<ExtDataControlDeviceV1, ()> for Client { | ||
| fn event( | ||
| state: &mut Self, | ||
| _proxy: &ExtDataControlDeviceV1, | ||
| event: <ExtDataControlDeviceV1 as wayland_client::Proxy>::Event, | ||
| _data: &(), | ||
| _conn: &Connection, | ||
| _qh: &QueueHandle<Self>, | ||
| ) { | ||
| match event { | ||
| ext_data_control_device_v1::Event::DataOffer { id } => { | ||
| state.data_control.on_data_offer_ext(id); | ||
| } | ||
| ext_data_control_device_v1::Event::Selection { id } => { | ||
| if id.is_some() { | ||
| state.data_control.on_selection(); | ||
| } else { | ||
| state.data_control.on_selection_cleared(); | ||
| } | ||
| } | ||
| ext_data_control_device_v1::Event::Finished => { | ||
| state.data_control.on_device_finished(); | ||
| } | ||
| ext_data_control_device_v1::Event::PrimarySelection { .. } => { | ||
| // Only the regular clipboard is handled, not the primary selection. | ||
| tracing::trace!("ext data control primary selection event (ignored)"); | ||
| } | ||
| _ => {} | ||
| } | ||
| } | ||
|
|
||
| // The `data_offer` event creates a child offer object; without this, | ||
| // wayland-client's default panics. | ||
| wayland_client::event_created_child!(Client, ExtDataControlDeviceV1, [ | ||
| ext_data_control_device_v1::EVT_DATA_OFFER_OPCODE => (ExtDataControlOfferV1, ()), | ||
| ]); | ||
| } | ||
|
|
||
| impl Dispatch<ExtDataControlSourceV1, ()> for Client { | ||
| fn event( | ||
| state: &mut Self, | ||
| _proxy: &ExtDataControlSourceV1, | ||
| event: <ExtDataControlSourceV1 as wayland_client::Proxy>::Event, | ||
| _data: &(), | ||
| _conn: &Connection, | ||
| _qh: &QueueHandle<Self>, | ||
| ) { | ||
| match event { | ||
| ext_data_control_source_v1::Event::Send { mime_type, fd } => { | ||
| state.data_control.on_source_send(&mime_type, fd); | ||
| } | ||
| ext_data_control_source_v1::Event::Cancelled => { | ||
| state.data_control.on_source_cancelled(); | ||
| } | ||
| _ => {} | ||
| } | ||
| } | ||
| } | ||
|
|
||
| impl Dispatch<ExtDataControlOfferV1, ()> for Client { | ||
| fn event( | ||
| state: &mut Self, | ||
| _proxy: &ExtDataControlOfferV1, | ||
| event: <ExtDataControlOfferV1 as wayland_client::Proxy>::Event, | ||
| _data: &(), | ||
| _conn: &Connection, | ||
| _qh: &QueueHandle<Self>, | ||
| ) { | ||
| if let ext_data_control_offer_v1::Event::Offer { mime_type } = event { | ||
| state.data_control.on_offer_mime_type(mime_type); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // === wlr-data-control-unstable-v1 === | ||
|
|
||
| impl Dispatch<ZwlrDataControlManagerV1, ()> for Client { | ||
| fn event( | ||
| _state: &mut Self, | ||
| _proxy: &ZwlrDataControlManagerV1, | ||
| _event: <ZwlrDataControlManagerV1 as wayland_client::Proxy>::Event, | ||
| _data: &(), | ||
| _conn: &Connection, | ||
| _qh: &QueueHandle<Self>, | ||
| ) { | ||
| // The manager has no events. | ||
| } | ||
| } | ||
|
|
||
| impl Dispatch<ZwlrDataControlDeviceV1, ()> for Client { | ||
| fn event( | ||
| state: &mut Self, | ||
| _proxy: &ZwlrDataControlDeviceV1, | ||
| event: <ZwlrDataControlDeviceV1 as wayland_client::Proxy>::Event, | ||
| _data: &(), | ||
| _conn: &Connection, | ||
| _qh: &QueueHandle<Self>, | ||
| ) { | ||
| match event { | ||
| zwlr_data_control_device_v1::Event::DataOffer { id } => { | ||
| state.data_control.on_data_offer_wlr(id); | ||
| } | ||
| zwlr_data_control_device_v1::Event::Selection { id } => { | ||
| if id.is_some() { | ||
| state.data_control.on_selection(); | ||
| } else { | ||
| state.data_control.on_selection_cleared(); | ||
| } | ||
| } | ||
| zwlr_data_control_device_v1::Event::Finished => { | ||
| state.data_control.on_device_finished(); | ||
| } | ||
| zwlr_data_control_device_v1::Event::PrimarySelection { .. } => { | ||
| tracing::trace!("wlr data control primary selection event (ignored)"); | ||
| } | ||
| _ => {} | ||
| } | ||
| } | ||
|
|
||
| wayland_client::event_created_child!(Client, ZwlrDataControlDeviceV1, [ | ||
| zwlr_data_control_device_v1::EVT_DATA_OFFER_OPCODE => (ZwlrDataControlOfferV1, ()), | ||
| ]); | ||
| } | ||
|
|
||
| impl Dispatch<ZwlrDataControlSourceV1, ()> for Client { | ||
| fn event( | ||
| state: &mut Self, | ||
| _proxy: &ZwlrDataControlSourceV1, | ||
| event: <ZwlrDataControlSourceV1 as wayland_client::Proxy>::Event, | ||
| _data: &(), | ||
| _conn: &Connection, | ||
| _qh: &QueueHandle<Self>, | ||
| ) { | ||
| match event { | ||
| zwlr_data_control_source_v1::Event::Send { mime_type, fd } => { | ||
| state.data_control.on_source_send(&mime_type, fd); | ||
| } | ||
| zwlr_data_control_source_v1::Event::Cancelled => { | ||
| state.data_control.on_source_cancelled(); | ||
| } | ||
| _ => {} | ||
| } | ||
| } | ||
| } | ||
|
|
||
| impl Dispatch<ZwlrDataControlOfferV1, ()> for Client { | ||
| fn event( | ||
| state: &mut Self, | ||
| _proxy: &ZwlrDataControlOfferV1, | ||
| event: <ZwlrDataControlOfferV1 as wayland_client::Proxy>::Event, | ||
| _data: &(), | ||
| _conn: &Connection, | ||
| _qh: &QueueHandle<Self>, | ||
| ) { | ||
| if let zwlr_data_control_offer_v1::Event::Offer { mime_type } = event { | ||
| state.data_control.on_offer_mime_type(mime_type); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
[code-compressor] Eight Dispatch impls are two mechanically identical copies of four handlers — low 🟡 — The ext and wlr handler sets are token-for-token identical except for proxy types, one state method name, and one trace message; a macro_rules! parameterized over (proxy type, offer type, opcode constant, state method) could generate all eight impls from one body (~170 lines down to ~40 plus invocations), including the event_created_child! blocks. Behavior is preserved since the generated impls match what is written today. This is optional, acknowledged compression of behaviorally-correct code rather than a defect, so it is published at low severity for maintainability.
| impl Shared { | ||
| /// Test-only: MIME types of the current selection. | ||
| pub fn mime_types(&self) -> &[String] { | ||
| &self.mime_types | ||
| } | ||
|
|
||
| /// Test-only: the selection change counter. | ||
| pub fn serial(&self) -> u32 { | ||
| self.serial | ||
| } | ||
|
|
||
| /// Test-only: install the change callback. | ||
| pub fn set_on_change(&mut self, callback: Arc<dyn Fn(Vec<String>) + Send + Sync>) { | ||
| self.on_change = Some(callback); | ||
| } | ||
|
|
||
| /// Test-only: install the transfer callback. | ||
| pub fn set_on_transfer(&mut self, callback: Arc<dyn Fn(u32, String) + Send + Sync>) { | ||
| self.on_transfer = Some(callback); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
[code-compressor] Test-only accessor methods restate direct field access — low 🟡 — The cfg(__test) impl block on Shared (mime_types, serial, set_on_change, set_on_transfer) plus the parallel State accessors at lines 291-314 are one-line wrappers over fields that tests could use directly: visibility::make(pub) already publishes the structs under __test, and declaring the needed fields pub is legal since field visibility is capped by struct visibility, so nothing leaks in non-test builds. Removing the shims deletes ~50 lines with unchanged behavior.
| if let Ok(mut shared) = self.shared_state.lock() { | ||
| shared.own_source_live = live; | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
[code-compressor] Two divergent lock-poisoning strategies for the same Shared mutex — low 🟡 — client.rs recovers from poisoning via unwrap_or_else(PoisonError::into_inner) in its shared() helper, while state.rs uses lock().ok() here and in on_selection, on_selection_cleared and on_source_send, silently skipping serial/mime_types updates and callbacks once the mutex is poisoned, while the public handle keeps reading stale-but-valid data through the recovering path. Extracting the existing recovery into one helper on Shared and using it at all lock sites removes the duplicated, divergent logic; behavior outside the poisoned case is identical.
This adds the data-control clipboard client I committed to on #2016. It lives in ironrdp-cliprdr-native as the data_control module, so a Linux CLIPRDR backend can be built on it.
The client speaks ext-data-control-v1 and wlr-data-control-unstable-v1 and uses whichever the compositor offers. One thread owns the Wayland connection and DataControl is the handle to call from anywhere. It has no async runtime.
It supports delayed rendering: a type advertised without data raises a TransferRequest when something pastes it, which maps onto the CLIPRDR Format Data Response. A selection this client set itself is not reported back as a local copy, so a backend does not echo its own clipboard to the server.
Reads are capped at 100 MiB and fail with a timeout if the source stalls for 5 seconds. MIME matching tolerates charset parameters. GNOME's Mutter offers no data-control protocol, so connecting returns an Unsupported error there.
This is not wired into ironrdp-client. #2016 adds a Linux backend, and it can use this module instead of arboard.
Dependencies are wayland-client, wayland-protocols, wayland-protocols-wlr and nix, all of which are already in the lockfile, so Cargo.lock only gains the new dependency edges. The state tests are in ironrdp-testsuite-core behind the __test feature, because inline tests are not built for this crate.
The client is a port of the standalone lamco-data-control crate. I ran its compositor tests against a KWin instance by hand: set and read back, a second client reading our selection, delayed rendering, and a replaced selection not served from a stale cache. They need a compositor, so they are not in the test suite.