1/*
2 * Copyright 2022 - 2025 Zed Industries, Inc.
3 * License: Apache-2.0
4 * See LICENSE-APACHE for complete license terms
5 *
6 * Adapted from the x11 submodule of the arboard project https://github.com/1Password/arboard
7 *
8 * SPDX-License-Identifier: Apache-2.0 OR MIT
9 *
10 * Copyright 2022 The Arboard contributors
11 *
12 * The project to which this file belongs is licensed under either of
13 * the Apache 2.0 or the MIT license at the licensee's choice. The terms
14 * and conditions of the chosen license apply to this file.
15*/
16
17// More info about using the clipboard on X11:
18// https://tronche.com/gui/x/icccm/sec-2.html#s-2.6
19// https://freedesktop.org/wiki/ClipboardManager/
20
21use std::{
22 borrow::Cow,
23 cell::RefCell,
24 collections::{HashMap, hash_map::Entry},
25 sync::{
26 Arc,
27 atomic::{AtomicBool, Ordering},
28 },
29 thread::JoinHandle,
30 thread_local,
31 time::{Duration, Instant},
32};
33
34use parking_lot::{Condvar, Mutex, MutexGuard, RwLock};
35use x11rb::{
36 COPY_DEPTH_FROM_PARENT, COPY_FROM_PARENT, NONE,
37 connection::Connection,
38 protocol::{
39 Event,
40 xproto::{
41 Atom, AtomEnum, ConnectionExt as _, CreateWindowAux, EventMask, PropMode, Property,
42 PropertyNotifyEvent, SELECTION_NOTIFY_EVENT, SelectionNotifyEvent,
43 SelectionRequestEvent, Time, WindowClass,
44 },
45 },
46 rust_connection::RustConnection,
47 wrapper::ConnectionExt as _,
48};
49
50use crate::{ClipboardItem, Image, ImageFormat, hash};
51
52type Result<T, E = Error> = std::result::Result<T, E>;
53
54static CLIPBOARD: Mutex<Option<GlobalClipboard>> = parking_lot::const_mutex(None);
55
56x11rb::atom_manager! {
57 pub Atoms: AtomCookies {
58 CLIPBOARD,
59 PRIMARY,
60 SECONDARY,
61
62 CLIPBOARD_MANAGER,
63 SAVE_TARGETS,
64 TARGETS,
65 ATOM,
66 INCR,
67
68 UTF8_STRING,
69 UTF8_MIME_0: b"text/plain;charset=utf-8",
70 UTF8_MIME_1: b"text/plain;charset=UTF-8",
71 // Text in ISO Latin-1 encoding
72 // See: https://tronche.com/gui/x/icccm/sec-2.html#s-2.6.2
73 STRING,
74 // Text in unknown encoding
75 // See: https://tronche.com/gui/x/icccm/sec-2.html#s-2.6.2
76 TEXT,
77 TEXT_MIME_UNKNOWN: b"text/plain",
78
79 // HTML: b"text/html",
80 // URI_LIST: b"text/uri-list",
81
82 PNG__MIME: ImageFormat::mime_type(ImageFormat::Png ).as_bytes(),
83 JPEG_MIME: ImageFormat::mime_type(ImageFormat::Jpeg).as_bytes(),
84 WEBP_MIME: ImageFormat::mime_type(ImageFormat::Webp).as_bytes(),
85 GIF__MIME: ImageFormat::mime_type(ImageFormat::Gif ).as_bytes(),
86 SVG__MIME: ImageFormat::mime_type(ImageFormat::Svg ).as_bytes(),
87 BMP__MIME: ImageFormat::mime_type(ImageFormat::Bmp ).as_bytes(),
88 TIFF_MIME: ImageFormat::mime_type(ImageFormat::Tiff).as_bytes(),
89
90 // This is just some random name for the property on our window, into which
91 // the clipboard owner writes the data we requested.
92 ARBOARD_CLIPBOARD,
93 }
94}
95
96thread_local! {
97 static ATOM_NAME_CACHE: RefCell<HashMap<Atom, &'static str>> = Default::default();
98}
99
100// Some clipboard items, like images, may take a very long time to produce a
101// `SelectionNotify`. Multiple seconds long.
102const LONG_TIMEOUT_DUR: Duration = Duration::from_millis(4000);
103const SHORT_TIMEOUT_DUR: Duration = Duration::from_millis(10);
104
105#[derive(Debug, PartialEq, Eq)]
106enum ManagerHandoverState {
107 Idle,
108 InProgress,
109 Finished,
110}
111
112struct GlobalClipboard {
113 inner: Arc<Inner>,
114
115 /// Join handle to the thread which serves selection requests.
116 server_handle: JoinHandle<()>,
117}
118
119struct XContext {
120 conn: RustConnection,
121 win_id: u32,
122}
123
124struct Inner {
125 /// The context for the thread which serves clipboard read
126 /// requests coming to us.
127 server: XContext,
128 atoms: Atoms,
129
130 clipboard: Selection,
131 primary: Selection,
132 secondary: Selection,
133
134 handover_state: Mutex<ManagerHandoverState>,
135 handover_cv: Condvar,
136
137 serve_stopped: AtomicBool,
138}
139
140impl XContext {
141 fn new() -> Result<Self> {
142 // create a new connection to an X11 server
143 let (conn, screen_num): (RustConnection, _) =
144 RustConnection::connect(None).map_err(|_| {
145 Error::unknown("X11 server connection timed out because it was unreachable")
146 })?;
147 let screen = conn
148 .setup()
149 .roots
150 .get(screen_num)
151 .ok_or(Error::unknown("no screen found"))?;
152 let win_id = conn.generate_id().map_err(into_unknown)?;
153
154 let event_mask =
155 // Just in case that some program reports SelectionNotify events
156 // with XCB_EVENT_MASK_PROPERTY_CHANGE mask.
157 EventMask::PROPERTY_CHANGE |
158 // To receive DestroyNotify event and stop the message loop.
159 EventMask::STRUCTURE_NOTIFY;
160 // create the window
161 conn.create_window(
162 // copy as much as possible from the parent, because no other specific input is needed
163 COPY_DEPTH_FROM_PARENT,
164 win_id,
165 screen.root,
166 0,
167 0,
168 1,
169 1,
170 0,
171 WindowClass::COPY_FROM_PARENT,
172 COPY_FROM_PARENT,
173 // don't subscribe to any special events because we are requesting everything we need ourselves
174 &CreateWindowAux::new().event_mask(event_mask),
175 )
176 .map_err(into_unknown)?;
177 conn.flush().map_err(into_unknown)?;
178
179 Ok(Self { conn, win_id })
180 }
181}
182
183#[derive(Default)]
184struct Selection {
185 data: RwLock<Option<Vec<ClipboardData>>>,
186 /// Mutex around nothing to use with the below condvar.
187 mutex: Mutex<()>,
188 /// A condvar that is notified when the contents of this clipboard are changed.
189 ///
190 /// This is associated with `Self::mutex`.
191 data_changed: Condvar,
192}
193
194#[derive(Debug, Clone)]
195struct ClipboardData {
196 bytes: Vec<u8>,
197
198 /// The atom representing the format in which the data is encoded.
199 format: Atom,
200}
201
202enum ReadSelNotifyResult {
203 GotData(ClipboardData),
204 IncrStarted,
205 EventNotRecognized,
206}
207
208impl Inner {
209 fn new() -> Result<Self> {
210 let server = XContext::new()?;
211 let atoms = Atoms::new(&server.conn)
212 .map_err(into_unknown)?
213 .reply()
214 .map_err(into_unknown)?;
215
216 Ok(Self {
217 server,
218 atoms,
219 clipboard: Selection::default(),
220 primary: Selection::default(),
221 secondary: Selection::default(),
222 handover_state: Mutex::new(ManagerHandoverState::Idle),
223 handover_cv: Condvar::new(),
224 serve_stopped: AtomicBool::new(false),
225 })
226 }
227
228 fn write(
229 &self,
230 data: Vec<ClipboardData>,
231 selection: ClipboardKind,
232 wait: WaitConfig,
233 ) -> Result<()> {
234 if self.serve_stopped.load(Ordering::Relaxed) {
235 return Err(Error::unknown(
236 "The clipboard handler thread seems to have stopped. Logging messages may reveal the cause. (See the `log` crate.)",
237 ));
238 }
239
240 let server_win = self.server.win_id;
241
242 // ICCCM version 2, section 2.6.1.3 states that we should re-assert ownership whenever data
243 // changes.
244 self.server
245 .conn
246 .set_selection_owner(server_win, self.atom_of(selection), Time::CURRENT_TIME)
247 .map_err(|_| Error::ClipboardOccupied)?;
248
249 self.server.conn.flush().map_err(into_unknown)?;
250
251 // Just setting the data, and the `serve_requests` will take care of the rest.
252 let selection = self.selection_of(selection);
253 let mut data_guard = selection.data.write();
254 *data_guard = Some(data);
255
256 // Lock the mutex to both ensure that no wakers of `data_changed` can wake us between
257 // dropping the `data_guard` and calling `wait[_for]` and that we don't we wake other
258 // threads in that position.
259 let mut guard = selection.mutex.lock();
260
261 // Notify any existing waiting threads that we have changed the data in the selection.
262 // It is important that the mutex is locked to prevent this notification getting lost.
263 selection.data_changed.notify_all();
264
265 match wait {
266 WaitConfig::None => {}
267 WaitConfig::Forever => {
268 drop(data_guard);
269 selection.data_changed.wait(&mut guard);
270 }
271 WaitConfig::Until(deadline) => {
272 drop(data_guard);
273 selection.data_changed.wait_until(&mut guard, deadline);
274 }
275 }
276
277 Ok(())
278 }
279
280 /// `formats` must be a slice of atoms, where each atom represents a target format.
281 /// The first format from `formats`, which the clipboard owner supports will be the
282 /// format of the return value.
283 fn read(&self, formats: &[Atom], selection: ClipboardKind) -> Result<ClipboardData> {
284 // if we are the current owner, we can get the current clipboard ourselves
285 if self.is_owner(selection)? {
286 let data = self.selection_of(selection).data.read();
287 if let Some(data_list) = &*data {
288 for data in data_list {
289 for format in formats {
290 if *format == data.format {
291 return Ok(data.clone());
292 }
293 }
294 }
295 }
296 return Err(Error::ContentNotAvailable);
297 }
298 let reader = XContext::new()?;
299
300 let highest_precedence_format =
301 match self.read_single(&reader, selection, self.atoms.TARGETS) {
302 Err(err) => {
303 log::trace!("Clipboard TARGETS query failed with {err:?}");
304 None
305 }
306 Ok(ClipboardData { bytes, format }) => {
307 if format == self.atoms.ATOM {
308 let available_formats = Self::parse_formats(&bytes);
309 formats
310 .iter()
311 .find(|format| available_formats.contains(format))
312 } else {
313 log::trace!(
314 "Unexpected clipboard TARGETS format {}",
315 self.atom_name(format)
316 );
317 None
318 }
319 }
320 };
321
322 if let Some(&format) = highest_precedence_format {
323 let data = self.read_single(&reader, selection, format)?;
324 if !formats.contains(&data.format) {
325 // This shouldn't happen since the format is from the TARGETS list.
326 log::trace!(
327 "Conversion to {} responded with {} which is not supported",
328 self.atom_name(format),
329 self.atom_name(data.format),
330 );
331 return Err(Error::ConversionFailure);
332 }
333 return Ok(data);
334 }
335
336 log::trace!("Falling back on attempting to convert clipboard to each format.");
337 for format in formats {
338 match self.read_single(&reader, selection, *format) {
339 Ok(data) => {
340 if formats.contains(&data.format) {
341 return Ok(data);
342 } else {
343 log::trace!(
344 "Conversion to {} responded with {} which is not supported",
345 self.atom_name(*format),
346 self.atom_name(data.format),
347 );
348 continue;
349 }
350 }
351 Err(Error::ContentNotAvailable) => {
352 continue;
353 }
354 Err(e) => {
355 log::trace!("Conversion to {} failed: {}", self.atom_name(*format), e);
356 return Err(e);
357 }
358 }
359 }
360 log::trace!("All conversions to supported formats failed.");
361 Err(Error::ContentNotAvailable)
362 }
363
364 fn parse_formats(bytes: &[u8]) -> Vec<Atom> {
365 bytes
366 .chunks_exact(4)
367 .map(|chunk| u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
368 .collect()
369 }
370
371 fn read_single(
372 &self,
373 reader: &XContext,
374 selection: ClipboardKind,
375 target_format: Atom,
376 ) -> Result<ClipboardData> {
377 // Delete the property so that we can detect (using property notify)
378 // when the selection owner receives our request.
379 reader
380 .conn
381 .delete_property(reader.win_id, self.atoms.ARBOARD_CLIPBOARD)
382 .map_err(into_unknown)?;
383
384 // request to convert the clipboard selection to our data type(s)
385 reader
386 .conn
387 .convert_selection(
388 reader.win_id,
389 self.atom_of(selection),
390 target_format,
391 self.atoms.ARBOARD_CLIPBOARD,
392 Time::CURRENT_TIME,
393 )
394 .map_err(into_unknown)?;
395 reader.conn.sync().map_err(into_unknown)?;
396
397 log::trace!("Finished `convert_selection`");
398
399 let mut incr_data: Vec<u8> = Vec::new();
400 let mut using_incr = false;
401
402 let mut timeout_end = Instant::now() + LONG_TIMEOUT_DUR;
403
404 while Instant::now() < timeout_end {
405 let event = reader.conn.poll_for_event().map_err(into_unknown)?;
406 let event = match event {
407 Some(e) => e,
408 None => {
409 std::thread::sleep(Duration::from_millis(1));
410 continue;
411 }
412 };
413 match event {
414 // The first response after requesting a selection.
415 Event::SelectionNotify(event) => {
416 log::trace!("Read SelectionNotify");
417 let result = self.handle_read_selection_notify(
418 reader,
419 target_format,
420 &mut using_incr,
421 &mut incr_data,
422 event,
423 )?;
424 match result {
425 ReadSelNotifyResult::GotData(data) => return Ok(data),
426 ReadSelNotifyResult::IncrStarted => {
427 // This means we received an indication that an the
428 // data is going to be sent INCRementally. Let's
429 // reset our timeout.
430 timeout_end += SHORT_TIMEOUT_DUR;
431 }
432 ReadSelNotifyResult::EventNotRecognized => (),
433 }
434 }
435 // If the previous SelectionNotify event specified that the data
436 // will be sent in INCR segments, each segment is transferred in
437 // a PropertyNotify event.
438 Event::PropertyNotify(event) => {
439 let result = self.handle_read_property_notify(
440 reader,
441 target_format,
442 using_incr,
443 &mut incr_data,
444 &mut timeout_end,
445 event,
446 )?;
447 if result {
448 return Ok(ClipboardData {
449 bytes: incr_data,
450 format: target_format,
451 });
452 }
453 }
454 _ => log::trace!(
455 "An unexpected event arrived while reading the clipboard: {:?}",
456 event
457 ),
458 }
459 }
460 log::info!("Time-out hit while reading the clipboard.");
461 Err(Error::ContentNotAvailable)
462 }
463
464 fn atom_of(&self, selection: ClipboardKind) -> Atom {
465 match selection {
466 ClipboardKind::Clipboard => self.atoms.CLIPBOARD,
467 ClipboardKind::Primary => self.atoms.PRIMARY,
468 ClipboardKind::Secondary => self.atoms.SECONDARY,
469 }
470 }
471
472 fn selection_of(&self, selection: ClipboardKind) -> &Selection {
473 match selection {
474 ClipboardKind::Clipboard => &self.clipboard,
475 ClipboardKind::Primary => &self.primary,
476 ClipboardKind::Secondary => &self.secondary,
477 }
478 }
479
480 fn kind_of(&self, atom: Atom) -> Option<ClipboardKind> {
481 match atom {
482 a if a == self.atoms.CLIPBOARD => Some(ClipboardKind::Clipboard),
483 a if a == self.atoms.PRIMARY => Some(ClipboardKind::Primary),
484 a if a == self.atoms.SECONDARY => Some(ClipboardKind::Secondary),
485 _ => None,
486 }
487 }
488
489 fn is_owner(&self, selection: ClipboardKind) -> Result<bool> {
490 let current = self
491 .server
492 .conn
493 .get_selection_owner(self.atom_of(selection))
494 .map_err(into_unknown)?
495 .reply()
496 .map_err(into_unknown)?
497 .owner;
498
499 Ok(current == self.server.win_id)
500 }
501
502 fn query_atom_name(&self, atom: x11rb::protocol::xproto::Atom) -> Result<String> {
503 String::from_utf8(
504 self.server
505 .conn
506 .get_atom_name(atom)
507 .map_err(into_unknown)?
508 .reply()
509 .map_err(into_unknown)?
510 .name,
511 )
512 .map_err(into_unknown)
513 }
514
515 fn atom_name(&self, atom: x11rb::protocol::xproto::Atom) -> &'static str {
516 ATOM_NAME_CACHE.with(|cache| {
517 let mut cache = cache.borrow_mut();
518 match cache.entry(atom) {
519 Entry::Occupied(entry) => *entry.get(),
520 Entry::Vacant(entry) => {
521 let s = self
522 .query_atom_name(atom)
523 .map(|s| Box::leak(s.into_boxed_str()) as &str)
524 .unwrap_or("FAILED-TO-GET-THE-ATOM-NAME");
525 entry.insert(s);
526 s
527 }
528 }
529 })
530 }
531
532 fn handle_read_selection_notify(
533 &self,
534 reader: &XContext,
535 target_format: u32,
536 using_incr: &mut bool,
537 incr_data: &mut Vec<u8>,
538 event: SelectionNotifyEvent,
539 ) -> Result<ReadSelNotifyResult> {
540 // The property being set to NONE means that the `convert_selection`
541 // failed.
542
543 // According to: https://tronche.com/gui/x/icccm/sec-2.html#s-2.4
544 // the target must be set to the same as what we requested.
545 if event.property == NONE || event.target != target_format {
546 return Err(Error::ContentNotAvailable);
547 }
548 if self.kind_of(event.selection).is_none() {
549 log::info!(
550 "Received a SelectionNotify for a selection other than CLIPBOARD, PRIMARY or SECONDARY. This is unexpected."
551 );
552 return Ok(ReadSelNotifyResult::EventNotRecognized);
553 }
554 if *using_incr {
555 log::warn!("Received a SelectionNotify while already expecting INCR segments.");
556 return Ok(ReadSelNotifyResult::EventNotRecognized);
557 }
558 // Accept any property type. The property type will typically match the format type except
559 // when it is `TARGETS` in which case it is `ATOM`. `ANY` is provided to handle the case
560 // where the clipboard is not convertible to the requested format. In this case
561 // `reply.type_` will have format information, but `bytes` will only be non-empty if `ANY`
562 // is provided.
563 let property_type = AtomEnum::ANY;
564 // request the selection
565 let mut reply = reader
566 .conn
567 .get_property(
568 true,
569 event.requestor,
570 event.property,
571 property_type,
572 0,
573 u32::MAX / 4,
574 )
575 .map_err(into_unknown)?
576 .reply()
577 .map_err(into_unknown)?;
578
579 // we found something
580 if reply.type_ == self.atoms.INCR {
581 // Note that we call the get_property again because we are
582 // indicating that we are ready to receive the data by deleting the
583 // property, however deleting only works if the type matches the
584 // property type. But the type didn't match in the previous call.
585 reply = reader
586 .conn
587 .get_property(
588 true,
589 event.requestor,
590 event.property,
591 self.atoms.INCR,
592 0,
593 u32::MAX / 4,
594 )
595 .map_err(into_unknown)?
596 .reply()
597 .map_err(into_unknown)?;
598 log::trace!("Receiving INCR segments");
599 *using_incr = true;
600 if reply.value_len == 4 {
601 let min_data_len = reply
602 .value32()
603 .and_then(|mut vals| vals.next())
604 .unwrap_or(0);
605 incr_data.reserve(min_data_len as usize);
606 }
607 Ok(ReadSelNotifyResult::IncrStarted)
608 } else {
609 Ok(ReadSelNotifyResult::GotData(ClipboardData {
610 bytes: reply.value,
611 format: reply.type_,
612 }))
613 }
614 }
615
616 /// Returns Ok(true) when the incr_data is ready
617 fn handle_read_property_notify(
618 &self,
619 reader: &XContext,
620 target_format: u32,
621 using_incr: bool,
622 incr_data: &mut Vec<u8>,
623 timeout_end: &mut Instant,
624 event: PropertyNotifyEvent,
625 ) -> Result<bool> {
626 if event.atom != self.atoms.ARBOARD_CLIPBOARD || event.state != Property::NEW_VALUE {
627 return Ok(false);
628 }
629 if !using_incr {
630 // This must mean the selection owner received our request, and is
631 // now preparing the data
632 return Ok(false);
633 }
634 let reply = reader
635 .conn
636 .get_property(
637 true,
638 event.window,
639 event.atom,
640 if target_format == self.atoms.TARGETS {
641 self.atoms.ATOM
642 } else {
643 target_format
644 },
645 0,
646 u32::MAX / 4,
647 )
648 .map_err(into_unknown)?
649 .reply()
650 .map_err(into_unknown)?;
651
652 // log::trace!("Received segment. value_len {}", reply.value_len,);
653 if reply.value_len == 0 {
654 // This indicates that all the data has been sent.
655 return Ok(true);
656 }
657 incr_data.extend(reply.value);
658
659 // Let's reset our timeout, since we received a valid chunk.
660 *timeout_end = Instant::now() + SHORT_TIMEOUT_DUR;
661
662 // Not yet complete
663 Ok(false)
664 }
665
666 fn handle_selection_request(&self, event: SelectionRequestEvent) -> Result<()> {
667 let selection = match self.kind_of(event.selection) {
668 Some(kind) => kind,
669 None => {
670 log::warn!(
671 "Received a selection request to a selection other than the CLIPBOARD, PRIMARY or SECONDARY. This is unexpected."
672 );
673 return Ok(());
674 }
675 };
676
677 let success;
678 // we are asked for a list of supported conversion targets
679 if event.target == self.atoms.TARGETS {
680 log::trace!(
681 "Handling TARGETS, dst property is {}",
682 self.atom_name(event.property)
683 );
684 let mut targets = Vec::with_capacity(10);
685 targets.push(self.atoms.TARGETS);
686 targets.push(self.atoms.SAVE_TARGETS);
687 let data = self.selection_of(selection).data.read();
688 if let Some(data_list) = &*data {
689 for data in data_list {
690 targets.push(data.format);
691 if data.format == self.atoms.UTF8_STRING {
692 // When we are storing a UTF8 string,
693 // add all equivalent formats to the supported targets
694 targets.push(self.atoms.UTF8_MIME_0);
695 targets.push(self.atoms.UTF8_MIME_1);
696 }
697 }
698 }
699 self.server
700 .conn
701 .change_property32(
702 PropMode::REPLACE,
703 event.requestor,
704 event.property,
705 // TODO: change to `AtomEnum::ATOM`
706 self.atoms.ATOM,
707 &targets,
708 )
709 .map_err(into_unknown)?;
710 self.server.conn.flush().map_err(into_unknown)?;
711 success = true;
712 } else {
713 log::trace!("Handling request for (probably) the clipboard contents.");
714 let data = self.selection_of(selection).data.read();
715 if let Some(data_list) = &*data {
716 success = match data_list.iter().find(|d| d.format == event.target) {
717 Some(data) => {
718 self.server
719 .conn
720 .change_property8(
721 PropMode::REPLACE,
722 event.requestor,
723 event.property,
724 event.target,
725 &data.bytes,
726 )
727 .map_err(into_unknown)?;
728 self.server.conn.flush().map_err(into_unknown)?;
729 true
730 }
731 None => false,
732 };
733 } else {
734 // This must mean that we lost ownership of the data
735 // since the other side requested the selection.
736 // Let's respond with the property set to none.
737 success = false;
738 }
739 }
740 // on failure we notify the requester of it
741 let property = if success {
742 event.property
743 } else {
744 AtomEnum::NONE.into()
745 };
746 // tell the requestor that we finished sending data
747 self.server
748 .conn
749 .send_event(
750 false,
751 event.requestor,
752 EventMask::NO_EVENT,
753 SelectionNotifyEvent {
754 response_type: SELECTION_NOTIFY_EVENT,
755 sequence: event.sequence,
756 time: event.time,
757 requestor: event.requestor,
758 selection: event.selection,
759 target: event.target,
760 property,
761 },
762 )
763 .map_err(into_unknown)?;
764
765 self.server.conn.flush().map_err(into_unknown)
766 }
767
768 fn ask_clipboard_manager_to_request_our_data(&self) -> Result<()> {
769 if self.server.win_id == 0 {
770 // This shouldn't really ever happen but let's just check.
771 log::error!("The server's window id was 0. This is unexpected");
772 return Ok(());
773 }
774
775 if !self.is_owner(ClipboardKind::Clipboard)? {
776 // We are not owning the clipboard, nothing to do.
777 return Ok(());
778 }
779 if self
780 .selection_of(ClipboardKind::Clipboard)
781 .data
782 .read()
783 .is_none()
784 {
785 // If we don't have any data, there's nothing to do.
786 return Ok(());
787 }
788
789 // It's important that we lock the state before sending the request
790 // because we don't want the request server thread to lock the state
791 // after the request but before we can lock it here.
792 let mut handover_state = self.handover_state.lock();
793
794 log::trace!("Sending the data to the clipboard manager");
795 self.server
796 .conn
797 .convert_selection(
798 self.server.win_id,
799 self.atoms.CLIPBOARD_MANAGER,
800 self.atoms.SAVE_TARGETS,
801 self.atoms.ARBOARD_CLIPBOARD,
802 Time::CURRENT_TIME,
803 )
804 .map_err(into_unknown)?;
805 self.server.conn.flush().map_err(into_unknown)?;
806
807 *handover_state = ManagerHandoverState::InProgress;
808 let max_handover_duration = Duration::from_millis(100);
809
810 // Note that we are using a parking_lot condvar here, which doesn't wake up
811 // spuriously
812 let result = self
813 .handover_cv
814 .wait_for(&mut handover_state, max_handover_duration);
815
816 if *handover_state == ManagerHandoverState::Finished {
817 return Ok(());
818 }
819 if result.timed_out() {
820 log::warn!(
821 "Could not hand the clipboard contents over to the clipboard manager. The request timed out."
822 );
823 return Ok(());
824 }
825
826 Err(Error::unknown(
827 "The handover was not finished and the condvar didn't time out, yet the condvar wait ended. This should be unreachable.",
828 ))
829 }
830}
831
832fn serve_requests(context: Arc<Inner>) -> Result<(), Box<dyn std::error::Error>> {
833 fn handover_finished(clip: &Arc<Inner>, mut handover_state: MutexGuard<ManagerHandoverState>) {
834 log::trace!("Finishing clipboard manager handover.");
835 *handover_state = ManagerHandoverState::Finished;
836
837 // Not sure if unlocking the mutex is necessary here but better safe than sorry.
838 drop(handover_state);
839
840 clip.handover_cv.notify_all();
841 }
842
843 log::trace!("Started serve requests thread.");
844
845 let _guard = util::defer(|| {
846 context.serve_stopped.store(true, Ordering::Relaxed);
847 });
848
849 let mut written = false;
850 let mut notified = false;
851
852 loop {
853 match context.server.conn.wait_for_event().map_err(into_unknown)? {
854 Event::DestroyNotify(_) => {
855 // This window is being destroyed.
856 log::trace!("Clipboard server window is being destroyed x_x");
857 return Ok(());
858 }
859 Event::SelectionClear(event) => {
860 // TODO: check if this works
861 // Someone else has new content in the clipboard, so it is
862 // notifying us that we should delete our data now.
863 log::trace!("Somebody else owns the clipboard now");
864
865 if let Some(selection) = context.kind_of(event.selection) {
866 let selection = context.selection_of(selection);
867 let mut data_guard = selection.data.write();
868 *data_guard = None;
869
870 // It is important that this mutex is locked at the time of calling
871 // `notify_all` to prevent notifications getting lost in case the sleeping
872 // thread has unlocked its `data_guard` and is just about to sleep.
873 // It is also important that the RwLock is kept write-locked for the same
874 // reason.
875 let _guard = selection.mutex.lock();
876 selection.data_changed.notify_all();
877 }
878 }
879 Event::SelectionRequest(event) => {
880 log::trace!(
881 "SelectionRequest - selection is: {}, target is {}",
882 context.atom_name(event.selection),
883 context.atom_name(event.target),
884 );
885 // Someone is requesting the clipboard content from us.
886 context
887 .handle_selection_request(event)
888 .map_err(into_unknown)?;
889
890 // if we are in the progress of saving to the clipboard manager
891 // make sure we save that we have finished writing
892 let handover_state = context.handover_state.lock();
893 if *handover_state == ManagerHandoverState::InProgress {
894 // Only set written, when the actual contents were written,
895 // not just a response to what TARGETS we have.
896 if event.target != context.atoms.TARGETS {
897 log::trace!("The contents were written to the clipboard manager.");
898 written = true;
899 // if we have written and notified, make sure to notify that we are done
900 if notified {
901 handover_finished(&context, handover_state);
902 }
903 }
904 }
905 }
906 Event::SelectionNotify(event) => {
907 // We've requested the clipboard content and this is the answer.
908 // Considering that this thread is not responsible for reading
909 // clipboard contents, this must come from the clipboard manager
910 // signaling that the data was handed over successfully.
911 if event.selection != context.atoms.CLIPBOARD_MANAGER {
912 log::error!(
913 "Received a `SelectionNotify` from a selection other than the CLIPBOARD_MANAGER. This is unexpected in this thread."
914 );
915 continue;
916 }
917 let handover_state = context.handover_state.lock();
918 if *handover_state == ManagerHandoverState::InProgress {
919 // Note that some clipboard managers send a selection notify
920 // before even sending a request for the actual contents.
921 // (That's why we use the "notified" & "written" flags)
922 log::trace!(
923 "The clipboard manager indicated that it's done requesting the contents from us."
924 );
925 notified = true;
926
927 // One would think that we could also finish if the property
928 // here is set 0, because that indicates failure. However
929 // this is not the case; for example on KDE plasma 5.18, we
930 // immediately get a SelectionNotify with property set to 0,
931 // but following that, we also get a valid SelectionRequest
932 // from the clipboard manager.
933 if written {
934 handover_finished(&context, handover_state);
935 }
936 }
937 }
938 _event => {
939 // May be useful for debugging but nothing else really.
940 //log::trace!("Received unwanted event: {:?}", event);
941 }
942 }
943 }
944}
945
946pub(crate) struct Clipboard {
947 inner: Arc<Inner>,
948}
949
950impl Clipboard {
951 pub(crate) fn new() -> Result<Self> {
952 let mut global_cb = CLIPBOARD.lock();
953 if let Some(global_cb) = &*global_cb {
954 return Ok(Self {
955 inner: Arc::clone(&global_cb.inner),
956 });
957 }
958 // At this point we know that the clipboard does not exist.
959 let ctx = Arc::new(Inner::new()?);
960 let join_handle = std::thread::Builder::new()
961 .name("Clipboard".to_owned())
962 .spawn({
963 let ctx = Arc::clone(&ctx);
964 move || {
965 if let Err(error) = serve_requests(ctx) {
966 log::error!("Worker thread errored with: {}", error);
967 }
968 }
969 })
970 .unwrap();
971 *global_cb = Some(GlobalClipboard {
972 inner: Arc::clone(&ctx),
973 server_handle: join_handle,
974 });
975 Ok(Self { inner: ctx })
976 }
977
978 pub(crate) fn set_text(
979 &self,
980 message: Cow<'_, str>,
981 selection: ClipboardKind,
982 wait: WaitConfig,
983 ) -> Result<()> {
984 let data = vec![ClipboardData {
985 bytes: message.into_owned().into_bytes(),
986 format: self.inner.atoms.UTF8_STRING,
987 }];
988 self.inner.write(data, selection, wait)
989 }
990
991 #[allow(unused)]
992 pub(crate) fn set_image(
993 &self,
994 image: Image,
995 selection: ClipboardKind,
996 wait: WaitConfig,
997 ) -> Result<()> {
998 let format = match image.format {
999 ImageFormat::Png => self.inner.atoms.PNG__MIME,
1000 ImageFormat::Jpeg => self.inner.atoms.JPEG_MIME,
1001 ImageFormat::Webp => self.inner.atoms.WEBP_MIME,
1002 ImageFormat::Gif => self.inner.atoms.GIF__MIME,
1003 ImageFormat::Svg => self.inner.atoms.SVG__MIME,
1004 ImageFormat::Bmp => self.inner.atoms.BMP__MIME,
1005 ImageFormat::Tiff => self.inner.atoms.TIFF_MIME,
1006 };
1007 let data = vec![ClipboardData {
1008 bytes: image.bytes,
1009 format: self.inner.atoms.PNG__MIME,
1010 }];
1011 self.inner.write(data, selection, wait)
1012 }
1013
1014 pub(crate) fn get_any(&self, selection: ClipboardKind) -> Result<ClipboardItem> {
1015 const IMAGE_FORMAT_COUNT: usize = 7;
1016 let image_format_atoms: [Atom; IMAGE_FORMAT_COUNT] = [
1017 self.inner.atoms.PNG__MIME,
1018 self.inner.atoms.JPEG_MIME,
1019 self.inner.atoms.WEBP_MIME,
1020 self.inner.atoms.GIF__MIME,
1021 self.inner.atoms.SVG__MIME,
1022 self.inner.atoms.BMP__MIME,
1023 self.inner.atoms.TIFF_MIME,
1024 ];
1025 let image_formats: [ImageFormat; IMAGE_FORMAT_COUNT] = [
1026 ImageFormat::Png,
1027 ImageFormat::Jpeg,
1028 ImageFormat::Webp,
1029 ImageFormat::Gif,
1030 ImageFormat::Svg,
1031 ImageFormat::Bmp,
1032 ImageFormat::Tiff,
1033 ];
1034
1035 const TEXT_FORMAT_COUNT: usize = 6;
1036 let text_format_atoms: [Atom; TEXT_FORMAT_COUNT] = [
1037 self.inner.atoms.UTF8_STRING,
1038 self.inner.atoms.UTF8_MIME_0,
1039 self.inner.atoms.UTF8_MIME_1,
1040 self.inner.atoms.STRING,
1041 self.inner.atoms.TEXT,
1042 self.inner.atoms.TEXT_MIME_UNKNOWN,
1043 ];
1044
1045 let atom_none: Atom = AtomEnum::NONE.into();
1046
1047 const FORMAT_ATOM_COUNT: usize = TEXT_FORMAT_COUNT + IMAGE_FORMAT_COUNT;
1048
1049 let mut format_atoms: [Atom; FORMAT_ATOM_COUNT] = [atom_none; FORMAT_ATOM_COUNT];
1050
1051 // image formats first, as they are more specific, and read will return the first
1052 // format that the contents can be converted to
1053 format_atoms[0..IMAGE_FORMAT_COUNT].copy_from_slice(&image_format_atoms);
1054 format_atoms[IMAGE_FORMAT_COUNT..].copy_from_slice(&text_format_atoms);
1055 debug_assert!(!format_atoms.contains(&atom_none));
1056
1057 let result = self.inner.read(&format_atoms, selection)?;
1058
1059 log::trace!(
1060 "read clipboard as format {:?}",
1061 self.inner.atom_name(result.format)
1062 );
1063
1064 for (format_atom, image_format) in image_format_atoms.into_iter().zip(image_formats) {
1065 if result.format == format_atom {
1066 let bytes = result.bytes;
1067 let id = hash(&bytes);
1068 return Ok(ClipboardItem::new_image(&Image {
1069 id,
1070 format: image_format,
1071 bytes,
1072 }));
1073 }
1074 }
1075
1076 let text = if result.format == self.inner.atoms.STRING {
1077 // ISO Latin-1
1078 // See: https://stackoverflow.com/questions/28169745/what-are-the-options-to-convert-iso-8859-1-latin-1-to-a-string-utf-8
1079 result.bytes.into_iter().map(|c| c as char).collect()
1080 } else {
1081 String::from_utf8(result.bytes).map_err(|_| Error::ConversionFailure)?
1082 };
1083 Ok(ClipboardItem::new_string(text))
1084 }
1085
1086 pub fn is_owner(&self, selection: ClipboardKind) -> bool {
1087 self.inner.is_owner(selection).unwrap_or(false)
1088 }
1089}
1090
1091impl Drop for Clipboard {
1092 fn drop(&mut self) {
1093 // There are always at least 3 owners:
1094 // the global, the server thread, and one `Clipboard::inner`
1095 const MIN_OWNERS: usize = 3;
1096
1097 // We start with locking the global guard to prevent race
1098 // conditions below.
1099 let mut global_cb = CLIPBOARD.lock();
1100 if Arc::strong_count(&self.inner) == MIN_OWNERS {
1101 // If the are the only owners of the clipboard are ourselves and
1102 // the global object, then we should destroy the global object,
1103 // and send the data to the clipboard manager
1104
1105 if let Err(e) = self.inner.ask_clipboard_manager_to_request_our_data() {
1106 log::error!(
1107 "Could not hand the clipboard data over to the clipboard manager: {}",
1108 e
1109 );
1110 }
1111 let global_cb = global_cb.take();
1112 if let Err(e) = self
1113 .inner
1114 .server
1115 .conn
1116 .destroy_window(self.inner.server.win_id)
1117 {
1118 log::error!("Failed to destroy the clipboard window. Error: {}", e);
1119 return;
1120 }
1121 if let Err(e) = self.inner.server.conn.flush() {
1122 log::error!("Failed to flush the clipboard window. Error: {}", e);
1123 return;
1124 }
1125 if let Some(global_cb) = global_cb
1126 && let Err(e) = global_cb.server_handle.join()
1127 {
1128 // Let's try extracting the error message
1129 let message;
1130 if let Some(msg) = e.downcast_ref::<&'static str>() {
1131 message = Some((*msg).to_string());
1132 } else if let Some(msg) = e.downcast_ref::<String>() {
1133 message = Some(msg.clone());
1134 } else {
1135 message = None;
1136 }
1137 if let Some(message) = message {
1138 log::error!(
1139 "The clipboard server thread panicked. Panic message: '{}'",
1140 message,
1141 );
1142 } else {
1143 log::error!("The clipboard server thread panicked.");
1144 }
1145 }
1146 }
1147 }
1148}
1149
1150fn into_unknown<E: std::fmt::Display>(error: E) -> Error {
1151 Error::Unknown {
1152 description: error.to_string(),
1153 }
1154}
1155
1156/// Clipboard selection
1157///
1158/// Linux has a concept of clipboard "selections" which tend to be used in different contexts. This
1159/// enum provides a way to get/set to a specific clipboard
1160///
1161/// See <https://specifications.freedesktop.org/clipboards-spec/clipboards-0.1.txt> for a better
1162/// description of the different clipboards.
1163#[derive(Copy, Clone, Debug)]
1164pub enum ClipboardKind {
1165 /// Typically used selection for explicit cut/copy/paste actions (ie. windows/macos like
1166 /// clipboard behavior)
1167 Clipboard,
1168
1169 /// Typically used for mouse selections and/or currently selected text. Accessible via middle
1170 /// mouse click.
1171 Primary,
1172
1173 /// The secondary clipboard is rarely used but theoretically available on X11.
1174 Secondary,
1175}
1176
1177/// Configuration on how long to wait for a new X11 copy event is emitted.
1178#[derive(Default)]
1179pub(crate) enum WaitConfig {
1180 /// Waits until the given [`Instant`] has reached.
1181 #[allow(
1182 unused,
1183 reason = "Right now we don't wait for clipboard contents to sync on app close, but we may in the future"
1184 )]
1185 Until(Instant),
1186
1187 /// Waits forever until a new event is reached.
1188 #[allow(unused)]
1189 #[allow(
1190 unused,
1191 reason = "Right now we don't wait for clipboard contents to sync on app close, but we may in the future"
1192 )]
1193 Forever,
1194
1195 /// It shouldn't wait.
1196 #[default]
1197 None,
1198}
1199
1200#[non_exhaustive]
1201pub enum Error {
1202 /// The clipboard contents were not available in the requested format.
1203 /// This could either be due to the clipboard being empty or the clipboard contents having
1204 /// an incompatible format to the requested one (eg when calling `get_image` on text)
1205 ContentNotAvailable,
1206
1207 /// The native clipboard is not accessible due to being held by an other party.
1208 ///
1209 /// This "other party" could be a different process or it could be within
1210 /// the same program. So for example you may get this error when trying
1211 /// to interact with the clipboard from multiple threads at once.
1212 ///
1213 /// Note that it's OK to have multiple `Clipboard` instances. The underlying
1214 /// implementation will make sure that the native clipboard is only
1215 /// opened for transferring data and then closed as soon as possible.
1216 ClipboardOccupied,
1217
1218 /// The image or the text that was about the be transferred to/from the clipboard could not be
1219 /// converted to the appropriate format.
1220 ConversionFailure,
1221
1222 /// Any error that doesn't fit the other error types.
1223 ///
1224 /// The `description` field is only meant to help the developer and should not be relied on as a
1225 /// means to identify an error case during runtime.
1226 Unknown { description: String },
1227}
1228
1229impl std::fmt::Display for Error {
1230 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1231 match self {
1232 Error::ContentNotAvailable => f.write_str("The clipboard contents were not available in the requested format or the clipboard is empty."),
1233 Error::ClipboardOccupied => f.write_str("The native clipboard is not accessible due to being held by an other party."),
1234 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."),
1235 Error::Unknown { description } => f.write_fmt(format_args!("Unknown error while interacting with the clipboard: {description}")),
1236 }
1237 }
1238}
1239
1240impl std::error::Error for Error {}
1241
1242impl std::fmt::Debug for Error {
1243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1244 use Error::*;
1245 macro_rules! kind_to_str {
1246 ($( $e: pat ),*) => {
1247 match self {
1248 $(
1249 $e => stringify!($e),
1250 )*
1251 }
1252 }
1253 }
1254 let name = kind_to_str!(
1255 ContentNotAvailable,
1256 ClipboardOccupied,
1257 ConversionFailure,
1258 Unknown { .. }
1259 );
1260 f.write_fmt(format_args!("{name} - \"{self}\""))
1261 }
1262}
1263
1264impl Error {
1265 pub(crate) fn unknown<M: Into<String>>(message: M) -> Self {
1266 Error::Unknown {
1267 description: message.into(),
1268 }
1269 }
1270}