wl_clipboard_rs/
paste.rs

1//! Getting the offered MIME types and the clipboard contents.
2
3use std::collections::{HashMap, HashSet};
4use std::ffi::OsString;
5use std::io;
6use std::os::fd::AsFd;
7
8use os_pipe::{pipe, PipeReader};
9use wayland_client::globals::GlobalListContents;
10use wayland_client::protocol::wl_registry::WlRegistry;
11use wayland_client::protocol::wl_seat::WlSeat;
12use wayland_client::{
13    delegate_dispatch, event_created_child, ConnectError, Dispatch, DispatchError, EventQueue,
14};
15
16use crate::common::{self, initialize};
17use crate::data_control::{self, impl_dispatch_device, impl_dispatch_manager, impl_dispatch_offer};
18use crate::utils::is_text;
19
20/// The clipboard to operate on.
21#[derive(Copy, Clone, Eq, PartialEq, Debug, Hash, PartialOrd, Ord, Default)]
22#[cfg_attr(test, derive(proptest_derive::Arbitrary))]
23pub enum ClipboardType {
24    /// The regular clipboard.
25    #[default]
26    Regular,
27    /// The "primary" clipboard.
28    ///
29    /// Working with the "primary" clipboard requires the compositor to support ext-data-control,
30    /// or wlr-data-control version 2 or above.
31    Primary,
32}
33
34/// MIME types that can be requested from the clipboard.
35#[derive(Copy, Clone, Eq, PartialEq, Debug, Hash, PartialOrd, Ord)]
36pub enum MimeType<'a> {
37    /// Request any available MIME type.
38    ///
39    /// If multiple MIME types are offered, the requested MIME type is unspecified and depends on
40    /// the order they are received from the Wayland compositor. However, plain text formats are
41    /// prioritized, so if a plain text format is available among others then it will be requested.
42    Any,
43    /// Request a plain text MIME type.
44    ///
45    /// This will request one of the multiple common plain text MIME types. It will prioritize MIME
46    /// types known to return UTF-8 text.
47    Text,
48    /// Request the given MIME type, and if it's not available fall back to `MimeType::Text`.
49    ///
50    /// Example use-case: pasting `text/html` should try `text/html` first, but if it's not
51    /// available, any other plain text format will do fine too.
52    TextWithPriority(&'a str),
53    /// Request a specific MIME type.
54    Specific(&'a str),
55}
56
57/// Seat to operate on.
58#[derive(Copy, Clone, Eq, PartialEq, Debug, Hash, PartialOrd, Ord, Default)]
59pub enum Seat<'a> {
60    /// Operate on one of the existing seats depending on the order returned by the compositor.
61    ///
62    /// This is perfectly fine when only a single seat is present, so for most configurations.
63    #[default]
64    Unspecified,
65    /// Operate on a seat with the given name.
66    Specific(&'a str),
67}
68
69struct State {
70    common: common::State,
71    // The value is the set of MIME types in the offer.
72    // TODO: We never remove offers from here, even if we don't use them or after destroying them.
73    offers: HashMap<data_control::Offer, HashSet<String>>,
74    got_primary_selection: bool,
75}
76
77delegate_dispatch!(State: [WlSeat: ()] => common::State);
78
79impl AsMut<common::State> for State {
80    fn as_mut(&mut self) -> &mut common::State {
81        &mut self.common
82    }
83}
84
85/// Errors that can occur for pasting and listing MIME types.
86///
87/// You may want to ignore some of these errors (rather than show an error message), like
88/// `NoSeats`, `ClipboardEmpty` or `NoMimeType` as they are essentially equivalent to an empty
89/// clipboard.
90#[derive(thiserror::Error, Debug)]
91pub enum Error {
92    #[error("There are no seats")]
93    NoSeats,
94
95    #[error("The clipboard of the requested seat is empty")]
96    ClipboardEmpty,
97
98    #[error("No suitable type of content copied")]
99    NoMimeType,
100
101    #[error("Couldn't open the provided Wayland socket")]
102    SocketOpenError(#[source] io::Error),
103
104    #[error("Couldn't connect to the Wayland compositor")]
105    WaylandConnection(#[source] ConnectError),
106
107    #[error("Wayland compositor communication error")]
108    WaylandCommunication(#[source] DispatchError),
109
110    #[error(
111        "A required Wayland protocol ({} version {}) is not supported by the compositor",
112        name,
113        version
114    )]
115    MissingProtocol { name: &'static str, version: u32 },
116
117    #[error("The compositor does not support primary selection")]
118    PrimarySelectionUnsupported,
119
120    #[error("The requested seat was not found")]
121    SeatNotFound,
122
123    #[error("Couldn't create a pipe for content transfer")]
124    PipeCreation(#[source] io::Error),
125}
126
127impl From<common::Error> for Error {
128    fn from(x: common::Error) -> Self {
129        use common::Error::*;
130
131        match x {
132            SocketOpenError(err) => Error::SocketOpenError(err),
133            WaylandConnection(err) => Error::WaylandConnection(err),
134            WaylandCommunication(err) => Error::WaylandCommunication(err.into()),
135            MissingProtocol { name, version } => Error::MissingProtocol { name, version },
136        }
137    }
138}
139
140impl Dispatch<WlRegistry, GlobalListContents> for State {
141    fn event(
142        _state: &mut Self,
143        _proxy: &WlRegistry,
144        _event: <WlRegistry as wayland_client::Proxy>::Event,
145        _data: &GlobalListContents,
146        _conn: &wayland_client::Connection,
147        _qhandle: &wayland_client::QueueHandle<Self>,
148    ) {
149    }
150}
151
152impl_dispatch_manager!(State);
153
154impl_dispatch_device!(State, WlSeat, |state: &mut Self, event, seat| {
155    match event {
156        Event::DataOffer { id } => {
157            let offer = data_control::Offer::from(id);
158            state.offers.insert(offer, HashSet::new());
159        }
160        Event::Selection { id } => {
161            let offer = id.map(data_control::Offer::from);
162            let seat = state.common.seats.get_mut(seat).unwrap();
163            seat.set_offer(offer);
164        }
165        Event::Finished => {
166            // Destroy the device stored in the seat as it's no longer valid.
167            let seat = state.common.seats.get_mut(seat).unwrap();
168            seat.set_device(None);
169        }
170        Event::PrimarySelection { id } => {
171            let offer = id.map(data_control::Offer::from);
172            state.got_primary_selection = true;
173            let seat = state.common.seats.get_mut(seat).unwrap();
174            seat.set_primary_offer(offer);
175        }
176        _ => (),
177    }
178});
179
180impl_dispatch_offer!(State, |state: &mut Self,
181                             offer: data_control::Offer,
182                             event| {
183    if let Event::Offer { mime_type } = event {
184        state.offers.get_mut(&offer).unwrap().insert(mime_type);
185    }
186});
187
188fn get_offer(
189    primary: bool,
190    seat: Seat<'_>,
191    socket_name: Option<OsString>,
192) -> Result<(EventQueue<State>, State, data_control::Offer), Error> {
193    let (mut queue, mut common) = initialize(primary, socket_name)?;
194
195    // Check if there are no seats.
196    if common.seats.is_empty() {
197        return Err(Error::NoSeats);
198    }
199
200    // Go through the seats and get their data devices.
201    for (seat, data) in &mut common.seats {
202        let device = common
203            .clipboard_manager
204            .get_data_device(seat, &queue.handle(), seat.clone());
205        data.set_device(Some(device));
206    }
207
208    let mut state = State {
209        common,
210        offers: HashMap::new(),
211        got_primary_selection: false,
212    };
213
214    // Retrieve all seat names and offers.
215    queue
216        .roundtrip(&mut state)
217        .map_err(Error::WaylandCommunication)?;
218
219    // Check if the compositor supports primary selection.
220    if primary && !state.got_primary_selection {
221        return Err(Error::PrimarySelectionUnsupported);
222    }
223
224    // Figure out which offer we're interested in.
225    let data = match seat {
226        Seat::Unspecified => state.common.seats.values().next(),
227        Seat::Specific(name) => state
228            .common
229            .seats
230            .values()
231            .find(|data| data.name.as_deref() == Some(name)),
232    };
233
234    let Some(data) = data else {
235        return Err(Error::SeatNotFound);
236    };
237
238    let offer = if primary {
239        &data.primary_offer
240    } else {
241        &data.offer
242    };
243
244    // Check if we found anything.
245    match offer.clone() {
246        Some(offer) => Ok((queue, state, offer)),
247        None => Err(Error::ClipboardEmpty),
248    }
249}
250
251/// Retrieves the offered MIME types.
252///
253/// If `seat` is `None`, uses an unspecified seat (it depends on the order returned by the
254/// compositor). This is perfectly fine when only a single seat is present, so for most
255/// configurations.
256///
257/// # Examples
258///
259/// ```no_run
260/// # extern crate wl_clipboard_rs;
261/// # use wl_clipboard_rs::paste::Error;
262/// # fn foo() -> Result<(), Error> {
263/// use wl_clipboard_rs::{paste::{get_mime_types, ClipboardType, Seat}};
264///
265/// let mime_types = get_mime_types(ClipboardType::Regular, Seat::Unspecified)?;
266/// for mime_type in mime_types {
267///     println!("{}", mime_type);
268/// }
269/// # Ok(())
270/// # }
271/// ```
272#[inline]
273pub fn get_mime_types(clipboard: ClipboardType, seat: Seat<'_>) -> Result<HashSet<String>, Error> {
274    get_mime_types_internal(clipboard, seat, None)
275}
276
277// The internal function accepts the socket name, used for tests.
278pub(crate) fn get_mime_types_internal(
279    clipboard: ClipboardType,
280    seat: Seat<'_>,
281    socket_name: Option<OsString>,
282) -> Result<HashSet<String>, Error> {
283    let primary = clipboard == ClipboardType::Primary;
284    let (_, mut state, offer) = get_offer(primary, seat, socket_name)?;
285    Ok(state.offers.remove(&offer).unwrap())
286}
287
288/// Retrieves the clipboard contents.
289///
290/// This function returns a tuple of the reading end of a pipe containing the clipboard contents
291/// and the actual MIME type of the contents.
292///
293/// If `seat` is `None`, uses an unspecified seat (it depends on the order returned by the
294/// compositor). This is perfectly fine when only a single seat is present, so for most
295/// configurations.
296///
297/// # Examples
298///
299/// ```no_run
300/// # extern crate wl_clipboard_rs;
301/// # fn foo() -> Result<(), Box<dyn std::error::Error>> {
302/// use std::io::Read;
303/// use wl_clipboard_rs::{paste::{get_contents, ClipboardType, Error, MimeType, Seat}};
304///
305/// let result = get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Any);
306/// match result {
307///     Ok((mut pipe, mime_type)) => {
308///         println!("Got data of the {} MIME type", &mime_type);
309///
310///         let mut contents = vec![];
311///         pipe.read_to_end(&mut contents)?;
312///         println!("Read {} bytes of data", contents.len());
313///     }
314///
315///     Err(Error::NoSeats) | Err(Error::ClipboardEmpty) | Err(Error::NoMimeType) => {
316///         // The clipboard is empty, nothing to worry about.
317///     }
318///
319///     Err(err) => Err(err)?
320/// }
321/// # Ok(())
322/// # }
323/// ```
324#[inline]
325pub fn get_contents(
326    clipboard: ClipboardType,
327    seat: Seat<'_>,
328    mime_type: MimeType<'_>,
329) -> Result<(PipeReader, String), Error> {
330    get_contents_internal(clipboard, seat, mime_type, None)
331}
332
333// The internal function accepts the socket name, used for tests.
334pub(crate) fn get_contents_internal(
335    clipboard: ClipboardType,
336    seat: Seat<'_>,
337    mime_type: MimeType<'_>,
338    socket_name: Option<OsString>,
339) -> Result<(PipeReader, String), Error> {
340    let primary = clipboard == ClipboardType::Primary;
341    let (mut queue, mut state, offer) = get_offer(primary, seat, socket_name)?;
342
343    let mut mime_types = state.offers.remove(&offer).unwrap();
344
345    // Find the desired MIME type.
346    let mime_type = match mime_type {
347        MimeType::Any => mime_types
348            .take("text/plain;charset=utf-8")
349            .or_else(|| mime_types.take("UTF8_STRING"))
350            .or_else(|| mime_types.iter().find(|x| is_text(x)).cloned())
351            .or_else(|| mime_types.drain().next()),
352        MimeType::Text => mime_types
353            .take("text/plain;charset=utf-8")
354            .or_else(|| mime_types.take("UTF8_STRING"))
355            .or_else(|| mime_types.drain().find(|x| is_text(x))),
356        MimeType::TextWithPriority(priority) => mime_types
357            .take(priority)
358            .or_else(|| mime_types.take("text/plain;charset=utf-8"))
359            .or_else(|| mime_types.take("UTF8_STRING"))
360            .or_else(|| mime_types.drain().find(|x| is_text(x))),
361        MimeType::Specific(mime_type) => mime_types.take(mime_type),
362    };
363
364    // Check if a suitable MIME type is copied.
365    if mime_type.is_none() {
366        return Err(Error::NoMimeType);
367    }
368
369    let mime_type = mime_type.unwrap();
370
371    // Create a pipe for content transfer.
372    let (read, write) = pipe().map_err(Error::PipeCreation)?;
373
374    // Start the transfer.
375    offer.receive(mime_type.clone(), write.as_fd());
376    drop(write);
377
378    // A flush() is not enough here, it will result in sometimes pasting empty contents. I suspect this is due to a
379    // race between the compositor reacting to the receive request, and the compositor reacting to wl-paste
380    // disconnecting after queue is dropped. The roundtrip solves that race.
381    queue
382        .roundtrip(&mut state)
383        .map_err(Error::WaylandCommunication)?;
384
385    Ok((read, mime_type))
386}