ashlar_native/
clipboard.rs1use crate::{NativeError, Result};
9
10pub const MAX_IMAGE_SIDE: u32 = 16_384;
12pub const MAX_CUSTOM_BYTES: usize = 256 * 1024 * 1024;
14
15#[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 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
37pub 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 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 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 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 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 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 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
192pub 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 #[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}