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}