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;