Skip to main content

ferroui_browser/storage/
browser_storage_provider.rs

1use super::blob_readable_stream::BlobReadableStream;
2use super::writeable_stream::{settle_pending_closes, WriteableStream};
3use crate::browser_app_builder::BrowserPlatformOptions;
4use crate::interop::completion_helper::PromiseError;
5use crate::interop::storage_helper::{self, StorageItemProperties};
6use crate::interop::{ferro_module, non_null, JsObject};
7use ferroui_base::animation::TimeSpan;
8use ferroui_base::input::LocalBoxFuture;
9use ferroui_base::platform::storage::file_io::{DecodeResult, StorageBookmarkHelper, StorageProviderHelpers};
10use ferroui_base::platform::storage::{
11    FilePickerFileType, FilePickerFileTypes, FilePickerOpenOptions, FilePickerSaveOptions, FolderPickerOpenOptions,
12    IStorageBookmarkFile, IStorageBookmarkFolder, IStorageBookmarkItem, IStorageFile, IStorageFolder, IStorageItem,
13    IStorageProvider, OpenFilePickerResult, SaveFilePickerResult, StorageItemProperties as ItemProperties,
14    WellKnownFolder,
15};
16use ferroui_base::reactive::IDisposable;
17use ferroui_base::utilities::{DateTimeOffset, Uri, UriKind};
18use ferroui_base::{FerroLocator, LocatorExtensions};
19use std::any::Any;
20use std::cell::RefCell;
21use std::io::{self, Read, Write};
22use std::rc::{Rc, Weak};
23use wasm_bindgen::JsCast;
24
25/// The platform key of the bookmarks of the browser.
26pub(crate) const BROWSER_BOOKMARK_KEY: &[u8] = b"browser";
27pub(crate) const PICKER_CANCEL_MESSAGE: &str = "The user aborted a request";
28pub(crate) const NO_PERMISSIONS_MESSAGE: &str = "Permissions denied";
29pub(crate) const FILE_FOLDER_NOT_FOUND_MESSAGE: &str = "A requested file or directory could not be found";
30pub(crate) const TYPE_MISSMATCH_MESSAGE: &str = "The path supplied exists, but was not an entry of requested type";
31
32/// The storage provider of the browser: file and folder pickers of the File
33/// System Access API (or of the `native-file-system-adapter` polyfill where
34/// the browser has none) and bookmarks kept in IndexedDB.
35///
36/// Every operation first imports the storage bundle of the script side.
37#[derive(Default)]
38pub struct BrowserStorageProvider;
39
40fn ready<T: 'static>(value: T) -> LocalBoxFuture<T> {
41    Box::pin(std::future::ready(value))
42}
43
44fn is_cancel(error: &PromiseError) -> bool {
45    error.message().contains(PICKER_CANCEL_MESSAGE)
46}
47
48fn denied(error: PromiseError) -> io::Error {
49    if error.message() == NO_PERMISSIONS_MESSAGE {
50        io::Error::new(io::ErrorKind::PermissionDenied, "User denied permissions to open the file")
51    } else {
52        error.into()
53    }
54}
55
56impl BrowserStorageProvider {
57    pub fn new() -> Self {
58        Self
59    }
60
61    fn prefer_polyfill() -> bool {
62        FerroLocator::current()
63            .get_service::<BrowserPlatformOptions>()
64            .is_some_and(|options| options.prefer_file_dialog_polyfill)
65    }
66
67    /// The script object of a start location the provider created itself.
68    fn start_in(location: Option<&Rc<dyn IStorageFolder>>) -> Option<JsObject> {
69        location.and_then(|location| JsStorageItem::of(&**location)).and_then(JsStorageItem::file_handle)
70    }
71
72    fn convert_file_types(input: Option<&[Rc<FilePickerFileType>]>) -> (Option<Vec<JsObject>>, bool) {
73        let all = FilePickerFileTypes::all();
74        let types: Option<Vec<JsObject>> = input.map(|input| {
75            input
76                .iter()
77                .filter(|t| t.mime_types().is_some_and(|m| !m.is_empty()) && !Rc::ptr_eq(t, &all))
78                .map(|t| {
79                    storage_helper::create_accept_type(
80                        t.name(),
81                        t.mime_types().map(|m| m.to_vec()).unwrap_or_default(),
82                        t.try_get_extensions().map(|e| e.iter().map(|e| format!(".{e}")).collect()),
83                    )
84                })
85                .collect()
86        });
87        let types = types.filter(|types| !types.is_empty());
88
89        let include_all = input.is_some_and(|input| input.iter().any(|t| Rc::ptr_eq(t, &all))) || types.is_none();
90
91        (types, !include_all)
92    }
93
94    async fn open_file_picker(options: FilePickerOpenOptions) -> io::Result<Vec<Rc<dyn IStorageFile>>> {
95        ferro_module::import_storage().await?;
96        let start_in = Self::start_in(options.suggested_start_location());
97
98        let (types, exclude_all) = Self::convert_file_types(options.file_type_filter());
99
100        let items = match storage_helper::open_file_dialog(
101            start_in.as_ref(),
102            options.allow_multiple(),
103            types,
104            exclude_all,
105            Self::prefer_polyfill(),
106        )
107        .await
108        {
109            Ok(items) => items,
110            Err(error) if is_cancel(&error) => return Ok(Vec::new()),
111            Err(error) => return Err(error.into()),
112        };
113        let Some(items) = non_null(items) else {
114            return Ok(Vec::new());
115        };
116
117        let items_array = storage_helper::items_array(&items);
118        Ok(items_array.into_iter().map(|item| JsStorageFile::new(item) as Rc<dyn IStorageFile>).collect())
119    }
120
121    async fn save_file_picker(options: FilePickerSaveOptions) -> io::Result<Option<Rc<dyn IStorageFile>>> {
122        ferro_module::import_storage().await?;
123        let start_in = Self::start_in(options.suggested_start_location());
124
125        let (types, exclude_all) = Self::convert_file_types(options.file_type_choices());
126
127        let suggested_name = StorageProviderHelpers::name_with_extension(
128            options.suggested_file_name(),
129            options.default_extension(),
130            None,
131        );
132        match storage_helper::save_file_dialog(
133            start_in.as_ref(),
134            suggested_name,
135            types,
136            exclude_all,
137            Self::prefer_polyfill(),
138        )
139        .await
140        {
141            Ok(item) => Ok(non_null(item).map(|item| JsStorageFile::new(item) as Rc<dyn IStorageFile>)),
142            Err(error) if is_cancel(&error) => Ok(None),
143            Err(error) => Err(error.into()),
144        }
145    }
146
147    async fn open_folder_picker(options: FolderPickerOpenOptions) -> io::Result<Vec<Rc<dyn IStorageFolder>>> {
148        ferro_module::import_storage().await?;
149        let start_in = Self::start_in(options.suggested_start_location());
150
151        match storage_helper::select_folder_dialog(start_in.as_ref(), Self::prefer_polyfill()).await {
152            Ok(item) => Ok(non_null(item)
153                .map(|item| vec![JsStorageFolder::new(item) as Rc<dyn IStorageFolder>])
154                .unwrap_or_default()),
155            Err(error) if is_cancel(&error) => Ok(Vec::new()),
156            Err(error) => Err(error.into()),
157        }
158    }
159
160    async fn decode_bookmark(bookmark: String) -> Option<JsStorageItemKind> {
161        ferro_module::import_storage().await.ok()?;
162        let item = match StorageBookmarkHelper::try_decode_bookmark(BROWSER_BOOKMARK_KEY, Some(&bookmark)) {
163            (DecodeResult::Success, Some(bytes)) => {
164                storage_helper::open_bookmark(&String::from_utf8_lossy(&bytes)).await.ok()
165            }
166            // Attempt to decode 11.0 browser bookmarks
167            (DecodeResult::InvalidFormat, _) => storage_helper::open_bookmark(&bookmark).await.ok(),
168            _ => None,
169        };
170        JsStorageItemKind::from_item(non_null(item?)?)
171    }
172
173    fn well_known_directory_name(well_known_folder: WellKnownFolder) -> &'static str {
174        match well_known_folder {
175            WellKnownFolder::Desktop => "desktop",
176            WellKnownFolder::Documents => "documents",
177            WellKnownFolder::Downloads => "downloads",
178            WellKnownFolder::Music => "music",
179            WellKnownFolder::Pictures => "pictures",
180            WellKnownFolder::Videos => "videos",
181        }
182    }
183}
184
185impl IStorageProvider for BrowserStorageProvider {
186    fn can_open(&self) -> bool {
187        true
188    }
189
190    fn open_file_picker_async(
191        &self,
192        options: FilePickerOpenOptions,
193    ) -> LocalBoxFuture<io::Result<Vec<Rc<dyn IStorageFile>>>> {
194        Box::pin(Self::open_file_picker(options))
195    }
196
197    fn open_file_picker_with_result_async(
198        &self,
199        options: FilePickerOpenOptions,
200    ) -> LocalBoxFuture<io::Result<OpenFilePickerResult>> {
201        let files = self.open_file_picker_async(options);
202        Box::pin(async move { Ok(OpenFilePickerResult { files: files.await?, selected_file_type: None }) })
203    }
204
205    fn can_save(&self) -> bool {
206        true
207    }
208
209    fn save_file_picker_async(
210        &self,
211        options: FilePickerSaveOptions,
212    ) -> LocalBoxFuture<io::Result<Option<Rc<dyn IStorageFile>>>> {
213        Box::pin(Self::save_file_picker(options))
214    }
215
216    fn save_file_picker_with_result_async(
217        &self,
218        options: FilePickerSaveOptions,
219    ) -> LocalBoxFuture<io::Result<SaveFilePickerResult>> {
220        let file = self.save_file_picker_async(options);
221        Box::pin(async move { Ok(SaveFilePickerResult { file: file.await?, selected_file_type: None }) })
222    }
223
224    fn can_pick_folder(&self) -> bool {
225        true
226    }
227
228    fn open_folder_picker_async(
229        &self,
230        options: FolderPickerOpenOptions,
231    ) -> LocalBoxFuture<io::Result<Vec<Rc<dyn IStorageFolder>>>> {
232        Box::pin(Self::open_folder_picker(options))
233    }
234
235    fn open_file_bookmark_async(&self, bookmark: &str) -> LocalBoxFuture<Option<Rc<dyn IStorageBookmarkFile>>> {
236        let bookmark = bookmark.to_string();
237        Box::pin(async move {
238            match Self::decode_bookmark(bookmark).await? {
239                JsStorageItemKind::File(file) => Some(file as Rc<dyn IStorageBookmarkFile>),
240                JsStorageItemKind::Folder(_) => None,
241            }
242        })
243    }
244
245    fn open_folder_bookmark_async(&self, bookmark: &str) -> LocalBoxFuture<Option<Rc<dyn IStorageBookmarkFolder>>> {
246        let bookmark = bookmark.to_string();
247        Box::pin(async move {
248            match Self::decode_bookmark(bookmark).await? {
249                JsStorageItemKind::Folder(folder) => Some(folder as Rc<dyn IStorageBookmarkFolder>),
250                JsStorageItemKind::File(_) => None,
251            }
252        })
253    }
254
255    fn try_get_file_from_path_async(&self, _file_path: &Uri) -> LocalBoxFuture<Option<Rc<dyn IStorageFile>>> {
256        ready(None)
257    }
258
259    fn try_get_folder_from_path_async(&self, _folder_path: &Uri) -> LocalBoxFuture<Option<Rc<dyn IStorageFolder>>> {
260        ready(None)
261    }
262
263    fn try_get_well_known_folder_async(
264        &self,
265        well_known_folder: WellKnownFolder,
266    ) -> LocalBoxFuture<Option<Rc<dyn IStorageFolder>>> {
267        Box::pin(async move {
268            ferro_module::import_storage().await.ok()?;
269            let directory =
270                storage_helper::create_well_known_directory(Self::well_known_directory_name(well_known_folder));
271            Some(JsStorageFolder::new(directory) as Rc<dyn IStorageFolder>)
272        })
273    }
274}
275
276/// A storage item of the script side, as a file or a folder by its `kind`.
277enum JsStorageItemKind {
278    File(Rc<JsStorageFile>),
279    Folder(Rc<JsStorageFolder>),
280}
281
282impl JsStorageItemKind {
283    fn from_item(item: JsObject) -> Option<Self> {
284        match storage_helper::kind_of(&item).as_deref() {
285            Some("directory") => Some(Self::Folder(JsStorageFolder::new(item))),
286            Some("file") => Some(Self::File(JsStorageFile::new(item))),
287            _ => None,
288        }
289    }
290
291    /// The item for a file system handle the script side returned.
292    fn from_handle(handle: JsObject) -> Option<Self> {
293        let item = storage_helper::storage_item_from_handle(&handle)?;
294        Self::from_item(item)
295    }
296
297    fn into_item(self) -> Rc<dyn IStorageItem> {
298        match self {
299            Self::File(file) => file,
300            Self::Folder(folder) => folder,
301        }
302    }
303}
304
305/// What files and folders share: the storage item of the script side.
306pub(crate) struct JsStorageItem {
307    file_handle: RefCell<Option<JsObject>>,
308    this: RefCell<Option<Weak<dyn IStorageItem>>>,
309}
310
311impl JsStorageItem {
312    fn new(file_handle: JsObject) -> Self {
313        Self { file_handle: RefCell::new(Some(file_handle)), this: RefCell::new(None) }
314    }
315
316    /// The shared part of `item`, if the provider created it.
317    fn of(item: &dyn IStorageItem) -> Option<&JsStorageItem> {
318        let item = item.as_any()?;
319        if let Some(file) = item.downcast_ref::<JsStorageFile>() {
320            return Some(&file.base);
321        }
322        item.downcast_ref::<JsStorageFolder>().map(|folder| &folder.base)
323    }
324
325    /// The script object, `None` once disposed.
326    pub(crate) fn file_handle(&self) -> Option<JsObject> {
327        self.file_handle.borrow().clone()
328    }
329
330    fn require_file_handle(&self) -> io::Result<JsObject> {
331        self.file_handle()
332            .ok_or_else(|| io::Error::other("Cannot access a disposed object. Object name: 'JSStorageItem'."))
333    }
334
335    fn name(&self) -> String {
336        self.file_handle().and_then(|handle| storage_helper::name_of(&handle)).unwrap_or_default()
337    }
338
339    fn path(&self) -> Uri {
340        let name = self.name();
341        // A name with a colon would read as a scheme; such a relative reference starts with "./".
342        Uri::new(&name, UriKind::Relative)
343            .or_else(|_| Uri::new(&format!("./{name}"), UriKind::Relative))
344            .expect("a dot segment makes any name a relative reference")
345    }
346
347    fn get_basic_properties_async(&self) -> LocalBoxFuture<ItemProperties> {
348        let handle = self.file_handle();
349        Box::pin(async move {
350            // The item may come from a data transfer, before the storage module was imported.
351            let properties = match handle {
352                Some(handle) if ferro_module::import_storage().await.is_ok() => {
353                    // Failures of earlier writes are left for the next opening of the file.
354                    let _ = settle_pending_closes(&handle, false).await;
355                    storage_helper::get_properties(&handle).await.ok().and_then(non_null)
356                }
357                _ => None,
358            };
359            let properties = properties.map(JsCast::unchecked_into::<StorageItemProperties>);
360            let size = properties.as_ref().and_then(|p| p.size()).map(|size| size as i64);
361            let last_modified = properties.as_ref().and_then(|p| p.last_modified()).map(|ms| ms as i64);
362
363            ItemProperties::new(
364                size.map(|size| size as u64),
365                None,
366                last_modified.filter(|ms| *ms > 0).map(from_unix_time_milliseconds),
367            )
368        })
369    }
370
371    fn can_bookmark(&self) -> bool {
372        storage_helper::has_native_file_picker()
373    }
374
375    fn save_bookmark_async(&self) -> LocalBoxFuture<Option<String>> {
376        if !self.can_bookmark() {
377            return ready(None);
378        }
379        let handle = self.file_handle();
380        Box::pin(async move {
381            ferro_module::import_storage().await.ok()?;
382            let native_bookmark = storage_helper::save_bookmark(&handle?).await.ok()?.as_string()?;
383            StorageBookmarkHelper::encode_bookmark(BROWSER_BOOKMARK_KEY, Some(&native_bookmark))
384        })
385    }
386
387    fn get_parent_async(&self) -> LocalBoxFuture<Option<Rc<dyn IStorageFolder>>> {
388        ready(None)
389    }
390
391    fn delete_async(&self) -> LocalBoxFuture<io::Result<()>> {
392        let handle = self.require_file_handle();
393        Box::pin(async move {
394            ferro_module::import_storage().await?;
395            let handle = handle?;
396            // A writable stream of the file that is still closing locks it.
397            let _ = settle_pending_closes(&handle, false).await;
398            storage_helper::delete_async(&handle).await?;
399            Ok(())
400        })
401    }
402
403    fn move_async(
404        &self,
405        destination: Rc<dyn IStorageFolder>,
406    ) -> LocalBoxFuture<io::Result<Option<Rc<dyn IStorageItem>>>> {
407        let Some(folder) = destination.as_any().and_then(|item| item.downcast_ref::<JsStorageFolder>()) else {
408            return ready(Err(io::Error::new(
409                io::ErrorKind::InvalidInput,
410                "Destination folder must be initialized the StorageProvider API.",
411            )));
412        };
413        let handle = self.require_file_handle();
414        let destination_handle = folder.base.require_file_handle();
415        let this = self.this.borrow().clone();
416        Box::pin(async move {
417            ferro_module::import_storage().await?;
418            let handle = handle?;
419            // A writable stream of the file that is still closing locks it: the browser refuses to
420            // move a locked handle.
421            let _ = settle_pending_closes(&handle, false).await;
422            let item_handle = storage_helper::move_async(&handle, &destination_handle?).await?;
423            let Some(item_handle) = non_null(item_handle) else {
424                return Ok(None);
425            };
426
427            Ok(match JsStorageItemKind::from_handle(item_handle) {
428                Some(item) => Some(item.into_item()),
429                None => this.and_then(|this| this.upgrade()),
430            })
431        })
432    }
433
434    fn release_bookmark_async(&self) -> LocalBoxFuture<()> {
435        if !self.can_bookmark() {
436            return ready(());
437        }
438        let handle = self.file_handle();
439        Box::pin(async move {
440            if let Some(handle) = handle {
441                if ferro_module::import_storage().await.is_err() {
442                    return;
443                }
444                let _ = storage_helper::delete_bookmark(&handle).await;
445            }
446        })
447    }
448
449    fn dispose(&self) {
450        self.file_handle.borrow_mut().take();
451    }
452}
453
454fn from_unix_time_milliseconds(milliseconds: i64) -> DateTimeOffset {
455    DateTimeOffset::UNIX_EPOCH.add(TimeSpan::from_ticks(milliseconds * 10_000))
456}
457
458macro_rules! impl_js_storage_item {
459    ($type:ty, $kind:ident, $trait:ident) => {
460        impl IDisposable for $type {
461            fn dispose(&self) {
462                self.base.dispose();
463            }
464        }
465
466        impl IStorageItem for $type {
467            fn name(&self) -> String {
468                self.base.name()
469            }
470
471            fn path(&self) -> Uri {
472                self.base.path()
473            }
474
475            fn get_basic_properties_async(&self) -> LocalBoxFuture<ItemProperties> {
476                self.base.get_basic_properties_async()
477            }
478
479            fn can_bookmark(&self) -> bool {
480                self.base.can_bookmark()
481            }
482
483            fn save_bookmark_async(&self) -> LocalBoxFuture<Option<String>> {
484                self.base.save_bookmark_async()
485            }
486
487            fn get_parent_async(&self) -> LocalBoxFuture<Option<Rc<dyn IStorageFolder>>> {
488                self.base.get_parent_async()
489            }
490
491            fn delete_async(&self) -> LocalBoxFuture<io::Result<()>> {
492                self.base.delete_async()
493            }
494
495            fn move_async(
496                &self,
497                destination: Rc<dyn IStorageFolder>,
498            ) -> LocalBoxFuture<io::Result<Option<Rc<dyn IStorageItem>>>> {
499                self.base.move_async(destination)
500            }
501
502            fn $kind(self: Rc<Self>) -> Option<Rc<dyn $trait>> {
503                Some(self)
504            }
505
506            fn as_any(&self) -> Option<&dyn Any> {
507                Some(self)
508            }
509        }
510
511        impl IStorageBookmarkItem for $type {
512            fn release_bookmark_async(&self) -> LocalBoxFuture<()> {
513                self.base.release_bookmark_async()
514            }
515        }
516    };
517}
518
519/// A file of the script side.
520pub(crate) struct JsStorageFile {
521    base: JsStorageItem,
522}
523
524impl JsStorageFile {
525    pub(crate) fn new(file_handle: JsObject) -> Rc<Self> {
526        let this = Rc::new(Self { base: JsStorageItem::new(file_handle) });
527        let weak: Weak<dyn IStorageItem> = Rc::downgrade(&this) as Weak<dyn IStorageItem>;
528        *this.base.this.borrow_mut() = Some(weak);
529        this
530    }
531
532    /// Opens the file for reading. The content is read into memory before
533    /// the stream is returned: the stream of the contract is synchronous.
534    pub async fn open_read_stream_async(&self) -> io::Result<BlobReadableStream> {
535        let handle = self.base.require_file_handle()?;
536        // A file of a data transfer (a drop, a paste) can be read before the storage module that
537        // reads it was imported: the import started with the event is not awaited.
538        ferro_module::import_storage().await?;
539        settle_pending_closes(&handle, true).await?;
540        let blob = storage_helper::open_read(&handle).await.map_err(denied)?;
541        let mut stream = BlobReadableStream::new(blob);
542        stream.load_async().await?;
543        Ok(stream)
544    }
545
546    /// Opens the file for writing, truncated.
547    pub async fn open_write_stream_async(&self) -> io::Result<WriteableStream> {
548        let handle = self.base.require_file_handle()?;
549        ferro_module::import_storage().await?;
550        settle_pending_closes(&handle, true).await?;
551        let stream_writer = storage_helper::open_write(&handle).await.map_err(denied)?;
552
553        // Upstream starts from the size the file had, although `openWrite` truncates it
554        // (`keepExistingData: false`), so a seek relative to the end landed past the content.
555        Ok(WriteableStream::new(stream_writer, Some(handle), 0))
556    }
557}
558
559impl_js_storage_item!(JsStorageFile, as_storage_file, IStorageFile);
560
561impl IStorageFile for JsStorageFile {
562    fn open_read_async(&self) -> LocalBoxFuture<io::Result<Box<dyn Read>>> {
563        let handle = self.base.file_handle();
564        Box::pin(async move {
565            let file = JsStorageFile::detached(handle)?;
566            Ok(Box::new(file.open_read_stream_async().await?) as Box<dyn Read>)
567        })
568    }
569
570    fn open_write_async(&self) -> LocalBoxFuture<io::Result<Box<dyn Write>>> {
571        let handle = self.base.file_handle();
572        Box::pin(async move {
573            let file = JsStorageFile::detached(handle)?;
574            Ok(Box::new(file.open_write_stream_async().await?) as Box<dyn Write>)
575        })
576    }
577}
578
579impl JsStorageFile {
580    /// A file over the same script object, for a future that must not
581    /// borrow `self`.
582    fn detached(handle: Option<JsObject>) -> io::Result<JsStorageFile> {
583        let base = JsStorageItem { file_handle: RefCell::new(handle), this: RefCell::new(None) };
584        base.require_file_handle()?;
585        Ok(JsStorageFile { base })
586    }
587}
588
589impl IStorageBookmarkFile for JsStorageFile {}
590
591/// A folder of the script side.
592pub(crate) struct JsStorageFolder {
593    base: JsStorageItem,
594}
595
596impl JsStorageFolder {
597    pub(crate) fn new(file_handle: JsObject) -> Rc<Self> {
598        let this = Rc::new(Self { base: JsStorageItem::new(file_handle) });
599        let weak: Weak<dyn IStorageItem> = Rc::downgrade(&this) as Weak<dyn IStorageItem>;
600        *this.base.this.borrow_mut() = Some(weak);
601        this
602    }
603
604    fn should_supress_error_on_file_access(error: &PromiseError) -> bool {
605        error.message() == NO_PERMISSIONS_MESSAGE
606            || error.message().contains(TYPE_MISSMATCH_MESSAGE)
607            || error.message().contains(FILE_FOLDER_NOT_FOUND_MESSAGE)
608    }
609}
610
611impl_js_storage_item!(JsStorageFolder, as_storage_folder, IStorageFolder);
612
613impl IStorageFolder for JsStorageFolder {
614    fn get_items_async(&self) -> LocalBoxFuture<io::Result<Vec<Rc<dyn IStorageItem>>>> {
615        let handle = self.base.require_file_handle();
616        Box::pin(async move {
617            let mut items: Vec<Rc<dyn IStorageItem>> = Vec::new();
618            let Some(items_iterator) = storage_helper::get_items_iterator(&handle?) else {
619                return Ok(items);
620            };
621
622            loop {
623                let Some(next_result) = non_null(items_iterator.next().await?) else {
624                    break;
625                };
626                let next_result = next_result.unchecked_into::<storage_helper::ItemsIteratorResult>();
627
628                if next_result.done().unwrap_or(false) {
629                    break;
630                }
631
632                // 0 - item name, 1 - item instance
633                let Some(storage_item) = next_result.item() else {
634                    break;
635                };
636
637                if let Some(item) = JsStorageItemKind::from_handle(storage_item) {
638                    items.push(item.into_item());
639                }
640            }
641
642            Ok(items)
643        })
644    }
645
646    fn get_folder_async(&self, name: &str) -> LocalBoxFuture<Option<Rc<dyn IStorageFolder>>> {
647        let handle = self.base.file_handle();
648        let name = name.to_string();
649        Box::pin(async move {
650            match storage_helper::get_folder(&handle?, &name).await {
651                Ok(folder_handle) => {
652                    let storage_folder = storage_helper::storage_item_from_handle(&non_null(folder_handle)?)?;
653                    Some(JsStorageFolder::new(storage_folder) as Rc<dyn IStorageFolder>)
654                }
655                Err(error) if Self::should_supress_error_on_file_access(&error) => None,
656                // The contract has no error channel here.
657                Err(_) => None,
658            }
659        })
660    }
661
662    fn get_file_async(&self, name: &str) -> LocalBoxFuture<Option<Rc<dyn IStorageFile>>> {
663        let handle = self.base.file_handle();
664        let name = name.to_string();
665        Box::pin(async move {
666            match storage_helper::get_file(&handle?, &name).await {
667                Ok(file_handle) => {
668                    let storage_file = storage_helper::storage_item_from_handle(&non_null(file_handle)?)?;
669                    Some(JsStorageFile::new(storage_file) as Rc<dyn IStorageFile>)
670                }
671                Err(error) if Self::should_supress_error_on_file_access(&error) => None,
672                // The contract has no error channel here.
673                Err(_) => None,
674            }
675        })
676    }
677
678    fn create_file_async(&self, name: &str) -> LocalBoxFuture<io::Result<Option<Rc<dyn IStorageFile>>>> {
679        let handle = self.base.require_file_handle();
680        let name = name.to_string();
681        Box::pin(async move {
682            let file_handle = storage_helper::create_file(&handle?, &name).await.map_err(denied)?;
683            let Some(file_handle) = non_null(file_handle) else {
684                return Ok(None);
685            };
686
687            Ok(storage_helper::storage_item_from_handle(&file_handle)
688                .map(|storage_file| JsStorageFile::new(storage_file) as Rc<dyn IStorageFile>))
689        })
690    }
691
692    fn create_folder_async(&self, name: &str) -> LocalBoxFuture<io::Result<Option<Rc<dyn IStorageFolder>>>> {
693        let handle = self.base.require_file_handle();
694        let name = name.to_string();
695        Box::pin(async move {
696            let folder_handle = storage_helper::create_folder(&handle?, &name).await.map_err(denied)?;
697            let Some(folder_handle) = non_null(folder_handle) else {
698                return Ok(None);
699            };
700
701            Ok(storage_helper::storage_item_from_handle(&folder_handle)
702                .map(|storage_folder| JsStorageFolder::new(storage_folder) as Rc<dyn IStorageFolder>))
703        })
704    }
705}
706
707impl IStorageBookmarkFolder for JsStorageFolder {}
708
709#[cfg(test)]
710mod tests {
711    // Not from upstream (upstream has no tests of the browser storage provider): the parts that
712    // do not need a page.
713    use super::*;
714
715    #[test]
716    fn convert_file_types_without_filter_includes_all() {
717        let (types, exclude_all) = BrowserStorageProvider::convert_file_types(None);
718        assert!(types.is_none());
719        assert!(!exclude_all);
720    }
721
722    #[test]
723    fn convert_file_types_with_only_all_includes_all() {
724        let all = [FilePickerFileTypes::all()];
725        let (types, exclude_all) = BrowserStorageProvider::convert_file_types(Some(&all));
726        assert!(types.is_none());
727        assert!(!exclude_all);
728    }
729
730    #[test]
731    fn convert_file_types_without_mime_types_includes_all() {
732        let patterns_only = [FilePickerFileType::new(Some("Text")).with_patterns(&["*.txt"])];
733        let (types, exclude_all) = BrowserStorageProvider::convert_file_types(Some(&patterns_only));
734        assert!(types.is_none());
735        assert!(!exclude_all);
736    }
737
738    #[test]
739    fn picker_cancel_and_permission_messages_are_recognised() {
740        assert!(is_cancel(&PromiseError::new("AbortError", "The user aborted a request.")));
741        assert!(!is_cancel(&PromiseError::new("", "Permissions denied")));
742        assert_eq!(denied(PromiseError::new("", "Permissions denied")).kind(), io::ErrorKind::PermissionDenied);
743        assert_eq!(denied(PromiseError::new("", "other")).kind(), io::ErrorKind::Other);
744        assert!(JsStorageFolder::should_supress_error_on_file_access(&PromiseError::new("", 
745            "A requested file or directory could not be found at the time an operation was processed."
746        )));
747        assert!(!JsStorageFolder::should_supress_error_on_file_access(&PromiseError::new("", "other")));
748    }
749
750    #[test]
751    fn last_modified_converts_from_unix_milliseconds() {
752        let date = from_unix_time_milliseconds(86_400_000);
753        assert_eq!(date, DateTimeOffset::UNIX_EPOCH.add_days(1.0));
754    }
755
756    #[test]
757    fn well_known_folders_map_to_the_picker_names() {
758        assert_eq!(BrowserStorageProvider::well_known_directory_name(WellKnownFolder::Desktop), "desktop");
759        assert_eq!(BrowserStorageProvider::well_known_directory_name(WellKnownFolder::Videos), "videos");
760    }
761}