Skip to content

feat(cliprdr): add a Wayland data-control clipboard client - #2055

Open
Greg Lamberson (glamberson) wants to merge 1 commit into
Devolutions:masterfrom
lamco-admin:feat/cliprdr-native-data-control
Open

Greg Lamberson (glamberson) wants to merge 1 commit into
Devolutions:masterfrom
lamco-admin:feat/cliprdr-native-data-control

Conversation

@glamberson

Copy link
Copy Markdown
Contributor

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.

@github-actions github-actions Bot added risk/medium Behavioral change that does not substantially alter a core public API size/XXL Size: 1300 or more counted lines or 50 or more files labels Sep 30, 2026
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.
@github-actions github-actions Bot added the triage/overlap Possible overlap with another pull request; advisory only label Sep 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

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).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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…

Comment on lines +195 to +199
/// 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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment on lines +18 to +19
#[cfg(target_os = "linux")]
pub mod data_control;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment on lines +334 to +373
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()),
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment on lines +497 to +508
/// 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()))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment on lines +60 to +231
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);
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment on lines +203 to +224
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);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment on lines +349 to +353
if let Ok(mut shared) = self.shared_state.lock() {
shared.own_source_live = live;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@github-actions github-actions Bot added ai-reviewed/1 One automated review completed needs-author-action The pull request author is the current next actor labels Oct 1, 2026

This branch was successfully deployed

1 active deployment
llm-providers — d57e221e Deployed Sep 30, 2026 by glamberson via Classify pull request #1204
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-reviewed/1 One automated review completed needs-author-action The pull request author is the current next actor risk/medium Behavioral change that does not substantially alter a core public API size/XXL Size: 1300 or more counted lines or 50 or more files triage/overlap Possible overlap with another pull request; advisory only

Development

Successfully merging this pull request may close these issues.

1 participant