From d0a2447926c08d4fcab48a99cb80de96985976a2 Mon Sep 17 00:00:00 2001 From: laund Date: Thu, 26 Sep 2024 04:33:39 +0200 Subject: [PATCH 1/2] Enable setting headers and query parameters. also add 'fake extensions' in case a URL doesn't have a file extension --- Cargo.toml | 13 ++- README.md | 30 +++-- src/web_asset_plugin.rs | 64 +++++++++- src/web_asset_source.rs | 250 ++++++++++++++++++++++++++++++++-------- 4 files changed, 294 insertions(+), 63 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9f40760..67c9f8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,9 +15,7 @@ repository = "https://github.com/johanhelsing/bevy_web_asset" version = "0.9.0" [dependencies] -bevy = { version = "0.14", default-features = false, features = [ - "bevy_asset", -] } +bevy = { version = "0.14", default-features = false, features = ["bevy_asset"] } pin-project = "1.1.5" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] @@ -26,7 +24,13 @@ surf = { version = "2.3", default-features = false, features = [ ] } [target.'cfg(target_arch = "wasm32")'.dependencies] -web-sys = { version = "0.3.67", default-features = false } +web-sys = { version = "0.3.67", default-features = false, features = [ + "Window", + "Request", + "Headers", + "RequestInit", + "RequestMode", +] } js-sys = { version = "0.3", default-features = false } wasm-bindgen = { version = "0.2", default-features = false } wasm-bindgen-futures = "0.4" @@ -37,6 +41,7 @@ bevy = { version = "0.14", default-features = false, features = [ "bevy_core_pipeline", "bevy_sprite", "png", + "jpeg", "webgl2", "x11", # GitHub Actions runners don't have libxkbcommon installed, so can't use Wayland ] } diff --git a/README.md b/README.md index 2b8f483..70654e1 100644 --- a/README.md +++ b/README.md @@ -55,20 +55,30 @@ commands.spawn(SpriteBundle { }); ``` +Supports adding headers and query parameters, specified initially when adding the plugin: + +```rust ignore +WebAssetPlugin::default() + .enable_fake_extensions() // for URLs which don't have a file extension, add "..png" which won't be sent + .push_header("x-api-key", "somekey") // set a api key for all requests + .push_header("Accept", "application/octet-stream") // we want a binary file + .push_query("quality", "high"), // this appends ?quality=high to the actual requests +``` + ## Bevy version support I intend to support the latest bevy release in the `main` branch. -|bevy|bevy_web_asset| -|----|--------------| -|0.14|0.9, main | -|0.13|0.8 | -|0.12|0.7 | -|0.9 |0.5 | -|0.8 |0.4 | -|0.7 |0.3 | -|0.6 |0.2 | -|0.5 |0.1 | +| bevy | bevy_web_asset | +| ---- | -------------- | +| 0.14 | 0.9, main | +| 0.13 | 0.8 | +| 0.12 | 0.7 | +| 0.9 | 0.5 | +| 0.8 | 0.4 | +| 0.7 | 0.3 | +| 0.6 | 0.2 | +| 0.5 | 0.1 | ## License diff --git a/src/web_asset_plugin.rs b/src/web_asset_plugin.rs index 3c3a793..47c7014 100644 --- a/src/web_asset_plugin.rs +++ b/src/web_asset_plugin.rs @@ -1,4 +1,4 @@ -use bevy::prelude::*; +use bevy::{prelude::*, utils::HashMap}; use crate::web_asset_source::*; use bevy::asset::io::AssetSource; @@ -21,17 +21,73 @@ use bevy::asset::io::AssetSource; /// )); /// ``` #[derive(Default)] -pub struct WebAssetPlugin; +pub struct WebAssetPlugin { + headers: HashMap>, + query: HashMap, + fake_extension: bool, +} + +impl WebAssetPlugin { + /// Headers will be passed along with each request + pub fn new(headers: HashMap>, query: HashMap) -> Self { + Self { + headers, + query, + fake_extension: false, + } + } + + /// Enable "fake extension". This turns "test/example..png" into "test/example", but leaves single dots alone. + pub fn enable_fake_extensions(mut self) -> Self { + self.fake_extension = true; + self + } + + /// Push a new header to be sent along every asset load. The same key can be pushed multiple times. + pub fn push_header(mut self, key: impl ToString, value: impl ToString) -> Self { + self.headers + .entry(key.to_string()) + .or_insert_with(Vec::new) + .push(value.to_string()); + self + } + + /// Push a query parameter, which will be appended to the reqeust before its sent + pub fn push_query(mut self, key: impl ToString, value: impl ToString) -> Self { + self.query.insert(key.to_string(), value.to_string()); + self + } +} impl Plugin for WebAssetPlugin { fn build(&self, app: &mut App) { + let headers = self.headers.clone(); + let query = self.query.clone(); + let fake_extension = self.fake_extension; app.register_asset_source( "http", - AssetSource::build().with_reader(|| Box::new(WebAssetReader::Http)), + AssetSource::build().with_reader(move || { + Box::new(WebAssetReader { + protocol: Protocol::Http, + headers: headers.clone(), + query: query.clone(), + fake_extensions: fake_extension, + }) + }), ); + + let query = self.query.clone(); + let headers = self.headers.clone(); app.register_asset_source( "https", - AssetSource::build().with_reader(|| Box::new(WebAssetReader::Https)), + AssetSource::build().with_reader(move || { + Box::new(WebAssetReader { + protocol: Protocol::Https, + headers: headers.clone(), + query: query.clone(), + fake_extensions: fake_extension, + }) + }), ); } } diff --git a/src/web_asset_source.rs b/src/web_asset_source.rs index c024fe7..b63bd71 100644 --- a/src/web_asset_source.rs +++ b/src/web_asset_source.rs @@ -1,42 +1,100 @@ -use bevy::{asset::io::PathStream, utils::ConditionalSendFuture}; -use std::path::{Path, PathBuf}; +use bevy::{ + asset::io::PathStream, + utils::{ConditionalSendFuture, HashMap}, +}; +use std::{ + ffi::OsString, + path::{Path, PathBuf}, +}; use bevy::asset::io::{AssetReader, AssetReaderError, Reader}; -/// Treats paths as urls to load assets from. -pub enum WebAssetReader { +/// Which protocol to use +pub enum Protocol { /// Unencrypted connections. Http, /// Use TLS for setting up connections. Https, } +/// Treats paths as urls to load assets from. +pub struct WebAssetReader { + /// The protocol whith which the request is sent + pub protocol: Protocol, + /// Headers will be passed along with each request + pub headers: HashMap>, + /// Query parameters will be passed along with each request + pub query: HashMap, + /// Fake extensions are those with 2 dots. They will be removed before sending the request. + pub fake_extensions: bool, +} + +fn strip_double_extension(path: &mut PathBuf) -> Option<()> { + let fname = path.file_name()?.to_str()?; + let ext_start = fname.len() - path.extension()?.len(); + + if &fname[ext_start - 2..ext_start] == ".." { + path.set_extension(""); + path.set_extension(""); + Some(()) + } else { + Some(()) + } +} + impl WebAssetReader { + fn make_header_iter(&self) -> impl Iterator { + self.headers.iter().map(|(k, v)| (k.as_str(), v.as_slice())) + } + fn make_uri(&self, path: &Path) -> PathBuf { - PathBuf::from(match self { - Self::Http => "http://", - Self::Https => "https://", + let mut buf = PathBuf::from(match self.protocol { + Protocol::Http => "http://", + Protocol::Https => "https://", }) - .join(path) + .join(path); + if self.fake_extensions { + strip_double_extension(&mut buf); + } + buf + } + + fn make_uri_query(&self, path: &Path) -> PathBuf { + let mut buf = self.make_uri(path); + let mut query = self.query.iter(); + let mut query_string = String::new(); + if let Some((query_k, val)) = query.next() { + query_string += &format!("?{query_k}={val}"); + } + + for (query_k, val) in query { + query_string += &format!("&{query_k}={val}"); + } + buf.push(query_string); + buf } /// See [bevy::asset::io::get_meta_path] fn make_meta_uri(&self, path: &Path) -> Option { + path.extension()?; let mut uri = self.make_uri(path); - let mut extension = path.extension()?.to_os_string(); - extension.push(".meta"); - uri.set_extension(extension); + let mut fname = uri.file_name()?.to_os_string(); + fname.push(".meta"); + uri.set_file_name(fname); Some(uri) } } #[cfg(target_arch = "wasm32")] -async fn get<'a>(path: PathBuf) -> Result>, AssetReaderError> { +async fn get<'a>( + path: PathBuf, + headers: impl Iterator, +) -> Result>, AssetReaderError> { use bevy::asset::io::VecReader; use js_sys::Uint8Array; use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; - use web_sys::Response; + use web_sys::{Request, RequestInit, RequestMode, Response}; fn js_value_to_err<'a>( context: &'a str, @@ -56,7 +114,18 @@ async fn get<'a>(path: PathBuf) -> Result>, AssetReaderError> { } let window = web_sys::window().unwrap(); - let resp_value = JsFuture::from(window.fetch_with_str(path.to_str().unwrap())) + let mut init = RequestInit::new(); + init.set_mode(RequestMode::Cors); + let request = Request::new_with_str_and_init(path.to_str().unwrap(), &init).unwrap(); + let request_headers = request.headers(); + for (header_name, header_values) in headers { + for header_value in header_values { + request_headers + .append(header_name, header_value.as_str()) + .map_err(js_value_to_err("append header"))?; + } + } + let resp_value = JsFuture::from(window.fetch_with_request(&request)) .await .map_err(js_value_to_err("fetch path"))?; let resp = resp_value @@ -81,13 +150,18 @@ async fn get<'a>(path: PathBuf) -> Result>, AssetReaderError> { } #[cfg(not(target_arch = "wasm32"))] -async fn get<'a>(path: PathBuf) -> Result>, AssetReaderError> { +async fn get<'a>( + path: PathBuf, + headers: impl Iterator, +) -> Result>, AssetReaderError> { use std::future::Future; use std::io; use std::pin::Pin; + use std::str::FromStr; use std::task::{Context, Poll}; use bevy::asset::io::VecReader; + use surf::http::headers::{HeaderValue, HeaderValues}; use surf::StatusCode; #[pin_project::pin_project] @@ -104,16 +178,41 @@ async fn get<'a>(path: PathBuf) -> Result>, AssetReaderError> { } } - let str_path = path.to_str().ok_or_else(|| { - AssetReaderError::Io( - io::Error::new( - io::ErrorKind::Other, - format!("non-utf8 path: {}", path.display()), + let str_path = path + .to_str() + .ok_or_else(|| { + AssetReaderError::Io( + io::Error::new( + io::ErrorKind::Other, + format!("non-utf8 path: {}", path.display()), + ) + .into(), ) - .into(), - ) - })?; - let mut response = ContinuousPoll(surf::get(str_path)).await.map_err(|err| { + })? + .to_string(); + + let mut request = surf::get(str_path); + + // From headers iter to surf headers + for (header_name, header_values) in headers { + let hvs: Result = header_values + .iter() + .map(|f| { + HeaderValue::from_str(f).map_err(|_| { + AssetReaderError::Io( + io::Error::new( + io::ErrorKind::InvalidData, + format!("Header values for {} should be ASCII", header_name), + ) + .into(), + ) + }) + }) + .collect(); + request = request.header(header_name, &hvs?); + } + + let mut response = ContinuousPoll(request).await.map_err(|err| { AssetReaderError::Io( io::Error::new( io::ErrorKind::Other, @@ -154,12 +253,12 @@ impl AssetReader for WebAssetReader { &'a self, path: &'a Path, ) -> impl ConditionalSendFuture>, AssetReaderError>> { - get(self.make_uri(path)) + get(self.make_uri_query(path), self.make_header_iter()) } async fn read_meta<'a>(&'a self, path: &'a Path) -> Result>, AssetReaderError> { match self.make_meta_uri(path) { - Some(uri) => get(uri).await, + Some(uri) => get(uri, self.make_header_iter()).await, None => Err(AssetReaderError::NotFound( "source path has no extension".into(), )), @@ -180,26 +279,54 @@ impl AssetReader for WebAssetReader { #[cfg(test)] mod tests { + use bevy::utils::default; + use super::*; #[test] fn make_http_uri() { assert_eq!( - WebAssetReader::Http - .make_uri(Path::new("s3.johanhelsing.studio/dump/favicon.png")) - .to_str() - .unwrap(), + WebAssetReader { + protocol: Protocol::Http, + headers: default(), + query: default(), + fake_extensions: true + } + .make_uri(Path::new("s3.johanhelsing.studio/dump/favicon.png")) + .to_str() + .unwrap(), "http://s3.johanhelsing.studio/dump/favicon.png" ); } + #[test] + fn make_http_uri_strip_fake() { + assert_eq!( + WebAssetReader { + protocol: Protocol::Http, + headers: default(), + query: default(), + fake_extensions: true + } + .make_uri(Path::new("s3.johanhelsing.studio/dump/favicon..png")) + .to_str() + .unwrap(), + "http://s3.johanhelsing.studio/dump/favicon" + ); + } + #[test] fn make_https_uri() { assert_eq!( - WebAssetReader::Https - .make_uri(Path::new("s3.johanhelsing.studio/dump/favicon.png")) - .to_str() - .unwrap(), + WebAssetReader { + protocol: Protocol::Https, + headers: default(), + query: default(), + fake_extensions: true, + } + .make_uri(Path::new("s3.johanhelsing.studio/dump/favicon.png")) + .to_str() + .unwrap(), "https://s3.johanhelsing.studio/dump/favicon.png" ); } @@ -207,23 +334,50 @@ mod tests { #[test] fn make_http_meta_uri() { assert_eq!( - WebAssetReader::Http - .make_meta_uri(Path::new("s3.johanhelsing.studio/dump/favicon.png")) - .expect("cannot create meta uri") - .to_str() - .unwrap(), + WebAssetReader { + protocol: Protocol::Http, + headers: default(), + query: default(), + fake_extensions: true, + } + .make_meta_uri(Path::new("s3.johanhelsing.studio/dump/favicon.png")) + .expect("cannot create meta uri") + .to_str() + .unwrap(), "http://s3.johanhelsing.studio/dump/favicon.png.meta" ); } + #[test] + fn make_http_meta_uri_strip_fake() { + assert_eq!( + WebAssetReader { + protocol: Protocol::Http, + headers: default(), + query: default(), + fake_extensions: true, + } + .make_meta_uri(Path::new("s3.johanhelsing.studio/dump/favicon..png")) + .expect("cannot create meta uri") + .to_str() + .unwrap(), + "http://s3.johanhelsing.studio/dump/favicon.meta" + ); + } + #[test] fn make_https_meta_uri() { assert_eq!( - WebAssetReader::Https - .make_meta_uri(Path::new("s3.johanhelsing.studio/dump/favicon.png")) - .expect("cannot create meta uri") - .to_str() - .unwrap(), + WebAssetReader { + protocol: Protocol::Https, + headers: default(), + query: default(), + fake_extensions: true, + } + .make_meta_uri(Path::new("s3.johanhelsing.studio/dump/favicon.png")) + .expect("cannot create meta uri") + .to_str() + .unwrap(), "https://s3.johanhelsing.studio/dump/favicon.png.meta" ); } @@ -231,7 +385,13 @@ mod tests { #[test] fn make_https_without_extension_meta_uri() { assert_eq!( - WebAssetReader::Https.make_meta_uri(Path::new("s3.johanhelsing.studio/dump/favicon")), + WebAssetReader { + protocol: Protocol::Https, + headers: default(), + query: default(), + fake_extensions: true, + } + .make_meta_uri(Path::new("s3.johanhelsing.studio/dump/favicon")), None ); } From b148d8f2f85a0a20f5a29da433084052530802ce Mon Sep 17 00:00:00 2001 From: laund Date: Thu, 26 Sep 2024 21:12:15 +0200 Subject: [PATCH 2/2] fix example --- examples/web_image.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/web_image.rs b/examples/web_image.rs index 5dcb006..df4b33f 100644 --- a/examples/web_image.rs +++ b/examples/web_image.rs @@ -6,7 +6,7 @@ fn main() { .add_plugins(( // The web asset plugin must be inserted before the `AssetPlugin` so // that the AssetPlugin recognizes the new sources. - WebAssetPlugin, + WebAssetPlugin::default(), DefaultPlugins, )) .add_systems(Startup, setup)