Skip to main content

wingy_appkit/
data_transfer.rs

1use std::cell::{Cell, RefCell};
2use std::collections::HashMap;
3use std::io;
4use std::ops::{BitOr, ControlFlow};
5use std::sync::atomic::{AtomicI64, Ordering};
6use std::sync::{Arc, OnceLock};
7
8use dispatch2::MainThreadBound;
9use objc2::rc::{Retained, Weak};
10use objc2::runtime::AnyObject;
11use objc2::{
12    AnyThread, ClassType, DefinedClass as _, MainThreadMarker, Message, define_class, msg_send,
13};
14use objc2_app_kit::{
15    NSDragOperation, NSPasteboard, NSPasteboardType, NSPasteboardTypeFileURL, NSPasteboardTypeHTML,
16    NSPasteboardTypePNG, NSPasteboardTypeSound, NSPasteboardTypeString, NSPasteboardTypeTIFF,
17    NSPasteboardWriting, NSPasteboardWritingOptions,
18};
19use objc2_foundation::{NSArray, NSData, NSObject, NSObjectProtocol, NSString, NSURL};
20use wingy_core::data_transfer::{
21    DataTransfer, DataTransferId, DataTransferSend, SendData, TransferType, TypeHint, TypedData,
22};
23use wingy_core::event_loop::DndAction;
24use wingy_core::window::WindowId;
25
26/// A thin wrapper around [`NSPasteboardType`], implementing [`TransferType`].
27#[derive(PartialEq, Eq, Debug, Clone)]
28pub struct PasteboardType {
29    hint: Option<TypeHint>,
30    // We need to convert `NSString` to `str` since `NSString` isn't `Send`/`Sync`
31    inner: Arc<str>,
32}
33
34impl PasteboardType {
35    fn from_hint(hint: TypeHint) -> Option<Self> {
36        let hint_to_pasteboard_type = unsafe {
37            [
38                (TypeHint::UriList, NSPasteboardTypeFileURL),
39                (TypeHint::Plaintext, NSPasteboardTypeString),
40                (TypeHint::Html, NSPasteboardTypeHTML),
41                (TypeHint::Image { extension_hint: Some("png") }, NSPasteboardTypePNG),
42                (TypeHint::Image { extension_hint: Some("tiff") }, NSPasteboardTypeTIFF),
43                (TypeHint::Audio { extension_hint: None }, NSPasteboardTypeSound),
44            ]
45        };
46
47        hint_to_pasteboard_type.into_iter().find_map(|(haystack, inner)| {
48            (haystack.matches(&hint))
49                .then(|| Self { hint: Some(hint), inner: inner.to_string().into() })
50        })
51    }
52}
53
54impl From<Retained<NSPasteboardType>> for PasteboardType {
55    fn from(value: Retained<NSPasteboardType>) -> Self {
56        let pasteboard_type_to_hint = unsafe {
57            [
58                // Just in case the source application uses the deprecated method, we handle it
59                // here
60                #[expect(deprecated)]
61                (objc2_app_kit::NSFilenamesPboardType, TypeHint::UriList),
62                (NSPasteboardTypeFileURL, TypeHint::UriList),
63                (NSPasteboardTypeString, TypeHint::Plaintext),
64                (NSPasteboardTypeHTML, TypeHint::Html),
65                (NSPasteboardTypePNG, TypeHint::Image { extension_hint: Some("png") }),
66                (NSPasteboardTypeTIFF, TypeHint::Image { extension_hint: Some("tiff") }),
67                (NSPasteboardTypeSound, TypeHint::Audio { extension_hint: None }),
68            ]
69        };
70
71        let hint = pasteboard_type_to_hint
72            .iter()
73            .find_map(|(pb_type, hint)| (**pb_type == *value).then_some(hint));
74
75        Self { hint: hint.copied(), inner: value.to_string().into() }
76    }
77}
78
79impl TransferType for PasteboardType {
80    fn hint(&self) -> Option<wingy_core::data_transfer::TypeHint> {
81        self.hint
82    }
83
84    fn matches(&self, other: &dyn TransferType) -> bool {
85        if let Some(other_pb_type) = other.cast_ref::<Self>() {
86            *self == *other_pb_type
87        } else {
88            // If either hint is `None`, return false
89            self.hint().is_some_and(|hint| other.hint() == Some(hint))
90        }
91    }
92}
93
94/// A thin wrapper around [`NSPasteboard`], implementing [`DataTransfer`].
95#[derive(Debug)]
96pub struct Pasteboard {
97    transfer_id: DataTransferId,
98    ns_pasteboard: MainThreadBound<Retained<NSPasteboard>>,
99    change_count: isize,
100    types: OnceLock<Arc<[PasteboardType]>>,
101}
102
103impl Clone for Pasteboard {
104    fn clone(&self) -> Self {
105        let inner = self.ns_pasteboard.get_on_main(|inner| {
106            MainThreadBound::new(inner.clone(), MainThreadMarker::new().unwrap())
107        });
108
109        Self {
110            transfer_id: self.transfer_id,
111            ns_pasteboard: inner,
112            change_count: self.change_count,
113            types: self.types.clone(),
114        }
115    }
116}
117
118impl Pasteboard {
119    pub(crate) fn new(
120        transfer_id: DataTransferId,
121        ns_pasteboard: Retained<NSPasteboard>,
122        mtm: MainThreadMarker,
123    ) -> Self {
124        let change_count = ns_pasteboard.changeCount();
125        Self {
126            transfer_id,
127            ns_pasteboard: MainThreadBound::new(ns_pasteboard, mtm),
128            change_count,
129            types: Default::default(),
130        }
131    }
132
133    pub(crate) fn change_count(&self) -> isize {
134        self.change_count
135    }
136
137    fn try_access<R: Send>(&self, f: impl FnOnce(&NSPasteboard) -> R + Send) -> io::Result<R> {
138        self.ns_pasteboard.get_on_main(|pasteboard| {
139            if pasteboard.changeCount() != self.change_count {
140                return Err(io::Error::other("pasteboard contents changed"));
141            }
142            let result = f(pasteboard);
143            if pasteboard.changeCount() != self.change_count {
144                return Err(io::Error::other("pasteboard contents changed"));
145            }
146            Ok(result)
147        })
148    }
149
150    /// Get the array of [`PasteboardType`]s advertized by this [`Pasteboard`].
151    pub fn types(&self) -> &[PasteboardType] {
152        self.types.get_or_init(|| {
153            self.try_access(|pb| {
154                pb.types()
155                    .map(|types| types.into_iter().map(PasteboardType::from).collect::<Vec<_>>())
156                    .unwrap_or_default()
157            })
158            .unwrap_or_default()
159            .into()
160        })
161    }
162
163    /// Get the `DataTransferId` of this pasteboard.
164    pub fn id(&self) -> DataTransferId {
165        self.transfer_id
166    }
167
168    /// Get a typed reader for this pasteboard. This is only necessary in the cross-platform case,
169    /// as a user downcasting to the platform-specific type can just access the `NSPasteboard`
170    /// directly.
171    pub(crate) fn with_type(&self, type_: PasteboardTypeSpec) -> PasteboardValue {
172        PasteboardValue { type_, pasteboard: self.clone() }
173    }
174}
175
176impl DataTransfer for Pasteboard {
177    fn for_each_available_type<'this>(
178        &'this self,
179        func: &'_ mut dyn FnMut(&'this dyn TransferType) -> std::ops::ControlFlow<()>,
180    ) {
181        let _ = self.types().iter().map(|mime| mime as &dyn TransferType).try_for_each(func);
182    }
183}
184
185#[derive(Debug, Clone)]
186pub(crate) enum PasteboardTypeSpec {
187    PasteboardType(PasteboardType),
188    TypeHint(TypeHint),
189}
190
191impl PasteboardTypeSpec {
192    pub(crate) fn from_dyn(type_: &dyn TransferType) -> Option<Self> {
193        match type_.cast_ref::<PasteboardType>() {
194            Some(pb_type) => Some(Self::PasteboardType(pb_type.clone())),
195            None => type_.hint().map(Into::into),
196        }
197    }
198}
199
200impl From<TypeHint> for PasteboardTypeSpec {
201    fn from(value: TypeHint) -> Self {
202        match PasteboardType::from_hint(value) {
203            Some(pb_type) => Self::PasteboardType(pb_type),
204            None => Self::TypeHint(value),
205        }
206    }
207}
208
209impl PasteboardTypeSpec {
210    fn pasteboard_type(&self) -> Option<&PasteboardType> {
211        match self {
212            PasteboardTypeSpec::PasteboardType(pasteboard_type) => Some(pasteboard_type),
213            PasteboardTypeSpec::TypeHint(_) => None,
214        }
215    }
216}
217
218pub fn dnd_action_to_ns_drag_operation(value: DndAction) -> NSDragOperation {
219    match value {
220        DndAction::Copy => NSDragOperation::Copy,
221        DndAction::Move => NSDragOperation::Move,
222        DndAction::Link => NSDragOperation::Link,
223        DndAction::Private => NSDragOperation::Private,
224        _ => NSDragOperation::empty(),
225    }
226}
227
228pub fn ns_drag_operation_to_dnd_action(value: NSDragOperation) -> Option<DndAction> {
229    [
230        (NSDragOperation::Copy, DndAction::Copy),
231        (NSDragOperation::Move, DndAction::Move),
232        (NSDragOperation::Link, DndAction::Link),
233        (NSDragOperation::Private, DndAction::Private),
234        // Sometimes the OS returns `Generic`, in which case we just fall back to `Copy`.
235        (NSDragOperation::Generic, DndAction::Copy),
236    ]
237    .into_iter()
238    .find_map(|(appkit, wingy)| value.contains(appkit).then_some(wingy))
239}
240
241pub fn dnd_actions_to_ns_drag_operation(value: &[DndAction]) -> NSDragOperation {
242    value
243        .iter()
244        .copied()
245        .map(dnd_action_to_ns_drag_operation)
246        .fold(NSDragOperation::empty(), BitOr::bitor)
247}
248
249pub fn preferred_drag_operation(
250    value: NSDragOperation,
251    preference: &[DndAction],
252) -> Option<DndAction> {
253    preference
254        .iter()
255        .find(|action| value.intersects(dnd_action_to_ns_drag_operation(**action)))
256        .copied()
257}
258
259/// A thin wrapper around [`NSPasteboard`], implementing [`TypedData`].
260#[derive(Debug)]
261pub struct PasteboardValue {
262    // The concept of "top-level" types for a pasteboard doesn't always make sense on macOS due to
263    // the use of `pasteboardItems`, so we allow using `TypeHint` instead to preserve the user's
264    // intention.
265    type_: PasteboardTypeSpec,
266    pasteboard: Pasteboard,
267}
268
269impl TypedData for PasteboardValue {
270    fn type_(&self) -> &dyn TransferType {
271        match &self.type_ {
272            PasteboardTypeSpec::PasteboardType(pasteboard_type) => {
273                pasteboard_type as &dyn TransferType
274            },
275            PasteboardTypeSpec::TypeHint(type_hint) => type_hint,
276        }
277    }
278
279    fn try_read(&self) -> Option<Box<dyn io::BufRead>> {
280        self.try_as_bytes()
281            .ok()
282            .map(|bytes| Box::new(io::Cursor::new(bytes)) as Box<dyn io::BufRead>)
283    }
284
285    fn try_as_bytes(&self) -> io::Result<Vec<u8>> {
286        let type_ = self.type_.clone();
287        self.pasteboard
288            .try_access(|pasteboard| {
289                let bytes =
290                    pasteboard.dataForType(&NSString::from_str(&type_.pasteboard_type()?.inner))?;
291                Some(bytes.to_vec())
292            })?
293            .ok_or_else(|| {
294                io::Error::other(format!(
295                    "NSPasteboard doesn't advertise a binary representation for type {:?}",
296                    self.type_
297                ))
298            })
299    }
300
301    fn try_as_uris(&self) -> io::Result<Vec<String>> {
302        if self.type_().hint() != Some(TypeHint::UriList) {
303            return Err(io::ErrorKind::InvalidData.into());
304        }
305
306        self.pasteboard.try_access(|pasteboard| {
307            let classes = NSArray::from_slice(&[NSURL::class()]);
308            let uris = unsafe { pasteboard.readObjectsForClasses_options(&classes, None) }
309                .into_iter()
310                .flatten()
311                .filter_map(|url| url.downcast::<NSURL>().ok())
312                .filter_map(|url| {
313                    url.filePathURL()
314                        .as_deref()
315                        .unwrap_or(&url)
316                        .absoluteString()
317                        .map(|uri| uri.to_string())
318                })
319                .collect::<Vec<_>>();
320
321            if !uris.is_empty() {
322                return Ok(uris);
323            }
324
325            // The pasteboard didn't expose any URLs, so we try with the deprecated method.
326            #[expect(deprecated)]
327            let property_list = pasteboard
328                .propertyListForType(unsafe { objc2_app_kit::NSFilenamesPboardType })
329                .ok_or(io::ErrorKind::InvalidData)?;
330
331            Ok(property_list
332                .downcast::<NSArray>()
333                .unwrap()
334                .into_iter()
335                .filter_map(|file| {
336                    NSURL::fileURLWithPath(&file.downcast::<NSString>().unwrap()).absoluteString()
337                })
338                .map(|uri| uri.to_string())
339                .collect())
340        })?
341    }
342
343    fn try_as_string(&self) -> io::Result<String> {
344        let type_ = self.type_.clone();
345
346        self.pasteboard.try_access(|pasteboard| {
347            pasteboard
348                .stringForType(&NSString::from_str(
349                    &type_.pasteboard_type().ok_or(io::ErrorKind::InvalidData)?.inner,
350                ))
351                .map(|ns_str| ns_str.to_string())
352                .ok_or_else(|| io::ErrorKind::InvalidData.into())
353        })?
354    }
355}
356
357#[derive(Debug)]
358struct ActivePasteboard {
359    window_id: WindowId,
360    pb: MainThreadBound<Weak<NSPasteboard>>,
361}
362
363#[derive(Debug, Default)]
364pub struct Pasteboards {
365    inner: RefCell<HashMap<DataTransferId, ActivePasteboard>>,
366}
367
368impl Pasteboards {
369    pub fn remove_deloaded_pasteboards(&self) {
370        self.inner.borrow_mut().retain(|_, ActivePasteboard { pb, .. }| {
371            pb.get_on_main(|state| state.load().is_some())
372        });
373    }
374
375    /// If the data transfer exists, update the pasteboard it points to.
376    pub fn set_pasteboard(
377        &self,
378        id: DataTransferId,
379        new_pb: &MainThreadBound<Retained<NSPasteboard>>,
380    ) {
381        let mut inner = self.inner.borrow_mut();
382        if let Some(ActivePasteboard { pb, .. }) = inner.get_mut(&id) {
383            *pb = new_pb.get_on_main(|pb| {
384                MainThreadBound::new(Weak::from_retained(pb), MainThreadMarker::new().unwrap())
385            });
386        }
387    }
388
389    pub fn insert(
390        &self,
391        transfer_id: DataTransferId,
392        pb: &MainThreadBound<Retained<NSPasteboard>>,
393        window_id: WindowId,
394    ) {
395        let mut inner = self.inner.borrow_mut();
396        let transfer = inner.entry(transfer_id).or_insert_with(move || {
397            pb.get_on_main(move |pb| ActivePasteboard {
398                window_id,
399                pb: MainThreadBound::new(Weak::from_retained(pb), MainThreadMarker::new().unwrap()),
400            })
401        });
402
403        transfer.window_id = window_id;
404    }
405
406    pub fn get(&self, id: DataTransferId) -> Option<Pasteboard> {
407        self.inner.borrow().get(&id).and_then(|ActivePasteboard { pb, .. }| {
408            pb.get_on_main(|state| {
409                Some(Pasteboard::new(id, state.load()?, MainThreadMarker::new().unwrap()))
410            })
411        })
412    }
413
414    /// Get the window ID that most-recently saw the provided data transfer.
415    pub fn window_id(&self, id: DataTransferId) -> Option<WindowId> {
416        self.inner.borrow().get(&id).map(|active_pasteboard| active_pasteboard.window_id)
417    }
418}
419
420#[derive(Debug)]
421struct DragState {
422    id: DataTransferId,
423    valid_actions: Vec<DndAction>,
424}
425
426#[derive(Debug, Default)]
427pub(crate) struct DataTransferState {
428    pasteboards: Pasteboards,
429    clipboard: Cell<Option<(isize, DataTransferId)>>,
430    drag: RefCell<Option<DragState>>,
431}
432
433impl DataTransferState {
434    pub(crate) fn pasteboards(&self) -> &Pasteboards {
435        &self.pasteboards
436    }
437
438    pub(crate) fn register_drag(
439        &self,
440        id: DataTransferId,
441        pb: &MainThreadBound<Retained<NSPasteboard>>,
442        window_id: WindowId,
443    ) {
444        self.pasteboards.insert(id, pb, window_id);
445        self.drag.replace(Some(DragState { id, valid_actions: vec![] }));
446    }
447
448    pub(crate) fn remove_drag(&self, id: DataTransferId) {
449        let mut state = self.drag.borrow_mut();
450        if state.as_ref().is_some_and(|s| s.id == id) {
451            *state = None;
452        }
453    }
454
455    pub(crate) fn current_drag_id(&self) -> Option<DataTransferId> {
456        self.drag.borrow().as_ref().map(|state| state.id)
457    }
458
459    pub(crate) fn set_valid_dnd_actions(&self, id: DataTransferId, actions: &[DndAction]) -> bool {
460        match &mut *self.drag.borrow_mut() {
461            Some(state) if state.id == id => {
462                state.valid_actions.clear();
463                state.valid_actions.extend_from_slice(actions);
464                true
465            },
466            _ => false,
467        }
468    }
469
470    pub(crate) fn proposed_drag_action(
471        &self,
472        source_operations: NSDragOperation,
473    ) -> Option<DndAction> {
474        self.drag
475            .borrow()
476            .as_ref()
477            .and_then(|state| preferred_drag_operation(source_operations, &state.valid_actions))
478    }
479
480    pub(crate) fn proposed_drag_operation(
481        &self,
482        source_operations: NSDragOperation,
483    ) -> NSDragOperation {
484        self.proposed_drag_action(source_operations)
485            .map(dnd_action_to_ns_drag_operation)
486            .unwrap_or(NSDragOperation::empty())
487    }
488
489    pub(crate) fn clipboard_transfer_id(&self, change_count: isize) -> DataTransferId {
490        // Counts downward to keep clipboard IDs disjoint from drag IDs.
491        static NEXT_ID: AtomicI64 = AtomicI64::new(-1);
492
493        match self.clipboard.get() {
494            Some((cached, id)) if cached == change_count => id,
495            _ => {
496                let id = DataTransferId::from_raw(NEXT_ID.fetch_sub(1, Ordering::Relaxed));
497                self.clipboard.set(Some((change_count, id)));
498                id
499            },
500        }
501    }
502}
503
504pub(crate) struct PasteboardWriterState {
505    data: Box<dyn DataTransferSend>,
506    // The macOS drag-and-drop API has some confusing aspects when handling multi-drag. The best
507    // we can really do is have the first element contain all the cross-platform items, and
508    // any further items are file paths only.
509    uri: Option<Retained<NSString>>,
510    writable_types: Retained<NSArray<NSPasteboardType>>,
511}
512
513impl PasteboardWriter {
514    pub(crate) fn new(
515        value: Box<dyn DataTransferSend>,
516        uri: Option<Retained<NSString>>,
517    ) -> Retained<Self> {
518        let mut writable_types = Vec::<Retained<NSPasteboardType>>::new();
519        value.for_each_available_type(&mut |type_| {
520            let Some(spec) = PasteboardTypeSpec::from_dyn(type_) else {
521                return ControlFlow::Continue(());
522            };
523
524            let Some(pb_type) = spec.pasteboard_type() else {
525                return ControlFlow::Continue(());
526            };
527
528            writable_types.push(NSString::from_str(&pb_type.inner));
529
530            ControlFlow::Continue(())
531        });
532
533        let pb_writer = Self::alloc().set_ivars(PasteboardWriterState {
534            data: value,
535            uri,
536            writable_types: NSArray::from_retained_slice(&writable_types),
537        });
538
539        // Unsure if there's an easier way to do this, but this is how `WindowDelegate` does it.
540        unsafe { msg_send![super(pb_writer), init] }
541    }
542}
543
544impl PasteboardWriterState {
545    fn data_for_pasteboard_type(
546        &self,
547        pasteboard_type: &NSPasteboardType,
548    ) -> Option<Retained<AnyObject>> {
549        if pasteboard_type == unsafe { NSPasteboardTypeFileURL } {
550            if let Some(out) = self.uri.clone().map(Into::into) {
551                return Some(out);
552            }
553        }
554        let pb_type = PasteboardType::from(pasteboard_type.retain());
555
556        let mut out = None;
557
558        self.data.for_each_available_type(&mut |haystack| {
559            if haystack.matches(&pb_type) {
560                out = self.data.data_for_type(haystack);
561                ControlFlow::Break(())
562            } else {
563                ControlFlow::Continue(())
564            }
565        });
566
567        match out? {
568            // This should be handled separately
569            // TODO: Is there a better way to do this?
570            SendData::Uris(_) => None,
571            SendData::String(string) => Some(NSString::from_str(&string).into()),
572            SendData::Bytes(binary) => Some(NSData::from_vec(binary).into()),
573            _ => None,
574        }
575    }
576}
577
578define_class!(
579    #[unsafe(super(NSObject))]
580    #[thread_kind = AnyThread]
581    #[name = "WingyPasteboardWriter"]
582    #[ivars = PasteboardWriterState]
583    pub(crate) struct PasteboardWriter;
584
585    unsafe impl NSObjectProtocol for PasteboardWriter {}
586
587    unsafe impl NSPasteboardWriting for PasteboardWriter {
588        #[unsafe(method_id(writableTypesForPasteboard:))]
589        fn writable_types_for_pasteboard(
590            &self,
591            _: &NSPasteboard,
592        ) -> Retained<NSArray<NSPasteboardType>> {
593            let vars = self.ivars();
594            vars.writable_types.clone()
595        }
596
597        #[unsafe(method(writingOptionsForType:pasteboard:))]
598        fn writing_options_for_type(
599            &self,
600            type_: &NSPasteboardType,
601            pasteboard: &NSPasteboard,
602        ) -> NSPasteboardWritingOptions {
603            let _ = type_;
604            let _ = pasteboard;
605            NSPasteboardWritingOptions::empty()
606        }
607
608        #[unsafe(method_id(pasteboardPropertyListForType:))]
609        fn pasteboard_property_list_for_type(
610            &self,
611            type_: &NSPasteboardType,
612        ) -> Option<Retained<AnyObject>> {
613            let vars = self.ivars();
614            vars.data_for_pasteboard_type(type_)
615        }
616    }
617);