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            ImageFormat::Pnm => Self::pnm(),
276        }
277    }
278}
279
280// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/
281pub struct UTType(id);
282
283impl UTType {
284    pub fn png() -> Self {
285        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png
286        Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType
287    }
288
289    pub fn jpeg() -> Self {
290        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg
291        Self(unsafe { ns_string("public.jpeg") })
292    }
293
294    pub fn gif() -> Self {
295        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif
296        Self(unsafe { ns_string("com.compuserve.gif") })
297    }
298
299    pub fn webp() -> Self {
300        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp
301        Self(unsafe { ns_string("org.webmproject.webp") })
302    }
303
304    pub fn bmp() -> Self {
305        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp
306        Self(unsafe { ns_string("com.microsoft.bmp") })
307    }
308
309    pub fn svg() -> Self {
310        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg
311        Self(unsafe { ns_string("public.svg-image") })
312    }
313
314    pub fn ico() -> Self {
315        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico
316        Self(unsafe { ns_string("com.microsoft.ico") })
317    }
318
319    pub fn tiff() -> Self {
320        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff
321        Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType
322    }
323
324    pub fn pnm() -> Self {
325        //https://en.wikipedia.org/w/index.php?title=Netpbm&oldid=1336679433 under Uniform Type Identifier
326        Self(unsafe { ns_string("public.pbm") })
327    }
328
329    fn inner(&self) -> *const Object {
330        self.0
331    }
332
333    pub fn inner_mut(&self) -> *mut Object {
334        self.0 as *mut _
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use cocoa::{
341        appkit::{NSFilenamesPboardType, NSPasteboard, NSPasteboardTypeString},
342        base::{id, nil},
343        foundation::{NSArray, NSData},
344    };
345    use std::ffi::c_void;
346
347    use gpui::{ClipboardEntry, ClipboardItem, ClipboardString, ImageFormat};
348
349    use super::*;
350
351    unsafe fn simulate_external_file_copy(pasteboard: &Pasteboard, paths: &[&str]) {
352        unsafe {
353            let ns_paths: Vec<id> = paths.iter().map(|p| ns_string(p)).collect();
354            let ns_array = NSArray::arrayWithObjects(nil, &ns_paths);
355
356            let mut types = vec![NSFilenamesPboardType];
357            types.push(NSPasteboardTypeString);
358
359            let types_array = NSArray::arrayWithObjects(nil, &types);
360            pasteboard.inner.declareTypes_owner(types_array, nil);
361
362            pasteboard
363                .inner
364                .setPropertyList_forType(ns_array, NSFilenamesPboardType);
365
366            let joined = paths.join("\n");
367            let bytes = NSData::dataWithBytes_length_(
368                nil,
369                joined.as_ptr() as *const c_void,
370                joined.len() as u64,
371            );
372            pasteboard
373                .inner
374                .setData_forType(bytes, NSPasteboardTypeString);
375        }
376    }
377
378    #[test]
379    fn test_string() {
380        let pasteboard = Pasteboard::unique();
381        assert_eq!(pasteboard.read(), None);
382
383        let item = ClipboardItem::new_string("1".to_string());
384        pasteboard.write(item.clone());
385        assert_eq!(pasteboard.read(), Some(item));
386
387        let item = ClipboardItem {
388            entries: vec![ClipboardEntry::String(
389                ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]),
390            )],
391        };
392        pasteboard.write(item.clone());
393        assert_eq!(pasteboard.read(), Some(item));
394
395        let text_from_other_app = "text from other app";
396        unsafe {
397            let bytes = NSData::dataWithBytes_length_(
398                nil,
399                text_from_other_app.as_ptr() as *const c_void,
400                text_from_other_app.len() as u64,
401            );
402            pasteboard
403                .inner
404                .setData_forType(bytes, NSPasteboardTypeString);
405        }
406        assert_eq!(
407            pasteboard.read(),
408            Some(ClipboardItem::new_string(text_from_other_app.to_string()))
409        );
410    }
411
412    #[test]
413    fn test_read_external_path() {
414        let pasteboard = Pasteboard::unique();
415
416        unsafe {
417            simulate_external_file_copy(&pasteboard, &["/test.txt"]);
418        }
419
420        let item = pasteboard.read().expect("should read clipboard item");
421
422        // Test both ExternalPaths and String entries exist
423        assert_eq!(item.entries.len(), 2);
424
425        // Test first entry is ExternalPaths
426        match &item.entries[0] {
427            ClipboardEntry::ExternalPaths(ep) => {
428                assert_eq!(ep.paths(), &[PathBuf::from("/test.txt")]);
429            }
430            other => panic!("expected ExternalPaths, got {:?}", other),
431        }
432
433        // Test second entry is String
434        match &item.entries[1] {
435            ClipboardEntry::String(s) => {
436                assert_eq!(s.text(), "/test.txt");
437            }
438            other => panic!("expected String, got {:?}", other),
439        }
440    }
441
442    #[test]
443    fn test_read_external_paths_with_spaces() {
444        let pasteboard = Pasteboard::unique();
445        let paths = ["/some file with spaces.txt"];
446
447        unsafe {
448            simulate_external_file_copy(&pasteboard, &paths);
449        }
450
451        let item = pasteboard.read().expect("should read clipboard item");
452
453        match &item.entries[0] {
454            ClipboardEntry::ExternalPaths(ep) => {
455                assert_eq!(ep.paths(), &[PathBuf::from("/some file with spaces.txt")]);
456            }
457            other => panic!("expected ExternalPaths, got {:?}", other),
458        }
459    }
460
461    #[test]
462    fn test_read_multiple_external_paths() {
463        let pasteboard = Pasteboard::unique();
464        let paths = ["/file.txt", "/image.png"];
465
466        unsafe {
467            simulate_external_file_copy(&pasteboard, &paths);
468        }
469
470        let item = pasteboard.read().expect("should read clipboard item");
471        assert_eq!(item.entries.len(), 2);
472
473        // Test both ExternalPaths and String entries exist
474        match &item.entries[0] {
475            ClipboardEntry::ExternalPaths(ep) => {
476                assert_eq!(
477                    ep.paths(),
478                    &[PathBuf::from("/file.txt"), PathBuf::from("/image.png"),]
479                );
480            }
481            other => panic!("expected ExternalPaths, got {:?}", other),
482        }
483
484        match &item.entries[1] {
485            ClipboardEntry::String(s) => {
486                assert_eq!(s.text(), "/file.txt\n/image.png");
487                assert_eq!(s.metadata, None);
488            }
489            other => panic!("expected String, got {:?}", other),
490        }
491    }
492
493    #[test]
494    fn test_read_image() {
495        let pasteboard = Pasteboard::unique();
496
497        // Smallest valid PNG: 1x1 transparent pixel
498        let png_bytes: &[u8] = &[
499            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
500            0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
501            0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78,
502            0x9C, 0x62, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE5, 0x27, 0xDE, 0xFC, 0x00, 0x00,
503            0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
504        ];
505
506        unsafe {
507            let ns_png_type = NSPasteboardTypePNG;
508            let types_array = NSArray::arrayWithObjects(nil, &[ns_png_type]);
509            pasteboard.inner.declareTypes_owner(types_array, nil);
510
511            let data = NSData::dataWithBytes_length_(
512                nil,
513                png_bytes.as_ptr() as *const c_void,
514                png_bytes.len() as u64,
515            );
516            pasteboard.inner.setData_forType(data, ns_png_type);
517        }
518
519        let item = pasteboard.read().expect("should read PNG image");
520
521        // Test Image entry exists
522        assert_eq!(item.entries.len(), 1);
523        match &item.entries[0] {
524            ClipboardEntry::Image(img) => {
525                assert_eq!(img.format, ImageFormat::Png);
526                assert_eq!(img.bytes, png_bytes);
527            }
528            other => panic!("expected Image, got {:?}", other),
529        }
530    }
531}