Skip to main content

ashlar_native/
clipboard.rs

1//! The system clipboard: text, RGBA images (arboard) and, on macOS, custom pasteboard types so an
2//! app can copy its own data alongside a plain-text fallback.
3//!
4//! Every call opens the clipboard briefly and closes it again, so nothing is held between calls.
5//! On Linux (X11) the clipboard is owned by the process that copied: arboard keeps a helper
6//! thread so text stays pasteable for a while; see arboard's docs for the details.
7
8use crate::{NativeError, Result};
9
10/// Largest image accepted, in pixels per side.
11pub const MAX_IMAGE_SIDE: u32 = 16_384;
12/// Largest custom payload, in bytes.
13pub const MAX_CUSTOM_BYTES: usize = 256 * 1024 * 1024;
14
15/// An image as RGBA8, row-major, no padding.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Image {
18    pub width: u32,
19    pub height: u32,
20    pub rgba: Vec<u8>,
21}
22
23impl Image {
24    /// Check the size against the pixel data; refuse empty and oversized images.
25    pub fn validate(&self) -> Result<()> {
26        let expected = (self.width as usize).checked_mul(self.height as usize).and_then(|n| n.checked_mul(4));
27        if self.width == 0 || self.height == 0 || self.width > MAX_IMAGE_SIDE || self.height > MAX_IMAGE_SIDE {
28            return Err(NativeError::Invalid(format!("an image of {}×{} pixels can't be copied", self.width, self.height)));
29        }
30        if expected != Some(self.rgba.len()) {
31            return Err(NativeError::Invalid("the pixel data does not match the image size".into()));
32        }
33        Ok(())
34    }
35}
36
37/// A pasteboard type name for custom data: a uniform type identifier on macOS
38/// (`com.example.app.clip`) or a MIME type (`application/x-example-clip`). Bounded and printable.
39pub fn valid_type(name: &str) -> bool {
40    !name.is_empty() && name.len() <= 128 && name.chars().all(|c| c.is_ascii_alphanumeric() || "._-+/".contains(c))
41}
42
43#[cfg(not(target_arch = "wasm32"))]
44mod imp {
45    use std::borrow::Cow;
46
47    use super::*;
48
49    fn map(e: arboard::Error) -> NativeError {
50        match e {
51            arboard::Error::ContentNotAvailable => NativeError::Os("the clipboard holds no such content".into()),
52            arboard::Error::ClipboardNotSupported => NativeError::Unsupported,
53            arboard::Error::ClipboardOccupied => NativeError::Os("another application is using the clipboard; try again".into()),
54            arboard::Error::ConversionFailure => NativeError::Os("the clipboard content could not be converted".into()),
55            // `Unknown` carries a description from the OS; never the clipboard content.
56            arboard::Error::Unknown { description } => NativeError::Os(crate::os_text(description)),
57            _ => NativeError::Os("the clipboard failed".into()),
58        }
59    }
60
61    fn open() -> Result<arboard::Clipboard> {
62        crate::guarded("the clipboard", || arboard::Clipboard::new().map_err(map))
63    }
64
65    /// The clipboard's text, `None` when it holds no text.
66    pub fn text() -> Result<Option<String>> {
67        let mut c = open()?;
68        crate::guarded("the clipboard", || match c.get_text() {
69            Ok(t) => Ok(Some(t)),
70            Err(arboard::Error::ContentNotAvailable) => Ok(None),
71            Err(e) => Err(map(e)),
72        })
73    }
74
75    pub fn set_text(text: &str) -> Result<()> {
76        let mut c = open()?;
77        crate::guarded("the clipboard", || c.set_text(text).map_err(map))
78    }
79
80    /// The clipboard's image as RGBA8, `None` when it holds no image.
81    pub fn image() -> Result<Option<Image>> {
82        let mut c = open()?;
83        crate::guarded("the clipboard", || match c.get_image() {
84            Ok(img) => {
85                let (Ok(width), Ok(height)) = (u32::try_from(img.width), u32::try_from(img.height)) else {
86                    return Err(NativeError::Os("the clipboard image is too large".into()));
87                };
88                let image = Image { width, height, rgba: img.bytes.into_owned() };
89                image.validate()?;
90                Ok(Some(image))
91            }
92            Err(arboard::Error::ContentNotAvailable) => Ok(None),
93            Err(e) => Err(map(e)),
94        })
95    }
96
97    pub fn set_image(image: &Image) -> Result<()> {
98        image.validate()?;
99        let mut c = open()?;
100        let data = arboard::ImageData { width: image.width as usize, height: image.height as usize, bytes: Cow::Borrowed(&image.rgba) };
101        crate::guarded("the clipboard", || c.set_image(data).map_err(map))
102    }
103
104    pub fn clear() -> Result<()> {
105        let mut c = open()?;
106        crate::guarded("the clipboard", || c.clear().map_err(map))
107    }
108}
109
110#[cfg(target_arch = "wasm32")]
111mod imp {
112    use super::*;
113
114    pub fn text() -> Result<Option<String>> {
115        Err(NativeError::Unsupported)
116    }
117    pub fn set_text(_: &str) -> Result<()> {
118        Err(NativeError::Unsupported)
119    }
120    pub fn image() -> Result<Option<Image>> {
121        Err(NativeError::Unsupported)
122    }
123    pub fn set_image(_: &Image) -> Result<()> {
124        Err(NativeError::Unsupported)
125    }
126    pub fn clear() -> Result<()> {
127        Err(NativeError::Unsupported)
128    }
129}
130
131pub use imp::{clear, image, set_image, set_text, text};
132
133#[cfg(target_os = "macos")]
134mod custom {
135    use objc2_app_kit::NSPasteboard;
136    use objc2_foundation::{NSData, NSString};
137
138    use super::*;
139
140    /// Replace the clipboard with these representations of one piece of content, for example
141    /// the app's own format plus `public.utf8-plain-text` for other apps.
142    pub fn set_custom(items: &[(&str, &[u8])]) -> Result<()> {
143        check(items)?;
144        let pb = NSPasteboard::generalPasteboard();
145        pb.clearContents();
146        for (ty, bytes) in items {
147            let data = NSData::with_bytes(bytes);
148            if !pb.setData_forType(Some(&data), &NSString::from_str(ty)) {
149                return Err(NativeError::Os(format!("the pasteboard refused type `{ty}`")));
150            }
151        }
152        Ok(())
153    }
154
155    /// The clipboard's data of type `ty`, `None` when it holds none.
156    pub fn custom(ty: &str) -> Result<Option<Vec<u8>>> {
157        if !valid_type(ty) {
158            return Err(NativeError::Invalid(format!("`{}` is not a pasteboard type", ty.chars().take(64).collect::<String>())));
159        }
160        let pb = NSPasteboard::generalPasteboard();
161        let data = pb.dataForType(&NSString::from_str(ty));
162        let Some(data) = data else { return Ok(None) };
163        if data.len() > MAX_CUSTOM_BYTES {
164            return Err(NativeError::Os("the clipboard data is too large".into()));
165        }
166        Ok(Some(data.to_vec()))
167    }
168
169    /// The type names the clipboard currently holds.
170    pub fn types() -> Result<Vec<String>> {
171        let pb = NSPasteboard::generalPasteboard();
172        Ok(pb.types().map(|a| a.iter().take(256).map(|t| t.to_string()).collect()).unwrap_or_default())
173    }
174}
175
176#[cfg(not(target_os = "macos"))]
177mod custom {
178    use super::*;
179
180    pub fn set_custom(items: &[(&str, &[u8])]) -> Result<()> {
181        check(items)?;
182        Err(NativeError::Unsupported)
183    }
184    pub fn custom(_: &str) -> Result<Option<Vec<u8>>> {
185        Err(NativeError::Unsupported)
186    }
187    pub fn types() -> Result<Vec<String>> {
188        Err(NativeError::Unsupported)
189    }
190}
191
192/// Custom types: `set_custom(&[("com.example.clip", bytes), ("public.utf8-plain-text", text)])`,
193/// `custom("com.example.clip")`, `types()`. macOS only for now; elsewhere `Unsupported`.
194pub use custom::{custom, set_custom, types};
195
196fn check(items: &[(&str, &[u8])]) -> Result<()> {
197    if items.is_empty() || items.len() > 16 {
198        return Err(NativeError::Invalid("copy between 1 and 16 representations".into()));
199    }
200    for (ty, bytes) in items {
201        if !valid_type(ty) {
202            return Err(NativeError::Invalid(format!("`{}` is not a pasteboard type", ty.chars().take(64).collect::<String>())));
203        }
204        if bytes.len() > MAX_CUSTOM_BYTES {
205            return Err(NativeError::Invalid(format!("`{ty}` is larger than {MAX_CUSTOM_BYTES} bytes")));
206        }
207    }
208    Ok(())
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn images_are_checked_before_the_os_sees_them() {
217        assert!(Image { width: 2, height: 2, rgba: vec![0; 16] }.validate().is_ok());
218        assert!(Image { width: 2, height: 2, rgba: vec![0; 15] }.validate().is_err());
219        assert!(Image { width: 0, height: 2, rgba: vec![] }.validate().is_err());
220        assert!(Image { width: u32::MAX, height: u32::MAX, rgba: vec![] }.validate().is_err());
221        assert!(set_image(&Image { width: 3, height: 1, rgba: vec![1, 2] }).is_err());
222    }
223
224    #[test]
225    fn custom_types_are_validated() {
226        assert!(valid_type("com.example.app.clip"));
227        assert!(valid_type("application/x-example+json"));
228        for bad in ["", "a b", "a\nb", "ü", &"x".repeat(129)] {
229            assert!(!valid_type(bad), "{bad:?}");
230        }
231        assert!(set_custom(&[]).is_err());
232        assert!(matches!(set_custom(&[("bad type", b"x")]), Err(NativeError::Invalid(_))));
233        assert!(matches!(custom("bad type"), Err(NativeError::Invalid(_)) | Err(NativeError::Unsupported)));
234    }
235
236    /// Writes to the real clipboard: `cargo test -p ashlar-native clipboard -- --ignored`.
237    #[test]
238    #[ignore = "replaces the user's clipboard"]
239    fn real_clipboard_round_trip() {
240        set_text("ashlar clipboard test").unwrap();
241        assert_eq!(text().unwrap().as_deref(), Some("ashlar clipboard test"));
242        let img = Image { width: 2, height: 1, rgba: vec![255, 0, 0, 255, 0, 0, 255, 255] };
243        set_image(&img).unwrap();
244        assert_eq!(image().unwrap().map(|i| (i.width, i.height)), Some((2, 1)));
245        if cfg!(target_os = "macos") {
246            set_custom(&[("com.example.ashlar.test", b"\x00\x01binary"), ("public.utf8-plain-text", b"fallback")]).unwrap();
247            assert_eq!(custom("com.example.ashlar.test").unwrap().as_deref(), Some(&b"\x00\x01binary"[..]));
248            assert_eq!(text().unwrap().as_deref(), Some("fallback"));
249            assert!(types().unwrap().iter().any(|t| t == "com.example.ashlar.test"));
250        }
251        clear().unwrap();
252    }
253}