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#[derive(Clone, Debug, PartialEq)]
18pub(crate) enum WriteableClipboardValue {
19 String(String),
20 Bytes(Vec<u8>),
21}
22
23#[derive(Clone, Debug, Default, PartialEq)]
26pub(crate) struct WriteableClipboardItem {
27 pub(crate) data: Vec<(String, WriteableClipboardValue)>,
28}
29
30#[derive(Clone, Debug, Default, PartialEq)]
33pub(crate) struct WriteableClipboardSource {
34 pub(crate) items: Vec<WriteableClipboardItem>,
35}
36
37pub(crate) struct ClipboardRead {
39 pub(crate) error: Option<String>,
40 pub(crate) items: Option<Rc<dyn IReadableDataItems>>,
41}
42
43pub(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 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
91pub 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 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 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 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 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 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 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}