From 02806cc984bcd9bca389375312e91b1dddd75a45 Mon Sep 17 00:00:00 2001 From: Thanos Apollo Date: Mon, 5 Oct 2026 13:57:20 +0300 Subject: [PATCH] fix(clipboard): read the Wayland clipboard on Hyprland Hyprland delivers selection offers only to the first wl_data_device a client creates. In Neomacs that is winit's drag-and-drop device, so smithay-clipboard's device never sees an offer and every CLIPBOARD read returns nil, even while Neomacs is focused. wlroots compositors send the selection to every device, which is why they were unaffected. When smithay-clipboard reports no selection offer or no text type, read the CLIPBOARD through ext-data-control-v1 on a private event queue of the same display. Each read creates a short-lived device, transfers the current selection with smithay-clipboard's MIME preference and line-end normalization, and destroys every object it received. Focus and seat errors still propagate, and PRIMARY reads are unchanged. --- Cargo.toml | 2 +- .../neomacs-display-runtime/src/clipboard.rs | 113 +++++-- .../src/clipboard/tests/clipboard_test.rs | 71 +++++ .../tests/wayland_data_control_test.rs | 75 +++++ .../src/clipboard/wayland_data_control.rs | 296 ++++++++++++++++++ 5 files changed, 537 insertions(+), 20 deletions(-) create mode 100644 crates/neomacs-display-runtime/src/clipboard/tests/wayland_data_control_test.rs create mode 100644 crates/neomacs-display-runtime/src/clipboard/wayland_data_control.rs diff --git a/Cargo.toml b/Cargo.toml index c53131b682..c5f565d7b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -144,7 +144,7 @@ errno = "0.3" # `XKeysymToString` tier of `modify_event_symbol`, and this is that table # without an X11 or xkbcommon dependency, so it works on every platform. keysymdefs = "0.2" -rustix = { version = "1", features = ["fs"] } +rustix = { version = "1", features = ["event", "fs"] } inotify = "0.11" fs4 = { version = "1", features = ["sync"] } sysinfo = "0.39" diff --git a/crates/neomacs-display-runtime/src/clipboard.rs b/crates/neomacs-display-runtime/src/clipboard.rs index 43628ca846..8534b37b88 100644 --- a/crates/neomacs-display-runtime/src/clipboard.rs +++ b/crates/neomacs-display-runtime/src/clipboard.rs @@ -11,6 +11,9 @@ use arboard::{ClearExtLinux, GetExtLinux, LinuxClipboardKind, SetExtLinux}; #[cfg(target_os = "linux")] use raw_window_handle::{HasDisplayHandle, RawDisplayHandle}; +#[cfg(target_os = "linux")] +mod wayland_data_control; + const WORKER_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(250); const REQUEST_QUEUE_CAPACITY: usize = 32; @@ -55,10 +58,7 @@ impl ClipboardService { let clipboard = unsafe { smithay_clipboard::Clipboard::new(raw_display.display.as_ptr()) }; - return Self::start(WaylandClipboard { - clipboard, - _display_owner: display, - }); + return Self::start(WaylandClipboard::new(clipboard, display)); } } _ => { @@ -372,11 +372,91 @@ impl ClipboardBackend for ArboardClipboard { #[cfg(target_os = "linux")] struct WaylandClipboard { - // Field order is significant: drop the protocol worker before its display. + // Field order is significant: drop the protocol objects before their display. clipboard: smithay_clipboard::Clipboard, + data_control: DataControl, _display_owner: OwnedDisplayHandle, } +/// Lazily bound fallback for CLIPBOARD reads; see `wayland_data_control`. +#[cfg(target_os = "linux")] +enum DataControl { + Unbound, + Unavailable, + Bound(wayland_data_control::DataControlReader), +} + +#[cfg(target_os = "linux")] +impl WaylandClipboard { + fn new(clipboard: smithay_clipboard::Clipboard, display: OwnedDisplayHandle) -> Self { + Self { + clipboard, + data_control: DataControl::Unbound, + _display_owner: display, + } + } + + fn data_control_text(&mut self) -> Result, String> { + if let DataControl::Unbound = self.data_control { + self.data_control = match self.bind_data_control() { + Ok(Some(reader)) => DataControl::Bound(reader), + Ok(None) => DataControl::Unavailable, + Err(err) => { + tracing::warn!("clipboard data-control fallback unavailable: {err}"); + DataControl::Unavailable + } + }; + } + match &mut self.data_control { + DataControl::Bound(reader) => reader.read_text(), + DataControl::Unbound | DataControl::Unavailable => Ok(None), + } + } + + fn bind_data_control(&self) -> Result, String> { + let RawDisplayHandle::Wayland(raw_display) = self + ._display_owner + .display_handle() + .map_err(|err| format!("failed to access the native display: {err}"))? + .as_raw() + else { + return Ok(None); + }; + // SAFETY: `_display_owner` keeps the display alive and is dropped + // after `data_control` (field order above). + unsafe { wayland_data_control::DataControlReader::connect(raw_display.display) } + } +} + +/// Whether smithay-clipboard saw no usable selection offer. +/// +/// smithay-clipboard reports a missing offer as the untyped error +/// `selection is empty` and a missing text type as `NotFound`; both mean +/// "nothing to paste" rather than failure. Its other errors (no seat, no +/// keyboard focus) are real failures. +#[cfg(target_os = "linux")] +fn smithay_saw_no_offer(err: &std::io::Error) -> bool { + err.kind() == std::io::ErrorKind::NotFound || err.to_string() == "selection is empty" +} + +/// Map a smithay-clipboard read result, consulting `fallback` only when it +/// saw no usable selection offer. A failed or timed-out fallback reads as +/// an empty selection (nil), not an error. +#[cfg(target_os = "linux")] +fn smithay_text_or_fallback( + result: std::io::Result, + fallback: impl FnOnce() -> Result, String>, +) -> Result, String> { + match result { + Ok(text) => Ok(Some(text)), + Err(err) if smithay_saw_no_offer(&err) => fallback().or_else(|err| { + tracing::warn!("clipboard data-control read failed: {err}"); + Ok(None) + }), + Err(err) => Err(err.to_string()), + } +} + #[cfg(target_os = "linux")] impl ClipboardBackend for WaylandClipboard { fn set_text( @@ -400,21 +480,16 @@ impl ClipboardBackend for WaylandClipboard { } fn text(&mut self, selection: ClipboardSelection) -> Result, String> { - let result = match selection { - ClipboardSelection::Clipboard => self.clipboard.load(), - ClipboardSelection::Primary => self.clipboard.load_primary(), - }; - match result { - Ok(text) => Ok(Some(text)), - // smithay-clipboard currently exposes an untyped io::Error for an - // unowned selection, including this stable error message. - Err(err) - if err.kind() == std::io::ErrorKind::NotFound - || err.to_string() == "selection is empty" => - { - Ok(None) + match selection { + ClipboardSelection::Clipboard => { + let result = self.clipboard.load(); + smithay_text_or_fallback(result, || self.data_control_text()) + } + // smithay-clipboard owns the only primary-selection device, so + // its result is complete. + ClipboardSelection::Primary => { + smithay_text_or_fallback(self.clipboard.load_primary(), || Ok(None)) } - Err(err) => Err(err.to_string()), } } diff --git a/crates/neomacs-display-runtime/src/clipboard/tests/clipboard_test.rs b/crates/neomacs-display-runtime/src/clipboard/tests/clipboard_test.rs index 17e21b0c04..c596225d76 100644 --- a/crates/neomacs-display-runtime/src/clipboard/tests/clipboard_test.rs +++ b/crates/neomacs-display-runtime/src/clipboard/tests/clipboard_test.rs @@ -327,3 +327,74 @@ fn arboard_backend_keeps_primary_in_process_local_state() { .expect("PRIMARY disown must not fail on this platform"); assert_eq!(backend.text(ClipboardSelection::Primary), Ok(None)); } + +#[cfg(target_os = "linux")] +mod wayland_fallback { + use super::super::smithay_text_or_fallback; + use std::cell::Cell; + use std::io; + + fn no_offer() -> io::Result { + Err(io::Error::other("selection is empty")) + } + + #[test] + fn smithay_text_is_returned_without_consulting_data_control() { + let consulted = Cell::new(false); + let result = smithay_text_or_fallback(Ok("native".to_owned()), || { + consulted.set(true); + Ok(Some("data-control".to_owned())) + }); + assert_eq!(result, Ok(Some("native".to_owned()))); + assert!(!consulted.get()); + } + + #[test] + fn missing_selection_offer_reads_through_data_control() { + // Hyprland delivers the selection only to winit's data device, so + // smithay-clipboard's device never holds an offer. + let result = smithay_text_or_fallback(no_offer(), || Ok(Some("foreign".to_owned()))); + assert_eq!(result, Ok(Some("foreign".to_owned()))); + } + + #[test] + fn missing_text_mime_reads_through_data_control() { + let result = smithay_text_or_fallback( + Err(io::Error::new( + io::ErrorKind::NotFound, + "supported mime-type is not found", + )), + || Ok(Some("text from data-control".to_owned())), + ); + assert_eq!(result, Ok(Some("text from data-control".to_owned()))); + } + + #[test] + fn empty_transfer_is_text_not_a_missing_selection() { + let result = smithay_text_or_fallback(Ok(String::new()), || { + panic!("an empty transfer must not consult data-control") + }); + assert_eq!(result, Ok(Some(String::new()))); + } + + #[test] + fn seat_and_focus_errors_are_not_masked_by_data_control() { + for message in [ + "client doesn't have focus", + "no events received on any seat", + ] { + let result = smithay_text_or_fallback(Err(io::Error::other(message)), || { + panic!("{message} must not consult data-control") + }); + assert_eq!(result, Err(message.to_owned())); + } + } + + #[test] + fn failed_data_control_read_keeps_the_empty_result() { + let result = smithay_text_or_fallback(no_offer(), || { + Err("data-control transfer timed out".to_owned()) + }); + assert_eq!(result, Ok(None)); + } +} diff --git a/crates/neomacs-display-runtime/src/clipboard/tests/wayland_data_control_test.rs b/crates/neomacs-display-runtime/src/clipboard/tests/wayland_data_control_test.rs new file mode 100644 index 0000000000..aa409d0145 --- /dev/null +++ b/crates/neomacs-display-runtime/src/clipboard/tests/wayland_data_control_test.rs @@ -0,0 +1,75 @@ +use super::*; + +fn offered(mimes: &[&str]) -> Vec { + mimes.iter().map(|mime| (*mime).to_owned()).collect() +} + +#[test] +fn utf8_types_win_in_offer_order_and_plain_text_is_a_fallback() { + assert_eq!( + TextMime::choose(&offered(&[ + "text/plain", + "UTF8_STRING", + "text/plain;charset=utf-8" + ])), + Some(TextMime::Utf8String) + ); + assert_eq!( + TextMime::choose(&offered(&["TEXT", "text/plain;charset=utf-8"])), + Some(TextMime::TextPlainUtf8) + ); + assert_eq!( + TextMime::choose(&offered(&["image/png", "text/plain"])), + Some(TextMime::TextPlain) + ); +} + +#[test] +fn offers_without_a_text_type_have_no_text() { + assert_eq!(TextMime::choose(&offered(&[])), None); + assert_eq!( + TextMime::choose(&offered(&[ + "image/png", + "text/plain;charset=iso-8859-1", + "STRING" + ])), + None + ); +} + +#[test] +fn plain_text_line_ends_are_normalized_like_smithay_clipboard() { + let bytes = b"a\r\nb\rc\n"; + assert_eq!(TextMime::TextPlainUtf8.decode(bytes), "a\nb\nc\n"); + assert_eq!(TextMime::TextPlain.decode(bytes), "a\nb\nc\n"); + assert_eq!(TextMime::Utf8String.decode(bytes), "a\r\nb\rc\n"); +} + +#[test] +fn invalid_utf8_is_decoded_lossily() { + assert_eq!(TextMime::Utf8String.decode(b"ok\xff"), "ok\u{fffd}"); +} + +#[test] +fn transfer_reads_until_the_owner_closes_its_end() { + use std::io::Write; + let (mut reader, mut writer) = std::io::pipe().unwrap(); + let owner = std::thread::spawn(move || { + writer.write_all(b"first ").unwrap(); + std::thread::sleep(Duration::from_millis(20)); + writer.write_all(b"second").unwrap(); + }); + let bytes = read_to_end_before(&mut reader, Instant::now() + Duration::from_secs(5)).unwrap(); + owner.join().unwrap(); + assert_eq!(bytes, b"first second"); +} + +#[test] +fn stalled_selection_owner_times_out() { + let (mut reader, writer) = std::io::pipe().unwrap(); + let started = Instant::now(); + let result = read_to_end_before(&mut reader, started + Duration::from_millis(50)); + assert_eq!(result, Err("data-control transfer timed out".to_owned())); + assert!(started.elapsed() < Duration::from_secs(2)); + drop(writer); +} diff --git a/crates/neomacs-display-runtime/src/clipboard/wayland_data_control.rs b/crates/neomacs-display-runtime/src/clipboard/wayland_data_control.rs new file mode 100644 index 0000000000..48b3c11c91 --- /dev/null +++ b/crates/neomacs-display-runtime/src/clipboard/wayland_data_control.rs @@ -0,0 +1,296 @@ +//! CLIPBOARD reads through the `ext-data-control-v1` protocol. +//! +//! Neomacs has two `wl_data_device` objects on its single Wayland +//! connection: winit's (drag and drop) and smithay-clipboard's. Wayland +//! lets a client create several devices, and wlroots sends the selection to +//! all of the focused client's devices. Hyprland sends it only to the first +//! device the client created, which is winit's, so smithay-clipboard's device +//! never receives a selection offer and every read reports an empty +//! clipboard, even while Neomacs has keyboard focus. +//! +//! A data-control device is not a `wl_data_device` and is not tied to +//! keyboard focus, so it observes the selection regardless of how the +//! compositor routes data-device offers. The reader is used only when +//! smithay-clipboard reports no offer; it creates one short-lived device per +//! read, takes the selection the compositor sends on creation, transfers it +//! and destroys every object it was given. + +use std::ffi::c_void; +use std::io::{ErrorKind, Read}; +use std::os::fd::AsFd; +use std::ptr::NonNull; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use rustix::event::{PollFd, PollFlags, Timespec, poll}; +use wayland_backend::client::{Backend, WaylandError}; +use wayland_client::globals::{BindError, GlobalListContents, registry_queue_init}; +use wayland_client::protocol::{wl_registry, wl_seat}; +use wayland_client::{ + Connection, Dispatch, EventQueue, Proxy, QueueHandle, delegate_noop, event_created_child, +}; +use wayland_protocols::ext::data_control::v1::client::{ + ext_data_control_device_v1, ext_data_control_manager_v1, ext_data_control_offer_v1, +}; + +/// Upper bound for one transfer. It stays below the GUI host's reply wait so +/// a stalled selection owner fails this read instead of the next request. +const TRANSFER_TIMEOUT: Duration = Duration::from_secs(3); + +/// Text MIME types in smithay-clipboard's preference order, so both read +/// paths choose the same representation of a selection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum TextMime { + TextPlainUtf8, + Utf8String, + TextPlain, +} + +impl TextMime { + fn as_str(self) -> &'static str { + match self { + Self::TextPlainUtf8 => "text/plain;charset=utf-8", + Self::Utf8String => "UTF8_STRING", + Self::TextPlain => "text/plain", + } + } + + /// The first UTF-8 type offered, else plain text as a fallback. + pub(super) fn choose(offered: &[String]) -> Option { + let mut fallback = None; + for mime in offered { + match mime.as_str() { + "text/plain;charset=utf-8" => return Some(Self::TextPlainUtf8), + "UTF8_STRING" => return Some(Self::Utf8String), + "text/plain" => fallback = Some(Self::TextPlain), + _ => {} + } + } + fallback + } + + /// Decode transferred bytes the way smithay-clipboard does: lossy UTF-8, + /// and CR/CRLF line ends normalized to LF for the `text/plain` types. + pub(super) fn decode(self, bytes: &[u8]) -> String { + let text = String::from_utf8_lossy(bytes).into_owned(); + match self { + Self::TextPlainUtf8 | Self::TextPlain => text.replace("\r\n", "\n").replace('\r', "\n"), + Self::Utf8String => text, + } + } +} + +#[derive(Default)] +struct ReaderState { + /// Every offer introduced while the per-read device existed. + offers: Vec, + selection: Option, +} + +#[derive(Default)] +struct OfferMimes(Mutex>); + +impl Dispatch for ReaderState { + fn event( + _: &mut Self, + _: &wl_registry::WlRegistry, + _: wl_registry::Event, + _: &GlobalListContents, + _: &Connection, + _: &QueueHandle, + ) { + } +} + +delegate_noop!(ReaderState: ignore wl_seat::WlSeat); +delegate_noop!(ReaderState: ignore ext_data_control_manager_v1::ExtDataControlManagerV1); + +impl Dispatch for ReaderState { + fn event( + state: &mut Self, + _: &ext_data_control_device_v1::ExtDataControlDeviceV1, + event: ext_data_control_device_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + match event { + ext_data_control_device_v1::Event::DataOffer { id } => state.offers.push(id), + ext_data_control_device_v1::Event::Selection { id } => state.selection = id, + // PRIMARY offers are collected in `offers` and destroyed with + // them; `finished` leaves no selection, which reads as empty. + _ => {} + } + } + + event_created_child!(ReaderState, ext_data_control_device_v1::ExtDataControlDeviceV1, [ + ext_data_control_device_v1::EVT_DATA_OFFER_OPCODE => + (ext_data_control_offer_v1::ExtDataControlOfferV1, OfferMimes::default()), + ]); +} + +impl Dispatch for ReaderState { + fn event( + _: &mut Self, + _: &ext_data_control_offer_v1::ExtDataControlOfferV1, + event: ext_data_control_offer_v1::Event, + mimes: &OfferMimes, + _: &Connection, + _: &QueueHandle, + ) { + if let ext_data_control_offer_v1::Event::Offer { mime_type } = event { + mimes + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(mime_type); + } + } +} + +/// A private event queue on the shared display, bound to data-control. +pub(super) struct DataControlReader { + connection: Connection, + queue: EventQueue, + manager: ext_data_control_manager_v1::ExtDataControlManagerV1, + seat: wl_seat::WlSeat, +} + +impl DataControlReader { + /// Bind data-control on `display`, or `Ok(None)` when the compositor + /// does not offer it (or offers no seat). + /// + /// # Safety + /// + /// `display` must be a live `wl_display` that outlives the reader. + pub(super) unsafe fn connect(display: NonNull) -> Result, String> { + // SAFETY: guaranteed by the caller. A foreign backend never + // disconnects the display it borrows. + let backend = unsafe { Backend::from_foreign_display(display.as_ptr().cast()) }; + let connection = Connection::from_backend(backend); + let (globals, queue) = registry_queue_init::(&connection) + .map_err(|err| format!("data-control registry: {err}"))?; + let handle = queue.handle(); + let manager: ext_data_control_manager_v1::ExtDataControlManagerV1 = + match globals.bind(&handle, 1..=1, ()) { + Ok(manager) => manager, + Err(BindError::NotPresent | BindError::UnsupportedVersion) => return Ok(None), + }; + // Read the first advertised seat. Desktop compositors expose one; + // on a multi-seat wlroots setup this may differ from the seat + // smithay-clipboard last saw active. + let seat = globals.contents().with_list(|list| { + list.iter() + .find(|global| global.interface == wl_seat::WlSeat::interface().name) + .map(|global| global.name) + }); + let Some(seat) = seat else { + manager.destroy(); + return Ok(None); + }; + let seat = globals + .registry() + .bind::(seat, 1, &handle, ()); + Ok(Some(Self { + connection, + queue, + manager, + seat, + })) + } + + /// The current CLIPBOARD selection as text: `Ok(None)` when there is no + /// selection or it offers no text type. + pub(super) fn read_text(&mut self) -> Result, String> { + let mut state = ReaderState::default(); + let device = self + .manager + .get_data_device(&self.seat, &self.queue.handle(), ()); + // The compositor sends the current selection when the device binds. + let result = self + .queue + .roundtrip(&mut state) + .map_err(|err| format!("data-control roundtrip: {err}")) + .and_then(|_| self.transfer(state.selection.as_ref())); + // Collect offers announced during the transfer so they are destroyed + // too; anything still in flight is ignored once the device is gone. + let _ = self.queue.dispatch_pending(&mut state); + for offer in state.offers.drain(..) { + offer.destroy(); + } + device.destroy(); + let _ = self.connection.flush(); + result + } + + fn transfer( + &self, + selection: Option<&ext_data_control_offer_v1::ExtDataControlOfferV1>, + ) -> Result, String> { + let Some(offer) = selection else { + return Ok(None); + }; + let mime = { + let mimes = offer + .data::() + .map(|mimes| mimes.0.lock().unwrap_or_else(|p| p.into_inner()).clone()) + .unwrap_or_default(); + TextMime::choose(&mimes) + }; + let Some(mime) = mime else { + return Ok(None); + }; + let (mut reader, writer) = + std::io::pipe().map_err(|err| format!("data-control pipe: {err}"))?; + offer.receive(mime.as_str().to_owned(), writer.as_fd()); + // Only the selection owner may hold the write end, or EOF never comes. + drop(writer); + match self.connection.flush() { + // A full socket sends the request later; the deadline still holds. + Ok(()) => {} + Err(WaylandError::Io(err)) if err.kind() == ErrorKind::WouldBlock => {} + Err(err) => return Err(format!("data-control flush: {err}")), + } + let bytes = read_to_end_before(&mut reader, Instant::now() + TRANSFER_TIMEOUT)?; + Ok(Some(mime.decode(&bytes))) + } +} + +impl Drop for DataControlReader { + fn drop(&mut self) { + self.manager.destroy(); + let _ = self.connection.flush(); + } +} + +fn read_to_end_before( + reader: &mut std::io::PipeReader, + deadline: Instant, +) -> Result, String> { + let mut bytes = Vec::new(); + let mut chunk = [0_u8; 8192]; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err("data-control transfer timed out".to_owned()); + } + let timeout = Timespec::try_from(remaining).map_err(|err| err.to_string())?; + let mut fds = [PollFd::new(reader, PollFlags::IN)]; + match poll(&mut fds, Some(&timeout)) { + Ok(0) => continue, + Ok(_) => {} + Err(rustix::io::Errno::INTR) => continue, + Err(err) => return Err(format!("data-control poll: {err}")), + } + match reader.read(&mut chunk) { + Ok(0) => return Ok(bytes), + Ok(n) => bytes.extend_from_slice(&chunk[..n]), + Err(err) if err.kind() == ErrorKind::Interrupted => {} + Err(err) => return Err(format!("data-control read: {err}")), + } + } +} + +#[cfg(test)] +#[path = "tests/wayland_data_control_test.rs"] +mod tests;