Skip to main content

inset_embedder/
clipboard.rs

1//! The system clipboard: what is on it, as items each offered in several forms named by MIME
2//! types, and writing it. Not a dart:ui file: Flutter's clipboard is plain text over a platform
3//! channel (`services/clipboard.dart`), which `inset-services` keeps as a layer over this one.
4//!
5//! The shape is the one Apple's, Android's and the web's clipboards share: one clipboard holding
6//! one or more items, each with one or more forms keyed by a type. A reader lists the items and
7//! their types without reading any content ([`Clipboard::items`]), then reads one item in one
8//! form ([`DataTransferItem::read`]); a writer gives each form's content at once or promises it
9//! ([`Content`]), and files travel as references. Where a system holds one item, as Windows and
10//! Linux do, its host writes the first.
11//!
12//! The invariant: a read never lands on another copy. An item is a handle on the clipboard as it
13//! was listed, and its read fails with [`ClipboardError::Changed`] once anything has replaced
14//! it, as the web's `ClipboardItem` and a stale `NSPasteboardItem` do.
15
16use std::fmt;
17use std::future::Future;
18use std::io;
19use std::path::PathBuf;
20use std::pin::Pin;
21use std::rc::Rc;
22use std::time::Duration;
23
24/// The type a pasted file is read in: its `file:` URL, one line per file (RFC 2483), as every
25/// system carries a file on its clipboard as a reference.
26pub const URI_LIST: &str = "text/uri-list";
27
28/// The system clipboard. Every call answers later, as a host may have to ask its user, or
29/// another process, first.
30pub trait Clipboard {
31    /// What is on the clipboard now: each item, to read in one of its types. Empty when the
32    /// clipboard is, and where the host may not look.
33    fn items(&self) -> ClipboardItemsFuture;
34
35    /// Replaces the clipboard with `items`, each in its forms, treated as `options` says.
36    /// Completes once the system holds them: a form promised is produced later, each time
37    /// someone pastes it.
38    fn write(&self, items: Vec<ClipboardItem>, options: CopyOptions) -> ClipboardWriteFuture;
39}
40
41/// What is on the clipboard, on its way.
42pub type ClipboardItemsFuture = Pin<Box<dyn Future<Output = Vec<Box<dyn DataTransferItem>>>>>;
43
44/// A write of the clipboard, on its way.
45pub type ClipboardWriteFuture = Pin<Box<dyn Future<Output = Result<(), ClipboardError>>>>;
46
47/// One form of a pasted item, on its way.
48pub type ClipboardReadFuture = Pin<Box<dyn Future<Output = Result<Vec<u8>, ClipboardError>>>>;
49
50/// A promised form being produced: its content, or none when it could not be made.
51pub type ContentFuture = Pin<Box<dyn Future<Output = Option<Vec<u8>>>>>;
52
53/// A promised file's bytes being produced into its sink: whole once it resolves `Ok`.
54pub type WriteFuture = Pin<Box<dyn Future<Output = io::Result<()>>>>;
55
56/// Where a promised file's bytes go, in order: the host's, which writes them into the file where
57/// the receiver chose, as `NSFilePromiseProvider` writes at the receiver's URL.
58pub trait FileSink {
59    /// Appends `bytes`; resolves once the host has taken them.
60    fn write<'a>(
61        &'a mut self,
62        bytes: &'a [u8],
63    ) -> Pin<Box<dyn Future<Output = io::Result<()>> + 'a>>;
64}
65
66/// One item on the clipboard, as it was when the clipboard was listed, or of a drag
67/// ([`DragReport`](crate::DragReport)): the web's `DataTransferItem`, the item its clipboard events
68/// and its drag and drop both hand over.
69pub trait DataTransferItem {
70    /// The forms it is offered in, by MIME type, in the order its writer gave them: the most
71    /// faithful first. A file is offered as [`URI_LIST`].
72    fn types(&self) -> &[String];
73
74    /// Its content in `mime`, one of its types. Fails with [`ClipboardError::Changed`] if the
75    /// clipboard changed since it was listed; a dragged item reads only while its drop is
76    /// reported. A file promised is written into a folder of the host's when its [`URI_LIST`] is
77    /// read, and the list names it there.
78    fn read(&self, mime: &str) -> ClipboardReadFuture;
79}
80
81/// One item to put on the clipboard: the same thing in several forms, each keyed by its MIME
82/// type, the most faithful first.
83pub struct ClipboardItem {
84    pub representations: Vec<(String, Content)>,
85}
86
87/// One form's content. A file, given or promised, is keyed by the type of what is in it, and
88/// a reader finds it as [`URI_LIST`].
89pub enum Content {
90    /// The bytes, given at once.
91    Data(Vec<u8>),
92    /// Produced when someone pastes it, each time.
93    Promise(Rc<dyn Fn() -> ContentFuture>),
94    /// A file the app has, put on the clipboard by its path.
95    File(PathBuf),
96    /// A file that exists only once pasted: its name, and what produces its bytes into the sink
97    /// the host hands it. The file is whole when the future resolves `Ok`; an `Err` fails the
98    /// paste, and the host removes what it wrote.
99    FilePromise {
100        name: String,
101        produce: Rc<dyn Fn(Box<dyn FileSink>) -> WriteFuture>,
102    },
103}
104
105impl fmt::Debug for Content {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        match self {
108            Content::Data(bytes) => f.debug_tuple("Data").field(&bytes.len()).finish(),
109            Content::Promise(_) => f.write_str("Promise"),
110            Content::File(path) => f.debug_tuple("File").field(path).finish(),
111            Content::FilePromise { name, .. } => {
112                f.debug_struct("FilePromise").field("name", name).finish()
113            }
114        }
115    }
116}
117
118impl fmt::Debug for ClipboardItem {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        f.debug_struct("ClipboardItem")
121            .field("representations", &self.representations)
122            .finish()
123    }
124}
125
126/// How a copy is to be treated. Each option names what it is for, and a host whose system lacks
127/// it leaves it out, but for the expiry, which a host does itself where the system cannot.
128#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
129pub struct CopyOptions {
130    /// A password or a code: kept out of previews, clipboard history and sync, as Android's
131    /// `EXTRA_IS_SENSITIVE` and macOS's `org.nspasteboard.ConcealedType` keep it.
132    pub sensitive: bool,
133    /// Kept off other devices' clipboards, as Apple's `localOnly` and `currentHostOnly` keep
134    /// it.
135    pub this_device_only: bool,
136    /// Cleared after this long, if still on the clipboard, as iOS's `expirationDate` clears it.
137    /// Where the system has no expiry of its own (macOS, Windows, Linux, Android), the host keeps
138    /// the time while the app runs: a copy whose app quits before its time stays on the clipboard.
139    pub expires_after: Option<Duration>,
140}
141
142impl CopyOptions {
143    /// A copy treated as any other.
144    pub const fn new() -> CopyOptions {
145        CopyOptions {
146            sensitive: false,
147            this_device_only: false,
148            expires_after: None,
149        }
150    }
151
152    pub const fn sensitive(mut self, sensitive: bool) -> CopyOptions {
153        self.sensitive = sensitive;
154        self
155    }
156
157    pub const fn this_device_only(mut self, this_device_only: bool) -> CopyOptions {
158        self.this_device_only = this_device_only;
159        self
160    }
161
162    pub const fn expires_after(mut self, expires_after: Duration) -> CopyOptions {
163        self.expires_after = Some(expires_after);
164        self
165    }
166}
167
168/// Why the clipboard was not read or written.
169#[derive(Clone, Debug, PartialEq, Eq)]
170pub enum ClipboardError {
171    /// The clipboard changed since the item was listed; for a dragged item, its drop was
172    /// reported, and the system let its data go.
173    Changed,
174    /// The item is not offered in that type.
175    NotFound,
176    /// The user, or what they set, does not let the app read or write it; for a dragged item,
177    /// the user has not dropped it.
178    Denied,
179    /// A form or a type this host's system cannot carry, such as a promised file where it has
180    /// no file promises.
181    Unsupported,
182    /// The system failed, or a promised form was not produced.
183    Failed(String),
184}
185
186impl fmt::Display for ClipboardError {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        match self {
189            ClipboardError::Changed => f.write_str("the clipboard changed since it was listed"),
190            ClipboardError::NotFound => f.write_str("the item is not offered in that type"),
191            ClipboardError::Denied => f.write_str("the clipboard may not be reached"),
192            ClipboardError::Unsupported => f.write_str("the system cannot carry that form"),
193            ClipboardError::Failed(reason) => write!(f, "the clipboard failed: {reason}"),
194        }
195    }
196}
197
198impl std::error::Error for ClipboardError {}
199
200/// The user copied, cut or pasted in a view through the platform's own command: the web's
201/// `copy`, `cut` and `paste` events, AppKit's `copy:`, `cut:` and `paste:` actions. What the
202/// user copies is written, and what they pasted read, through the event, which may reach what
203/// the app's own calls may not.
204pub enum ClipboardEvent {
205    /// Write what is selected.
206    Copy(CopyRequest),
207    /// Write what is selected, and remove it.
208    Cut(CopyRequest),
209    /// The clipboard as the user pasted it.
210    Paste(Vec<Box<dyn DataTransferItem>>),
211}
212
213impl fmt::Debug for ClipboardEvent {
214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        match self {
216            ClipboardEvent::Copy(_) => f.write_str("Copy"),
217            ClipboardEvent::Cut(_) => f.write_str("Cut"),
218            ClipboardEvent::Paste(items) => f.debug_tuple("Paste").field(&items.len()).finish(),
219        }
220    }
221}
222
223/// Where the user's copy or cut is written: the clipboard, as the platform lets the user's own
224/// copy write it.
225pub struct CopyRequest(Box<dyn Fn(Vec<ClipboardItem>, CopyOptions) -> ClipboardWriteFuture>);
226
227impl CopyRequest {
228    /// A request whose writes go to `write`.
229    pub fn new(
230        write: impl Fn(Vec<ClipboardItem>, CopyOptions) -> ClipboardWriteFuture + 'static,
231    ) -> CopyRequest {
232        CopyRequest(Box::new(write))
233    }
234
235    /// Replaces the clipboard with `items`, as [`Clipboard::write`] does.
236    pub fn write(&self, items: Vec<ClipboardItem>, options: CopyOptions) -> ClipboardWriteFuture {
237        (self.0)(items, options)
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn copy_options_start_as_any_other_copy_and_set_one_option_each() {
247        assert_eq!(CopyOptions::new(), CopyOptions::default());
248        let options = CopyOptions::new()
249            .sensitive(true)
250            .expires_after(Duration::from_secs(60));
251        assert!(options.sensitive);
252        assert!(!options.this_device_only);
253        assert_eq!(options.expires_after, Some(Duration::from_secs(60)));
254    }
255
256    #[test]
257    fn a_copy_request_writes_through_what_it_was_made_with() {
258        use std::cell::RefCell;
259
260        let written = Rc::new(RefCell::new(Vec::new()));
261        let into = Rc::clone(&written);
262        let request = CopyRequest::new(move |items, _options| {
263            into.borrow_mut().extend(items);
264            Box::pin(std::future::ready(Ok(())))
265        });
266        let item = ClipboardItem {
267            representations: vec![("text/plain".to_owned(), Content::Data(b"hi".to_vec()))],
268        };
269        let mut write = request.write(vec![item], CopyOptions::new());
270        let mut context = std::task::Context::from_waker(std::task::Waker::noop());
271        let done = write.as_mut().poll(&mut context);
272        assert_eq!(done, std::task::Poll::Ready(Ok(())));
273        assert_eq!(written.borrow().len(), 1);
274    }
275}