Skip to main content

ferroui_browser/
clipboard_impl.rs

1use crate::browser_clipboard_data_transfer::BrowserClipboardDataTransfer;
2use crate::browser_data_format_helper::to_browser_format;
3use crate::browser_data_transfer_helper::{IReadableDataItems, JsReadableDataItems};
4use crate::interop::completion_helper::PromiseError;
5use crate::interop::input_helper;
6use crate::windowing_platform::BrowserWindowingPlatform;
7use ferroui_base::input::platform::{ClipboardError, IClipboardImpl};
8use ferroui_base::input::{
9    AsyncDataTransferItemExtensions, DataFormat, DataFormatKind, IAsyncDataTransfer, IAsyncDataTransferItem,
10    LocalBoxFuture,
11};
12use ferroui_base::logging::{LogArea, LogEventLevel, Logger};
13use ferroui_base::media::imaging::{BitmapEncoderOptions, PngBitmapEncoderOptions};
14use std::rc::Rc;
15
16/// A value of an item written to the clipboard.
17#[derive(Clone, Debug, PartialEq)]
18pub(crate) enum WriteableClipboardValue {
19    String(String),
20    Bytes(Vec<u8>),
21}
22
23/// An item written to the clipboard: its values by format string
24/// (`WriteableClipboardItem` of the script module).
25#[derive(Clone, Debug, Default, PartialEq)]
26pub(crate) struct WriteableClipboardItem {
27    pub(crate) data: Vec<(String, WriteableClipboardValue)>,
28}
29
30/// The items written to the clipboard (`WriteableClipboardSource` of the
31/// script module).
32#[derive(Clone, Debug, Default, PartialEq)]
33pub(crate) struct WriteableClipboardSource {
34    pub(crate) items: Vec<WriteableClipboardItem>,
35}
36
37/// What reading the clipboard of the page gives: an error, or the items.
38pub(crate) struct ClipboardRead {
39    pub(crate) error: Option<String>,
40    pub(crate) items: Option<Rc<dyn IReadableDataItems>>,
41}
42
43/// The calls the clipboard makes into the page; replaced by a fake in the
44/// tests.
45pub(crate) trait IClipboardPage {
46    fn is_clipboard_format_supported(&self, format: &str) -> bool;
47    fn read_clipboard(&self) -> LocalBoxFuture<Result<ClipboardRead, PromiseError>>;
48    fn write_clipboard(&self, source: Option<WriteableClipboardSource>) -> LocalBoxFuture<Result<String, PromiseError>>;
49}
50
51struct ClipboardPage;
52
53impl IClipboardPage for ClipboardPage {
54    fn is_clipboard_format_supported(&self, format: &str) -> bool {
55        input_helper::is_clipboard_format_supported(format)
56    }
57
58    fn read_clipboard(&self) -> LocalBoxFuture<Result<ClipboardRead, PromiseError>> {
59        Box::pin(async {
60            let result = input_helper::read_clipboard_async(&BrowserWindowingPlatform::global_this()).await?;
61            Ok(ClipboardRead { error: result.error, items: result.items.map(JsReadableDataItems::new) })
62        })
63    }
64
65    fn write_clipboard(&self, source: Option<WriteableClipboardSource>) -> LocalBoxFuture<Result<String, PromiseError>> {
66        // The objects of the script module are created from the items gathered by the clipboard.
67        let js_source = source.map(|source| {
68            let js_source = input_helper::create_writeable_clipboard_source();
69            for item in &source.items {
70                let js_item = input_helper::create_writeable_clipboard_item(&js_source);
71                for (format, value) in &item.data {
72                    match value {
73                        WriteableClipboardValue::String(value) => {
74                            input_helper::add_string_to_writeable_clipboard_item(&js_item, format, value)
75                        }
76                        WriteableClipboardValue::Bytes(value) => {
77                            input_helper::add_bytes_to_writeable_clipboard_item(&js_item, format, value)
78                        }
79                    }
80                }
81            }
82            js_source
83        });
84
85        Box::pin(async move {
86            input_helper::write_clipboard_async(&BrowserWindowingPlatform::global_this(), js_source.as_ref()).await
87        })
88    }
89}
90
91/// The clipboard of the page: the asynchronous Clipboard API, and the
92/// "paste" event in browsers without it.
93pub struct ClipboardImpl {
94    page: Rc<dyn IClipboardPage>,
95}
96
97impl Default for ClipboardImpl {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103impl ClipboardImpl {
104    /// Creates the clipboard of the page.
105    pub fn new() -> Self {
106        Self { page: Rc::new(ClipboardPage) }
107    }
108
109    #[cfg(test)]
110    fn with_page(page: Rc<dyn IClipboardPage>) -> Self {
111        Self { page }
112    }
113
114    async fn write_clipboard_async(
115        page: &dyn IClipboardPage,
116        source: Option<WriteableClipboardSource>,
117    ) -> Result<(), ClipboardError> {
118        let error = page.write_clipboard(source).await.map_err(to_clipboard_error)?;
119
120        if error == "denied" {
121            return Err(ClipboardError::access_denied("Write permission is not granted for clipboard"));
122        }
123
124        Ok(())
125    }
126
127    async fn try_add_item_async(
128        page: &dyn IClipboardPage,
129        data_transfer_item: &Rc<dyn IAsyncDataTransferItem>,
130        source: &mut WriteableClipboardSource,
131    ) -> Result<(), ClipboardError> {
132        let mut writeable_item: Option<WriteableClipboardItem> = None;
133
134        for format in data_transfer_item.try_formats()?.iter() {
135            if format.kind() == DataFormatKind::InProcess {
136                continue;
137            }
138
139            let format_string = to_browser_format(format);
140            if !page.is_clipboard_format_supported(&format_string) {
141                continue;
142            }
143
144            if DataFormat::text() == *format {
145                let text = data_transfer_item.try_get_value_async(&DataFormat::text()).await?.unwrap_or_default();
146                writeable_item
147                    .get_or_insert_with(WriteableClipboardItem::default)
148                    .data
149                    .push((format_string, WriteableClipboardValue::String(text)));
150                continue;
151            }
152
153            if DataFormat::bitmap() == *format {
154                let bitmap = data_transfer_item.try_get_value_async(&DataFormat::bitmap()).await?;
155                if let Some(bitmap) = bitmap {
156                    let mut stream = Vec::new();
157                    bitmap
158                        .save(&mut stream, &BitmapEncoderOptions::Png(PngBitmapEncoderOptions::DEFAULT))
159                        .map_err(|error| ClipboardError::other(error.to_string()))?;
160
161                    writeable_item
162                        .get_or_insert_with(WriteableClipboardItem::default)
163                        .data
164                        .push((format_string, WriteableClipboardValue::Bytes(stream)));
165                }
166
167                continue;
168            }
169
170            // The original tells string formats from byte formats by their data type, which the
171            // formats of the framework do not carry: the type of the value decides.
172            let value = data_transfer_item.try_get_raw_async(format).await?;
173            match value {
174                None => continue,
175                Some(value) => {
176                    if let Some(string_value) = value.downcast_ref::<String>() {
177                        writeable_item
178                            .get_or_insert_with(WriteableClipboardItem::default)
179                            .data
180                            .push((format_string, WriteableClipboardValue::String(string_value.clone())));
181                        continue;
182                    }
183
184                    if let Some(bytes) = value.downcast_ref::<Rc<[u8]>>() {
185                        writeable_item
186                            .get_or_insert_with(WriteableClipboardItem::default)
187                            .data
188                            .push((format_string, WriteableClipboardValue::Bytes(bytes.to_vec())));
189                        continue;
190                    }
191                }
192            }
193
194            // Note: the file format isn't supported, we can't put arbitrary files onto the
195            // clipboard on the browser for security reasons.
196
197            if let Some(logger) = Logger::try_get(LogEventLevel::Warning, LogArea::BROWSER_PLATFORM) {
198                logger.log(None, &format!("Unsupported data format {format}"));
199            }
200        }
201
202        if let Some(writeable_item) = writeable_item {
203            source.items.push(writeable_item);
204        }
205
206        Ok(())
207    }
208}
209
210fn to_clipboard_error(error: PromiseError) -> ClipboardError {
211    ClipboardError::other(error.to_string())
212}
213
214impl IClipboardImpl for ClipboardImpl {
215    fn try_get_data_async(&self) -> LocalBoxFuture<Result<Option<Rc<dyn IAsyncDataTransfer>>, ClipboardError>> {
216        let read = self.page.read_clipboard();
217        Box::pin(async move {
218            let result = read.await.map_err(to_clipboard_error)?;
219            if result.error.as_deref() == Some("denied") {
220                return Err(ClipboardError::access_denied("Read permission is not granted for clipboard"));
221            }
222
223            Ok(match result.items {
224                Some(items) if items.len() > 0 => {
225                    Some(BrowserClipboardDataTransfer::new(items) as Rc<dyn IAsyncDataTransfer>)
226                }
227                _ => None,
228            })
229        })
230    }
231
232    fn set_data_async(&self, data_transfer: Rc<dyn IAsyncDataTransfer>) -> LocalBoxFuture<Result<(), ClipboardError>> {
233        let page = self.page.clone();
234        Box::pin(async move {
235            let mut source = WriteableClipboardSource::default();
236
237            for data_transfer_item in data_transfer.try_items()?.iter() {
238                Self::try_add_item_async(&*page, data_transfer_item, &mut source).await?;
239            }
240
241            Self::write_clipboard_async(&*page, Some(source)).await
242        })
243    }
244
245    fn clear_async(&self) -> LocalBoxFuture<Result<(), ClipboardError>> {
246        let page = self.page.clone();
247        Box::pin(async move { Self::write_clipboard_async(&*page, None).await })
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::browser_data_transfer_helper::tests::{FakeReadableDataItem, FakeReadableDataItems, FakeValue};
255    use ferroui_base::input::platform::ClipboardErrorKind;
256    use ferroui_base::input::{DataTransfer, DataTransferItem};
257    use std::cell::RefCell;
258    use std::future::Future;
259    use std::task::{Context, Poll, Waker};
260
261    /// Runs a future that completes without waiting.
262    fn run<T>(future: impl Future<Output = T>) -> T {
263        let mut future = std::pin::pin!(future);
264        let mut context = Context::from_waker(Waker::noop());
265        match future.as_mut().poll(&mut context) {
266            Poll::Ready(value) => value,
267            Poll::Pending => panic!("the future waits"),
268        }
269    }
270
271    #[derive(Default)]
272    struct FakePage {
273        supported: Vec<&'static str>,
274        read_error: Option<&'static str>,
275        read_items: RefCell<Option<Vec<Rc<FakeReadableDataItem>>>>,
276        reject: Option<&'static str>,
277        write_answer: &'static str,
278        written: RefCell<Vec<Option<WriteableClipboardSource>>>,
279    }
280
281    impl IClipboardPage for FakePage {
282        fn is_clipboard_format_supported(&self, format: &str) -> bool {
283            self.supported.contains(&format)
284        }
285
286        fn read_clipboard(&self) -> LocalBoxFuture<Result<ClipboardRead, PromiseError>> {
287            if let Some(reason) = self.reject {
288                return Box::pin(std::future::ready(Err(PromiseError::new("TypeError", reason))));
289            }
290            let items = self
291                .read_items
292                .borrow_mut()
293                .take()
294                .map(|items| Rc::new(FakeReadableDataItems(items)) as Rc<dyn IReadableDataItems>);
295            Box::pin(std::future::ready(Ok(ClipboardRead { error: self.read_error.map(str::to_string), items })))
296        }
297
298        fn write_clipboard(
299            &self,
300            source: Option<WriteableClipboardSource>,
301        ) -> LocalBoxFuture<Result<String, PromiseError>> {
302            if let Some(reason) = self.reject {
303                return Box::pin(std::future::ready(Err(PromiseError::new("TypeError", reason))));
304            }
305            self.written.borrow_mut().push(source);
306            Box::pin(std::future::ready(Ok(self.write_answer.to_string())))
307        }
308    }
309
310    fn page() -> FakePage {
311        FakePage { supported: vec!["text/plain", "text/html", "image/png"], ..Default::default() }
312    }
313
314    fn string(value: &str) -> WriteableClipboardValue {
315        WriteableClipboardValue::String(value.to_string())
316    }
317
318    #[test]
319    fn reading_an_empty_clipboard_gives_no_data() {
320        let page = Rc::new(page());
321        *page.read_items.borrow_mut() = Some(Vec::new());
322        let clipboard = ClipboardImpl::with_page(page.clone());
323        assert!(run(clipboard.try_get_data_async()).unwrap().is_none());
324
325        let clipboard = ClipboardImpl::with_page(page);
326        assert!(run(clipboard.try_get_data_async()).unwrap().is_none());
327    }
328
329    #[test]
330    fn a_denied_read_is_an_access_denied_error() {
331        let page = Rc::new(FakePage { read_error: Some("denied"), ..page() });
332        let error = run(ClipboardImpl::with_page(page).try_get_data_async()).err().expect("an error");
333        assert_eq!(ClipboardErrorKind::AccessDenied, error.kind());
334        assert_eq!("Read permission is not granted for clipboard", error.message());
335    }
336
337    #[test]
338    fn a_failed_read_is_an_error_with_the_message_of_the_page() {
339        let page = Rc::new(FakePage { reject: Some("no clipboard"), ..page() });
340        let error = run(ClipboardImpl::with_page(page).try_get_data_async()).err().expect("an error");
341        assert_eq!(ClipboardErrorKind::Other, error.kind());
342        assert_eq!("TypeError: no clipboard", error.message());
343    }
344
345    #[test]
346    fn the_items_of_the_clipboard_are_read_through_the_data_transfer() {
347        let page = Rc::new(page());
348        *page.read_items.borrow_mut() = Some(vec![
349            FakeReadableDataItem::new(&["text/plain", "text/html"], &[
350                ("text/plain", FakeValue::String("plain")),
351                ("text/html", FakeValue::String("<b>html</b>")),
352            ]),
353            FakeReadableDataItem::new(&["text/plain"], &[("text/plain", FakeValue::String("second"))]),
354        ]);
355        let data = run(ClipboardImpl::with_page(page).try_get_data_async()).unwrap().expect("data");
356
357        let formats = data.formats();
358        assert_eq!(2, formats.len());
359        assert!(DataFormat::text() == formats[0]);
360        assert_eq!("text/html", formats[1].identifier());
361
362        let items = data.items();
363        assert_eq!(2, items.len());
364        assert_eq!(Some("plain".to_string()), run(items[0].try_get_text_async()).unwrap());
365        assert_eq!(Some("second".to_string()), run(items[1].try_get_text_async()).unwrap());
366        let html: DataFormat = formats[1].clone();
367        let value = run(items[0].try_get_raw_async(&html)).unwrap().expect("the html");
368        assert_eq!(Some(&"<b>html</b>".to_string()), value.downcast_ref::<String>());
369        // A format the item does not have is not read.
370        assert_eq!(None, run(items[1].try_get_raw_async(&html)).unwrap().map(|_| ()));
371    }
372
373    #[test]
374    fn values_of_other_formats_are_read_as_bytes() {
375        let page = Rc::new(page());
376        *page.read_items.borrow_mut() = Some(vec![FakeReadableDataItem::new(
377            &["application/frn-fmt.custom"],
378            &[("application/frn-fmt.custom", FakeValue::Bytes(vec![4, 5, 6]))],
379        )]);
380        let data = run(ClipboardImpl::with_page(page).try_get_data_async()).unwrap().expect("data");
381        let custom = DataFormat::create_bytes_application_format("custom");
382        let value = run(data.items()[0].try_get_value_async(&custom)).unwrap();
383        assert_eq!(Some(vec![4u8, 5, 6]), value.map(|bytes| bytes.to_vec()));
384    }
385
386    #[test]
387    fn a_failed_value_read_is_an_error() {
388        let page = Rc::new(page());
389        *page.read_items.borrow_mut() =
390            Some(vec![FakeReadableDataItem::new(&["text/plain"], &[("text/plain", FakeValue::Rejected("gone"))])]);
391        let data = run(ClipboardImpl::with_page(page).try_get_data_async()).unwrap().expect("data");
392        let error = run(data.items()[0].try_get_text_async()).err().expect("an error");
393        assert_eq!("Error: gone", error.message());
394    }
395
396    #[test]
397    fn text_is_written_as_plain_text() {
398        let page = Rc::new(page());
399        let data = DataTransfer::new();
400        data.add(DataTransferItem::create_text(Some("hello")));
401        run(ClipboardImpl::with_page(page.clone()).set_data_async(data)).unwrap();
402
403        let expected =
404            WriteableClipboardSource { items: vec![WriteableClipboardItem { data: vec![("text/plain".into(), string("hello"))] }] };
405        assert_eq!(vec![Some(expected)], *page.written.borrow());
406    }
407
408    #[test]
409    fn each_item_with_supported_values_becomes_an_item_of_the_clipboard() {
410        let page = Rc::new(page());
411        let html = DataFormat::create_string_platform_format("text/html");
412        let custom = DataFormat::create_bytes_application_format("custom");
413        let local = DataFormat::create_in_process_format::<String>("local");
414
415        let first = DataTransferItem::new();
416        first.set(&DataFormat::text(), Some("text".to_string()));
417        first.set(&html, Some("<i>html</i>".to_string()));
418        first.set(&local, Some("in process".to_string()));
419        let second = DataTransferItem::new();
420        // Not supported by the clipboard of the page: no item.
421        second.set(&custom, Some(Rc::<[u8]>::from(vec![1u8, 2])));
422        let data = DataTransfer::new();
423        data.add(first);
424        data.add(second);
425
426        run(ClipboardImpl::with_page(page.clone()).set_data_async(data)).unwrap();
427
428        let expected = WriteableClipboardSource {
429            items: vec![WriteableClipboardItem {
430                data: vec![("text/plain".into(), string("text")), ("text/html".into(), string("<i>html</i>"))],
431            }],
432        };
433        assert_eq!(vec![Some(expected)], *page.written.borrow());
434    }
435
436    #[test]
437    fn byte_values_are_written_as_bytes() {
438        let page = Rc::new(FakePage { supported: vec!["application/frn-fmt.custom"], ..page() });
439        let custom = DataFormat::create_bytes_application_format("custom");
440        let item = DataTransferItem::new();
441        item.set(&custom, Some(Rc::<[u8]>::from(vec![1u8, 2, 3])));
442        let data = DataTransfer::new();
443        data.add(item);
444
445        run(ClipboardImpl::with_page(page.clone()).set_data_async(data)).unwrap();
446
447        let expected = WriteableClipboardSource {
448            items: vec![WriteableClipboardItem {
449                data: vec![("application/frn-fmt.custom".into(), WriteableClipboardValue::Bytes(vec![1, 2, 3]))],
450            }],
451        };
452        assert_eq!(vec![Some(expected)], *page.written.borrow());
453    }
454
455    #[test]
456    fn a_denied_write_is_an_access_denied_error() {
457        let page = Rc::new(FakePage { write_answer: "denied", ..page() });
458        let data = DataTransfer::new();
459        data.add(DataTransferItem::create_text(Some("hello")));
460        let error = run(ClipboardImpl::with_page(page).set_data_async(data)).err().expect("an error");
461        assert_eq!(ClipboardErrorKind::AccessDenied, error.kind());
462        assert_eq!("Write permission is not granted for clipboard", error.message());
463    }
464
465    #[test]
466    fn clearing_writes_no_source() {
467        let page = Rc::new(page());
468        run(ClipboardImpl::with_page(page.clone()).clear_async()).unwrap();
469        assert_eq!(vec![None], *page.written.borrow());
470    }
471}