diff --git a/Cargo.toml b/Cargo.toml index 947f4875278e9..05ad6d4ad2e28 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 = true + # Async Tasks [[example]] name = "async_compute" diff --git a/crates/bevy_asset/Cargo.toml b/crates/bevy_asset/Cargo.toml index e380be18b287a..1356742b26147 100644 --- a/crates/bevy_asset/Cargo.toml +++ b/crates/bevy_asset/Cargo.toml @@ -51,12 +51,23 @@ 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 } +tempfile = "3.10.1" [dev-dependencies] bevy_core = { path = "../bevy_core", version = "0.14.0-dev" } diff --git a/crates/bevy_asset/src/io/wasm.rs b/crates/bevy_asset/src/io/wasm.rs index e8b99a1cc641e..2444900d497a5 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 web_file_system; + +pub use web_file_system::*; + /// Represents the global object in the JavaScript context #[wasm_bindgen] extern "C" { diff --git a/crates/bevy_asset/src/io/wasm/web_file_system.rs b/crates/bevy_asset/src/io/wasm/web_file_system.rs new file mode 100644 index 0000000000000..bc196c75d8c62 --- /dev/null +++ b/crates/bevy_asset/src/io/wasm/web_file_system.rs @@ -0,0 +1,767 @@ +use crate::io::wasm::Global; +use crate::io::{ + get_meta_path, AssetReader, AssetReaderError, AssetWriter, AssetWriterError, PathStream, + Reader, Writer, +}; +use futures_lite::{AsyncReadExt, AsyncWriteExt, Stream, StreamExt}; +use js_sys::{JsString, JSON}; +use std::path::{Component, Path, PathBuf}; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::spawn_local; +use web_sys::{ + FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetDirectoryOptions, + FileSystemGetFileOptions, FileSystemRemoveOptions, FileSystemWritableFileStream, +}; + +use utils::*; + +/// 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: 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 } + } + + /// 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(); + + 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::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" + ), + )); + }; + } + Component::RootDir => { + canonical_path.clear(); + } + 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"), + )); + }; + + canonical_path.push(name); + } + } + } + + Ok(canonical_path) + } + + /// 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() { + 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())?; + + 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 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()))? + .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(Box::new(reader)) + } + + async fn read_meta<'a>(&'a self, path: &'a Path) -> Result>, AssetReaderError> { + let path = &get_meta_path(path); + 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()))? + .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(Box::new(reader)) + } + + async fn read_directory<'a>( + &'a self, + path: &'a Path, + ) -> Result, AssetReaderError> { + let shadow_root = self.shadow_root().await?; + let handle = get_directory(&shadow_root, Self::canonical(path)?, false).await?; + let entries = get_entries(&handle).await; + + let (stream, task) = IndirectStream::wrap(entries); + + spawn_local(task); + + Ok(Box::new(stream)) + } + + async fn is_directory<'a>(&'a self, path: &'a Path) -> Result { + let shadow_root = self.shadow_root().await?; + let result = get_directory(&shadow_root, Self::canonical(path)?, false) + .await + .is_ok(); + + Ok(result) + } +} + +impl AssetWriter for OriginPrivateFileSystem { + async fn write<'a>(&'a self, path: &'a Path) -> Result, AssetWriterError> { + let shadow_root = self.shadow_root().await?; + let handle = get_file(&shadow_root, Self::canonical(path)?, true, true).await?; + + 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> { + self.write(&get_meta_path(path)).await + } + + async fn remove<'a>(&'a self, path: &'a Path) -> Result<(), AssetWriterError> { + let shadow_root = self.shadow_root().await?; + let canon = Self::canonical(path)?; + + let [parent @ .., file] = canon.as_slice() else { + 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 file and exists + let _ = get_file(&parent_handle, [*file], false, false).await?; + + remove_entry(&parent_handle, *file).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 shadow_root = self.shadow_root().await?; + let canon = Self::canonical(path)?; + + let [parent @ .., directory] = canon.as_slice() else { + 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(()) + } + + 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, false).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 shadow_root = self.shadow_root().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 { + 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(()) + } +} + +/// 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::web_file_system::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, + } + } + } +} diff --git a/crates/bevy_asset/src/lib.rs b/crates/bevy_asset/src/lib.rs index d6db4b988a283..fffe55c7cf708 100644 --- a/crates/bevy_asset/src/lib.rs +++ b/crates/bevy_asset/src/lib.rs @@ -32,6 +32,8 @@ mod path; mod reflect; mod server; +mod temp; + pub use assets::*; pub use bevy_asset_macros::Asset; pub use direct_access_ext::DirectAssetAccessExt; @@ -47,6 +49,7 @@ pub use loader_builders::{ pub use path::*; pub use reflect::*; pub use server::*; +pub use temp::TempDirectory; /// Rusty Object Notation, a crate used to serialize and deserialize bevy assets. pub use ron; @@ -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,20 @@ impl Plugin for AssetPlugin { ); embedded.register_source(&mut sources); } + + 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..41635c5ab1825 --- /dev/null +++ b/crates/bevy_asset/src/temp.rs @@ -0,0 +1,171 @@ +use std::{ + io::{Error, ErrorKind}, + path::{Path, PathBuf}, +}; + +use bevy_ecs::{system::Resource, world::World}; +use bevy_utils::Duration; + +use crate::io::{AssetSource, AssetSourceBuilder}; + +/// 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. + #[cfg(not(target_arch = "wasm32"))] + 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. + #[cfg(not(target_arch = "wasm32"))] + 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. +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. + #[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. + Persist(PathBuf), +} + +impl TempDirectoryKind { + #[cfg(not(target_arch = "wasm32"))] + 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 { + #[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()); + + 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( + 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::new_persistent(path), + None => { + #[cfg(not(target_arch = "wasm32"))] + { + TempDirectory::new_transient()? + } + + #[cfg(target_arch = "wasm32")] + { + TempDirectory::new_persistent("bevy_temp") + } + } + }, + }; + + let path: &str = temp_dir + .path() + .as_os_str() + .try_into() + .map_err(|error| Error::new(ErrorKind::InvalidData, error))?; + + let path = path.to_owned(); + let debounce = Duration::from_millis(300); + + let source = AssetSourceBuilder::default() + .with_reader({ + #[cfg(not(target_arch = "wasm32"))] + { + AssetSource::get_default_reader(path.clone()) + } + + #[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")] + { + let path = path.clone(); + move |_condition| { + Some(Box::new( + crate::io::wasm::WebFileSystem::origin_private().with_root(path.clone()), + )) + } + } + }) + .with_watcher(AssetSource::get_default_watcher(path.clone(), debounce)) + .with_watch_warning(AssetSource::get_default_watch_warning()); + + 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..7599b6ec911ba --- /dev/null +++ b/examples/asset/temp_asset.rs @@ -0,0 +1,196 @@ +//! 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, TempDirectory, + }, + prelude::*, + tasks::IoTaskPool, +}; + +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, (load_or_unload_asset, display_text)) + .run(); +} + +/// Attempt to save an asset to the temporary asset source. +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!\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. + 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 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. + info!( + "Temporary Assets will be saved in {:?}", + temp_directory.path() + ); +} + +/// Load or unload the temporary asset based on user input +fn load_or_unload_asset( + assets: Res, + mut commands: Commands, + keyboard_input: Res>, +) { + 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::(); + } +} + +/// 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( + "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. +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("Press the Up Arrow Key to Load The Asset..."); + + 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(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(()) + } + } +}