1use core::slice;
2use std::ffi::c_void;
3
4use cocoa::{
5 appkit::{NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString, NSPasteboardTypeTIFF},
6 base::{id, nil},
7 foundation::NSData,
8};
9use objc::{msg_send, runtime::Object, sel, sel_impl};
10use strum::IntoEnumIterator as _;
11
12use crate::{
13 ClipboardEntry, ClipboardItem, ClipboardString, Image, ImageFormat, asset_cache::hash,
14 platform::mac::ns_string,
15};
16
17pub struct Pasteboard {
18 inner: id,
19 text_hash_type: id,
20 metadata_type: id,
21}
22
23impl Pasteboard {
24 pub fn general() -> Self {
25 unsafe { Self::new(NSPasteboard::generalPasteboard(nil)) }
26 }
27
28 pub fn find() -> Self {
29 unsafe { Self::new(NSPasteboard::pasteboardWithName(nil, NSPasteboardNameFind)) }
30 }
31
32 #[cfg(test)]
33 pub fn unique() -> Self {
34 unsafe { Self::new(NSPasteboard::pasteboardWithUniqueName(nil)) }
35 }
36
37 unsafe fn new(inner: id) -> Self {
38 Self {
39 inner,
40 text_hash_type: unsafe { ns_string("zed-text-hash") },
41 metadata_type: unsafe { ns_string("zed-metadata") },
42 }
43 }
44
45 pub fn read(&self) -> Option<ClipboardItem> {
46 // First, see if it's a string.
47 unsafe {
48 let pasteboard_types: id = self.inner.types();
49 let string_type: id = ns_string("public.utf8-plain-text");
50
51 if msg_send![pasteboard_types, containsObject: string_type] {
52 let data = self.inner.dataForType(string_type);
53 if data == nil {
54 return None;
55 } else if data.bytes().is_null() {
56 // https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc
57 // "If the length of the NSData object is 0, this property returns nil."
58 return Some(self.read_string(&[]));
59 } else {
60 let bytes =
61 slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize);
62
63 return Some(self.read_string(bytes));
64 }
65 }
66
67 // If it wasn't a string, try the various supported image types.
68 for format in ImageFormat::iter() {
69 if let Some(item) = self.read_image(format) {
70 return Some(item);
71 }
72 }
73 }
74
75 // If it wasn't a string or a supported image type, give up.
76 None
77 }
78
79 fn read_image(&self, format: ImageFormat) -> Option<ClipboardItem> {
80 let mut ut_type: UTType = format.into();
81
82 unsafe {
83 let types: id = self.inner.types();
84 if msg_send![types, containsObject: ut_type.inner()] {
85 self.data_for_type(ut_type.inner_mut()).map(|bytes| {
86 let bytes = bytes.to_vec();
87 let id = hash(&bytes);
88
89 ClipboardItem {
90 entries: vec![ClipboardEntry::Image(Image { format, bytes, id })],
91 }
92 })
93 } else {
94 None
95 }
96 }
97 }
98
99 fn read_string(&self, text_bytes: &[u8]) -> ClipboardItem {
100 unsafe {
101 let text = String::from_utf8_lossy(text_bytes).to_string();
102 let metadata = self
103 .data_for_type(self.text_hash_type)
104 .and_then(|hash_bytes| {
105 let hash_bytes = hash_bytes.try_into().ok()?;
106 let hash = u64::from_be_bytes(hash_bytes);
107 let metadata = self.data_for_type(self.metadata_type)?;
108
109 if hash == ClipboardString::text_hash(&text) {
110 String::from_utf8(metadata.to_vec()).ok()
111 } else {
112 None
113 }
114 });
115
116 ClipboardItem {
117 entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })],
118 }
119 }
120 }
121
122 unsafe fn data_for_type(&self, kind: id) -> Option<&[u8]> {
123 unsafe {
124 let data = self.inner.dataForType(kind);
125 if data == nil {
126 None
127 } else {
128 Some(slice::from_raw_parts(
129 data.bytes() as *mut u8,
130 data.length() as usize,
131 ))
132 }
133 }
134 }
135
136 pub fn write(&self, item: ClipboardItem) {
137 unsafe {
138 match item.entries.as_slice() {
139 [] => {
140 // Writing an empty list of entries just clears the clipboard.
141 self.inner.clearContents();
142 }
143 [ClipboardEntry::String(string)] => {
144 self.write_plaintext(string);
145 }
146 [ClipboardEntry::Image(image)] => {
147 self.write_image(image);
148 }
149 [ClipboardEntry::ExternalPaths(_)] => {}
150 _ => {
151 // Agus NB: We're currently only writing string entries to the clipboard when we have more than one.
152 //
153 // This was the existing behavior before I refactored the outer clipboard code:
154 // https://github.com/zed-industries/zed/blob/65f7412a0265552b06ce122655369d6cc7381dd6/crates/gpui/src/platform/mac/platform.rs#L1060-L1110
155 //
156 // Note how `any_images` is always `false`. We should fix that, but that's orthogonal to the refactor.
157
158 let mut combined = ClipboardString {
159 text: String::new(),
160 metadata: None,
161 };
162
163 for entry in item.entries {
164 match entry {
165 ClipboardEntry::String(text) => {
166 combined.text.push_str(&text.text());
167 if combined.metadata.is_none() {
168 combined.metadata = text.metadata;
169 }
170 }
171 _ => {}
172 }
173 }
174
175 self.write_plaintext(&combined);
176 }
177 }
178 }
179 }
180
181 fn write_plaintext(&self, string: &ClipboardString) {
182 unsafe {
183 self.inner.clearContents();
184
185 let text_bytes = NSData::dataWithBytes_length_(
186 nil,
187 string.text.as_ptr() as *const c_void,
188 string.text.len() as u64,
189 );
190 self.inner
191 .setData_forType(text_bytes, NSPasteboardTypeString);
192
193 if let Some(metadata) = string.metadata.as_ref() {
194 let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes();
195 let hash_bytes = NSData::dataWithBytes_length_(
196 nil,
197 hash_bytes.as_ptr() as *const c_void,
198 hash_bytes.len() as u64,
199 );
200 self.inner.setData_forType(hash_bytes, self.text_hash_type);
201
202 let metadata_bytes = NSData::dataWithBytes_length_(
203 nil,
204 metadata.as_ptr() as *const c_void,
205 metadata.len() as u64,
206 );
207 self.inner
208 .setData_forType(metadata_bytes, self.metadata_type);
209 }
210 }
211 }
212
213 unsafe fn write_image(&self, image: &Image) {
214 unsafe {
215 self.inner.clearContents();
216
217 let bytes = NSData::dataWithBytes_length_(
218 nil,
219 image.bytes.as_ptr() as *const c_void,
220 image.bytes.len() as u64,
221 );
222
223 self.inner
224 .setData_forType(bytes, Into::<UTType>::into(image.format).inner_mut());
225 }
226 }
227}
228
229#[link(name = "AppKit", kind = "framework")]
230unsafe extern "C" {
231 /// [Apple's documentation](https://developer.apple.com/documentation/appkit/nspasteboardnamefind?language=objc)
232 pub static NSPasteboardNameFind: id;
233}
234
235impl From<ImageFormat> for UTType {
236 fn from(value: ImageFormat) -> Self {
237 match value {
238 ImageFormat::Png => Self::png(),
239 ImageFormat::Jpeg => Self::jpeg(),
240 ImageFormat::Tiff => Self::tiff(),
241 ImageFormat::Webp => Self::webp(),
242 ImageFormat::Gif => Self::gif(),
243 ImageFormat::Bmp => Self::bmp(),
244 ImageFormat::Svg => Self::svg(),
245 ImageFormat::Ico => Self::ico(),
246 }
247 }
248}
249
250// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/
251pub struct UTType(id);
252
253impl UTType {
254 pub fn png() -> Self {
255 // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png
256 Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType
257 }
258
259 pub fn jpeg() -> Self {
260 // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg
261 Self(unsafe { ns_string("public.jpeg") })
262 }
263
264 pub fn gif() -> Self {
265 // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif
266 Self(unsafe { ns_string("com.compuserve.gif") })
267 }
268
269 pub fn webp() -> Self {
270 // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp
271 Self(unsafe { ns_string("org.webmproject.webp") })
272 }
273
274 pub fn bmp() -> Self {
275 // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp
276 Self(unsafe { ns_string("com.microsoft.bmp") })
277 }
278
279 pub fn svg() -> Self {
280 // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg
281 Self(unsafe { ns_string("public.svg-image") })
282 }
283
284 pub fn ico() -> Self {
285 // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico
286 Self(unsafe { ns_string("com.microsoft.ico") })
287 }
288
289 pub fn tiff() -> Self {
290 // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff
291 Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType
292 }
293
294 fn inner(&self) -> *const Object {
295 self.0
296 }
297
298 pub fn inner_mut(&self) -> *mut Object {
299 self.0 as *mut _
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use cocoa::{appkit::NSPasteboardTypeString, foundation::NSData};
306
307 use crate::{ClipboardEntry, ClipboardItem, ClipboardString};
308
309 use super::*;
310
311 #[test]
312 fn test_string() {
313 let pasteboard = Pasteboard::unique();
314 assert_eq!(pasteboard.read(), None);
315
316 let item = ClipboardItem::new_string("1".to_string());
317 pasteboard.write(item.clone());
318 assert_eq!(pasteboard.read(), Some(item));
319
320 let item = ClipboardItem {
321 entries: vec![ClipboardEntry::String(
322 ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]),
323 )],
324 };
325 pasteboard.write(item.clone());
326 assert_eq!(pasteboard.read(), Some(item));
327
328 let text_from_other_app = "text from other app";
329 unsafe {
330 let bytes = NSData::dataWithBytes_length_(
331 nil,
332 text_from_other_app.as_ptr() as *const c_void,
333 text_from_other_app.len() as u64,
334 );
335 pasteboard
336 .inner
337 .setData_forType(bytes, NSPasteboardTypeString);
338 }
339 assert_eq!(
340 pasteboard.read(),
341 Some(ClipboardItem::new_string(text_from_other_app.to_string()))
342 );
343 }
344}