From 516f5e6767673d379f3e8f5f989fff5113d9e395 Mon Sep 17 00:00:00 2001 From: Zac Harrold Date: Fri, 17 May 2024 16:01:52 +1000 Subject: [PATCH 1/6] Initial Implementation of `temp://` Asset Source --- Cargo.toml | 11 ++ crates/bevy_asset/Cargo.toml | 1 + crates/bevy_asset/src/lib.rs | 24 +++++ crates/bevy_asset/src/temp.rs | 56 +++++++++++ examples/README.md | 1 + examples/asset/temp_asset.rs | 183 ++++++++++++++++++++++++++++++++++ 6 files changed, 276 insertions(+) create mode 100644 crates/bevy_asset/src/temp.rs create mode 100644 examples/asset/temp_asset.rs diff --git a/Cargo.toml b/Cargo.toml index 947f4875278e9..a462e93542183 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1437,6 +1437,17 @@ description = "Demonstrates how to wait for multiple assets to be loaded." category = "Assets" wasm = true +[[example]] +name = "temp_asset" +path = "examples/asset/temp_asset.rs" +doc-scrape-examples = true + +[package.metadata.example.temp_asset] +name = "Temporary assets" +description = "How to use the temporary asset source" +category = "Assets" +wasm = false + # Async Tasks [[example]] name = "async_compute" diff --git a/crates/bevy_asset/Cargo.toml b/crates/bevy_asset/Cargo.toml index e380be18b287a..4882764522243 100644 --- a/crates/bevy_asset/Cargo.toml +++ b/crates/bevy_asset/Cargo.toml @@ -57,6 +57,7 @@ js-sys = "0.3" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] notify-debouncer-full = { version = "0.3.1", optional = true } +tempfile = "3.10.1" [dev-dependencies] bevy_core = { path = "../bevy_core", version = "0.14.0-dev" } diff --git a/crates/bevy_asset/src/lib.rs b/crates/bevy_asset/src/lib.rs index d6db4b988a283..d1517b8922413 100644 --- a/crates/bevy_asset/src/lib.rs +++ b/crates/bevy_asset/src/lib.rs @@ -32,6 +32,9 @@ mod path; mod reflect; mod server; +#[cfg(not(target_arch = "wasm32"))] +mod temp; + pub use assets::*; pub use bevy_asset_macros::Asset; pub use direct_access_ext::DirectAssetAccessExt; @@ -95,6 +98,9 @@ pub struct AssetPlugin { pub mode: AssetMode, /// How/If asset meta files should be checked. pub meta_check: AssetMetaCheck, + /// The path to use for temporary assets (relative to the project root). + /// If not provided, a platform specific folder will be created and deleted upon exit. + pub temporary_file_path: Option, } #[derive(Debug)] @@ -142,6 +148,7 @@ impl Default for AssetPlugin { processed_file_path: Self::DEFAULT_PROCESSED_FILE_PATH.to_string(), watch_for_changes_override: None, meta_check: AssetMetaCheck::default(), + temporary_file_path: None, } } } @@ -167,6 +174,23 @@ impl Plugin for AssetPlugin { ); embedded.register_source(&mut sources); } + + #[cfg(not(target_arch = "wasm32"))] + { + match temp::get_temp_source(app.world_mut(), self.temporary_file_path.clone()) { + Ok(source) => { + let mut sources = app + .world_mut() + .get_resource_or_insert_with::(Default::default); + + sources.insert("temp", source); + } + Err(error) => { + error!("Could not setup temp:// AssetSource due to an IO Error: {error}"); + } + }; + } + { let mut watch = cfg!(feature = "watch"); if let Some(watch_override) = self.watch_for_changes_override { diff --git a/crates/bevy_asset/src/temp.rs b/crates/bevy_asset/src/temp.rs new file mode 100644 index 0000000000000..f5889efd0edf6 --- /dev/null +++ b/crates/bevy_asset/src/temp.rs @@ -0,0 +1,56 @@ +use std::{ + io::{Error, ErrorKind}, + path::{Path, PathBuf}, +}; + +use bevy_ecs::{system::Resource, world::World}; + +use crate::io::AssetSourceBuilder; + +/// Private resource to store the temporary directory used by `temp://`. +/// Kept private as it should only be removed on application exit. +#[derive(Resource)] +enum TempDirectory { + /// Uses [`TempDir`](tempfile::TempDir)'s drop behaviour to delete the directory. + /// Note that this is not _guaranteed_ to succeed, so it is possible to leak files from this + /// option until the underlying OS cleans temporary directories. For secure files, consider using + /// [`tempfile`](tempfile::tempfile) directly. + Delete(tempfile::TempDir), + /// Will not delete the temporary directory on exit, leaving cleanup the responsibility of + /// the user or their system. + Persist(PathBuf), +} + +impl TempDirectory { + fn path(&self) -> &Path { + match self { + TempDirectory::Delete(x) => x.path(), + TempDirectory::Persist(x) => x.as_ref(), + } + } +} + +pub(crate) fn get_temp_source( + world: &mut World, + temporary_file_path: Option, +) -> std::io::Result { + let temp_dir = match world.remove_resource::() { + Some(resource) => resource, + None => match temporary_file_path { + Some(path) => TempDirectory::Persist(path.into()), + None => TempDirectory::Delete(tempfile::tempdir()?), + }, + }; + + let path = temp_dir + .path() + .as_os_str() + .try_into() + .map_err(|error| Error::new(ErrorKind::InvalidData, error))?; + + let source = AssetSourceBuilder::platform_default(path, None); + + world.insert_resource(temp_dir); + + Ok(source) +} diff --git a/examples/README.md b/examples/README.md index 4bc8aadf1305e..5bc7f53df0729 100644 --- a/examples/README.md +++ b/examples/README.md @@ -217,6 +217,7 @@ Example | Description [Hot Reloading of Assets](../examples/asset/hot_asset_reloading.rs) | Demonstrates automatic reloading of assets when modified on disk [Mult-asset synchronization](../examples/asset/multi_asset_sync.rs) | Demonstrates how to wait for multiple assets to be loaded. [Repeated texture configuration](../examples/asset/repeated_texture.rs) | How to configure the texture to repeat instead of the default clamp to edges +[Temporary assets](../examples/asset/temp_asset.rs) | How to use the temporary asset source ## Async Tasks diff --git a/examples/asset/temp_asset.rs b/examples/asset/temp_asset.rs new file mode 100644 index 0000000000000..0d2061385e5cb --- /dev/null +++ b/examples/asset/temp_asset.rs @@ -0,0 +1,183 @@ +//! This example shows how to use the temporary asset source, `temp://`. +//! First, a [`TextAsset`] is created in-memory, then saved into the temporary asset source. +//! Once the save operation is completed, we load the asset just like any other file, and display its contents! + +use bevy::{ + asset::{ + saver::{AssetSaver, ErasedAssetSaver}, + AssetPath, ErasedLoadedAsset, LoadedAsset, + }, + prelude::*, + tasks::{block_on, IoTaskPool, Task}, +}; + +use futures_lite::future; +use text_asset::{TextAsset, TextLoader, TextSaver}; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .init_asset::() + .register_asset_loader(TextLoader) + .add_systems(Startup, (save_temp_asset, setup_ui)) + .add_systems(Update, (wait_until_temp_saved, display_text)) + .run(); +} + +/// Attempt to save an asset to the temporary asset source. +fn save_temp_asset(assets: Res, mut commands: Commands) { + // This is the asset we will attempt to save. + let my_text_asset = TextAsset("Hello World!".to_owned()); + + // To ensure the `Task` can outlive this function, we must provide owned versions + // of the `AssetServer` and our desired path. + let path = AssetPath::from("temp://message.txt").into_owned(); + let server = assets.clone(); + + let task = IoTaskPool::get().spawn(async move { + save_asset(my_text_asset, path, server, TextSaver) + .await + .unwrap(); + }); + + // To ensure the task completes before we try loading, we will manually poll this task + // so we can react to its completion. + commands.spawn(SavingTask(task)); +} + +/// Poll the save tasks until completion, and then start loading our temporary text asset. +fn wait_until_temp_saved( + assets: Res, + mut tasks: Query<(Entity, &mut SavingTask)>, + mut commands: Commands, +) { + for (entity, mut task) in tasks.iter_mut() { + if let Some(()) = block_on(future::poll_once(&mut task.0)) { + commands.insert_resource(MyTempText { + text: assets.load("temp://message.txt"), + }); + + commands.entity(entity).despawn_recursive(); + } + } +} + +/// Setup a basic UI to display our [`TextAsset`] once it's loaded. +fn setup_ui(mut commands: Commands) { + commands.spawn(Camera2dBundle::default()); + + commands.spawn((TextBundle::from_section("Loading...", default()) + .with_text_justify(JustifyText::Center) + .with_style(Style { + position_type: PositionType::Absolute, + bottom: Val::Percent(50.), + right: Val::Percent(50.), + ..default() + }),)); +} + +/// Once the [`TextAsset`] is loaded, update our display text to its contents. +fn display_text( + mut query: Query<&mut Text>, + my_text: Option>, + texts: Res>, +) { + let message = my_text + .as_ref() + .and_then(|resource| texts.get(&resource.text)) + .map(|text| text.0.as_str()) + .unwrap_or("Loading..."); + + for mut text in query.iter_mut() { + *text = Text::from_section(message, default()); + } +} + +/// Save an [`Asset`] at the provided path. Returns [`None`] on failure. +async fn save_asset( + asset: A, + path: AssetPath<'_>, + server: AssetServer, + saver: impl AssetSaver + ErasedAssetSaver, +) -> Option<()> { + let asset = ErasedLoadedAsset::from(LoadedAsset::from(asset)); + let source = server.get_source(path.source()).ok()?; + let writer = source.writer().ok()?; + + let mut writer = writer.write(path.path()).await.ok()?; + ErasedAssetSaver::save(&saver, &mut writer, &asset, &()) + .await + .ok()?; + + Some(()) +} + +#[derive(Component)] +struct SavingTask(Task<()>); + +#[derive(Resource)] +struct MyTempText { + text: Handle, +} + +mod text_asset { + //! Putting the implementation of an asset loader and writer for a text asset in this module to avoid clutter. + //! While this is required for this example to function, it isn't the focus. + + use bevy::{ + asset::{ + io::{Reader, Writer}, + saver::{AssetSaver, SavedAsset}, + AssetLoader, LoadContext, + }, + prelude::*, + }; + use futures_lite::{AsyncReadExt, AsyncWriteExt}; + + #[derive(Asset, TypePath, Debug)] + pub struct TextAsset(pub String); + + #[derive(Default)] + pub struct TextLoader; + + impl AssetLoader for TextLoader { + type Asset = TextAsset; + type Settings = (); + type Error = std::io::Error; + async fn load<'a>( + &'a self, + reader: &'a mut Reader<'_>, + _settings: &'a Self::Settings, + _load_context: &'a mut LoadContext<'_>, + ) -> Result { + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).await?; + let value = String::from_utf8(bytes).unwrap(); + Ok(TextAsset(value)) + } + + fn extensions(&self) -> &[&str] { + &["txt"] + } + } + + #[derive(Default)] + pub struct TextSaver; + + impl AssetSaver for TextSaver { + type Asset = TextAsset; + type Settings = (); + type OutputLoader = TextLoader; + type Error = std::io::Error; + + async fn save<'a>( + &'a self, + writer: &'a mut Writer, + asset: SavedAsset<'a, Self::Asset>, + _settings: &'a Self::Settings, + ) -> Result<(), Self::Error> { + writer.write_all(asset.0.as_bytes()).await?; + Ok(()) + } + } +} From 63a50dd3aaa0137eca11c4ec35ac0345ce05eb6f Mon Sep 17 00:00:00 2001 From: Zac Harrold Date: Tue, 21 May 2024 08:06:56 +1000 Subject: [PATCH 2/6] Prefix `temp://` Directory with "bevy" To aid with discoverability of the temp folder. Note that the directory is explicitly _not_ logged (e.g., `trace!`) to avoid logging sensitive data (e.g., user names) Co-Authored-By: Ricky Taylor <262786+ricky26@users.noreply.github.com> --- crates/bevy_asset/src/temp.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_asset/src/temp.rs b/crates/bevy_asset/src/temp.rs index f5889efd0edf6..77c24a84054e4 100644 --- a/crates/bevy_asset/src/temp.rs +++ b/crates/bevy_asset/src/temp.rs @@ -38,7 +38,7 @@ pub(crate) fn get_temp_source( Some(resource) => resource, None => match temporary_file_path { Some(path) => TempDirectory::Persist(path.into()), - None => TempDirectory::Delete(tempfile::tempdir()?), + None => TempDirectory::Delete(tempfile::TempDir::with_prefix("bevy")?), }, }; From c47df24809502136d7c26d9b5e20acf6ecdab616 Mon Sep 17 00:00:00 2001 From: Zac Harrold Date: Tue, 21 May 2024 10:56:44 +1000 Subject: [PATCH 3/6] Published `TempDirectory` Resrouce Allows configuration of the temp directory after startup, and the retrieval of the `Path` for 3rd party use. --- crates/bevy_asset/src/lib.rs | 3 + crates/bevy_asset/src/temp.rs | 244 ++++++++++++++++++++++++++++++++-- examples/asset/temp_asset.rs | 21 ++- 3 files changed, 255 insertions(+), 13 deletions(-) diff --git a/crates/bevy_asset/src/lib.rs b/crates/bevy_asset/src/lib.rs index d1517b8922413..50d0fbdc94bde 100644 --- a/crates/bevy_asset/src/lib.rs +++ b/crates/bevy_asset/src/lib.rs @@ -51,6 +51,9 @@ pub use path::*; pub use reflect::*; pub use server::*; +#[cfg(not(target_arch = "wasm32"))] +pub use temp::TempDirectory; + /// Rusty Object Notation, a crate used to serialize and deserialize bevy assets. pub use ron; diff --git a/crates/bevy_asset/src/temp.rs b/crates/bevy_asset/src/temp.rs index 77c24a84054e4..97aaf135faf9e 100644 --- a/crates/bevy_asset/src/temp.rs +++ b/crates/bevy_asset/src/temp.rs @@ -4,14 +4,55 @@ use std::{ }; use bevy_ecs::{system::Resource, world::World}; +use bevy_utils::Duration; -use crate::io::AssetSourceBuilder; +use crate::io::{ + AssetReader, AssetSource, AssetSourceBuilder, AssetSourceEvent, AssetWatcher, AssetWriter, + ErasedAssetReader, ErasedAssetWriter, +}; + +/// A [resource](`Resource`) providing access to the temporary directory used by the `temp://` +/// [asset source](`AssetSource`). +#[derive(Resource)] +pub struct TempDirectory { + directory: TempDirectoryKind, +} + +impl TempDirectory { + /// Try to create a new [`TempDirectory`] resource, which uses a randomly created + /// directory in the user's temporary directory. This can fail if the platform does not + /// provide an appropriate temporary directory, or the directory itself could not be created. + pub fn new_transient() -> std::io::Result { + let directory = TempDirectoryKind::new_transient()?; + + Ok(Self { directory }) + } + + /// Create a new [`TempDirectory`] resource, which uses a provided directory to store temporary + /// assets. It is assumed this directory already exists, and it will _not_ be deleted on exit. + pub fn new_persistent(path: impl Into) -> Self { + let directory = TempDirectoryKind::new_persistent(path); + + Self { directory } + } + + /// Get the [`Path`] to the directory used for temporary assets. + pub fn path(&self) -> &Path { + self.directory.path() + } + + /// Persist the current temporary asset directory after application exit. + pub fn persist(&mut self) -> &mut Self { + self.directory.persist(); + + self + } +} /// Private resource to store the temporary directory used by `temp://`. /// Kept private as it should only be removed on application exit. -#[derive(Resource)] -enum TempDirectory { - /// Uses [`TempDir`](tempfile::TempDir)'s drop behaviour to delete the directory. +enum TempDirectoryKind { + /// Uses [`TempDir`](tempfile::TempDir)'s drop behavior to delete the directory. /// Note that this is not _guaranteed_ to succeed, so it is possible to leak files from this /// option until the underlying OS cleans temporary directories. For secure files, consider using /// [`tempfile`](tempfile::tempfile) directly. @@ -21,13 +62,37 @@ enum TempDirectory { Persist(PathBuf), } -impl TempDirectory { +impl TempDirectoryKind { + fn new_transient() -> std::io::Result { + let directory = tempfile::TempDir::with_prefix("bevy_")?; + Ok(Self::Delete(directory)) + } + + fn new_persistent(path: impl Into) -> Self { + Self::Persist(path.into()) + } + fn path(&self) -> &Path { match self { - TempDirectory::Delete(x) => x.path(), - TempDirectory::Persist(x) => x.as_ref(), + Self::Delete(x) => x.as_ref(), + Self::Persist(x) => x.as_ref(), } } + + fn persist(&mut self) -> &mut Self { + let mut swap = Self::Persist(PathBuf::new()); + + std::mem::swap(self, &mut swap); + + let new = match swap { + Self::Delete(x) => Self::Persist(x.into_path()), + x @ Self::Persist(_) => x, + }; + + *self = new; + + self + } } pub(crate) fn get_temp_source( @@ -37,20 +102,177 @@ pub(crate) fn get_temp_source( let temp_dir = match world.remove_resource::() { Some(resource) => resource, None => match temporary_file_path { - Some(path) => TempDirectory::Persist(path.into()), - None => TempDirectory::Delete(tempfile::TempDir::with_prefix("bevy")?), + Some(path) => TempDirectory::new_persistent(path), + None => TempDirectory::new_transient()?, }, }; - let path = temp_dir + let path: &str = temp_dir .path() .as_os_str() .try_into() .map_err(|error| Error::new(ErrorKind::InvalidData, error))?; - let source = AssetSourceBuilder::platform_default(path, None); + let path = path.to_owned(); + let debounce = Duration::from_millis(300); + + let source = AssetSourceBuilder::default() + .with_reader(TempAssetReader::get_default(path.clone())) + .with_writer(TempAssetWriter::get_default(path.clone())) + .with_watcher(TempAssetWatcher::get_default(path.clone(), debounce)) + .with_watch_warning(TempAssetWatcher::get_default_watch_warning()); world.insert_resource(temp_dir); Ok(source) } + +struct TempAssetReader { + inner: Box, +} + +impl TempAssetReader { + fn get_default(path: String) -> impl FnMut() -> Box + Send + Sync { + move || { + let mut getter = AssetSource::get_default_reader(path.clone()); + let inner = getter(); + + Box::new(Self { inner }) + } + } +} + +impl AssetReader for TempAssetReader { + async fn read<'a>( + &'a self, + path: &'a Path, + ) -> Result>, crate::io::AssetReaderError> { + self.inner.read(path).await + } + + async fn read_meta<'a>( + &'a self, + path: &'a Path, + ) -> Result>, crate::io::AssetReaderError> { + self.inner.read_meta(path).await + } + + async fn read_directory<'a>( + &'a self, + path: &'a Path, + ) -> Result, crate::io::AssetReaderError> { + self.inner.read_directory(path).await + } + + async fn is_directory<'a>( + &'a self, + path: &'a Path, + ) -> Result { + self.inner.is_directory(path).await + } +} + +struct TempAssetWriter { + inner: Box, +} + +impl TempAssetWriter { + fn get_default( + path: String, + ) -> impl FnMut(bool) -> Option> + Send + Sync { + move |condition| { + let mut getter = AssetSource::get_default_writer(path.clone()); + let inner = getter(condition)?; + + Some(Box::new(Self { inner })) + } + } +} + +impl AssetWriter for TempAssetWriter { + async fn write<'a>( + &'a self, + path: &'a Path, + ) -> Result, crate::io::AssetWriterError> { + self.inner.write(path).await + } + + async fn write_meta<'a>( + &'a self, + path: &'a Path, + ) -> Result, crate::io::AssetWriterError> { + self.inner.write_meta(path).await + } + + async fn remove<'a>(&'a self, path: &'a Path) -> Result<(), crate::io::AssetWriterError> { + self.inner.remove(path).await + } + + async fn remove_meta<'a>(&'a self, path: &'a Path) -> Result<(), crate::io::AssetWriterError> { + self.inner.remove_meta(path).await + } + + async fn rename<'a>( + &'a self, + old_path: &'a Path, + new_path: &'a Path, + ) -> Result<(), crate::io::AssetWriterError> { + self.inner.rename(old_path, new_path).await + } + + async fn rename_meta<'a>( + &'a self, + old_path: &'a Path, + new_path: &'a Path, + ) -> Result<(), crate::io::AssetWriterError> { + self.inner.rename_meta(old_path, new_path).await + } + + async fn remove_directory<'a>( + &'a self, + path: &'a Path, + ) -> Result<(), crate::io::AssetWriterError> { + self.inner.remove_directory(path).await + } + + async fn remove_empty_directory<'a>( + &'a self, + path: &'a Path, + ) -> Result<(), crate::io::AssetWriterError> { + self.inner.remove_empty_directory(path).await + } + + async fn remove_assets_in_directory<'a>( + &'a self, + path: &'a Path, + ) -> Result<(), crate::io::AssetWriterError> { + self.inner.remove_assets_in_directory(path).await + } +} + +struct TempAssetWatcher { + _inner: Box, +} + +impl TempAssetWatcher { + fn get_default( + path: String, + file_debounce_wait_time: Duration, + ) -> impl FnMut(crossbeam_channel::Sender) -> Option> + + Send + + Sync { + move |channel| { + let mut getter = + AssetSource::get_default_watcher(path.clone(), file_debounce_wait_time); + let _inner = getter(channel)?; + + Some(Box::new(Self { _inner })) + } + } + + fn get_default_watch_warning() -> &'static str { + AssetSource::get_default_watch_warning() + } +} + +impl AssetWatcher for TempAssetWatcher {} diff --git a/examples/asset/temp_asset.rs b/examples/asset/temp_asset.rs index 0d2061385e5cb..f8e89945a7822 100644 --- a/examples/asset/temp_asset.rs +++ b/examples/asset/temp_asset.rs @@ -5,7 +5,7 @@ use bevy::{ asset::{ saver::{AssetSaver, ErasedAssetSaver}, - AssetPath, ErasedLoadedAsset, LoadedAsset, + AssetPath, ErasedLoadedAsset, LoadedAsset, TempDirectory, }, prelude::*, tasks::{block_on, IoTaskPool, Task}, @@ -25,7 +25,11 @@ fn main() { } /// Attempt to save an asset to the temporary asset source. -fn save_temp_asset(assets: Res, mut commands: Commands) { +fn save_temp_asset( + assets: Res, + mut commands: Commands, + temp_directory: Res, +) { // This is the asset we will attempt to save. let my_text_asset = TextAsset("Hello World!".to_owned()); @@ -34,6 +38,10 @@ fn save_temp_asset(assets: Res, mut commands: Commands) { let path = AssetPath::from("temp://message.txt").into_owned(); let server = assets.clone(); + // We use Bevy's IoTaskPool to run the saving task asynchronously. This ensures + // our application doesn't block during the (potentially lengthy!) saving process. + // In this example, the asset is small so the blocking time will be short, but + // that wont always be the case, especially for large assets. let task = IoTaskPool::get().spawn(async move { save_asset(my_text_asset, path, server, TextSaver) .await @@ -43,6 +51,13 @@ fn save_temp_asset(assets: Res, mut commands: Commands) { // To ensure the task completes before we try loading, we will manually poll this task // so we can react to its completion. commands.spawn(SavingTask(task)); + + // You can check the logged path to see the temporary directory yourself. Note + // that the directory will be deleted once this example quits. + info!( + "Temporary Assets will be saved in {:?}", + temp_directory.path() + ); } /// Poll the save tasks until completion, and then start loading our temporary text asset. @@ -52,7 +67,9 @@ fn wait_until_temp_saved( mut commands: Commands, ) { for (entity, mut task) in tasks.iter_mut() { + // Check our SavingTask to see if it's done... if let Some(()) = block_on(future::poll_once(&mut task.0)) { + // ...and if so, load the temporary asset! commands.insert_resource(MyTempText { text: assets.load("temp://message.txt"), }); From 4a6b538224995d6ed49820f342699c2a23fa07f7 Mon Sep 17 00:00:00 2001 From: Zachary Harrold Date: Sun, 26 May 2024 22:32:10 +1000 Subject: [PATCH 4/6] WIP: WASM Support Initial support for WASM `temp://` using OPFS. --- Cargo.toml | 2 +- crates/bevy_asset/Cargo.toml | 10 + crates/bevy_asset/src/io/wasm.rs | 4 + crates/bevy_asset/src/io/wasm/opfs.rs | 665 ++++++++++++++++++++++++++ crates/bevy_asset/src/lib.rs | 28 +- crates/bevy_asset/src/temp.rs | 52 +- examples/asset/temp_asset.rs | 78 ++- 7 files changed, 774 insertions(+), 65 deletions(-) create mode 100644 crates/bevy_asset/src/io/wasm/opfs.rs diff --git a/Cargo.toml b/Cargo.toml index a462e93542183..05ad6d4ad2e28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1446,7 +1446,7 @@ doc-scrape-examples = true name = "Temporary assets" description = "How to use the temporary asset source" category = "Assets" -wasm = false +wasm = true # Async Tasks [[example]] diff --git a/crates/bevy_asset/Cargo.toml b/crates/bevy_asset/Cargo.toml index 4882764522243..1356742b26147 100644 --- a/crates/bevy_asset/Cargo.toml +++ b/crates/bevy_asset/Cargo.toml @@ -51,9 +51,19 @@ web-sys = { version = "0.3", features = [ "Window", "Response", "WorkerGlobalScope", + "Navigator", + "StorageManager", + "FileSystemFileHandle", + "FileSystemDirectoryHandle", + "FileSystemGetDirectoryOptions", + "FileSystemGetFileOptions", + "File", + "FileSystemWritableFileStream", + "FileSystemRemoveOptions", ] } wasm-bindgen-futures = "0.4" js-sys = "0.3" +async-channel = "2.2.0" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] notify-debouncer-full = { version = "0.3.1", optional = true } diff --git a/crates/bevy_asset/src/io/wasm.rs b/crates/bevy_asset/src/io/wasm.rs index e8b99a1cc641e..637135b2ce2ab 100644 --- a/crates/bevy_asset/src/io/wasm.rs +++ b/crates/bevy_asset/src/io/wasm.rs @@ -8,6 +8,10 @@ use wasm_bindgen::{prelude::wasm_bindgen, JsCast, JsValue}; use wasm_bindgen_futures::JsFuture; use web_sys::Response; +mod opfs; + +pub use opfs::*; + /// Represents the global object in the JavaScript context #[wasm_bindgen] extern "C" { diff --git a/crates/bevy_asset/src/io/wasm/opfs.rs b/crates/bevy_asset/src/io/wasm/opfs.rs new file mode 100644 index 0000000000000..cd8629cadce97 --- /dev/null +++ b/crates/bevy_asset/src/io/wasm/opfs.rs @@ -0,0 +1,665 @@ +use crate::io::wasm::Global; +use crate::io::{ + get_meta_path, AssetReader, AssetReaderError, AssetWriter, AssetWriterError, PathStream, + Reader, VecReader, Writer, +}; +use async_channel::TrySendError; +use bevy_utils::tracing::{error, info}; +use futures_io::AsyncWrite; +use futures_lite::{pin, AsyncReadExt, AsyncWriteExt, FutureExt, Stream, StreamExt}; +use js_sys::{ArrayBuffer, AsyncIterator, JsString, Uint8Array, JSON}; +use std::path::{Component, Path, PathBuf}; +use std::pin::Pin; +use std::task::{Context, Poll, Waker}; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::{spawn_local, JsFuture}; +use web_sys::{ + FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetDirectoryOptions, + FileSystemGetFileOptions, FileSystemRemoveOptions, FileSystemWritableFileStream, + StorageManager, +}; + +#[wasm_bindgen(inline_js = "export function get_keys_for_handle(a) { return a.keys(); }")] +extern "C" { + /// Workaround to provide [keys](https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryHandle/keys) + fn get_keys_for_handle(a: &FileSystemDirectoryHandle) -> AsyncIterator; +} + +fn js_value_to_err( + context: &str, + kind: std::io::ErrorKind, +) -> impl FnOnce(JsValue) -> std::io::Error + '_ { + move |value| { + let message = match JSON::stringify(&value) { + Ok(js_str) => format!("JS Failure: '{context}': {js_str}"), + Err(_) => { + format!("Failed to {context} and also failed to stringify the JSValue of the error") + } + }; + + std::io::Error::new(kind, message) + } +} + +/// Get the [`StorageManager`] from the global context. Will return [`None`] if the context is not either +/// standard (e.g., with access to `window`), or a worker. +fn get_storage_manager() -> std::io::Result { + let global: Global = js_sys::global().unchecked_into(); + + if !global.window().is_undefined() { + let window: web_sys::Window = global.unchecked_into(); + Ok(window.navigator().storage()) + } else if !global.worker().is_undefined() { + let worker: web_sys::WorkerGlobalScope = global.unchecked_into(); + Ok(worker.navigator().storage()) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "Unsupported global context", + )) + } +} + +/// Extension method to allow for a more ergonomic handling of [promises](`js_sys::Promise`). +trait IntoJsFuture: Into { + /// Convert this [thenable](`js_sys::Promise`) into a [`JsFuture`]. + fn into_js_future(self) -> JsFuture { + self.into() + } +} + +impl> IntoJsFuture for T {} + +/// Get the [`FileSystemDirectoryHandle`] for the root Origin Private File System. See +/// [MDN](https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/getDirectory) for details. +/// +/// Can fail if a `SecurityError` exception is thrown by the JS runtime. +async fn get_storage_root() -> std::io::Result { + get_storage_manager()? + .get_directory() + .into_js_future() + .await + .map_err(js_value_to_err( + "Cannot get StorageManager", + std::io::ErrorKind::PermissionDenied, + )) + .map(|value| value.unchecked_into()) +} + +/// Open a directory relative to `start` from a given `path`. +/// Will create directories based on the provided `path` if `create` is `true`. +async fn get_directory( + start: &FileSystemDirectoryHandle, + path: impl AsRef, + create: bool, +) -> std::io::Result { + let path = path.as_ref(); + + let mut options = FileSystemGetDirectoryOptions::new(); + options.create(create); + + let mut current = start.clone(); + + for component in path.components() { + match component { + Component::Prefix(x) => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Cannot parse path '{path:?}': Prefix '{x:?}' is not supported"), + )); + } + Component::RootDir => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Cannot parse path '{path:?}': Cannot use an absolute path"), + )); + } + Component::ParentDir => { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("Cannot parse path '{path:?}': Relative traversal up the hierarchy is not supported"))); + } + Component::CurDir => { + // No-op + continue; + } + Component::Normal(name) => { + let Some(name) = name.to_str() else { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("Cannot parse path '{path:?}': Segment '{name:?}' cannot be used as a UTF-8 string"))); + }; + + current = current + .get_directory_handle_with_options(name, &options) + .into_js_future() + .await + .map_err(js_value_to_err( + "Cannot get Directory Handle", + std::io::ErrorKind::Other, + )) + .map(|value| value.unchecked_into())?; + } + } + } + + Ok(current) +} + +/// Get child entries of this directory. +async fn get_entries(start: &FileSystemDirectoryHandle) -> impl Stream + Unpin { + struct EntriesStream { + inner: AsyncIterator, + current: Option, + } + + impl Stream for EntriesStream { + type Item = PathBuf; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut current = match self.current.take() { + Some(current) => current, + None => { + let Ok(next) = self.inner.next() else { + return Poll::Ready(None); + }; + + next.into_js_future() + } + }; + + match current.poll(cx) { + Poll::Ready(result) => { + let result = result + .ok() + .and_then(|value| value.dyn_ref::().cloned()) + .map(String::from) + .map(PathBuf::from); + + Poll::Ready(result) + } + Poll::Pending => { + self.current = Some(current); + + Poll::Pending + } + } + } + } + + EntriesStream { + inner: get_keys_for_handle(start), + current: None, + } +} + +/// Open a file relative to `start` from a given `path`. +/// Will create directories and the final file based on the provided `path` if `create` is `true`. +async fn get_file( + start: &FileSystemDirectoryHandle, + path: impl AsRef, + create_file: bool, + create_path: bool, +) -> std::io::Result { + let path = path.as_ref(); + + let mut components = path.components(); + + let Some(file_name) = components.next_back() else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Provided path is empty", + )); + }; + + let Component::Normal(file_name) = file_name else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Cannot parse path '{path:?}': final component must be a file name"), + )); + }; + + let Some(file_name) = file_name.to_str() else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "Cannot parse path '{path:?}': file name '{file_name:?}' cannot be used as a UTF-8 string" + ), + )); + }; + + get_directory(start, components.collect::(), create_path) + .await? + .get_file_handle_with_options(file_name, &{ + let mut options = FileSystemGetFileOptions::new(); + options.create(create_file); + options + }) + .into_js_future() + .await + .map_err(js_value_to_err( + format!("File not available: '{path:?}'").as_str(), + std::io::ErrorKind::NotFound, + )) + .map(|value| value.unchecked_into()) +} + +/// Read the contents of a [file handle](`FileSystemFileHandle`). +async fn read_file<'a>(handle: &FileSystemFileHandle) -> std::io::Result>> { + let file: web_sys::File = handle + .get_file() + .into_js_future() + .await + .map_err(js_value_to_err( + "Cannot get File from Handle", + std::io::ErrorKind::Other, + ))? + .unchecked_into(); + + let buffer: ArrayBuffer = file + .array_buffer() + .into_js_future() + .await + .map_err(js_value_to_err( + "Cannot get Buffer from File", + std::io::ErrorKind::Other, + ))? + .unchecked_into(); + + let bytes = Uint8Array::new(&buffer).to_vec(); + + Ok(Box::new(VecReader::new(bytes))) +} + +async fn write_file(handle: &FileSystemFileHandle) -> std::io::Result> { + enum Command { + Write(Box<[u8]>, Waker), + Flush(Waker), + Close(Waker), + } + + struct FileStreamWriter { + commands: async_channel::Sender, + } + + impl AsyncWrite for FileStreamWriter { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + match self.commands.try_send(Command::Write( + buf.to_owned().into_boxed_slice(), + cx.waker().clone(), + )) { + Ok(()) => Poll::Ready(Ok(buf.len())), + Err(TrySendError::Closed(..)) => Poll::Ready(Ok(0)), + Err(TrySendError::Full(..)) => Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "Could not send write request to writer", + ))), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.commands.try_send(Command::Flush(cx.waker().clone())) { + Ok(()) => Poll::Ready(Ok(())), + Err(TrySendError::Closed(..)) => Poll::Ready(Ok(())), + Err(TrySendError::Full(..)) => Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "Could not send flush request to writer", + ))), + } + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.commands.try_send(Command::Close(cx.waker().clone())) { + Ok(()) => Poll::Pending, + Err(TrySendError::Closed(..)) => Poll::Ready(Ok(())), + Err(TrySendError::Full(..)) => Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "Could not send close request to writer", + ))), + } + } + } + + let stream: FileSystemWritableFileStream = handle + .create_writable() + .into_js_future() + .await + .map_err(js_value_to_err( + "Cannot get Create Writable Stream", + std::io::ErrorKind::Other, + ))? + .unchecked_into(); + + let (sender, receiver) = async_channel::unbounded::(); + + spawn_local(async move { + info!("Starting a writer..."); + let stream = stream; + let receiver = receiver; + pin!(receiver); + + let maybe_waker = loop { + let Some(command) = receiver.next().await else { + break None; + }; + + match command { + Command::Write(buf, waker) => { + info!("Writing {:?}", buf); + let Ok(promise) = stream.write_with_u8_array(&buf) else { + error!("Cannot Write to Stream!"); + break None; + }; + + let Ok(_) = promise.into_js_future().await else { + error!("Cannot Write to Stream!"); + break None; + }; + + waker.wake(); + } + Command::Flush(waker) => { + info!("Flushing"); + waker.wake(); + } + Command::Close(waker) => { + info!("Closing"); + let Ok(_) = stream.close().into_js_future().await else { + error!("Cannot Close Stream!"); + break None; + }; + + break Some(waker); + } + } + }; + + drop(receiver); + + if let Some(waker) = maybe_waker { + waker.wake(); + } else { + if stream.close().into_js_future().await.is_err() { + error!("Stream was closed unexpectedly and could not be closed properly."); + } + } + }); + + Ok(Box::new(FileStreamWriter { commands: sender })) +} + +async fn remove_entry(handle: &FileSystemDirectoryHandle, entry: &str) -> std::io::Result<()> { + handle + .remove_entry_with_options(entry, &{ + let mut options = FileSystemRemoveOptions::new(); + options.recursive(true); + options + }) + .into_js_future() + .await + .map_err(js_value_to_err( + "Cannot remove Directory", + std::io::ErrorKind::Other, + ))? + .is_undefined() + .then_some(()) + .ok_or(std::io::Error::new( + std::io::ErrorKind::Other, + "Failed to remove entry", + )) +} + +/// Bevy compatible wrapper for the [Origin Private File System API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system) +pub struct OriginPrivateFileSystem { + root: PathBuf, +} + +impl OriginPrivateFileSystem { + /// Constructs a new [`OriginPrivateFileSystem`] with the provided shadow-root. + pub fn new(root: PathBuf) -> Self { + Self { root } + } +} + +impl AssetReader for OriginPrivateFileSystem { + async fn read<'a>(&'a self, path: &'a Path) -> Result>, AssetReaderError> { + let root = get_storage_root().await?; + let shadow_root = get_directory(&root, &self.root, true).await?; + let handle = get_file(&shadow_root, path, false, false) + .await + .map_err(|error| AssetReaderError::NotFound(path.to_owned()))?; + let reader = read_file(&handle).await?; + + Ok(reader) + } + + async fn read_meta<'a>(&'a self, path: &'a Path) -> Result>, AssetReaderError> { + let path = &get_meta_path(path); + let root = get_storage_root().await?; + let shadow_root = get_directory(&root, &self.root, true).await?; + let handle = get_file(&shadow_root, path, false, false) + .await + .map_err(|error| AssetReaderError::NotFound(path.to_owned()))?; + let reader = read_file(&handle).await?; + + Ok(reader) + } + + async fn read_directory<'a>( + &'a self, + path: &'a Path, + ) -> Result, AssetReaderError> { + struct VecStream { + inner: Box<[T]>, + cursor: usize, + } + + impl Stream for VecStream { + type Item = T; + + fn poll_next( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + let item = self.inner.get(self.cursor).cloned(); + + if item.is_some() { + self.cursor += 1; + } + + Poll::Ready(item) + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = self.inner.len().saturating_sub(self.cursor); + + (remaining, Some(remaining)) + } + } + + let root = get_storage_root().await?; + let shadow_root = get_directory(&root, &self.root, true).await?; + let handle = get_directory(&shadow_root, path, false).await?; + + let mut entries = get_entries(&handle).await; + let mut final_entries = Vec::new(); + + while let Some(entry) = entries.next().await { + final_entries.push(entry); + } + + Ok(Box::new(VecStream { + inner: final_entries.into_boxed_slice(), + cursor: 0, + })) + } + + async fn is_directory<'a>(&'a self, path: &'a Path) -> Result { + let root = get_storage_root().await?; + let shadow_root = get_directory(&root, &self.root, true).await?; + let result = get_directory(&shadow_root, path, false).await.is_ok(); + + Ok(result) + } +} + +impl AssetWriter for OriginPrivateFileSystem { + async fn write<'a>(&'a self, path: &'a Path) -> Result, AssetWriterError> { + let root = get_storage_root().await?; + let shadow_root = get_directory(&root, &self.root, true).await?; + let handle = get_file(&shadow_root, path, true, true).await?; + let writer = write_file(&handle).await?; + + Ok(writer) + } + + async fn write_meta<'a>(&'a self, path: &'a Path) -> Result, AssetWriterError> { + self.write(&get_meta_path(path)).await + } + + async fn remove<'a>(&'a self, path: &'a Path) -> Result<(), AssetWriterError> { + let root = get_storage_root().await?; + let shadow_root = get_directory(&root, &self.root, false).await?; + let _ = get_file(&shadow_root, path, false, false).await?; + + let mut components = path.components(); + + let Some(entry) = components.next_back() else { + return Err(AssetWriterError::from(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Provided path is empty", + ))); + }; + + let Component::Normal(entry) = entry else { + return Err(AssetWriterError::from(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Cannot parse path '{path:?}': final component must be an entry name"), + ))); + }; + + let Some(entry) = entry.to_str() else { + return Err(AssetWriterError::from(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "Cannot parse path '{path:?}': entry name '{entry:?}' cannot be used as a UTF-8 string" + ), + ))); + }; + + let parent_handle = + get_directory(&shadow_root, components.collect::(), false).await?; + + remove_entry(&parent_handle, entry).await?; + + Ok(()) + } + + async fn remove_meta<'a>(&'a self, path: &'a Path) -> Result<(), AssetWriterError> { + self.remove(&get_meta_path(path)).await + } + + async fn rename<'a>( + &'a self, + old_path: &'a Path, + new_path: &'a Path, + ) -> Result<(), AssetWriterError> { + let mut buffer = Vec::new(); + + self.read(old_path) + .await + .map_err(|error| { + AssetWriterError::from(std::io::Error::new(std::io::ErrorKind::Other, error)) + })? + .read_to_end(&mut buffer) + .await?; + self.write(new_path).await?.write(&buffer).await?; + self.remove(old_path).await?; + + Ok(()) + } + + async fn rename_meta<'a>( + &'a self, + old_path: &'a Path, + new_path: &'a Path, + ) -> Result<(), AssetWriterError> { + self.rename(&get_meta_path(old_path), &get_meta_path(new_path)) + .await + } + + async fn remove_directory<'a>(&'a self, path: &'a Path) -> Result<(), AssetWriterError> { + let root = get_storage_root().await?; + let shadow_root = get_directory(&root, &self.root, true).await?; + let _ = get_directory(&shadow_root, path, true).await?; + + let mut components = path.components(); + + let Some(entry) = components.next_back() else { + return Err(AssetWriterError::from(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Provided path is empty", + ))); + }; + + let Component::Normal(entry) = entry else { + return Err(AssetWriterError::from(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Cannot parse path '{path:?}': final component must be an entry name"), + ))); + }; + + let Some(entry) = entry.to_str() else { + return Err(AssetWriterError::from(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "Cannot parse path '{path:?}': entry name '{entry:?}' cannot be used as a UTF-8 string" + ), + ))); + }; + + let parent_handle = + get_directory(&shadow_root, components.collect::(), false).await?; + + remove_entry(&parent_handle, entry).await?; + + Ok(()) + } + + async fn remove_empty_directory<'a>(&'a self, path: &'a Path) -> Result<(), AssetWriterError> { + let root = get_storage_root().await?; + let shadow_root = get_directory(&root, &self.root, true).await?; + let handle = get_directory(&shadow_root, path, true).await?; + let mut stream = get_entries(&handle).await; + + if stream.next().await.is_some() { + return Err(AssetWriterError::from(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Directory is not empty", + ))); + } + + self.remove_directory(path).await + } + + async fn remove_assets_in_directory<'a>( + &'a self, + path: &'a Path, + ) -> Result<(), AssetWriterError> { + let root = get_storage_root().await?; + let shadow_root = get_directory(&root, &self.root, true).await?; + let handle = get_directory(&shadow_root, path, true).await?; + let mut stream = get_entries(&handle).await; + + while let Some(entry) = stream.next().await { + let Some(entry) = entry.to_str() else { + unreachable!("Only valid UTF-8 is storable in the Origin Private File System") + }; + + remove_entry(&handle, entry).await?; + } + + Ok(()) + } +} diff --git a/crates/bevy_asset/src/lib.rs b/crates/bevy_asset/src/lib.rs index 50d0fbdc94bde..fffe55c7cf708 100644 --- a/crates/bevy_asset/src/lib.rs +++ b/crates/bevy_asset/src/lib.rs @@ -32,7 +32,6 @@ mod path; mod reflect; mod server; -#[cfg(not(target_arch = "wasm32"))] mod temp; pub use assets::*; @@ -50,8 +49,6 @@ pub use loader_builders::{ pub use path::*; pub use reflect::*; pub use server::*; - -#[cfg(not(target_arch = "wasm32"))] pub use temp::TempDirectory; /// Rusty Object Notation, a crate used to serialize and deserialize bevy assets. @@ -178,21 +175,18 @@ impl Plugin for AssetPlugin { embedded.register_source(&mut sources); } - #[cfg(not(target_arch = "wasm32"))] - { - match temp::get_temp_source(app.world_mut(), self.temporary_file_path.clone()) { - Ok(source) => { - let mut sources = app - .world_mut() - .get_resource_or_insert_with::(Default::default); + match temp::get_temp_source(app.world_mut(), self.temporary_file_path.clone()) { + Ok(source) => { + let mut sources = app + .world_mut() + .get_resource_or_insert_with::(Default::default); - sources.insert("temp", source); - } - Err(error) => { - error!("Could not setup temp:// AssetSource due to an IO Error: {error}"); - } - }; - } + sources.insert("temp", source); + } + Err(error) => { + error!("Could not setup temp:// AssetSource due to an IO Error: {error}"); + } + }; { let mut watch = cfg!(feature = "watch"); diff --git a/crates/bevy_asset/src/temp.rs b/crates/bevy_asset/src/temp.rs index 97aaf135faf9e..aa1394b577902 100644 --- a/crates/bevy_asset/src/temp.rs +++ b/crates/bevy_asset/src/temp.rs @@ -22,6 +22,7 @@ impl TempDirectory { /// Try to create a new [`TempDirectory`] resource, which uses a randomly created /// directory in the user's temporary directory. This can fail if the platform does not /// provide an appropriate temporary directory, or the directory itself could not be created. + #[cfg(not(target_arch = "wasm32"))] pub fn new_transient() -> std::io::Result { let directory = TempDirectoryKind::new_transient()?; @@ -42,6 +43,7 @@ impl TempDirectory { } /// Persist the current temporary asset directory after application exit. + #[cfg(not(target_arch = "wasm32"))] pub fn persist(&mut self) -> &mut Self { self.directory.persist(); @@ -56,6 +58,7 @@ enum TempDirectoryKind { /// Note that this is not _guaranteed_ to succeed, so it is possible to leak files from this /// option until the underlying OS cleans temporary directories. For secure files, consider using /// [`tempfile`](tempfile::tempfile) directly. + #[cfg(not(target_arch = "wasm32"))] Delete(tempfile::TempDir), /// Will not delete the temporary directory on exit, leaving cleanup the responsibility of /// the user or their system. @@ -63,6 +66,7 @@ enum TempDirectoryKind { } impl TempDirectoryKind { + #[cfg(not(target_arch = "wasm32"))] fn new_transient() -> std::io::Result { let directory = tempfile::TempDir::with_prefix("bevy_")?; Ok(Self::Delete(directory)) @@ -74,11 +78,13 @@ impl TempDirectoryKind { fn path(&self) -> &Path { match self { + #[cfg(not(target_arch = "wasm32"))] Self::Delete(x) => x.as_ref(), Self::Persist(x) => x.as_ref(), } } + #[cfg(not(target_arch = "wasm32"))] fn persist(&mut self) -> &mut Self { let mut swap = Self::Persist(PathBuf::new()); @@ -103,7 +109,17 @@ pub(crate) fn get_temp_source( Some(resource) => resource, None => match temporary_file_path { Some(path) => TempDirectory::new_persistent(path), - None => TempDirectory::new_transient()?, + None => { + #[cfg(not(target_arch = "wasm32"))] + { + TempDirectory::new_transient()? + } + + #[cfg(target_arch = "wasm32")] + { + TempDirectory::new_persistent("bevy_temp") + } + } }, }; @@ -134,8 +150,20 @@ struct TempAssetReader { impl TempAssetReader { fn get_default(path: String) -> impl FnMut() -> Box + Send + Sync { move || { - let mut getter = AssetSource::get_default_reader(path.clone()); - let inner = getter(); + let inner = { + #[cfg(not(target_arch = "wasm32"))] + { + let mut getter = AssetSource::get_default_reader(path.clone()); + getter() + } + + #[cfg(target_arch = "wasm32")] + { + Box::new(crate::io::wasm::OriginPrivateFileSystem::new( + path.clone().into(), + )) + } + }; Box::new(Self { inner }) } @@ -180,9 +208,21 @@ impl TempAssetWriter { fn get_default( path: String, ) -> impl FnMut(bool) -> Option> + Send + Sync { - move |condition| { - let mut getter = AssetSource::get_default_writer(path.clone()); - let inner = getter(condition)?; + move |_condition| { + let inner = { + #[cfg(not(target_arch = "wasm32"))] + { + let mut getter = AssetSource::get_default_writer(path.clone()); + getter(_condition)? + } + + #[cfg(target_arch = "wasm32")] + { + Box::new(crate::io::wasm::OriginPrivateFileSystem::new( + path.clone().into(), + )) + } + }; Some(Box::new(Self { inner })) } diff --git a/examples/asset/temp_asset.rs b/examples/asset/temp_asset.rs index f8e89945a7822..fd2469b78f665 100644 --- a/examples/asset/temp_asset.rs +++ b/examples/asset/temp_asset.rs @@ -8,10 +8,9 @@ use bevy::{ AssetPath, ErasedLoadedAsset, LoadedAsset, TempDirectory, }, prelude::*, - tasks::{block_on, IoTaskPool, Task}, + tasks::IoTaskPool, }; -use futures_lite::future; use text_asset::{TextAsset, TextLoader, TextSaver}; fn main() { @@ -25,13 +24,10 @@ fn main() { } /// Attempt to save an asset to the temporary asset source. -fn save_temp_asset( - assets: Res, - mut commands: Commands, - temp_directory: Res, -) { +fn save_temp_asset(assets: Res, temp_directory: Res) { // This is the asset we will attempt to save. - let my_text_asset = TextAsset("Hello World!".to_owned()); + let my_text_asset = + TextAsset("Hello World!\nPress the Down Arrow Key to Discard the Asset".to_owned()); // To ensure the `Task` can outlive this function, we must provide owned versions // of the `AssetServer` and our desired path. @@ -41,16 +37,16 @@ fn save_temp_asset( // We use Bevy's IoTaskPool to run the saving task asynchronously. This ensures // our application doesn't block during the (potentially lengthy!) saving process. // In this example, the asset is small so the blocking time will be short, but - // that wont always be the case, especially for large assets. - let task = IoTaskPool::get().spawn(async move { - save_asset(my_text_asset, path, server, TextSaver) - .await - .unwrap(); - }); - - // To ensure the task completes before we try loading, we will manually poll this task - // so we can react to its completion. - commands.spawn(SavingTask(task)); + // that won't always be the case, especially for large assets. + IoTaskPool::get() + .spawn(async move { + info!("Saving my asset..."); + save_asset(my_text_asset, path, server, TextSaver) + .await + .expect("Should've saved..."); + info!("...Saved!"); + }) + .detach(); // You can check the logged path to see the temporary directory yourself. Note // that the directory will be deleted once this example quits. @@ -63,19 +59,19 @@ fn save_temp_asset( /// Poll the save tasks until completion, and then start loading our temporary text asset. fn wait_until_temp_saved( assets: Res, - mut tasks: Query<(Entity, &mut SavingTask)>, mut commands: Commands, + keyboard_input: Res>, ) { - for (entity, mut task) in tasks.iter_mut() { - // Check our SavingTask to see if it's done... - if let Some(()) = block_on(future::poll_once(&mut task.0)) { - // ...and if so, load the temporary asset! - commands.insert_resource(MyTempText { - text: assets.load("temp://message.txt"), - }); - - commands.entity(entity).despawn_recursive(); - } + if keyboard_input.just_pressed(KeyCode::ArrowUp) { + info!("Loading Asset..."); + commands.insert_resource(MyTempText { + text: assets.load("temp://message.txt"), + }); + } + + if keyboard_input.just_pressed(KeyCode::ArrowDown) { + info!("Discarding Asset..."); + commands.remove_resource::(); } } @@ -83,14 +79,17 @@ fn wait_until_temp_saved( fn setup_ui(mut commands: Commands) { commands.spawn(Camera2dBundle::default()); - commands.spawn((TextBundle::from_section("Loading...", default()) - .with_text_justify(JustifyText::Center) - .with_style(Style { - position_type: PositionType::Absolute, - bottom: Val::Percent(50.), - right: Val::Percent(50.), - ..default() - }),)); + commands.spawn((TextBundle::from_section( + "Press the Up Arrow Key to Load The Asset...", + default(), + ) + .with_text_justify(JustifyText::Center) + .with_style(Style { + position_type: PositionType::Absolute, + bottom: Val::Percent(50.), + right: Val::Percent(50.), + ..default() + }),)); } /// Once the [`TextAsset`] is loaded, update our display text to its contents. @@ -103,7 +102,7 @@ fn display_text( .as_ref() .and_then(|resource| texts.get(&resource.text)) .map(|text| text.0.as_str()) - .unwrap_or("Loading..."); + .unwrap_or("Press the Up Arrow Key to Load The Asset..."); for mut text in query.iter_mut() { *text = Text::from_section(message, default()); @@ -129,9 +128,6 @@ async fn save_asset( Some(()) } -#[derive(Component)] -struct SavingTask(Task<()>); - #[derive(Resource)] struct MyTempText { text: Handle, From 98b21d58186f6ed868237ecd9933ab47451b0630 Mon Sep 17 00:00:00 2001 From: Zac Harrold Date: Mon, 27 May 2024 21:10:26 +1000 Subject: [PATCH 5/6] WIP: Refactored `opfs` Module Split out certain utility methods which are more general WASM items. --- crates/bevy_asset/src/io/wasm/opfs.rs | 1100 +++++++++++++------------ 1 file changed, 585 insertions(+), 515 deletions(-) diff --git a/crates/bevy_asset/src/io/wasm/opfs.rs b/crates/bevy_asset/src/io/wasm/opfs.rs index cd8629cadce97..d983fd72aeb9e 100644 --- a/crates/bevy_asset/src/io/wasm/opfs.rs +++ b/crates/bevy_asset/src/io/wasm/opfs.rs @@ -1,504 +1,175 @@ use crate::io::wasm::Global; use crate::io::{ get_meta_path, AssetReader, AssetReaderError, AssetWriter, AssetWriterError, PathStream, - Reader, VecReader, Writer, + Reader, Writer, }; -use async_channel::TrySendError; -use bevy_utils::tracing::{error, info}; -use futures_io::AsyncWrite; -use futures_lite::{pin, AsyncReadExt, AsyncWriteExt, FutureExt, Stream, StreamExt}; -use js_sys::{ArrayBuffer, AsyncIterator, JsString, Uint8Array, JSON}; +use futures_lite::{AsyncReadExt, AsyncWriteExt, Stream, StreamExt}; +use js_sys::{JsString, JSON}; use std::path::{Component, Path, PathBuf}; -use std::pin::Pin; -use std::task::{Context, Poll, Waker}; -use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::{JsCast, JsValue}; -use wasm_bindgen_futures::{spawn_local, JsFuture}; +use wasm_bindgen_futures::spawn_local; use web_sys::{ FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetDirectoryOptions, FileSystemGetFileOptions, FileSystemRemoveOptions, FileSystemWritableFileStream, - StorageManager, }; -#[wasm_bindgen(inline_js = "export function get_keys_for_handle(a) { return a.keys(); }")] -extern "C" { - /// Workaround to provide [keys](https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryHandle/keys) - fn get_keys_for_handle(a: &FileSystemDirectoryHandle) -> AsyncIterator; -} - -fn js_value_to_err( - context: &str, - kind: std::io::ErrorKind, -) -> impl FnOnce(JsValue) -> std::io::Error + '_ { - move |value| { - let message = match JSON::stringify(&value) { - Ok(js_str) => format!("JS Failure: '{context}': {js_str}"), - Err(_) => { - format!("Failed to {context} and also failed to stringify the JSValue of the error") - } - }; - - std::io::Error::new(kind, message) - } -} +use utils::*; -/// Get the [`StorageManager`] from the global context. Will return [`None`] if the context is not either -/// standard (e.g., with access to `window`), or a worker. -fn get_storage_manager() -> std::io::Result { - let global: Global = js_sys::global().unchecked_into(); - - if !global.window().is_undefined() { - let window: web_sys::Window = global.unchecked_into(); - Ok(window.navigator().storage()) - } else if !global.worker().is_undefined() { - let worker: web_sys::WorkerGlobalScope = global.unchecked_into(); - Ok(worker.navigator().storage()) - } else { - Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - "Unsupported global context", - )) - } -} - -/// Extension method to allow for a more ergonomic handling of [promises](`js_sys::Promise`). -trait IntoJsFuture: Into { - /// Convert this [thenable](`js_sys::Promise`) into a [`JsFuture`]. - fn into_js_future(self) -> JsFuture { - self.into() - } -} - -impl> IntoJsFuture for T {} - -/// Get the [`FileSystemDirectoryHandle`] for the root Origin Private File System. See -/// [MDN](https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/getDirectory) for details. -/// -/// Can fail if a `SecurityError` exception is thrown by the JS runtime. -async fn get_storage_root() -> std::io::Result { - get_storage_manager()? - .get_directory() - .into_js_future() - .await - .map_err(js_value_to_err( - "Cannot get StorageManager", - std::io::ErrorKind::PermissionDenied, - )) - .map(|value| value.unchecked_into()) +/// Bevy compatible wrapper for the [Origin Private File System API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system) +pub struct OriginPrivateFileSystem { + root: Vec, } -/// Open a directory relative to `start` from a given `path`. -/// Will create directories based on the provided `path` if `create` is `true`. -async fn get_directory( - start: &FileSystemDirectoryHandle, - path: impl AsRef, - create: bool, -) -> std::io::Result { - let path = path.as_ref(); - - let mut options = FileSystemGetDirectoryOptions::new(); - options.create(create); - - let mut current = start.clone(); - - for component in path.components() { - match component { - Component::Prefix(x) => { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("Cannot parse path '{path:?}': Prefix '{x:?}' is not supported"), - )); - } - Component::RootDir => { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("Cannot parse path '{path:?}': Cannot use an absolute path"), - )); - } - Component::ParentDir => { - return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("Cannot parse path '{path:?}': Relative traversal up the hierarchy is not supported"))); - } - Component::CurDir => { - // No-op - continue; - } - Component::Normal(name) => { - let Some(name) = name.to_str() else { - return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("Cannot parse path '{path:?}': Segment '{name:?}' cannot be used as a UTF-8 string"))); - }; - - current = current - .get_directory_handle_with_options(name, &options) - .into_js_future() - .await - .map_err(js_value_to_err( - "Cannot get Directory Handle", - std::io::ErrorKind::Other, - )) - .map(|value| value.unchecked_into())?; - } - } - } - - Ok(current) -} +impl OriginPrivateFileSystem { + /// Constructs a new [`OriginPrivateFileSystem`] with the provided shadow-root. + pub fn new(root: PathBuf) -> Self { + let root = Self::canonical(&root) + .expect("Provided path is not valid") + .into_iter() + .map(|component| component.to_owned()) + .collect(); -/// Get child entries of this directory. -async fn get_entries(start: &FileSystemDirectoryHandle) -> impl Stream + Unpin { - struct EntriesStream { - inner: AsyncIterator, - current: Option, + Self { root } } - impl Stream for EntriesStream { - type Item = PathBuf; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let mut current = match self.current.take() { - Some(current) => current, - None => { - let Ok(next) = self.inner.next() else { - return Poll::Ready(None); - }; - - next.into_js_future() - } - }; - - match current.poll(cx) { - Poll::Ready(result) => { - let result = result - .ok() - .and_then(|value| value.dyn_ref::().cloned()) - .map(String::from) - .map(PathBuf::from); - - Poll::Ready(result) - } - Poll::Pending => { - self.current = Some(current); - - Poll::Pending + /// Constructs a canonical path (as components) from the provided `path`. + pub(crate) fn canonical<'a>(path: &'a Path) -> std::io::Result> { + let mut canonical_path = Vec::new(); + + for component in path.components() { + match component { + Component::Prefix(x) => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Cannot parse path '{path:?}': Prefix '{x:?}' is not supported"), + )); } - } - } - } - - EntriesStream { - inner: get_keys_for_handle(start), - current: None, - } -} - -/// Open a file relative to `start` from a given `path`. -/// Will create directories and the final file based on the provided `path` if `create` is `true`. -async fn get_file( - start: &FileSystemDirectoryHandle, - path: impl AsRef, - create_file: bool, - create_path: bool, -) -> std::io::Result { - let path = path.as_ref(); - - let mut components = path.components(); - - let Some(file_name) = components.next_back() else { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "Provided path is empty", - )); - }; - - let Component::Normal(file_name) = file_name else { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("Cannot parse path '{path:?}': final component must be a file name"), - )); - }; - - let Some(file_name) = file_name.to_str() else { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!( - "Cannot parse path '{path:?}': file name '{file_name:?}' cannot be used as a UTF-8 string" - ), - )); - }; - - get_directory(start, components.collect::(), create_path) - .await? - .get_file_handle_with_options(file_name, &{ - let mut options = FileSystemGetFileOptions::new(); - options.create(create_file); - options - }) - .into_js_future() - .await - .map_err(js_value_to_err( - format!("File not available: '{path:?}'").as_str(), - std::io::ErrorKind::NotFound, - )) - .map(|value| value.unchecked_into()) -} - -/// Read the contents of a [file handle](`FileSystemFileHandle`). -async fn read_file<'a>(handle: &FileSystemFileHandle) -> std::io::Result>> { - let file: web_sys::File = handle - .get_file() - .into_js_future() - .await - .map_err(js_value_to_err( - "Cannot get File from Handle", - std::io::ErrorKind::Other, - ))? - .unchecked_into(); - - let buffer: ArrayBuffer = file - .array_buffer() - .into_js_future() - .await - .map_err(js_value_to_err( - "Cannot get Buffer from File", - std::io::ErrorKind::Other, - ))? - .unchecked_into(); - - let bytes = Uint8Array::new(&buffer).to_vec(); - - Ok(Box::new(VecReader::new(bytes))) -} - -async fn write_file(handle: &FileSystemFileHandle) -> std::io::Result> { - enum Command { - Write(Box<[u8]>, Waker), - Flush(Waker), - Close(Waker), - } - - struct FileStreamWriter { - commands: async_channel::Sender, - } - - impl AsyncWrite for FileStreamWriter { - fn poll_write( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &[u8], - ) -> Poll> { - match self.commands.try_send(Command::Write( - buf.to_owned().into_boxed_slice(), - cx.waker().clone(), - )) { - Ok(()) => Poll::Ready(Ok(buf.len())), - Err(TrySendError::Closed(..)) => Poll::Ready(Ok(0)), - Err(TrySendError::Full(..)) => Poll::Ready(Err(std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "Could not send write request to writer", - ))), - } - } - - fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match self.commands.try_send(Command::Flush(cx.waker().clone())) { - Ok(()) => Poll::Ready(Ok(())), - Err(TrySendError::Closed(..)) => Poll::Ready(Ok(())), - Err(TrySendError::Full(..)) => Poll::Ready(Err(std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "Could not send flush request to writer", - ))), - } - } - - fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match self.commands.try_send(Command::Close(cx.waker().clone())) { - Ok(()) => Poll::Pending, - Err(TrySendError::Closed(..)) => Poll::Ready(Ok(())), - Err(TrySendError::Full(..)) => Poll::Ready(Err(std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "Could not send close request to writer", - ))), - } - } - } - - let stream: FileSystemWritableFileStream = handle - .create_writable() - .into_js_future() - .await - .map_err(js_value_to_err( - "Cannot get Create Writable Stream", - std::io::ErrorKind::Other, - ))? - .unchecked_into(); - - let (sender, receiver) = async_channel::unbounded::(); - - spawn_local(async move { - info!("Starting a writer..."); - let stream = stream; - let receiver = receiver; - pin!(receiver); - - let maybe_waker = loop { - let Some(command) = receiver.next().await else { - break None; - }; - - match command { - Command::Write(buf, waker) => { - info!("Writing {:?}", buf); - let Ok(promise) = stream.write_with_u8_array(&buf) else { - error!("Cannot Write to Stream!"); - break None; - }; - - let Ok(_) = promise.into_js_future().await else { - error!("Cannot Write to Stream!"); - break None; + Component::ParentDir => { + let Some(_) = canonical_path.pop() else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "Cannot parse path '{path:?}': Cannot get parent directory of root" + ), + )); }; - - waker.wake(); } - Command::Flush(waker) => { - info!("Flushing"); - waker.wake(); + Component::RootDir => { + let _ = canonical_path.drain(..); } - Command::Close(waker) => { - info!("Closing"); - let Ok(_) = stream.close().into_js_future().await else { - error!("Cannot Close Stream!"); - break None; + Component::CurDir => { + // No-op + continue; + } + Component::Normal(name) => { + let Some(name) = name.to_str() else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Cannot parse path '{path:?}': Segment '{name:?}' cannot be used as a UTF-8 string"), + )); }; - break Some(waker); + canonical_path.push(name); } } - }; - - drop(receiver); - - if let Some(waker) = maybe_waker { - waker.wake(); - } else { - if stream.close().into_js_future().await.is_err() { - error!("Stream was closed unexpectedly and could not be closed properly."); - } } - }); - Ok(Box::new(FileStreamWriter { commands: sender })) -} + Ok(canonical_path) + } -async fn remove_entry(handle: &FileSystemDirectoryHandle, entry: &str) -> std::io::Result<()> { - handle - .remove_entry_with_options(entry, &{ - let mut options = FileSystemRemoveOptions::new(); - options.recursive(true); - options - }) - .into_js_future() - .await - .map_err(js_value_to_err( - "Cannot remove Directory", - std::io::ErrorKind::Other, - ))? - .is_undefined() - .then_some(()) - .ok_or(std::io::Error::new( - std::io::ErrorKind::Other, - "Failed to remove entry", - )) -} + /// Get the [`FileSystemDirectoryHandle`] for the root directory pointed to by `self.root`. + pub(crate) async fn shadow_root(&self) -> std::io::Result { + let global: Global = js_sys::global().unchecked_into(); -/// Bevy compatible wrapper for the [Origin Private File System API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system) -pub struct OriginPrivateFileSystem { - root: PathBuf, -} + let storage_manager = if !global.window().is_undefined() { + let window: web_sys::Window = global.unchecked_into(); + Ok(window.navigator().storage()) + } else if !global.worker().is_undefined() { + let worker: web_sys::WorkerGlobalScope = global.unchecked_into(); + Ok(worker.navigator().storage()) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "Unsupported global context", + )) + }?; + + let root = storage_manager + .get_directory() + .into_js_future() + .await + .map_err(js_value_to_err( + "Cannot get StorageManager", + std::io::ErrorKind::PermissionDenied, + )) + .map(|value| value.unchecked_into())?; -impl OriginPrivateFileSystem { - /// Constructs a new [`OriginPrivateFileSystem`] with the provided shadow-root. - pub fn new(root: PathBuf) -> Self { - Self { root } + get_directory(&root, self.root.iter().map(|value| value.as_str()), true).await } } impl AssetReader for OriginPrivateFileSystem { async fn read<'a>(&'a self, path: &'a Path) -> Result>, AssetReaderError> { - let root = get_storage_root().await?; - let shadow_root = get_directory(&root, &self.root, true).await?; - let handle = get_file(&shadow_root, path, false, false) + let shadow_root = self.shadow_root().await?; + + let reader = get_file(&shadow_root, Self::canonical(path)?, false, false) .await - .map_err(|error| AssetReaderError::NotFound(path.to_owned()))?; - let reader = read_file(&handle).await?; + .map_err(|_error| AssetReaderError::NotFound(path.to_owned()))? + .get_file() + .into_js_future() + .await + .map_err(js_value_to_err( + "Cannot get File from Handle", + std::io::ErrorKind::Other, + ))? + .unchecked_into::() + .get_async_reader() + .await?; - Ok(reader) + Ok(Box::new(reader)) } async fn read_meta<'a>(&'a self, path: &'a Path) -> Result>, AssetReaderError> { let path = &get_meta_path(path); - let root = get_storage_root().await?; - let shadow_root = get_directory(&root, &self.root, true).await?; - let handle = get_file(&shadow_root, path, false, false) + let shadow_root = self.shadow_root().await?; + + let reader = get_file(&shadow_root, Self::canonical(path)?, false, false) .await - .map_err(|error| AssetReaderError::NotFound(path.to_owned()))?; - let reader = read_file(&handle).await?; + .map_err(|_error| AssetReaderError::NotFound(path.to_owned()))? + .get_file() + .into_js_future() + .await + .map_err(js_value_to_err( + "Cannot get File from Handle", + std::io::ErrorKind::Other, + ))? + .unchecked_into::() + .get_async_reader() + .await?; - Ok(reader) + Ok(Box::new(reader)) } async fn read_directory<'a>( &'a self, path: &'a Path, ) -> Result, AssetReaderError> { - struct VecStream { - inner: Box<[T]>, - cursor: usize, - } - - impl Stream for VecStream { - type Item = T; - - fn poll_next( - mut self: Pin<&mut Self>, - _cx: &mut Context<'_>, - ) -> Poll> { - let item = self.inner.get(self.cursor).cloned(); - - if item.is_some() { - self.cursor += 1; - } - - Poll::Ready(item) - } + let shadow_root = self.shadow_root().await?; + let handle = get_directory(&shadow_root, Self::canonical(path)?, false).await?; + let entries = get_entries(&handle).await; - fn size_hint(&self) -> (usize, Option) { - let remaining = self.inner.len().saturating_sub(self.cursor); + let (stream, task) = IndirectStream::wrap(entries); - (remaining, Some(remaining)) - } - } - - let root = get_storage_root().await?; - let shadow_root = get_directory(&root, &self.root, true).await?; - let handle = get_directory(&shadow_root, path, false).await?; - - let mut entries = get_entries(&handle).await; - let mut final_entries = Vec::new(); + spawn_local(task); - while let Some(entry) = entries.next().await { - final_entries.push(entry); - } - - Ok(Box::new(VecStream { - inner: final_entries.into_boxed_slice(), - cursor: 0, - })) + Ok(Box::new(stream)) } async fn is_directory<'a>(&'a self, path: &'a Path) -> Result { - let root = get_storage_root().await?; - let shadow_root = get_directory(&root, &self.root, true).await?; - let result = get_directory(&shadow_root, path, false).await.is_ok(); + let shadow_root = self.shadow_root().await?; + let result = get_directory(&shadow_root, Self::canonical(path)?, false) + .await + .is_ok(); Ok(result) } @@ -506,12 +177,20 @@ impl AssetReader for OriginPrivateFileSystem { impl AssetWriter for OriginPrivateFileSystem { async fn write<'a>(&'a self, path: &'a Path) -> Result, AssetWriterError> { - let root = get_storage_root().await?; - let shadow_root = get_directory(&root, &self.root, true).await?; - let handle = get_file(&shadow_root, path, true, true).await?; - let writer = write_file(&handle).await?; + let shadow_root = self.shadow_root().await?; + let handle = get_file(&shadow_root, Self::canonical(path)?, true, true).await?; - Ok(writer) + let stream: FileSystemWritableFileStream = handle + .create_writable() + .into_js_future() + .await + .map_err(js_value_to_err( + "Cannot get Create Writable Stream", + std::io::ErrorKind::Other, + ))? + .unchecked_into(); + + Ok(Box::new(stream.into_async_writer())) } async fn write_meta<'a>(&'a self, path: &'a Path) -> Result, AssetWriterError> { @@ -519,39 +198,17 @@ impl AssetWriter for OriginPrivateFileSystem { } async fn remove<'a>(&'a self, path: &'a Path) -> Result<(), AssetWriterError> { - let root = get_storage_root().await?; - let shadow_root = get_directory(&root, &self.root, false).await?; - let _ = get_file(&shadow_root, path, false, false).await?; - - let mut components = path.components(); - - let Some(entry) = components.next_back() else { - return Err(AssetWriterError::from(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "Provided path is empty", - ))); - }; + let shadow_root = self.shadow_root().await?; + let canon = Self::canonical(path)?; + let _ = get_file(&shadow_root, canon.iter().copied(), false, false).await?; - let Component::Normal(entry) = entry else { - return Err(AssetWriterError::from(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("Cannot parse path '{path:?}': final component must be an entry name"), - ))); + let [parent @ .., file] = canon.as_slice() else { + unreachable!("path valid based on above guard"); }; - let Some(entry) = entry.to_str() else { - return Err(AssetWriterError::from(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!( - "Cannot parse path '{path:?}': entry name '{entry:?}' cannot be used as a UTF-8 string" - ), - ))); - }; - - let parent_handle = - get_directory(&shadow_root, components.collect::(), false).await?; + let parent_handle = get_directory(&shadow_root, parent.iter().copied(), false).await?; - remove_entry(&parent_handle, entry).await?; + remove_entry(&parent_handle, file).await?; Ok(()) } @@ -590,47 +247,25 @@ impl AssetWriter for OriginPrivateFileSystem { } async fn remove_directory<'a>(&'a self, path: &'a Path) -> Result<(), AssetWriterError> { - let root = get_storage_root().await?; - let shadow_root = get_directory(&root, &self.root, true).await?; - let _ = get_directory(&shadow_root, path, true).await?; + let shadow_root = self.shadow_root().await?; + let canon = Self::canonical(path)?; + let _ = get_directory(&shadow_root, canon.iter().copied(), true).await?; - let mut components = path.components(); - - let Some(entry) = components.next_back() else { - return Err(AssetWriterError::from(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "Provided path is empty", - ))); + let [parent @ .., directory] = canon.as_slice() else { + unreachable!("path valid based on above guard"); }; - let Component::Normal(entry) = entry else { - return Err(AssetWriterError::from(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("Cannot parse path '{path:?}': final component must be an entry name"), - ))); - }; + let parent_handle = get_directory(&shadow_root, parent.iter().copied(), false).await?; - let Some(entry) = entry.to_str() else { - return Err(AssetWriterError::from(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!( - "Cannot parse path '{path:?}': entry name '{entry:?}' cannot be used as a UTF-8 string" - ), - ))); - }; - - let parent_handle = - get_directory(&shadow_root, components.collect::(), false).await?; - - remove_entry(&parent_handle, entry).await?; + remove_entry(&parent_handle, directory).await?; Ok(()) } async fn remove_empty_directory<'a>(&'a self, path: &'a Path) -> Result<(), AssetWriterError> { - let root = get_storage_root().await?; - let shadow_root = get_directory(&root, &self.root, true).await?; - let handle = get_directory(&shadow_root, path, true).await?; + let shadow_root = self.shadow_root().await?; + let canon = Self::canonical(path)?; + let handle = get_directory(&shadow_root, canon, true).await?; let mut stream = get_entries(&handle).await; if stream.next().await.is_some() { @@ -647,9 +282,8 @@ impl AssetWriter for OriginPrivateFileSystem { &'a self, path: &'a Path, ) -> Result<(), AssetWriterError> { - let root = get_storage_root().await?; - let shadow_root = get_directory(&root, &self.root, true).await?; - let handle = get_directory(&shadow_root, path, true).await?; + let shadow_root = self.shadow_root().await?; + let handle = get_directory(&shadow_root, Self::canonical(path)?, true).await?; let mut stream = get_entries(&handle).await; while let Some(entry) = stream.next().await { @@ -663,3 +297,439 @@ impl AssetWriter for OriginPrivateFileSystem { Ok(()) } } + +/// Reduced boilerplate for generating [error](std::io::Error) values. +fn js_value_to_err( + context: &str, + kind: std::io::ErrorKind, +) -> impl FnOnce(JsValue) -> std::io::Error + '_ { + move |value| { + let error = JSON::stringify(&value) + .map(String::from) + .ok() + .unwrap_or_else(|| "failed to stringify the JSValue of the error".to_owned()); + + let message = format!("JS Failure: '{context}': {error}"); + + std::io::Error::new(kind, message) + } +} + +/// Open a directory relative to `start` from a given `path`. +/// Will create directories based on the provided `path` if `create` is `true`. +async fn get_directory( + start: &FileSystemDirectoryHandle, + path: impl IntoIterator, + create: bool, +) -> std::io::Result { + let options = { + let mut options = FileSystemGetDirectoryOptions::new(); + options.create(create); + options + }; + + let mut current = start.clone(); + + for component in path { + current = current + .get_directory_handle_with_options(component, &options) + .into_js_future() + .await + .map_err(js_value_to_err( + "Cannot get Directory Handle", + std::io::ErrorKind::NotFound, + )) + .map(|value| value.unchecked_into())?; + } + + Ok(current) +} + +/// Get child entries of this directory. +async fn get_entries(start: &FileSystemDirectoryHandle) -> impl Stream + Unpin { + JsStream::from(start.keys()) + .flat_map(|result| futures_lite::stream::iter(result.ok())) + .flat_map(|value| futures_lite::stream::iter(value.dyn_into::().ok())) + .map(String::from) + .map(PathBuf::from) +} + +/// Open a file relative to `start` from a given `path`. +/// Will create directories and the final file based on the provided `path` if `create` is `true`. +async fn get_file( + start: &FileSystemDirectoryHandle, + path: impl IntoIterator, + create_file: bool, + create_path: bool, +) -> std::io::Result { + let mut current = start.clone(); + + let mut iter = path.into_iter().peekable(); + + let options = { + let mut options = FileSystemGetDirectoryOptions::new(); + options.create(create_path); + options + }; + + let file_name = loop { + let Some(path) = iter.next() else { + break None; + }; + + if iter.peek().is_none() { + break Some(path); + }; + + current = current + .get_directory_handle_with_options(path, &options) + .into_js_future() + .await + .map_err(js_value_to_err( + "Cannot get Directory Handle", + std::io::ErrorKind::NotFound, + )) + .map(|value| value.unchecked_into())?; + }; + + let Some(file_name) = file_name else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Provided path is empty", + )); + }; + + current + .get_file_handle_with_options(file_name, &{ + let mut options = FileSystemGetFileOptions::new(); + options.create(create_file); + options + }) + .into_js_future() + .await + .map_err(js_value_to_err( + "File not available", + std::io::ErrorKind::NotFound, + )) + .map(|value| value.unchecked_into()) +} + +async fn remove_entry(handle: &FileSystemDirectoryHandle, entry: &str) -> std::io::Result<()> { + handle + .remove_entry_with_options(entry, &{ + let mut options = FileSystemRemoveOptions::new(); + options.recursive(true); + options + }) + .into_js_future() + .await + .map_err(js_value_to_err( + "Cannot remove Directory", + std::io::ErrorKind::Other, + ))? + .is_undefined() + .then_some(()) + .ok_or(std::io::Error::new( + std::io::ErrorKind::Other, + "Failed to remove entry", + )) +} + +mod utils { + use crate::io::wasm::opfs::js_value_to_err; + use crate::io::VecReader; + use async_channel::{TryRecvError, TrySendError}; + use bevy_utils::tracing::error; + use futures_io::{AsyncRead, AsyncSeek, AsyncWrite}; + use futures_lite::{pin, FutureExt, Stream, StreamExt}; + use js_sys::{ArrayBuffer, AsyncIterator, IteratorNext, Uint8Array}; + use std::pin::Pin; + use std::task::{Context, Poll, Waker}; + use wasm_bindgen::prelude::wasm_bindgen; + use wasm_bindgen::{JsCast, JsValue}; + use wasm_bindgen_futures::{spawn_local, JsFuture}; + use web_sys::{Blob, FileSystemDirectoryHandle, FileSystemWritableFileStream}; + + /// Extension method to allow for a more ergonomic handling of [promises](`js_sys::Promise`). + pub(crate) trait IntoJsFuture: Into { + /// Convert this [thenable](`js_sys::Promise`) into a [`JsFuture`]. + fn into_js_future(self) -> JsFuture { + self.into() + } + } + + impl> IntoJsFuture for T {} + + /// A [`Stream`] that yields values from an underlying [`AsyncIterator`] + /// + /// Based on [`wasm_bindgen_futures::stream::JsStream`](https://github.com/olanod/wasm-bindgen/blob/a8edfb117c79654773cf3d9b4da3e4a01b9884ab/crates/futures/src/stream.rs). + /// Can be removed once [#2399](https://github.com/rustwasm/wasm-bindgen/issues/2399) is resolved. + pub(crate) struct JsStream { + iter: AsyncIterator, + next: Option, + done: bool, + } + + impl Stream for JsStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.done { + return Poll::Ready(None); + } + + let future = match self.next.as_mut() { + Some(val) => val, + None => match self.iter.next().map(JsFuture::from) { + Ok(val) => { + self.next = Some(val); + self.next.as_mut().unwrap() + } + Err(e) => { + self.done = true; + return Poll::Ready(Some(Err(e))); + } + }, + }; + + match Pin::new(future).poll(cx) { + Poll::Ready(res) => match res { + Ok(iter_next) => { + let next = iter_next.unchecked_into::(); + if next.done() { + self.done = true; + Poll::Ready(None) + } else { + self.next.take(); + Poll::Ready(Some(Ok(next.value()))) + } + } + Err(e) => { + self.done = true; + Poll::Ready(Some(Err(e))) + } + }, + Poll::Pending => Poll::Pending, + } + } + } + + impl From for JsStream { + fn from(value: AsyncIterator) -> Self { + Self { + iter: value, + next: None, + done: false, + } + } + } + + /// Extension trait providing access to the async iterator methods on [`FileSystemDirectoryHandle`] + /// which are currently missing from [`wasm-bindgen`](`wasm_bindgen`) + pub(crate) trait FileSystemDirectoryHandleExt { + fn keys(&self) -> AsyncIterator; + } + + impl FileSystemDirectoryHandleExt for FileSystemDirectoryHandle { + /// The `keys()` method. + /// + /// [MDN Documentation](https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryHandle/keys) + /// + /// *This API requires the following crate features to be activated: `FileSystemDirectoryHandle`* + fn keys(&self) -> AsyncIterator { + #[wasm_bindgen( + inline_js = "export function get_keys_for_handle(a) { return a.keys(); }" + )] + extern "C" { + fn get_keys_for_handle(a: &FileSystemDirectoryHandle) -> AsyncIterator; + } + + get_keys_for_handle(self) + } + } + + /// Uses channels to create a [`Send`] + [`Sync`] wrapper around a [`Stream`]. + pub(crate) struct IndirectStream { + request: Pin>>, + response: Pin>>, + } + + impl Stream for IndirectStream { + type Item = T; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.response.try_recv() { + Ok(value) => Poll::Ready(Some(value)), + Err(TryRecvError::Closed) => Poll::Ready(None), + Err(TryRecvError::Empty) => match self.request.try_send(cx.waker().clone()) { + Ok(_) | Err(TrySendError::Full(_)) => Poll::Pending, + Err(TrySendError::Closed(_)) => Poll::Ready(None), + }, + } + } + } + + impl IndirectStream { + /// Take the provided `stream` and split it into a [`Send`] + [`Sync`] stream and a backing task. + /// It is the callers responsibility to ensure the task is run on an appropriate runtime. + /// + /// Internally uses [async channels](`async_channel`) to request values from the stream whilst + /// also passing an appropriate [`Waker`]. + pub(crate) fn wrap( + stream: impl Stream + 'static, + ) -> (Self, impl std::future::Future) { + let (send_waker, receive_waker) = async_channel::bounded::(1); + let (send_value, receive_value) = async_channel::bounded::(1); + + let task = async move { + pin!(stream); + pin!(receive_waker); + pin!(send_value); + + while let Some(waker) = receive_waker.next().await { + if let Some(item) = stream.next().await { + if let Ok(_) = send_value.send(item).await { + waker.wake(); + continue; + } + } + + waker.wake(); + break; + } + }; + + let stream = Self { + request: Box::into_pin(Box::new(send_waker)), + response: Box::into_pin(Box::new(receive_value)), + }; + + (stream, task) + } + } + + pub(crate) trait BlobExt { + async fn get_async_reader( + &self, + ) -> std::io::Result; + } + + impl BlobExt for Blob { + async fn get_async_reader( + &self, + ) -> std::io::Result { + let buffer: ArrayBuffer = self + .array_buffer() + .into_js_future() + .await + .map_err(js_value_to_err( + "Cannot get Buffer from Blob", + std::io::ErrorKind::Other, + ))? + .unchecked_into(); + + let bytes = Uint8Array::new(&buffer).to_vec(); + + Ok(VecReader::new(bytes)) + } + } + + pub(crate) trait FileSystemWritableFileStreamExt { + fn into_async_writer(self) -> impl AsyncWrite + Unpin + Send + Sync; + } + + impl FileSystemWritableFileStreamExt for FileSystemWritableFileStream { + /// Create an [async writer](`AsyncWrite`) from this [`FileSystemWritableFileStream`]. + fn into_async_writer(self) -> impl AsyncWrite + Unpin + Send + Sync { + struct FileStreamWriter { + writes: async_channel::Sender>, + wake_on_closed: async_channel::Sender, + } + + impl AsyncWrite for FileStreamWriter { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + if self.writes.is_full() { + return Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "Could not send write request to writer", + ))); + } + + if self.writes.is_closed() { + return Poll::Ready(Ok(0)); + } + + let write = buf.to_owned().into_boxed_slice(); + + let Ok(_) = self.writes.try_send(write) else { + return Poll::Ready(Ok(0)); + }; + + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + return Poll::Ready(Ok(())); + } + + fn poll_close( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + if self.wake_on_closed.is_closed() { + return Poll::Ready(Ok(())); + } + + match self.wake_on_closed.try_send(cx.waker().clone()) { + Ok(_) => Poll::Pending, + Err(TrySendError::Closed(_)) => Poll::Ready(Ok(())), + Err(TrySendError::Full(_)) => Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "Could not send close request to AsyncWrite stream", + ))), + } + } + } + + let (send_bytes, receive_bytes) = async_channel::unbounded::>(); + let (send_waker, receive_waker) = async_channel::unbounded::(); + + spawn_local(async move { + pin!(receive_bytes); + pin!(receive_waker); + + while let Some(buf) = receive_bytes.next().await { + if let Ok(promise) = self.write_with_u8_array(&buf) { + if let Ok(_) = promise.into_js_future().await { + continue; + } + } + + break; + } + + receive_bytes.close(); + + if self.close().into_js_future().await.is_err() { + error!("FileSystemWritableFileStream could not be closed properly."); + } + + while let Ok(waker) = receive_waker.try_recv() { + waker.wake() + } + }); + + FileStreamWriter { + writes: send_bytes, + wake_on_closed: send_waker, + } + } + } +} From 1aaa4d874d8a2100d7752e4dc460d41f7cc351f2 Mon Sep 17 00:00:00 2001 From: Zac Harrold Date: Tue, 28 May 2024 12:32:03 +1000 Subject: [PATCH 6/6] Updates to `opfs` based on feedback Co-Authored-By: Ricky Taylor <262786+ricky26@users.noreply.github.com> --- crates/bevy_asset/src/io/wasm.rs | 4 +- .../io/wasm/{opfs.rs => web_file_system.rs} | 56 ++++- crates/bevy_asset/src/temp.rs | 211 +++--------------- examples/asset/temp_asset.rs | 6 +- 4 files changed, 81 insertions(+), 196 deletions(-) rename crates/bevy_asset/src/io/wasm/{opfs.rs => web_file_system.rs} (92%) diff --git a/crates/bevy_asset/src/io/wasm.rs b/crates/bevy_asset/src/io/wasm.rs index 637135b2ce2ab..2444900d497a5 100644 --- a/crates/bevy_asset/src/io/wasm.rs +++ b/crates/bevy_asset/src/io/wasm.rs @@ -8,9 +8,9 @@ use wasm_bindgen::{prelude::wasm_bindgen, JsCast, JsValue}; use wasm_bindgen_futures::JsFuture; use web_sys::Response; -mod opfs; +mod web_file_system; -pub use opfs::*; +pub use web_file_system::*; /// Represents the global object in the JavaScript context #[wasm_bindgen] diff --git a/crates/bevy_asset/src/io/wasm/opfs.rs b/crates/bevy_asset/src/io/wasm/web_file_system.rs similarity index 92% rename from crates/bevy_asset/src/io/wasm/opfs.rs rename to crates/bevy_asset/src/io/wasm/web_file_system.rs index d983fd72aeb9e..bc196c75d8c62 100644 --- a/crates/bevy_asset/src/io/wasm/opfs.rs +++ b/crates/bevy_asset/src/io/wasm/web_file_system.rs @@ -15,15 +15,25 @@ use web_sys::{ use utils::*; -/// Bevy compatible wrapper for the [Origin Private File System API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system) +/// Abstraction over the [File System API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API). +pub struct WebFileSystem; + +impl WebFileSystem { + /// Get access to the [Origin Private File System](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system). + pub fn origin_private() -> OriginPrivateFileSystem { + OriginPrivateFileSystem { root: Vec::new() } + } +} + +/// Abstraction over the [Origin Private File System API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system) pub struct OriginPrivateFileSystem { root: Vec, } impl OriginPrivateFileSystem { /// Constructs a new [`OriginPrivateFileSystem`] with the provided shadow-root. - pub fn new(root: PathBuf) -> Self { - let root = Self::canonical(&root) + pub fn new(root: impl Into) -> Self { + let root = Self::canonical(&root.into()) .expect("Provided path is not valid") .into_iter() .map(|component| component.to_owned()) @@ -32,6 +42,17 @@ impl OriginPrivateFileSystem { Self { root } } + /// Replace the shadow-root with the provided value. + pub fn with_root(self, root: impl Into) -> Self { + let root = Self::canonical(&root.into()) + .expect("Provided path is not valid") + .into_iter() + .map(|component| component.to_owned()) + .collect(); + + Self { root, ..self } + } + /// Constructs a canonical path (as components) from the provided `path`. pub(crate) fn canonical<'a>(path: &'a Path) -> std::io::Result> { let mut canonical_path = Vec::new(); @@ -55,7 +76,7 @@ impl OriginPrivateFileSystem { }; } Component::RootDir => { - let _ = canonical_path.drain(..); + canonical_path.clear(); } Component::CurDir => { // No-op @@ -79,6 +100,7 @@ impl OriginPrivateFileSystem { /// Get the [`FileSystemDirectoryHandle`] for the root directory pointed to by `self.root`. pub(crate) async fn shadow_root(&self) -> std::io::Result { + // TODO: Investigate caching the this handle. let global: Global = js_sys::global().unchecked_into(); let storage_manager = if !global.window().is_undefined() { @@ -200,15 +222,20 @@ impl AssetWriter for OriginPrivateFileSystem { async fn remove<'a>(&'a self, path: &'a Path) -> Result<(), AssetWriterError> { let shadow_root = self.shadow_root().await?; let canon = Self::canonical(path)?; - let _ = get_file(&shadow_root, canon.iter().copied(), false, false).await?; let [parent @ .., file] = canon.as_slice() else { - unreachable!("path valid based on above guard"); + return Err(AssetWriterError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Cannot remove an empty path", + ))); }; let parent_handle = get_directory(&shadow_root, parent.iter().copied(), false).await?; - remove_entry(&parent_handle, file).await?; + // Ensure the entry to remove is a file and exists + let _ = get_file(&parent_handle, [*file], false, false).await?; + + remove_entry(&parent_handle, *file).await?; Ok(()) } @@ -249,14 +276,19 @@ impl AssetWriter for OriginPrivateFileSystem { async fn remove_directory<'a>(&'a self, path: &'a Path) -> Result<(), AssetWriterError> { let shadow_root = self.shadow_root().await?; let canon = Self::canonical(path)?; - let _ = get_directory(&shadow_root, canon.iter().copied(), true).await?; let [parent @ .., directory] = canon.as_slice() else { - unreachable!("path valid based on above guard"); + return Err(AssetWriterError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Cannot remove an empty path", + ))); }; let parent_handle = get_directory(&shadow_root, parent.iter().copied(), false).await?; + // Ensure the entry to remove is a directory and exists + let _ = get_directory(&parent_handle, [*directory], true).await?; + remove_entry(&parent_handle, directory).await?; Ok(()) @@ -265,7 +297,7 @@ impl AssetWriter for OriginPrivateFileSystem { async fn remove_empty_directory<'a>(&'a self, path: &'a Path) -> Result<(), AssetWriterError> { let shadow_root = self.shadow_root().await?; let canon = Self::canonical(path)?; - let handle = get_directory(&shadow_root, canon, true).await?; + let handle = get_directory(&shadow_root, canon, false).await?; let mut stream = get_entries(&handle).await; if stream.next().await.is_some() { @@ -283,7 +315,7 @@ impl AssetWriter for OriginPrivateFileSystem { path: &'a Path, ) -> Result<(), AssetWriterError> { let shadow_root = self.shadow_root().await?; - let handle = get_directory(&shadow_root, Self::canonical(path)?, true).await?; + let handle = get_directory(&shadow_root, Self::canonical(path)?, false).await?; let mut stream = get_entries(&handle).await; while let Some(entry) = stream.next().await { @@ -436,7 +468,7 @@ async fn remove_entry(handle: &FileSystemDirectoryHandle, entry: &str) -> std::i } mod utils { - use crate::io::wasm::opfs::js_value_to_err; + use crate::io::wasm::web_file_system::js_value_to_err; use crate::io::VecReader; use async_channel::{TryRecvError, TrySendError}; use bevy_utils::tracing::error; diff --git a/crates/bevy_asset/src/temp.rs b/crates/bevy_asset/src/temp.rs index aa1394b577902..41635c5ab1825 100644 --- a/crates/bevy_asset/src/temp.rs +++ b/crates/bevy_asset/src/temp.rs @@ -6,10 +6,7 @@ use std::{ use bevy_ecs::{system::Resource, world::World}; use bevy_utils::Duration; -use crate::io::{ - AssetReader, AssetSource, AssetSourceBuilder, AssetSourceEvent, AssetWatcher, AssetWriter, - ErasedAssetReader, ErasedAssetWriter, -}; +use crate::io::{AssetSource, AssetSourceBuilder}; /// A [resource](`Resource`) providing access to the temporary directory used by the `temp://` /// [asset source](`AssetSource`). @@ -133,186 +130,42 @@ pub(crate) fn get_temp_source( let debounce = Duration::from_millis(300); let source = AssetSourceBuilder::default() - .with_reader(TempAssetReader::get_default(path.clone())) - .with_writer(TempAssetWriter::get_default(path.clone())) - .with_watcher(TempAssetWatcher::get_default(path.clone(), debounce)) - .with_watch_warning(TempAssetWatcher::get_default_watch_warning()); - - world.insert_resource(temp_dir); - - Ok(source) -} - -struct TempAssetReader { - inner: Box, -} - -impl TempAssetReader { - fn get_default(path: String) -> impl FnMut() -> Box + Send + Sync { - move || { - let inner = { - #[cfg(not(target_arch = "wasm32"))] - { - let mut getter = AssetSource::get_default_reader(path.clone()); - getter() - } - - #[cfg(target_arch = "wasm32")] - { - Box::new(crate::io::wasm::OriginPrivateFileSystem::new( - path.clone().into(), - )) - } - }; - - Box::new(Self { inner }) - } - } -} - -impl AssetReader for TempAssetReader { - async fn read<'a>( - &'a self, - path: &'a Path, - ) -> Result>, crate::io::AssetReaderError> { - self.inner.read(path).await - } - - async fn read_meta<'a>( - &'a self, - path: &'a Path, - ) -> Result>, crate::io::AssetReaderError> { - self.inner.read_meta(path).await - } - - async fn read_directory<'a>( - &'a self, - path: &'a Path, - ) -> Result, crate::io::AssetReaderError> { - self.inner.read_directory(path).await - } - - async fn is_directory<'a>( - &'a self, - path: &'a Path, - ) -> Result { - self.inner.is_directory(path).await - } -} - -struct TempAssetWriter { - inner: Box, -} + .with_reader({ + #[cfg(not(target_arch = "wasm32"))] + { + AssetSource::get_default_reader(path.clone()) + } -impl TempAssetWriter { - fn get_default( - path: String, - ) -> impl FnMut(bool) -> Option> + Send + Sync { - move |_condition| { - let inner = { - #[cfg(not(target_arch = "wasm32"))] - { - let mut getter = AssetSource::get_default_writer(path.clone()); - getter(_condition)? + #[cfg(target_arch = "wasm32")] + { + let path = path.clone(); + move || { + Box::new( + crate::io::wasm::WebFileSystem::origin_private().with_root(path.clone()), + ) } + } + }) + .with_writer({ + #[cfg(not(target_arch = "wasm32"))] + { + AssetSource::get_default_writer(path.clone()) + } - #[cfg(target_arch = "wasm32")] - { - Box::new(crate::io::wasm::OriginPrivateFileSystem::new( - path.clone().into(), + #[cfg(target_arch = "wasm32")] + { + let path = path.clone(); + move |_condition| { + Some(Box::new( + crate::io::wasm::WebFileSystem::origin_private().with_root(path.clone()), )) } - }; - - Some(Box::new(Self { inner })) - } - } -} - -impl AssetWriter for TempAssetWriter { - async fn write<'a>( - &'a self, - path: &'a Path, - ) -> Result, crate::io::AssetWriterError> { - self.inner.write(path).await - } - - async fn write_meta<'a>( - &'a self, - path: &'a Path, - ) -> Result, crate::io::AssetWriterError> { - self.inner.write_meta(path).await - } - - async fn remove<'a>(&'a self, path: &'a Path) -> Result<(), crate::io::AssetWriterError> { - self.inner.remove(path).await - } - - async fn remove_meta<'a>(&'a self, path: &'a Path) -> Result<(), crate::io::AssetWriterError> { - self.inner.remove_meta(path).await - } - - async fn rename<'a>( - &'a self, - old_path: &'a Path, - new_path: &'a Path, - ) -> Result<(), crate::io::AssetWriterError> { - self.inner.rename(old_path, new_path).await - } - - async fn rename_meta<'a>( - &'a self, - old_path: &'a Path, - new_path: &'a Path, - ) -> Result<(), crate::io::AssetWriterError> { - self.inner.rename_meta(old_path, new_path).await - } - - async fn remove_directory<'a>( - &'a self, - path: &'a Path, - ) -> Result<(), crate::io::AssetWriterError> { - self.inner.remove_directory(path).await - } - - async fn remove_empty_directory<'a>( - &'a self, - path: &'a Path, - ) -> Result<(), crate::io::AssetWriterError> { - self.inner.remove_empty_directory(path).await - } - - async fn remove_assets_in_directory<'a>( - &'a self, - path: &'a Path, - ) -> Result<(), crate::io::AssetWriterError> { - self.inner.remove_assets_in_directory(path).await - } -} - -struct TempAssetWatcher { - _inner: Box, -} - -impl TempAssetWatcher { - fn get_default( - path: String, - file_debounce_wait_time: Duration, - ) -> impl FnMut(crossbeam_channel::Sender) -> Option> - + Send - + Sync { - move |channel| { - let mut getter = - AssetSource::get_default_watcher(path.clone(), file_debounce_wait_time); - let _inner = getter(channel)?; + } + }) + .with_watcher(AssetSource::get_default_watcher(path.clone(), debounce)) + .with_watch_warning(AssetSource::get_default_watch_warning()); - Some(Box::new(Self { _inner })) - } - } + world.insert_resource(temp_dir); - fn get_default_watch_warning() -> &'static str { - AssetSource::get_default_watch_warning() - } + Ok(source) } - -impl AssetWatcher for TempAssetWatcher {} diff --git a/examples/asset/temp_asset.rs b/examples/asset/temp_asset.rs index fd2469b78f665..7599b6ec911ba 100644 --- a/examples/asset/temp_asset.rs +++ b/examples/asset/temp_asset.rs @@ -19,7 +19,7 @@ fn main() { .init_asset::() .register_asset_loader(TextLoader) .add_systems(Startup, (save_temp_asset, setup_ui)) - .add_systems(Update, (wait_until_temp_saved, display_text)) + .add_systems(Update, (load_or_unload_asset, display_text)) .run(); } @@ -56,8 +56,8 @@ fn save_temp_asset(assets: Res, temp_directory: Res) ); } -/// Poll the save tasks until completion, and then start loading our temporary text asset. -fn wait_until_temp_saved( +/// Load or unload the temporary asset based on user input +fn load_or_unload_asset( assets: Res, mut commands: Commands, keyboard_input: Res>,