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