arboard/
lib.rs

1/*
2SPDX-License-Identifier: Apache-2.0 OR MIT
3
4Copyright 2022 The Arboard contributors
5
6The project to which this file belongs is licensed under either of
7the Apache 2.0 or the MIT license at the licensee's choice. The terms
8and conditions of the chosen license apply to this file.
9*/
10#![warn(unreachable_pub)]
11
12mod common;
13use std::{
14	borrow::Cow,
15	path::{Path, PathBuf},
16};
17
18pub use common::Error;
19#[cfg(feature = "image-data")]
20pub use common::ImageData;
21
22mod platform;
23
24#[cfg(all(
25	unix,
26	not(any(target_os = "macos", target_os = "android", target_os = "emscripten")),
27))]
28pub use platform::{ClearExtLinux, GetExtLinux, LinuxClipboardKind, SetExtLinux};
29
30#[cfg(windows)]
31pub use platform::SetExtWindows;
32
33#[cfg(target_os = "macos")]
34pub use platform::SetExtApple;
35
36/// The OS independent struct for accessing the clipboard.
37///
38/// Any number of `Clipboard` instances are allowed to exist at a single point in time. Note however
39/// that all `Clipboard`s must be 'dropped' before the program exits. In most scenarios this happens
40/// automatically but there are frameworks (for example, `winit`) that take over the execution
41/// and where the objects don't get dropped when the application exits. In these cases you have to
42/// make sure the object is dropped by taking ownership of it in a confined scope when detecting
43/// that your application is about to quit.
44///
45/// It is also valid to have these multiple `Clipboards` on separate threads at once but note that
46/// executing multiple clipboard operations in parallel might fail with a `ClipboardOccupied` error.
47///
48/// # Platform-specific behavior
49///
50/// `arboard` does its best to abstract over different platforms, but sometimes the platform-specific
51/// behavior leaks through unsolvably. These differences, depending on which platforms are being targeted,
52/// may affect your app's clipboard architecture (ex, opening and closing a [`Clipboard`] every time
53/// or keeping one open in some application/global state).
54///
55/// ## Linux
56///
57/// Using either Wayland and X11, the clipboard and its content is "hosted" inside of the application
58/// that last put data onto it. This means that when the last `Clipboard` instance is dropped, the contents
59/// may become unavailable to other apps. See [SetExtLinux] for more details.
60///
61/// ## Windows
62///
63/// The clipboard on Windows is a global object, which may only be opened on one thread at once.
64/// This means that `arboard` only truly opens the clipboard during each operation to prevent
65/// multiple `Clipboard`s from existing at once.
66///
67/// This means that attempting operations in parallel has a high likelihood to return an error or
68/// deadlock. As such, it is recommended to avoid creating/operating clipboard objects on >1 thread.
69#[allow(rustdoc::broken_intra_doc_links)]
70pub struct Clipboard {
71	pub(crate) platform: platform::Clipboard,
72}
73
74impl Clipboard {
75	/// Creates an instance of the clipboard.
76	///
77	/// # Errors
78	///
79	/// On some platforms or desktop environments, an error can be returned if clipboards are not
80	/// supported. This may be retried.
81	pub fn new() -> Result<Self, Error> {
82		Ok(Clipboard { platform: platform::Clipboard::new()? })
83	}
84
85	/// Fetches UTF-8 text from the clipboard and returns it.
86	///
87	/// # Errors
88	///
89	/// Returns error if clipboard is empty or contents are not UTF-8 text.
90	pub fn get_text(&mut self) -> Result<String, Error> {
91		self.get().text()
92	}
93
94	/// Places the text onto the clipboard. Any valid UTF-8 string is accepted.
95	///
96	/// # Errors
97	///
98	/// Returns error if `text` failed to be stored on the clipboard.
99	pub fn set_text<'a, T: Into<Cow<'a, str>>>(&mut self, text: T) -> Result<(), Error> {
100		self.set().text(text)
101	}
102
103	/// Places the HTML as well as a plain-text alternative onto the clipboard.
104	///
105	/// Any valid UTF-8 string is accepted.
106	///
107	/// # Errors
108	///
109	/// Returns error if both `html` and `alt_text` failed to be stored on the clipboard.
110	pub fn set_html<'a, T: Into<Cow<'a, str>>>(
111		&mut self,
112		html: T,
113		alt_text: Option<T>,
114	) -> Result<(), Error> {
115		self.set().html(html, alt_text)
116	}
117
118	/// Fetches image data from the clipboard, and returns the decoded pixels.
119	///
120	/// Any image data placed on the clipboard with `set_image` will be possible read back, using
121	/// this function. However it's of not guaranteed that an image placed on the clipboard by any
122	/// other application will be of a supported format.
123	///
124	/// # Errors
125	///
126	/// Returns error if clipboard is empty, contents are not an image, or the contents cannot be
127	/// converted to an appropriate format and stored in the [`ImageData`] type.
128	#[cfg(feature = "image-data")]
129	pub fn get_image(&mut self) -> Result<ImageData<'static>, Error> {
130		self.get().image()
131	}
132
133	/// Places an image to the clipboard.
134	///
135	/// The chosen output format, depending on the platform is the following:
136	///
137	/// - On macOS: `NSImage` object
138	/// - On Linux: PNG, under the atom `image/png`
139	/// - On Windows: In order of priority `CF_DIB` and `CF_BITMAP`
140	///
141	/// # Errors
142	///
143	/// Returns error if `image` cannot be converted to an appropriate format or if it failed to be
144	/// stored on the clipboard.
145	#[cfg(feature = "image-data")]
146	pub fn set_image(&mut self, image: ImageData) -> Result<(), Error> {
147		self.set().image(image)
148	}
149
150	/// Clears any contents that may be present from the platform's default clipboard,
151	/// regardless of the format of the data.
152	///
153	/// # Errors
154	///
155	/// Returns error on Windows or Linux if clipboard cannot be cleared.
156	pub fn clear(&mut self) -> Result<(), Error> {
157		self.clear_with().default()
158	}
159
160	/// Begins a "clear" option to remove data from the clipboard.
161	pub fn clear_with(&mut self) -> Clear<'_> {
162		Clear { platform: platform::Clear::new(&mut self.platform) }
163	}
164
165	/// Begins a "get" operation to retrieve data from the clipboard.
166	pub fn get(&mut self) -> Get<'_> {
167		Get { platform: platform::Get::new(&mut self.platform) }
168	}
169
170	/// Begins a "set" operation to set the clipboard's contents.
171	pub fn set(&mut self) -> Set<'_> {
172		Set { platform: platform::Set::new(&mut self.platform) }
173	}
174}
175
176/// A builder for an operation that gets a value from the clipboard.
177#[must_use]
178pub struct Get<'clipboard> {
179	pub(crate) platform: platform::Get<'clipboard>,
180}
181
182impl Get<'_> {
183	/// Completes the "get" operation by fetching UTF-8 text from the clipboard.
184	pub fn text(self) -> Result<String, Error> {
185		self.platform.text()
186	}
187
188	/// Completes the "get" operation by fetching image data from the clipboard and returning the
189	/// decoded pixels.
190	///
191	/// Any image data placed on the clipboard with `set_image` will be possible read back, using
192	/// this function. However it's of not guaranteed that an image placed on the clipboard by any
193	/// other application will be of a supported format.
194	#[cfg(feature = "image-data")]
195	pub fn image(self) -> Result<ImageData<'static>, Error> {
196		self.platform.image()
197	}
198
199	/// Completes the "get" operation by fetching HTML from the clipboard.
200	pub fn html(self) -> Result<String, Error> {
201		self.platform.html()
202	}
203
204	/// Completes the "get" operation by fetching a list of file paths from the clipboard.
205	pub fn file_list(self) -> Result<Vec<PathBuf>, Error> {
206		self.platform.file_list()
207	}
208}
209
210/// A builder for an operation that sets a value to the clipboard.
211#[must_use]
212pub struct Set<'clipboard> {
213	pub(crate) platform: platform::Set<'clipboard>,
214}
215
216impl Set<'_> {
217	/// Completes the "set" operation by placing text onto the clipboard. Any valid UTF-8 string
218	/// is accepted.
219	pub fn text<'a, T: Into<Cow<'a, str>>>(self, text: T) -> Result<(), Error> {
220		let text = text.into();
221		self.platform.text(text)
222	}
223
224	/// Completes the "set" operation by placing HTML as well as a plain-text alternative onto the
225	/// clipboard.
226	///
227	/// Any valid UTF-8 string is accepted.
228	pub fn html<'a, T: Into<Cow<'a, str>>>(
229		self,
230		html: T,
231		alt_text: Option<T>,
232	) -> Result<(), Error> {
233		let html = html.into();
234		let alt_text = alt_text.map(|e| e.into());
235		self.platform.html(html, alt_text)
236	}
237
238	/// Completes the "set" operation by placing an image onto the clipboard.
239	///
240	/// The chosen output format, depending on the platform is the following:
241	///
242	/// - On macOS: `NSImage` object
243	/// - On Linux: PNG, under the atom `image/png`
244	/// - On Windows: In order of priority `CF_DIB` and `CF_BITMAP`
245	#[cfg(feature = "image-data")]
246	pub fn image(self, image: ImageData) -> Result<(), Error> {
247		self.platform.image(image)
248	}
249
250	/// Completes the "set" operation by placing a list of file paths onto the clipboard.
251	pub fn file_list(self, file_list: &[impl AsRef<Path>]) -> Result<(), Error> {
252		self.platform.file_list(file_list)
253	}
254}
255
256/// A builder for an operation that clears the data from the clipboard.
257#[must_use]
258pub struct Clear<'clipboard> {
259	pub(crate) platform: platform::Clear<'clipboard>,
260}
261
262impl Clear<'_> {
263	/// Completes the "clear" operation by deleting any existing clipboard data,
264	/// regardless of the format.
265	pub fn default(self) -> Result<(), Error> {
266		self.platform.clear()
267	}
268}
269
270/// All tests grouped in one because the windows clipboard cannot be open on
271/// multiple threads at once.
272#[cfg(test)]
273mod tests {
274	use super::*;
275	use std::{sync::Arc, thread, time::Duration};
276
277	#[test]
278	fn all_tests() {
279		let _ = env_logger::builder().is_test(true).try_init();
280		{
281			let mut ctx = Clipboard::new().unwrap();
282			let text = "some string";
283			ctx.set_text(text).unwrap();
284			assert_eq!(ctx.get_text().unwrap(), text);
285
286			// We also need to check that the content persists after the drop; this is
287			// especially important on X11
288			drop(ctx);
289
290			// Give any external mechanism a generous amount of time to take over
291			// responsibility for the clipboard, in case that happens asynchronously
292			// (it appears that this is the case on X11 plus Mutter 3.34+, see #4)
293			thread::sleep(Duration::from_millis(300));
294
295			let mut ctx = Clipboard::new().unwrap();
296			assert_eq!(ctx.get_text().unwrap(), text);
297		}
298		{
299			let mut ctx = Clipboard::new().unwrap();
300			let text = "Some utf8: 🤓 ∑φ(n)<ε 🐔";
301			ctx.set_text(text).unwrap();
302			assert_eq!(ctx.get_text().unwrap(), text);
303		}
304		{
305			let mut ctx = Clipboard::new().unwrap();
306			let text = "hello world";
307
308			ctx.set_text(text).unwrap();
309			assert_eq!(ctx.get_text().unwrap(), text);
310
311			ctx.clear().unwrap();
312
313			match ctx.get_text() {
314				Ok(text) => assert!(text.is_empty()),
315				Err(Error::ContentNotAvailable) => {}
316				Err(e) => panic!("unexpected error: {e}"),
317			};
318
319			// confirm it is OK to clear when already empty.
320			ctx.clear().unwrap();
321		}
322		{
323			let mut ctx = Clipboard::new().unwrap();
324			let html = "<b>hello</b> <i>world</i>!";
325
326			ctx.set_html(html, None).unwrap();
327
328			match ctx.get_text() {
329				Ok(text) => assert!(text.is_empty()),
330				Err(Error::ContentNotAvailable) => {}
331				Err(e) => panic!("unexpected error: {e}"),
332			};
333		}
334		{
335			let mut ctx = Clipboard::new().unwrap();
336
337			let html = "<b>hello</b> <i>world</i>!";
338			let alt_text = "hello world!";
339
340			ctx.set_html(html, Some(alt_text)).unwrap();
341			assert_eq!(ctx.get_text().unwrap(), alt_text);
342		}
343		{
344			let mut ctx = Clipboard::new().unwrap();
345
346			let html = "<b>hello</b> <i>world</i>!";
347
348			ctx.set().html(html, None).unwrap();
349
350			if cfg!(target_os = "macos") {
351				// Copying HTML on macOS adds wrapper content to work around
352				// historical platform bugs. We control this wrapper, so we are
353				// able to check that the full user data still appears and at what
354				// position in the final copy contents.
355				let content = ctx.get().html().unwrap();
356				assert!(content.ends_with(&format!("{html}</body></html>")));
357			} else {
358				assert_eq!(ctx.get().html().unwrap(), html);
359			}
360		}
361		{
362			let mut ctx = Clipboard::new().unwrap();
363
364			let this_dir = env!("CARGO_MANIFEST_DIR");
365
366			let paths = &[
367				PathBuf::from(this_dir).join("README.md"),
368				PathBuf::from(this_dir).join("Cargo.toml"),
369			];
370
371			ctx.set().file_list(paths).unwrap();
372			assert_eq!(ctx.get().file_list().unwrap().as_slice(), paths);
373		}
374		#[cfg(feature = "image-data")]
375		{
376			let mut ctx = Clipboard::new().unwrap();
377			#[rustfmt::skip]
378			let bytes = [
379				255, 100, 100, 255,
380				100, 255, 100, 100,
381				100, 100, 255, 100,
382				0, 0, 0, 255,
383			];
384			let img_data = ImageData { width: 2, height: 2, bytes: bytes.as_ref().into() };
385
386			// Make sure that setting one format overwrites the other.
387			ctx.set_image(img_data.clone()).unwrap();
388			assert!(matches!(ctx.get_text(), Err(Error::ContentNotAvailable)));
389
390			ctx.set_text("clipboard test").unwrap();
391			assert!(matches!(ctx.get_image(), Err(Error::ContentNotAvailable)));
392
393			// Test if we get the same image that we put onto the clipboard
394			ctx.set_image(img_data.clone()).unwrap();
395			let got = ctx.get_image().unwrap();
396			assert_eq!(img_data.bytes, got.bytes);
397
398			#[rustfmt::skip]
399			let big_bytes = vec![
400				255, 100, 100, 255,
401				100, 255, 100, 100,
402				100, 100, 255, 100,
403
404				0, 1, 2, 255,
405				0, 1, 2, 255,
406				0, 1, 2, 255,
407			];
408			let bytes_cloned = big_bytes.clone();
409			let big_img_data = ImageData { width: 3, height: 2, bytes: big_bytes.into() };
410			ctx.set_image(big_img_data).unwrap();
411			let got = ctx.get_image().unwrap();
412			assert_eq!(bytes_cloned.as_slice(), got.bytes.as_ref());
413		}
414		#[cfg(all(
415			unix,
416			not(any(target_os = "macos", target_os = "android", target_os = "emscripten")),
417		))]
418		{
419			use crate::{LinuxClipboardKind, SetExtLinux};
420			use std::sync::atomic::{self, AtomicBool};
421
422			let mut ctx = Clipboard::new().unwrap();
423
424			const TEXT1: &str = "I'm a little teapot,";
425			const TEXT2: &str = "short and stout,";
426			const TEXT3: &str = "here is my handle";
427
428			ctx.set().clipboard(LinuxClipboardKind::Clipboard).text(TEXT1.to_string()).unwrap();
429
430			ctx.set().clipboard(LinuxClipboardKind::Primary).text(TEXT2.to_string()).unwrap();
431
432			// The secondary clipboard is not available under wayland
433			if !cfg!(feature = "wayland-data-control")
434				|| std::env::var_os("WAYLAND_DISPLAY").is_none()
435			{
436				ctx.set().clipboard(LinuxClipboardKind::Secondary).text(TEXT3.to_string()).unwrap();
437			}
438
439			assert_eq!(TEXT1, &ctx.get().clipboard(LinuxClipboardKind::Clipboard).text().unwrap());
440
441			assert_eq!(TEXT2, &ctx.get().clipboard(LinuxClipboardKind::Primary).text().unwrap());
442
443			// The secondary clipboard is not available under wayland
444			if !cfg!(feature = "wayland-data-control")
445				|| std::env::var_os("WAYLAND_DISPLAY").is_none()
446			{
447				assert_eq!(
448					TEXT3,
449					&ctx.get().clipboard(LinuxClipboardKind::Secondary).text().unwrap()
450				);
451			}
452
453			let was_replaced = Arc::new(AtomicBool::new(false));
454
455			let setter = thread::spawn({
456				let was_replaced = was_replaced.clone();
457				move || {
458					thread::sleep(Duration::from_millis(100));
459					let mut ctx = Clipboard::new().unwrap();
460					ctx.set_text("replacement text".to_owned()).unwrap();
461					was_replaced.store(true, atomic::Ordering::Release);
462				}
463			});
464
465			ctx.set().wait().text("initial text".to_owned()).unwrap();
466
467			assert!(was_replaced.load(atomic::Ordering::Acquire));
468
469			setter.join().unwrap();
470		}
471	}
472
473	// The cross-platform abstraction should allow any number of clipboards
474	// to be open at once without issue, as documented under [Clipboard].
475	#[test]
476	fn multiple_clipboards_at_once() {
477		const THREAD_COUNT: usize = 100;
478
479		let mut handles = Vec::with_capacity(THREAD_COUNT);
480		let barrier = Arc::new(std::sync::Barrier::new(THREAD_COUNT));
481
482		for _ in 0..THREAD_COUNT {
483			let barrier = barrier.clone();
484			handles.push(thread::spawn(move || {
485				// As long as the clipboard isn't used multiple times at once, multiple instances
486				// are perfectly fine.
487				let _ctx = Clipboard::new().unwrap();
488
489				thread::sleep(Duration::from_millis(10));
490
491				barrier.wait();
492			}));
493		}
494
495		for thread_handle in handles {
496			thread_handle.join().unwrap();
497		}
498	}
499
500	#[test]
501	fn clipboard_trait_consistently() {
502		fn assert_send_sync<T: Send + Sync + 'static>() {}
503
504		assert_send_sync::<Clipboard>();
505		assert!(std::mem::needs_drop::<Clipboard>());
506	}
507}