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}