pasteboard.rs

  1use core::slice;
  2use std::ffi::{CStr, c_void};
  3use std::path::PathBuf;
  4
  5use cocoa::{
  6    appkit::{
  7        NSFilenamesPboardType, NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString,
  8        NSPasteboardTypeTIFF,
  9    },
 10    base::{id, nil},
 11    foundation::{NSArray, NSData, NSFastEnumeration, NSString},
 12};
 13use objc::{msg_send, runtime::Object, sel, sel_impl};
 14use smallvec::SmallVec;
 15use strum::IntoEnumIterator as _;
 16
 17use crate::ns_string;
 18use gpui::{
 19    ClipboardEntry, ClipboardItem, ClipboardString, ExternalPaths, Image, ImageFormat, hash,
 20};
 21
 22pub struct Pasteboard {
 23    inner: id,
 24    text_hash_type: id,
 25    metadata_type: id,
 26}
 27
 28impl Pasteboard {
 29    pub fn general() -> Self {
 30        unsafe { Self::new(NSPasteboard::generalPasteboard(nil)) }
 31    }
 32
 33    pub fn find() -> Self {
 34        unsafe { Self::new(NSPasteboard::pasteboardWithName(nil, NSPasteboardNameFind)) }
 35    }
 36
 37    #[cfg(test)]
 38    pub fn unique() -> Self {
 39        unsafe { Self::new(NSPasteboard::pasteboardWithUniqueName(nil)) }
 40    }
 41
 42    unsafe fn new(inner: id) -> Self {
 43        Self {
 44            inner,
 45            text_hash_type: unsafe { ns_string("zed-text-hash") },
 46            metadata_type: unsafe { ns_string("zed-metadata") },
 47        }
 48    }
 49
 50    pub fn read(&self) -> Option<ClipboardItem> {
 51        unsafe {
 52            // Check for file paths first
 53            let filenames = NSPasteboard::propertyListForType(self.inner, NSFilenamesPboardType);
 54            if filenames != nil && NSArray::count(filenames) > 0 {
 55                let mut paths = SmallVec::new();
 56                for file in filenames.iter() {
 57                    let f = NSString::UTF8String(file);
 58                    let path = CStr::from_ptr(f).to_string_lossy().into_owned();
 59                    paths.push(PathBuf::from(path));
 60                }
 61                if !paths.is_empty() {
 62                    let mut entries = vec![ClipboardEntry::ExternalPaths(ExternalPaths(paths))];
 63
 64                    // Also include the string representation so text editors can
 65                    // paste the path as text.
 66                    if let Some(string_item) = self.read_string_from_pasteboard() {
 67                        entries.push(string_item);
 68                    }
 69
 70                    return Some(ClipboardItem { entries });
 71                }
 72            }
 73
 74            // Next, check for a plain string.
 75            if let Some(string_entry) = self.read_string_from_pasteboard() {
 76                return Some(ClipboardItem {
 77                    entries: vec![string_entry],
 78                });
 79            }
 80
 81            // Finally, try the various supported image types.
 82            for format in ImageFormat::iter() {
 83                if let Some(item) = self.read_image(format) {
 84                    return Some(item);
 85                }
 86            }
 87        }
 88
 89        None
 90    }
 91
 92    fn read_image(&self, format: ImageFormat) -> Option<ClipboardItem> {
 93        let ut_type: UTType = format.into();
 94
 95        unsafe {
 96            let types: id = self.inner.types();
 97            if msg_send![types, containsObject: ut_type.inner()] {
 98                self.data_for_type(ut_type.inner_mut()).map(|bytes| {
 99                    let bytes = bytes.to_vec();
100                    let id = hash(&bytes);
101
102                    ClipboardItem {
103                        entries: vec![ClipboardEntry::Image(Image { format, bytes, id })],
104                    }
105                })
106            } else {
107                None
108            }
109        }
110    }
111
112    unsafe fn read_string_from_pasteboard(&self) -> Option<ClipboardEntry> {
113        unsafe {
114            let pasteboard_types: id = self.inner.types();
115            let string_type: id = ns_string("public.utf8-plain-text");
116
117            if !msg_send![pasteboard_types, containsObject: string_type] {
118                return None;
119            }
120
121            let data = self.inner.dataForType(string_type);
122            let text_bytes: &[u8] = if data == nil {
123                return None;
124            } else if data.bytes().is_null() {
125                // https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc
126                // "If the length of the NSData object is 0, this property returns nil."
127                &[]
128            } else {
129                slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize)
130            };
131
132            let text = String::from_utf8_lossy(text_bytes).to_string();
133            let metadata = self
134                .data_for_type(self.text_hash_type)
135                .and_then(|hash_bytes| {
136                    let hash_bytes = hash_bytes.try_into().ok()?;
137                    let hash = u64::from_be_bytes(hash_bytes);
138                    let metadata = self.data_for_type(self.metadata_type)?;
139
140                    if hash == ClipboardString::text_hash(&text) {
141                        String::from_utf8(metadata.to_vec()).ok()
142                    } else {
143                        None
144                    }
145                });
146
147            Some(ClipboardEntry::String(ClipboardString { text, metadata }))
148        }
149    }
150
151    unsafe fn data_for_type(&self, kind: id) -> Option<&[u8]> {
152        unsafe {
153            let data = self.inner.dataForType(kind);
154            if data == nil {
155                None
156            } else {
157                Some(slice::from_raw_parts(
158                    data.bytes() as *mut u8,
159                    data.length() as usize,
160                ))
161            }
162        }
163    }
164
165    pub fn write(&self, item: ClipboardItem) {
166        unsafe {
167            match item.entries.as_slice() {
168                [] => {
169                    // Writing an empty list of entries just clears the clipboard.
170                    self.inner.clearContents();
171                }
172                [ClipboardEntry::String(string)] => {
173                    self.write_plaintext(string);
174                }
175                [ClipboardEntry::Image(image)] => {
176                    self.write_image(image);
177                }
178                [ClipboardEntry::ExternalPaths(_)] => {}
179                _ => {
180                    // Agus NB: We're currently only writing string entries to the clipboard when we have more than one.
181                    //
182                    // This was the existing behavior before I refactored the outer clipboard code:
183                    // https://github.com/zed-industries/zed/blob/65f7412a0265552b06ce122655369d6cc7381dd6/crates/gpui/src/platform/mac/platform.rs#L1060-L1110
184                    //
185                    // Note how `any_images` is always `false`. We should fix that, but that's orthogonal to the refactor.
186
187                    let mut combined = ClipboardString {
188                        text: String::new(),
189                        metadata: None,
190                    };
191
192                    for entry in item.entries {
193                        match entry {
194                            ClipboardEntry::String(text) => {
195                                combined.text.push_str(&text.text());
196                                if combined.metadata.is_none() {
197                                    combined.metadata = text.metadata;
198                                }
199                            }
200                            _ => {}
201                        }
202                    }
203
204                    self.write_plaintext(&combined);
205                }
206            }
207        }
208    }
209
210    fn write_plaintext(&self, string: &ClipboardString) {
211        unsafe {
212            self.inner.clearContents();
213
214            let text_bytes = NSData::dataWithBytes_length_(
215                nil,
216                string.text.as_ptr() as *const c_void,
217                string.text.len() as u64,
218            );
219            self.inner
220                .setData_forType(text_bytes, NSPasteboardTypeString);
221
222            if let Some(metadata) = string.metadata.as_ref() {
223                let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes();
224                let hash_bytes = NSData::dataWithBytes_length_(
225                    nil,
226                    hash_bytes.as_ptr() as *const c_void,
227                    hash_bytes.len() as u64,
228                );
229                self.inner.setData_forType(hash_bytes, self.text_hash_type);
230
231                let metadata_bytes = NSData::dataWithBytes_length_(
232                    nil,
233                    metadata.as_ptr() as *const c_void,
234                    metadata.len() as u64,
235                );
236                self.inner
237                    .setData_forType(metadata_bytes, self.metadata_type);
238            }
239        }
240    }
241
242    unsafe fn write_image(&self, image: &Image) {
243        unsafe {
244            self.inner.clearContents();
245
246            let bytes = NSData::dataWithBytes_length_(
247                nil,
248                image.bytes.as_ptr() as *const c_void,
249                image.bytes.len() as u64,
250            );
251
252            self.inner
253                .setData_forType(bytes, Into::<UTType>::into(image.format).inner_mut());
254        }
255    }
256}
257
258#[link(name = "AppKit", kind = "framework")]
259unsafe extern "C" {
260    /// [Apple's documentation](https://developer.apple.com/documentation/appkit/nspasteboardnamefind?language=objc)
261    pub static NSPasteboardNameFind: id;
262}
263
264impl From<ImageFormat> for UTType {
265    fn from(value: ImageFormat) -> Self {
266        match value {
267            ImageFormat::Png => Self::png(),
268            ImageFormat::Jpeg => Self::jpeg(),
269            ImageFormat::Tiff => Self::tiff(),
270            ImageFormat::Webp => Self::webp(),
271            ImageFormat::Gif => Self::gif(),
272            ImageFormat::Bmp => Self::bmp(),
273            ImageFormat::Svg => Self::svg(),
274            ImageFormat::Ico => Self::ico(),
275        }
276    }
277}
278
279// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/
280pub struct UTType(id);
281
282impl UTType {
283    pub fn png() -> Self {
284        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png
285        Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType
286    }
287
288    pub fn jpeg() -> Self {
289        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg
290        Self(unsafe { ns_string("public.jpeg") })
291    }
292
293    pub fn gif() -> Self {
294        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif
295        Self(unsafe { ns_string("com.compuserve.gif") })
296    }
297
298    pub fn webp() -> Self {
299        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp
300        Self(unsafe { ns_string("org.webmproject.webp") })
301    }
302
303    pub fn bmp() -> Self {
304        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp
305        Self(unsafe { ns_string("com.microsoft.bmp") })
306    }
307
308    pub fn svg() -> Self {
309        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg
310        Self(unsafe { ns_string("public.svg-image") })
311    }
312
313    pub fn ico() -> Self {
314        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico
315        Self(unsafe { ns_string("com.microsoft.ico") })
316    }
317
318    pub fn tiff() -> Self {
319        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff
320        Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType
321    }
322
323    fn inner(&self) -> *const Object {
324        self.0
325    }
326
327    pub fn inner_mut(&self) -> *mut Object {
328        self.0 as *mut _
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use cocoa::{
335        appkit::{NSFilenamesPboardType, NSPasteboard, NSPasteboardTypeString},
336        base::{id, nil},
337        foundation::{NSArray, NSData},
338    };
339    use std::ffi::c_void;
340
341    use gpui::{ClipboardEntry, ClipboardItem, ClipboardString, ImageFormat};
342
343    use super::*;
344
345    unsafe fn simulate_external_file_copy(pasteboard: &Pasteboard, paths: &[&str]) {
346        unsafe {
347            let ns_paths: Vec<id> = paths.iter().map(|p| ns_string(p)).collect();
348            let ns_array = NSArray::arrayWithObjects(nil, &ns_paths);
349
350            let mut types = vec![NSFilenamesPboardType];
351            types.push(NSPasteboardTypeString);
352
353            let types_array = NSArray::arrayWithObjects(nil, &types);
354            pasteboard.inner.declareTypes_owner(types_array, nil);
355
356            pasteboard
357                .inner
358                .setPropertyList_forType(ns_array, NSFilenamesPboardType);
359
360            let joined = paths.join("\n");
361            let bytes = NSData::dataWithBytes_length_(
362                nil,
363                joined.as_ptr() as *const c_void,
364                joined.len() as u64,
365            );
366            pasteboard
367                .inner
368                .setData_forType(bytes, NSPasteboardTypeString);
369        }
370    }
371
372    #[test]
373    fn test_string() {
374        let pasteboard = Pasteboard::unique();
375        assert_eq!(pasteboard.read(), None);
376
377        let item = ClipboardItem::new_string("1".to_string());
378        pasteboard.write(item.clone());
379        assert_eq!(pasteboard.read(), Some(item));
380
381        let item = ClipboardItem {
382            entries: vec![ClipboardEntry::String(
383                ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]),
384            )],
385        };
386        pasteboard.write(item.clone());
387        assert_eq!(pasteboard.read(), Some(item));
388
389        let text_from_other_app = "text from other app";
390        unsafe {
391            let bytes = NSData::dataWithBytes_length_(
392                nil,
393                text_from_other_app.as_ptr() as *const c_void,
394                text_from_other_app.len() as u64,
395            );
396            pasteboard
397                .inner
398                .setData_forType(bytes, NSPasteboardTypeString);
399        }
400        assert_eq!(
401            pasteboard.read(),
402            Some(ClipboardItem::new_string(text_from_other_app.to_string()))
403        );
404    }
405
406    #[test]
407    fn test_read_external_path() {
408        let pasteboard = Pasteboard::unique();
409
410        unsafe {
411            simulate_external_file_copy(&pasteboard, &["/test.txt"]);
412        }
413
414        let item = pasteboard.read().expect("should read clipboard item");
415
416        // Test both ExternalPaths and String entries exist
417        assert_eq!(item.entries.len(), 2);
418
419        // Test first entry is ExternalPaths
420        match &item.entries[0] {
421            ClipboardEntry::ExternalPaths(ep) => {
422                assert_eq!(ep.paths(), &[PathBuf::from("/test.txt")]);
423            }
424            other => panic!("expected ExternalPaths, got {:?}", other),
425        }
426
427        // Test second entry is String
428        match &item.entries[1] {
429            ClipboardEntry::String(s) => {
430                assert_eq!(s.text(), "/test.txt");
431            }
432            other => panic!("expected String, got {:?}", other),
433        }
434    }
435
436    #[test]
437    fn test_read_external_paths_with_spaces() {
438        let pasteboard = Pasteboard::unique();
439        let paths = ["/some file with spaces.txt"];
440
441        unsafe {
442            simulate_external_file_copy(&pasteboard, &paths);
443        }
444
445        let item = pasteboard.read().expect("should read clipboard item");
446
447        match &item.entries[0] {
448            ClipboardEntry::ExternalPaths(ep) => {
449                assert_eq!(ep.paths(), &[PathBuf::from("/some file with spaces.txt")]);
450            }
451            other => panic!("expected ExternalPaths, got {:?}", other),
452        }
453    }
454
455    #[test]
456    fn test_read_multiple_external_paths() {
457        let pasteboard = Pasteboard::unique();
458        let paths = ["/file.txt", "/image.png"];
459
460        unsafe {
461            simulate_external_file_copy(&pasteboard, &paths);
462        }
463
464        let item = pasteboard.read().expect("should read clipboard item");
465        assert_eq!(item.entries.len(), 2);
466
467        // Test both ExternalPaths and String entries exist
468        match &item.entries[0] {
469            ClipboardEntry::ExternalPaths(ep) => {
470                assert_eq!(
471                    ep.paths(),
472                    &[PathBuf::from("/file.txt"), PathBuf::from("/image.png"),]
473                );
474            }
475            other => panic!("expected ExternalPaths, got {:?}", other),
476        }
477
478        match &item.entries[1] {
479            ClipboardEntry::String(s) => {
480                assert_eq!(s.text(), "/file.txt\n/image.png");
481                assert_eq!(s.metadata, None);
482            }
483            other => panic!("expected String, got {:?}", other),
484        }
485    }
486
487    #[test]
488    fn test_read_image() {
489        let pasteboard = Pasteboard::unique();
490
491        // Smallest valid PNG: 1x1 transparent pixel
492        let png_bytes: &[u8] = &[
493            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
494            0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
495            0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78,
496            0x9C, 0x62, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE5, 0x27, 0xDE, 0xFC, 0x00, 0x00,
497            0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
498        ];
499
500        unsafe {
501            let ns_png_type = NSPasteboardTypePNG;
502            let types_array = NSArray::arrayWithObjects(nil, &[ns_png_type]);
503            pasteboard.inner.declareTypes_owner(types_array, nil);
504
505            let data = NSData::dataWithBytes_length_(
506                nil,
507                png_bytes.as_ptr() as *const c_void,
508                png_bytes.len() as u64,
509            );
510            pasteboard.inner.setData_forType(data, ns_png_type);
511        }
512
513        let item = pasteboard.read().expect("should read PNG image");
514
515        // Test Image entry exists
516        assert_eq!(item.entries.len(), 1);
517        match &item.entries[0] {
518            ClipboardEntry::Image(img) => {
519                assert_eq!(img.format, ImageFormat::Png);
520                assert_eq!(img.bytes, png_bytes);
521            }
522            other => panic!("expected Image, got {:?}", other),
523        }
524    }
525}