@@ -1,4 +1,3 @@
-use crate::platform::scap_screen_capture::scap_screen_sources;
use core::str;
use std::{
cell::RefCell,
@@ -41,8 +40,9 @@ use xkbc::x11::ffi::{XKB_X11_MIN_MAJOR_XKB_VERSION, XKB_X11_MIN_MINOR_XKB_VERSIO
use xkbcommon::xkb::{self as xkbc, LayoutIndex, ModMask, STATE_LAYOUT_EFFECTIVE};
use super::{
- ButtonOrScroll, ScrollDirection, button_or_scroll_from_event_detail, get_valuator_axis_index,
- modifiers_from_state, pressed_button_from_mask,
+ ButtonOrScroll, ScrollDirection, button_or_scroll_from_event_detail,
+ clipboard::{self, Clipboard},
+ get_valuator_axis_index, modifiers_from_state, pressed_button_from_mask,
};
use super::{X11Display, X11WindowStatePtr, XcbAtoms};
use super::{XimCallbackEvent, XimHandler};
@@ -56,6 +56,7 @@ use crate::platform::{
reveal_path_internal,
xdg_desktop_portal::{Event as XDPEvent, XDPEventSource},
},
+ scap_screen_capture::scap_screen_sources,
};
use crate::{
AnyWindowHandle, Bounds, ClipboardItem, CursorStyle, DisplayId, FileDropEvent, Keystroke,
@@ -201,7 +202,7 @@ pub struct X11ClientState {
pointer_device_states: BTreeMap<xinput::DeviceId, PointerDeviceState>,
pub(crate) common: LinuxCommon,
- pub(crate) clipboard: x11_clipboard::Clipboard,
+ pub(crate) clipboard: Clipboard,
pub(crate) clipboard_item: Option<ClipboardItem>,
pub(crate) xdnd_state: Xdnd,
}
@@ -388,7 +389,7 @@ impl X11Client {
.reply()
.unwrap();
- let clipboard = x11_clipboard::Clipboard::new().unwrap();
+ let clipboard = Clipboard::new().unwrap();
let xcb_connection = Rc::new(xcb_connection);
@@ -1496,39 +1497,36 @@ impl LinuxClient for X11Client {
let state = self.0.borrow_mut();
state
.clipboard
- .store(
- state.clipboard.setter.atoms.primary,
- state.clipboard.setter.atoms.utf8_string,
- item.text().unwrap_or_default().as_bytes(),
+ .set_text(
+ std::borrow::Cow::Owned(item.text().unwrap_or_default()),
+ clipboard::ClipboardKind::Primary,
+ clipboard::WaitConfig::None,
)
- .ok();
+ .context("Failed to write to clipboard (primary)")
+ .log_with_level(log::Level::Debug);
}
fn write_to_clipboard(&self, item: crate::ClipboardItem) {
let mut state = self.0.borrow_mut();
state
.clipboard
- .store(
- state.clipboard.setter.atoms.clipboard,
- state.clipboard.setter.atoms.utf8_string,
- item.text().unwrap_or_default().as_bytes(),
+ .set_text(
+ std::borrow::Cow::Owned(item.text().unwrap_or_default()),
+ clipboard::ClipboardKind::Clipboard,
+ clipboard::WaitConfig::None,
)
- .ok();
+ .context("Failed to write to clipboard (clipboard)")
+ .log_with_level(log::Level::Debug);
state.clipboard_item.replace(item);
}
fn read_from_primary(&self) -> Option<crate::ClipboardItem> {
let state = self.0.borrow_mut();
- state
+ return state
.clipboard
- .load(
- state.clipboard.getter.atoms.primary,
- state.clipboard.getter.atoms.utf8_string,
- state.clipboard.getter.atoms.property,
- Duration::from_secs(3),
- )
- .map(|text| crate::ClipboardItem::new_string(String::from_utf8(text).unwrap()))
- .ok()
+ .get_any(clipboard::ClipboardKind::Primary)
+ .context("Failed to read from clipboard (primary)")
+ .log_with_level(log::Level::Debug);
}
fn read_from_clipboard(&self) -> Option<crate::ClipboardItem> {
@@ -1537,26 +1535,15 @@ impl LinuxClient for X11Client {
// which has metadata attached.
if state
.clipboard
- .setter
- .connection
- .get_selection_owner(state.clipboard.setter.atoms.clipboard)
- .ok()
- .and_then(|r| r.reply().ok())
- .map(|reply| reply.owner == state.clipboard.setter.window)
- .unwrap_or(false)
+ .is_owner(clipboard::ClipboardKind::Clipboard)
{
return state.clipboard_item.clone();
}
- state
+ return state
.clipboard
- .load(
- state.clipboard.getter.atoms.clipboard,
- state.clipboard.getter.atoms.utf8_string,
- state.clipboard.getter.atoms.property,
- Duration::from_secs(3),
- )
- .map(|text| crate::ClipboardItem::new_string(String::from_utf8(text).unwrap()))
- .ok()
+ .get_any(clipboard::ClipboardKind::Clipboard)
+ .context("Failed to read from clipboard (clipboard)")
+ .log_with_level(log::Level::Debug);
}
fn run(&self) {
@@ -0,0 +1,1196 @@
+/*
+ * Copyright 2022 - 2025 Zed Industries, Inc.
+ * License: Apache-2.0
+ * See LICENSE-APACHE for complete license terms
+ *
+ * Adapted from the x11 submodule of the arboard project https://github.com/1Password/arboard
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ * Copyright 2022 The Arboard contributors
+ *
+ * The project to which this file belongs is licensed under either of
+ * the Apache 2.0 or the MIT license at the licensee's choice. The terms
+ * and conditions of the chosen license apply to this file.
+*/
+
+// More info about using the clipboard on X11:
+// https://tronche.com/gui/x/icccm/sec-2.html#s-2.6
+// https://freedesktop.org/wiki/ClipboardManager/
+
+use std::{
+ borrow::Cow,
+ cell::RefCell,
+ collections::{HashMap, hash_map::Entry},
+ sync::{
+ Arc,
+ atomic::{AtomicBool, Ordering},
+ },
+ thread::JoinHandle,
+ thread_local,
+ time::{Duration, Instant},
+};
+
+use parking_lot::{Condvar, Mutex, MutexGuard, RwLock};
+use x11rb::{
+ COPY_DEPTH_FROM_PARENT, COPY_FROM_PARENT, NONE,
+ connection::Connection,
+ protocol::{
+ Event,
+ xproto::{
+ Atom, AtomEnum, ConnectionExt as _, CreateWindowAux, EventMask, PropMode, Property,
+ PropertyNotifyEvent, SELECTION_NOTIFY_EVENT, SelectionNotifyEvent,
+ SelectionRequestEvent, Time, WindowClass,
+ },
+ },
+ rust_connection::RustConnection,
+ wrapper::ConnectionExt as _,
+};
+
+use crate::{ClipboardItem, Image, ImageFormat, hash};
+
+type Result<T, E = Error> = std::result::Result<T, E>;
+
+static CLIPBOARD: Mutex<Option<GlobalClipboard>> = parking_lot::const_mutex(None);
+
+x11rb::atom_manager! {
+ pub Atoms: AtomCookies {
+ CLIPBOARD,
+ PRIMARY,
+ SECONDARY,
+
+ CLIPBOARD_MANAGER,
+ SAVE_TARGETS,
+ TARGETS,
+ ATOM,
+ INCR,
+
+ UTF8_STRING,
+ UTF8_MIME_0: b"text/plain;charset=utf-8",
+ UTF8_MIME_1: b"text/plain;charset=UTF-8",
+ // Text in ISO Latin-1 encoding
+ // See: https://tronche.com/gui/x/icccm/sec-2.html#s-2.6.2
+ STRING,
+ // Text in unknown encoding
+ // See: https://tronche.com/gui/x/icccm/sec-2.html#s-2.6.2
+ TEXT,
+ TEXT_MIME_UNKNOWN: b"text/plain",
+
+ // HTML: b"text/html",
+ // URI_LIST: b"text/uri-list",
+
+ PNG__MIME: ImageFormat::mime_type(ImageFormat::Png ).as_bytes(),
+ JPEG_MIME: ImageFormat::mime_type(ImageFormat::Jpeg).as_bytes(),
+ WEBP_MIME: ImageFormat::mime_type(ImageFormat::Webp).as_bytes(),
+ GIF__MIME: ImageFormat::mime_type(ImageFormat::Gif ).as_bytes(),
+ SVG__MIME: ImageFormat::mime_type(ImageFormat::Svg ).as_bytes(),
+ BMP__MIME: ImageFormat::mime_type(ImageFormat::Bmp ).as_bytes(),
+ TIFF_MIME: ImageFormat::mime_type(ImageFormat::Tiff).as_bytes(),
+
+ // This is just some random name for the property on our window, into which
+ // the clipboard owner writes the data we requested.
+ ARBOARD_CLIPBOARD,
+ }
+}
+
+thread_local! {
+ static ATOM_NAME_CACHE: RefCell<HashMap<Atom, &'static str>> = Default::default();
+}
+
+// Some clipboard items, like images, may take a very long time to produce a
+// `SelectionNotify`. Multiple seconds long.
+const LONG_TIMEOUT_DUR: Duration = Duration::from_millis(4000);
+const SHORT_TIMEOUT_DUR: Duration = Duration::from_millis(10);
+
+#[derive(Debug, PartialEq, Eq)]
+enum ManagerHandoverState {
+ Idle,
+ InProgress,
+ Finished,
+}
+
+struct GlobalClipboard {
+ inner: Arc<Inner>,
+
+ /// Join handle to the thread which serves selection requests.
+ server_handle: JoinHandle<()>,
+}
+
+struct XContext {
+ conn: RustConnection,
+ win_id: u32,
+}
+
+struct Inner {
+ /// The context for the thread which serves clipboard read
+ /// requests coming to us.
+ server: XContext,
+ atoms: Atoms,
+
+ clipboard: Selection,
+ primary: Selection,
+ secondary: Selection,
+
+ handover_state: Mutex<ManagerHandoverState>,
+ handover_cv: Condvar,
+
+ serve_stopped: AtomicBool,
+}
+
+impl XContext {
+ fn new() -> Result<Self> {
+ // create a new connection to an X11 server
+ let (conn, screen_num): (RustConnection, _) =
+ RustConnection::connect(None).map_err(|_| {
+ Error::unknown("X11 server connection timed out because it was unreachable")
+ })?;
+ let screen = conn
+ .setup()
+ .roots
+ .get(screen_num)
+ .ok_or(Error::unknown("no screen found"))?;
+ let win_id = conn.generate_id().map_err(into_unknown)?;
+
+ let event_mask =
+ // Just in case that some program reports SelectionNotify events
+ // with XCB_EVENT_MASK_PROPERTY_CHANGE mask.
+ EventMask::PROPERTY_CHANGE |
+ // To receive DestroyNotify event and stop the message loop.
+ EventMask::STRUCTURE_NOTIFY;
+ // create the window
+ conn.create_window(
+ // copy as much as possible from the parent, because no other specific input is needed
+ COPY_DEPTH_FROM_PARENT,
+ win_id,
+ screen.root,
+ 0,
+ 0,
+ 1,
+ 1,
+ 0,
+ WindowClass::COPY_FROM_PARENT,
+ COPY_FROM_PARENT,
+ // don't subscribe to any special events because we are requesting everything we need ourselves
+ &CreateWindowAux::new().event_mask(event_mask),
+ )
+ .map_err(into_unknown)?;
+ conn.flush().map_err(into_unknown)?;
+
+ Ok(Self { conn, win_id })
+ }
+}
+
+#[derive(Default)]
+struct Selection {
+ data: RwLock<Option<Vec<ClipboardData>>>,
+ /// Mutex around nothing to use with the below condvar.
+ mutex: Mutex<()>,
+ /// A condvar that is notified when the contents of this clipboard are changed.
+ ///
+ /// This is associated with `Self::mutex`.
+ data_changed: Condvar,
+}
+
+#[derive(Debug, Clone)]
+struct ClipboardData {
+ bytes: Vec<u8>,
+
+ /// The atom representing the format in which the data is encoded.
+ format: Atom,
+}
+
+enum ReadSelNotifyResult {
+ GotData(Vec<u8>),
+ IncrStarted,
+ EventNotRecognized,
+}
+
+impl Inner {
+ fn new() -> Result<Self> {
+ let server = XContext::new()?;
+ let atoms = Atoms::new(&server.conn)
+ .map_err(into_unknown)?
+ .reply()
+ .map_err(into_unknown)?;
+
+ Ok(Self {
+ server,
+ atoms,
+ clipboard: Selection::default(),
+ primary: Selection::default(),
+ secondary: Selection::default(),
+ handover_state: Mutex::new(ManagerHandoverState::Idle),
+ handover_cv: Condvar::new(),
+ serve_stopped: AtomicBool::new(false),
+ })
+ }
+
+ fn write(
+ &self,
+ data: Vec<ClipboardData>,
+ selection: ClipboardKind,
+ wait: WaitConfig,
+ ) -> Result<()> {
+ if self.serve_stopped.load(Ordering::Relaxed) {
+ return Err(Error::unknown(
+ "The clipboard handler thread seems to have stopped. Logging messages may reveal the cause. (See the `log` crate.)",
+ ));
+ }
+
+ let server_win = self.server.win_id;
+
+ // ICCCM version 2, section 2.6.1.3 states that we should re-assert ownership whenever data
+ // changes.
+ self.server
+ .conn
+ .set_selection_owner(server_win, self.atom_of(selection), Time::CURRENT_TIME)
+ .map_err(|_| Error::ClipboardOccupied)?;
+
+ self.server.conn.flush().map_err(into_unknown)?;
+
+ // Just setting the data, and the `serve_requests` will take care of the rest.
+ let selection = self.selection_of(selection);
+ let mut data_guard = selection.data.write();
+ *data_guard = Some(data);
+
+ // Lock the mutex to both ensure that no wakers of `data_changed` can wake us between
+ // dropping the `data_guard` and calling `wait[_for]` and that we don't we wake other
+ // threads in that position.
+ let mut guard = selection.mutex.lock();
+
+ // Notify any existing waiting threads that we have changed the data in the selection.
+ // It is important that the mutex is locked to prevent this notification getting lost.
+ selection.data_changed.notify_all();
+
+ match wait {
+ WaitConfig::None => {}
+ WaitConfig::Forever => {
+ drop(data_guard);
+ selection.data_changed.wait(&mut guard);
+ }
+ WaitConfig::Until(deadline) => {
+ drop(data_guard);
+ selection.data_changed.wait_until(&mut guard, deadline);
+ }
+ }
+
+ Ok(())
+ }
+
+ /// `formats` must be a slice of atoms, where each atom represents a target format.
+ /// The first format from `formats`, which the clipboard owner supports will be the
+ /// format of the return value.
+ fn read(&self, formats: &[Atom], selection: ClipboardKind) -> Result<ClipboardData> {
+ // if we are the current owner, we can get the current clipboard ourselves
+ if self.is_owner(selection)? {
+ let data = self.selection_of(selection).data.read();
+ if let Some(data_list) = &*data {
+ for data in data_list {
+ for format in formats {
+ if *format == data.format {
+ return Ok(data.clone());
+ }
+ }
+ }
+ }
+ return Err(Error::ContentNotAvailable);
+ }
+ let reader = XContext::new()?;
+
+ log::trace!("Trying to get the clipboard data.");
+ for format in formats {
+ match self.read_single(&reader, selection, *format) {
+ Ok(bytes) => {
+ return Ok(ClipboardData {
+ bytes,
+ format: *format,
+ });
+ }
+ Err(Error::ContentNotAvailable) => {
+ continue;
+ }
+ Err(e) => return Err(e),
+ }
+ }
+ Err(Error::ContentNotAvailable)
+ }
+
+ fn read_single(
+ &self,
+ reader: &XContext,
+ selection: ClipboardKind,
+ target_format: Atom,
+ ) -> Result<Vec<u8>> {
+ // Delete the property so that we can detect (using property notify)
+ // when the selection owner receives our request.
+ reader
+ .conn
+ .delete_property(reader.win_id, self.atoms.ARBOARD_CLIPBOARD)
+ .map_err(into_unknown)?;
+
+ // request to convert the clipboard selection to our data type(s)
+ reader
+ .conn
+ .convert_selection(
+ reader.win_id,
+ self.atom_of(selection),
+ target_format,
+ self.atoms.ARBOARD_CLIPBOARD,
+ Time::CURRENT_TIME,
+ )
+ .map_err(into_unknown)?;
+ reader.conn.sync().map_err(into_unknown)?;
+
+ log::trace!("Finished `convert_selection`");
+
+ let mut incr_data: Vec<u8> = Vec::new();
+ let mut using_incr = false;
+
+ let mut timeout_end = Instant::now() + LONG_TIMEOUT_DUR;
+
+ while Instant::now() < timeout_end {
+ let event = reader.conn.poll_for_event().map_err(into_unknown)?;
+ let event = match event {
+ Some(e) => e,
+ None => {
+ std::thread::sleep(Duration::from_millis(1));
+ continue;
+ }
+ };
+ match event {
+ // The first response after requesting a selection.
+ Event::SelectionNotify(event) => {
+ log::trace!("Read SelectionNotify");
+ let result = self.handle_read_selection_notify(
+ reader,
+ target_format,
+ &mut using_incr,
+ &mut incr_data,
+ event,
+ )?;
+ match result {
+ ReadSelNotifyResult::GotData(data) => return Ok(data),
+ ReadSelNotifyResult::IncrStarted => {
+ // This means we received an indication that an the
+ // data is going to be sent INCRementally. Let's
+ // reset our timeout.
+ timeout_end += SHORT_TIMEOUT_DUR;
+ }
+ ReadSelNotifyResult::EventNotRecognized => (),
+ }
+ }
+ // If the previous SelectionNotify event specified that the data
+ // will be sent in INCR segments, each segment is transferred in
+ // a PropertyNotify event.
+ Event::PropertyNotify(event) => {
+ let result = self.handle_read_property_notify(
+ reader,
+ target_format,
+ using_incr,
+ &mut incr_data,
+ &mut timeout_end,
+ event,
+ )?;
+ if result {
+ return Ok(incr_data);
+ }
+ }
+ _ => log::trace!("An unexpected event arrived while reading the clipboard."),
+ }
+ }
+ log::info!("Time-out hit while reading the clipboard.");
+ Err(Error::ContentNotAvailable)
+ }
+
+ fn atom_of(&self, selection: ClipboardKind) -> Atom {
+ match selection {
+ ClipboardKind::Clipboard => self.atoms.CLIPBOARD,
+ ClipboardKind::Primary => self.atoms.PRIMARY,
+ ClipboardKind::Secondary => self.atoms.SECONDARY,
+ }
+ }
+
+ fn selection_of(&self, selection: ClipboardKind) -> &Selection {
+ match selection {
+ ClipboardKind::Clipboard => &self.clipboard,
+ ClipboardKind::Primary => &self.primary,
+ ClipboardKind::Secondary => &self.secondary,
+ }
+ }
+
+ fn kind_of(&self, atom: Atom) -> Option<ClipboardKind> {
+ match atom {
+ a if a == self.atoms.CLIPBOARD => Some(ClipboardKind::Clipboard),
+ a if a == self.atoms.PRIMARY => Some(ClipboardKind::Primary),
+ a if a == self.atoms.SECONDARY => Some(ClipboardKind::Secondary),
+ _ => None,
+ }
+ }
+
+ fn is_owner(&self, selection: ClipboardKind) -> Result<bool> {
+ let current = self
+ .server
+ .conn
+ .get_selection_owner(self.atom_of(selection))
+ .map_err(into_unknown)?
+ .reply()
+ .map_err(into_unknown)?
+ .owner;
+
+ Ok(current == self.server.win_id)
+ }
+
+ fn atom_name(&self, atom: x11rb::protocol::xproto::Atom) -> Result<String> {
+ String::from_utf8(
+ self.server
+ .conn
+ .get_atom_name(atom)
+ .map_err(into_unknown)?
+ .reply()
+ .map_err(into_unknown)?
+ .name,
+ )
+ .map_err(into_unknown)
+ }
+
+ fn atom_name_dbg(&self, atom: x11rb::protocol::xproto::Atom) -> &'static str {
+ ATOM_NAME_CACHE.with(|cache| {
+ let mut cache = cache.borrow_mut();
+ match cache.entry(atom) {
+ Entry::Occupied(entry) => *entry.get(),
+ Entry::Vacant(entry) => {
+ let s = self
+ .atom_name(atom)
+ .map(|s| Box::leak(s.into_boxed_str()) as &str)
+ .unwrap_or("FAILED-TO-GET-THE-ATOM-NAME");
+ entry.insert(s);
+ s
+ }
+ }
+ })
+ }
+
+ fn handle_read_selection_notify(
+ &self,
+ reader: &XContext,
+ target_format: u32,
+ using_incr: &mut bool,
+ incr_data: &mut Vec<u8>,
+ event: SelectionNotifyEvent,
+ ) -> Result<ReadSelNotifyResult> {
+ // The property being set to NONE means that the `convert_selection`
+ // failed.
+
+ // According to: https://tronche.com/gui/x/icccm/sec-2.html#s-2.4
+ // the target must be set to the same as what we requested.
+ if event.property == NONE || event.target != target_format {
+ return Err(Error::ContentNotAvailable);
+ }
+ if self.kind_of(event.selection).is_none() {
+ log::info!(
+ "Received a SelectionNotify for a selection other than CLIPBOARD, PRIMARY or SECONDARY. This is unexpected."
+ );
+ return Ok(ReadSelNotifyResult::EventNotRecognized);
+ }
+ if *using_incr {
+ log::warn!("Received a SelectionNotify while already expecting INCR segments.");
+ return Ok(ReadSelNotifyResult::EventNotRecognized);
+ }
+ // request the selection
+ let mut reply = reader
+ .conn
+ .get_property(
+ true,
+ event.requestor,
+ event.property,
+ event.target,
+ 0,
+ u32::MAX / 4,
+ )
+ .map_err(into_unknown)?
+ .reply()
+ .map_err(into_unknown)?;
+
+ //log::trace!("Property.type: {:?}", self.atom_name(reply.type_));
+
+ // we found something
+ if reply.type_ == target_format {
+ Ok(ReadSelNotifyResult::GotData(reply.value))
+ } else if reply.type_ == self.atoms.INCR {
+ // Note that we call the get_property again because we are
+ // indicating that we are ready to receive the data by deleting the
+ // property, however deleting only works if the type matches the
+ // property type. But the type didn't match in the previous call.
+ reply = reader
+ .conn
+ .get_property(
+ true,
+ event.requestor,
+ event.property,
+ self.atoms.INCR,
+ 0,
+ u32::MAX / 4,
+ )
+ .map_err(into_unknown)?
+ .reply()
+ .map_err(into_unknown)?;
+ log::trace!("Receiving INCR segments");
+ *using_incr = true;
+ if reply.value_len == 4 {
+ let min_data_len = reply
+ .value32()
+ .and_then(|mut vals| vals.next())
+ .unwrap_or(0);
+ incr_data.reserve(min_data_len as usize);
+ }
+ Ok(ReadSelNotifyResult::IncrStarted)
+ } else {
+ // this should never happen, we have sent a request only for supported types
+ Err(Error::unknown("incorrect type received from clipboard"))
+ }
+ }
+
+ /// Returns Ok(true) when the incr_data is ready
+ fn handle_read_property_notify(
+ &self,
+ reader: &XContext,
+ target_format: u32,
+ using_incr: bool,
+ incr_data: &mut Vec<u8>,
+ timeout_end: &mut Instant,
+ event: PropertyNotifyEvent,
+ ) -> Result<bool> {
+ if event.atom != self.atoms.ARBOARD_CLIPBOARD || event.state != Property::NEW_VALUE {
+ return Ok(false);
+ }
+ if !using_incr {
+ // This must mean the selection owner received our request, and is
+ // now preparing the data
+ return Ok(false);
+ }
+ let reply = reader
+ .conn
+ .get_property(
+ true,
+ event.window,
+ event.atom,
+ target_format,
+ 0,
+ u32::MAX / 4,
+ )
+ .map_err(into_unknown)?
+ .reply()
+ .map_err(into_unknown)?;
+
+ // log::trace!("Received segment. value_len {}", reply.value_len,);
+ if reply.value_len == 0 {
+ // This indicates that all the data has been sent.
+ return Ok(true);
+ }
+ incr_data.extend(reply.value);
+
+ // Let's reset our timeout, since we received a valid chunk.
+ *timeout_end = Instant::now() + SHORT_TIMEOUT_DUR;
+
+ // Not yet complete
+ Ok(false)
+ }
+
+ fn handle_selection_request(&self, event: SelectionRequestEvent) -> Result<()> {
+ let selection = match self.kind_of(event.selection) {
+ Some(kind) => kind,
+ None => {
+ log::warn!(
+ "Received a selection request to a selection other than the CLIPBOARD, PRIMARY or SECONDARY. This is unexpected."
+ );
+ return Ok(());
+ }
+ };
+
+ let success;
+ // we are asked for a list of supported conversion targets
+ if event.target == self.atoms.TARGETS {
+ log::trace!(
+ "Handling TARGETS, dst property is {}",
+ self.atom_name_dbg(event.property)
+ );
+ let mut targets = Vec::with_capacity(10);
+ targets.push(self.atoms.TARGETS);
+ targets.push(self.atoms.SAVE_TARGETS);
+ let data = self.selection_of(selection).data.read();
+ if let Some(data_list) = &*data {
+ for data in data_list {
+ targets.push(data.format);
+ if data.format == self.atoms.UTF8_STRING {
+ // When we are storing a UTF8 string,
+ // add all equivalent formats to the supported targets
+ targets.push(self.atoms.UTF8_MIME_0);
+ targets.push(self.atoms.UTF8_MIME_1);
+ }
+ }
+ }
+ self.server
+ .conn
+ .change_property32(
+ PropMode::REPLACE,
+ event.requestor,
+ event.property,
+ // TODO: change to `AtomEnum::ATOM`
+ self.atoms.ATOM,
+ &targets,
+ )
+ .map_err(into_unknown)?;
+ self.server.conn.flush().map_err(into_unknown)?;
+ success = true;
+ } else {
+ log::trace!("Handling request for (probably) the clipboard contents.");
+ let data = self.selection_of(selection).data.read();
+ if let Some(data_list) = &*data {
+ success = match data_list.iter().find(|d| d.format == event.target) {
+ Some(data) => {
+ self.server
+ .conn
+ .change_property8(
+ PropMode::REPLACE,
+ event.requestor,
+ event.property,
+ event.target,
+ &data.bytes,
+ )
+ .map_err(into_unknown)?;
+ self.server.conn.flush().map_err(into_unknown)?;
+ true
+ }
+ None => false,
+ };
+ } else {
+ // This must mean that we lost ownership of the data
+ // since the other side requested the selection.
+ // Let's respond with the property set to none.
+ success = false;
+ }
+ }
+ // on failure we notify the requester of it
+ let property = if success {
+ event.property
+ } else {
+ AtomEnum::NONE.into()
+ };
+ // tell the requestor that we finished sending data
+ self.server
+ .conn
+ .send_event(
+ false,
+ event.requestor,
+ EventMask::NO_EVENT,
+ SelectionNotifyEvent {
+ response_type: SELECTION_NOTIFY_EVENT,
+ sequence: event.sequence,
+ time: event.time,
+ requestor: event.requestor,
+ selection: event.selection,
+ target: event.target,
+ property,
+ },
+ )
+ .map_err(into_unknown)?;
+
+ self.server.conn.flush().map_err(into_unknown)
+ }
+
+ fn ask_clipboard_manager_to_request_our_data(&self) -> Result<()> {
+ if self.server.win_id == 0 {
+ // This shouldn't really ever happen but let's just check.
+ log::error!("The server's window id was 0. This is unexpected");
+ return Ok(());
+ }
+
+ if !self.is_owner(ClipboardKind::Clipboard)? {
+ // We are not owning the clipboard, nothing to do.
+ return Ok(());
+ }
+ if self
+ .selection_of(ClipboardKind::Clipboard)
+ .data
+ .read()
+ .is_none()
+ {
+ // If we don't have any data, there's nothing to do.
+ return Ok(());
+ }
+
+ // It's important that we lock the state before sending the request
+ // because we don't want the request server thread to lock the state
+ // after the request but before we can lock it here.
+ let mut handover_state = self.handover_state.lock();
+
+ log::trace!("Sending the data to the clipboard manager");
+ self.server
+ .conn
+ .convert_selection(
+ self.server.win_id,
+ self.atoms.CLIPBOARD_MANAGER,
+ self.atoms.SAVE_TARGETS,
+ self.atoms.ARBOARD_CLIPBOARD,
+ Time::CURRENT_TIME,
+ )
+ .map_err(into_unknown)?;
+ self.server.conn.flush().map_err(into_unknown)?;
+
+ *handover_state = ManagerHandoverState::InProgress;
+ let max_handover_duration = Duration::from_millis(100);
+
+ // Note that we are using a parking_lot condvar here, which doesn't wake up
+ // spuriously
+ let result = self
+ .handover_cv
+ .wait_for(&mut handover_state, max_handover_duration);
+
+ if *handover_state == ManagerHandoverState::Finished {
+ return Ok(());
+ }
+ if result.timed_out() {
+ log::warn!(
+ "Could not hand the clipboard contents over to the clipboard manager. The request timed out."
+ );
+ return Ok(());
+ }
+
+ Err(Error::unknown(
+ "The handover was not finished and the condvar didn't time out, yet the condvar wait ended. This should be unreachable.",
+ ))
+ }
+}
+
+fn serve_requests(context: Arc<Inner>) -> Result<(), Box<dyn std::error::Error>> {
+ fn handover_finished(clip: &Arc<Inner>, mut handover_state: MutexGuard<ManagerHandoverState>) {
+ log::trace!("Finishing clipboard manager handover.");
+ *handover_state = ManagerHandoverState::Finished;
+
+ // Not sure if unlocking the mutex is necessary here but better safe than sorry.
+ drop(handover_state);
+
+ clip.handover_cv.notify_all();
+ }
+
+ log::trace!("Started serve requests thread.");
+
+ let _guard = util::defer(|| {
+ context.serve_stopped.store(true, Ordering::Relaxed);
+ });
+
+ let mut written = false;
+ let mut notified = false;
+
+ loop {
+ match context.server.conn.wait_for_event().map_err(into_unknown)? {
+ Event::DestroyNotify(_) => {
+ // This window is being destroyed.
+ log::trace!("Clipboard server window is being destroyed x_x");
+ return Ok(());
+ }
+ Event::SelectionClear(event) => {
+ // TODO: check if this works
+ // Someone else has new content in the clipboard, so it is
+ // notifying us that we should delete our data now.
+ log::trace!("Somebody else owns the clipboard now");
+
+ if let Some(selection) = context.kind_of(event.selection) {
+ let selection = context.selection_of(selection);
+ let mut data_guard = selection.data.write();
+ *data_guard = None;
+
+ // It is important that this mutex is locked at the time of calling
+ // `notify_all` to prevent notifications getting lost in case the sleeping
+ // thread has unlocked its `data_guard` and is just about to sleep.
+ // It is also important that the RwLock is kept write-locked for the same
+ // reason.
+ let _guard = selection.mutex.lock();
+ selection.data_changed.notify_all();
+ }
+ }
+ Event::SelectionRequest(event) => {
+ log::trace!(
+ "SelectionRequest - selection is: {}, target is {}",
+ context.atom_name_dbg(event.selection),
+ context.atom_name_dbg(event.target),
+ );
+ // Someone is requesting the clipboard content from us.
+ context
+ .handle_selection_request(event)
+ .map_err(into_unknown)?;
+
+ // if we are in the progress of saving to the clipboard manager
+ // make sure we save that we have finished writing
+ let handover_state = context.handover_state.lock();
+ if *handover_state == ManagerHandoverState::InProgress {
+ // Only set written, when the actual contents were written,
+ // not just a response to what TARGETS we have.
+ if event.target != context.atoms.TARGETS {
+ log::trace!("The contents were written to the clipboard manager.");
+ written = true;
+ // if we have written and notified, make sure to notify that we are done
+ if notified {
+ handover_finished(&context, handover_state);
+ }
+ }
+ }
+ }
+ Event::SelectionNotify(event) => {
+ // We've requested the clipboard content and this is the answer.
+ // Considering that this thread is not responsible for reading
+ // clipboard contents, this must come from the clipboard manager
+ // signaling that the data was handed over successfully.
+ if event.selection != context.atoms.CLIPBOARD_MANAGER {
+ log::error!(
+ "Received a `SelectionNotify` from a selection other than the CLIPBOARD_MANAGER. This is unexpected in this thread."
+ );
+ continue;
+ }
+ let handover_state = context.handover_state.lock();
+ if *handover_state == ManagerHandoverState::InProgress {
+ // Note that some clipboard managers send a selection notify
+ // before even sending a request for the actual contents.
+ // (That's why we use the "notified" & "written" flags)
+ log::trace!(
+ "The clipboard manager indicated that it's done requesting the contents from us."
+ );
+ notified = true;
+
+ // One would think that we could also finish if the property
+ // here is set 0, because that indicates failure. However
+ // this is not the case; for example on KDE plasma 5.18, we
+ // immediately get a SelectionNotify with property set to 0,
+ // but following that, we also get a valid SelectionRequest
+ // from the clipboard manager.
+ if written {
+ handover_finished(&context, handover_state);
+ }
+ }
+ }
+ _event => {
+ // May be useful for debugging but nothing else really.
+ //log::trace!("Received unwanted event: {:?}", event);
+ }
+ }
+ }
+}
+
+pub(crate) struct Clipboard {
+ inner: Arc<Inner>,
+}
+
+impl Clipboard {
+ pub(crate) fn new() -> Result<Self> {
+ let mut global_cb = CLIPBOARD.lock();
+ if let Some(global_cb) = &*global_cb {
+ return Ok(Self {
+ inner: Arc::clone(&global_cb.inner),
+ });
+ }
+ // At this point we know that the clipboard does not exist.
+ let ctx = Arc::new(Inner::new()?);
+ let join_handle;
+ {
+ let ctx = Arc::clone(&ctx);
+ join_handle = std::thread::spawn(move || {
+ if let Err(error) = serve_requests(ctx) {
+ log::error!("Worker thread errored with: {}", error);
+ }
+ });
+ }
+ *global_cb = Some(GlobalClipboard {
+ inner: Arc::clone(&ctx),
+ server_handle: join_handle,
+ });
+ Ok(Self { inner: ctx })
+ }
+
+ pub(crate) fn set_text(
+ &self,
+ message: Cow<'_, str>,
+ selection: ClipboardKind,
+ wait: WaitConfig,
+ ) -> Result<()> {
+ let data = vec![ClipboardData {
+ bytes: message.into_owned().into_bytes(),
+ format: self.inner.atoms.UTF8_STRING,
+ }];
+ self.inner.write(data, selection, wait)
+ }
+
+ #[allow(unused)]
+ pub(crate) fn set_image(
+ &self,
+ image: Image,
+ selection: ClipboardKind,
+ wait: WaitConfig,
+ ) -> Result<()> {
+ let format = match image.format {
+ ImageFormat::Png => self.inner.atoms.PNG__MIME,
+ ImageFormat::Jpeg => self.inner.atoms.JPEG_MIME,
+ ImageFormat::Webp => self.inner.atoms.WEBP_MIME,
+ ImageFormat::Gif => self.inner.atoms.GIF__MIME,
+ ImageFormat::Svg => self.inner.atoms.SVG__MIME,
+ ImageFormat::Bmp => self.inner.atoms.BMP__MIME,
+ ImageFormat::Tiff => self.inner.atoms.TIFF_MIME,
+ };
+ let data = vec![ClipboardData {
+ bytes: image.bytes,
+ format: self.inner.atoms.PNG__MIME,
+ }];
+ self.inner.write(data, selection, wait)
+ }
+
+ pub(crate) fn get_any(&self, selection: ClipboardKind) -> Result<ClipboardItem> {
+ const IMAGE_FORMAT_COUNT: usize = 7;
+ let image_format_atoms: [Atom; IMAGE_FORMAT_COUNT] = [
+ self.inner.atoms.PNG__MIME,
+ self.inner.atoms.JPEG_MIME,
+ self.inner.atoms.WEBP_MIME,
+ self.inner.atoms.GIF__MIME,
+ self.inner.atoms.SVG__MIME,
+ self.inner.atoms.BMP__MIME,
+ self.inner.atoms.TIFF_MIME,
+ ];
+ let image_formats: [ImageFormat; IMAGE_FORMAT_COUNT] = [
+ ImageFormat::Png,
+ ImageFormat::Jpeg,
+ ImageFormat::Webp,
+ ImageFormat::Gif,
+ ImageFormat::Svg,
+ ImageFormat::Bmp,
+ ImageFormat::Tiff,
+ ];
+
+ const TEXT_FORMAT_COUNT: usize = 6;
+ let text_format_atoms: [Atom; TEXT_FORMAT_COUNT] = [
+ self.inner.atoms.UTF8_STRING,
+ self.inner.atoms.UTF8_MIME_0,
+ self.inner.atoms.UTF8_MIME_1,
+ self.inner.atoms.STRING,
+ self.inner.atoms.TEXT,
+ self.inner.atoms.TEXT_MIME_UNKNOWN,
+ ];
+
+ let atom_none: Atom = AtomEnum::NONE.into();
+
+ const FORMAT_ATOM_COUNT: usize = TEXT_FORMAT_COUNT + IMAGE_FORMAT_COUNT;
+
+ let mut format_atoms: [Atom; FORMAT_ATOM_COUNT] = [atom_none; FORMAT_ATOM_COUNT];
+
+ // image formats first, as they are more specific, and read will return the first
+ // format that the contents can be converted to
+ format_atoms[0..IMAGE_FORMAT_COUNT].copy_from_slice(&image_format_atoms);
+ format_atoms[IMAGE_FORMAT_COUNT..].copy_from_slice(&text_format_atoms);
+ debug_assert!(!format_atoms.iter().any(|&a| a == atom_none));
+
+ let result = self.inner.read(&format_atoms, selection)?;
+
+ for (format_atom, image_format) in image_format_atoms.into_iter().zip(image_formats) {
+ if result.format == format_atom {
+ let bytes = result.bytes;
+ let id = hash(&bytes);
+ return Ok(ClipboardItem::new_image(&Image {
+ id,
+ format: image_format,
+ bytes,
+ }));
+ }
+ }
+
+ let text = if result.format == self.inner.atoms.STRING {
+ // ISO Latin-1
+ // See: https://stackoverflow.com/questions/28169745/what-are-the-options-to-convert-iso-8859-1-latin-1-to-a-string-utf-8
+ result.bytes.into_iter().map(|c| c as char).collect()
+ } else {
+ String::from_utf8(result.bytes).map_err(|_| Error::ConversionFailure)?
+ };
+ return Ok(ClipboardItem::new_string(text));
+ }
+
+ pub fn is_owner(&self, selection: ClipboardKind) -> bool {
+ return self.inner.is_owner(selection).unwrap_or(false);
+ }
+}
+
+impl Drop for Clipboard {
+ fn drop(&mut self) {
+ // There are always at least 3 owners:
+ // the global, the server thread, and one `Clipboard::inner`
+ const MIN_OWNERS: usize = 3;
+
+ // We start with locking the global guard to prevent race
+ // conditions below.
+ let mut global_cb = CLIPBOARD.lock();
+ if Arc::strong_count(&self.inner) == MIN_OWNERS {
+ // If the are the only owners of the clipboard are ourselves and
+ // the global object, then we should destroy the global object,
+ // and send the data to the clipboard manager
+
+ if let Err(e) = self.inner.ask_clipboard_manager_to_request_our_data() {
+ log::error!(
+ "Could not hand the clipboard data over to the clipboard manager: {}",
+ e
+ );
+ }
+ let global_cb = global_cb.take();
+ if let Err(e) = self
+ .inner
+ .server
+ .conn
+ .destroy_window(self.inner.server.win_id)
+ {
+ log::error!("Failed to destroy the clipboard window. Error: {}", e);
+ return;
+ }
+ if let Err(e) = self.inner.server.conn.flush() {
+ log::error!("Failed to flush the clipboard window. Error: {}", e);
+ return;
+ }
+ if let Some(global_cb) = global_cb {
+ if let Err(e) = global_cb.server_handle.join() {
+ // Let's try extracting the error message
+ let message;
+ if let Some(msg) = e.downcast_ref::<&'static str>() {
+ message = Some((*msg).to_string());
+ } else if let Some(msg) = e.downcast_ref::<String>() {
+ message = Some(msg.clone());
+ } else {
+ message = None;
+ }
+ if let Some(message) = message {
+ log::error!(
+ "The clipboard server thread panicked. Panic message: '{}'",
+ message,
+ );
+ } else {
+ log::error!("The clipboard server thread panicked.");
+ }
+ }
+ }
+ }
+ }
+}
+
+fn into_unknown<E: std::fmt::Display>(error: E) -> Error {
+ Error::Unknown {
+ description: error.to_string(),
+ }
+}
+
+/// Clipboard selection
+///
+/// Linux has a concept of clipboard "selections" which tend to be used in different contexts. This
+/// enum provides a way to get/set to a specific clipboard
+///
+/// See <https://specifications.freedesktop.org/clipboards-spec/clipboards-0.1.txt> for a better
+/// description of the different clipboards.
+#[derive(Copy, Clone, Debug)]
+pub enum ClipboardKind {
+ /// Typically used selection for explicit cut/copy/paste actions (ie. windows/macos like
+ /// clipboard behavior)
+ Clipboard,
+
+ /// Typically used for mouse selections and/or currently selected text. Accessible via middle
+ /// mouse click.
+ Primary,
+
+ /// The secondary clipboard is rarely used but theoretically available on X11.
+ Secondary,
+}
+
+/// Configuration on how long to wait for a new X11 copy event is emitted.
+#[derive(Default)]
+pub(crate) enum WaitConfig {
+ /// Waits until the given [`Instant`] has reached.
+ #[allow(
+ unused,
+ reason = "Right now we don't wait for clipboard contents to sync on app close, but we may in the future"
+ )]
+ Until(Instant),
+
+ /// Waits forever until a new event is reached.
+ #[allow(unused)]
+ #[allow(
+ unused,
+ reason = "Right now we don't wait for clipboard contents to sync on app close, but we may in the future"
+ )]
+ Forever,
+
+ /// It shouldn't wait.
+ #[default]
+ None,
+}
+
+#[non_exhaustive]
+pub enum Error {
+ /// The clipboard contents were not available in the requested format.
+ /// This could either be due to the clipboard being empty or the clipboard contents having
+ /// an incompatible format to the requested one (eg when calling `get_image` on text)
+ ContentNotAvailable,
+
+ /// The native clipboard is not accessible due to being held by an other party.
+ ///
+ /// This "other party" could be a different process or it could be within
+ /// the same program. So for example you may get this error when trying
+ /// to interact with the clipboard from multiple threads at once.
+ ///
+ /// Note that it's OK to have multiple `Clipboard` instances. The underlying
+ /// implementation will make sure that the native clipboard is only
+ /// opened for transferring data and then closed as soon as possible.
+ ClipboardOccupied,
+
+ /// The image or the text that was about the be transferred to/from the clipboard could not be
+ /// converted to the appropriate format.
+ ConversionFailure,
+
+ /// Any error that doesn't fit the other error types.
+ ///
+ /// The `description` field is only meant to help the developer and should not be relied on as a
+ /// means to identify an error case during runtime.
+ Unknown { description: String },
+}
+
+impl std::fmt::Display for Error {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Error::ContentNotAvailable => f.write_str("The clipboard contents were not available in the requested format or the clipboard is empty."),
+ Error::ClipboardOccupied => f.write_str("The native clipboard is not accessible due to being held by an other party."),
+ Error::ConversionFailure => f.write_str("The image or the text that was about the be transferred to/from the clipboard could not be converted to the appropriate format."),
+ Error::Unknown { description } => f.write_fmt(format_args!("Unknown error while interacting with the clipboard: {description}")),
+ }
+ }
+}
+
+impl std::error::Error for Error {}
+
+impl std::fmt::Debug for Error {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ use Error::*;
+ macro_rules! kind_to_str {
+ ($( $e: pat ),*) => {
+ match self {
+ $(
+ $e => stringify!($e),
+ )*
+ }
+ }
+ }
+ let name = kind_to_str!(
+ ContentNotAvailable,
+ ClipboardOccupied,
+ ConversionFailure,
+ Unknown { .. }
+ );
+ f.write_fmt(format_args!("{name} - \"{self}\""))
+ }
+}
+
+impl Error {
+ pub(crate) fn unknown<M: Into<String>>(message: M) -> Self {
+ Error::Unknown {
+ description: message.into(),
+ }
+ }
+}