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}