feat: http asset sources
This commit is contained in:
parent
ea578415e1
commit
46b568641e
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,6 +1,6 @@
|
||||
# If your IDE needs additional project specific files, configure git to ignore them:
|
||||
# https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files#configuring-ignored-files-for-all-repositories-on-your-computer
|
||||
|
||||
.vscode
|
||||
# Rust build artifacts
|
||||
target
|
||||
crates/**/target
|
||||
@ -22,6 +22,7 @@ Cargo.lock
|
||||
assets/**/*.meta
|
||||
crates/bevy_asset/imported_assets
|
||||
imported_assets
|
||||
.http-asset-cache
|
||||
|
||||
# Bevy Examples
|
||||
example_showcase_config.ron
|
||||
|
||||
22
Cargo.toml
22
Cargo.toml
@ -455,6 +455,12 @@ file_watcher = ["bevy_internal/file_watcher"]
|
||||
# Enables watching in memory asset providers for Bevy Asset hot-reloading
|
||||
embedded_watcher = ["bevy_internal/embedded_watcher"]
|
||||
|
||||
# Enables using assets from HTTP sources
|
||||
http_source = ["bevy_internal/http_source"]
|
||||
|
||||
# Assets downloaded from HTTP sources are cached
|
||||
http_source_cache = ["bevy_internal/http_source_cache"]
|
||||
|
||||
# Enable stepping-based debugging of Bevy systems
|
||||
bevy_debug_stepping = ["bevy_internal/bevy_debug_stepping"]
|
||||
|
||||
@ -522,7 +528,7 @@ accesskit = "0.17"
|
||||
smol = "2"
|
||||
smol-macros = "0.1"
|
||||
smol-hyper = "0.1"
|
||||
ureq = { version = "2.10.1", features = ["json"] }
|
||||
ureq = { version = "3", features = ["json"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
|
||||
wasm-bindgen = { version = "0.2" }
|
||||
@ -1737,12 +1743,24 @@ path = "examples/asset/extra_source.rs"
|
||||
doc-scrape-examples = true
|
||||
|
||||
[package.metadata.example.extra_asset_source]
|
||||
name = "Extra asset source"
|
||||
name = "Extra Asset Source"
|
||||
description = "Load an asset from a non-standard asset source"
|
||||
category = "Assets"
|
||||
# Uses non-standard asset path
|
||||
wasm = false
|
||||
|
||||
[[example]]
|
||||
name = "http_source"
|
||||
path = "examples/asset/http_source.rs"
|
||||
doc-scrape-examples = true
|
||||
required-features = ["http_source"]
|
||||
|
||||
[package.metadata.example.http_source]
|
||||
name = "HTTP Asset Source"
|
||||
description = "Load an asset from a http source"
|
||||
category = "Assets"
|
||||
wasm = true
|
||||
|
||||
[[example]]
|
||||
name = "hot_asset_reloading"
|
||||
path = "examples/asset/hot_asset_reloading.rs"
|
||||
|
||||
@ -14,6 +14,8 @@ keywords = ["bevy"]
|
||||
file_watcher = ["notify-debouncer-full", "watch"]
|
||||
embedded_watcher = ["file_watcher"]
|
||||
multi_threaded = ["bevy_tasks/multi_threaded"]
|
||||
http_source = ["ureq", "rustls"]
|
||||
http_source_cache = []
|
||||
asset_processor = []
|
||||
watch = []
|
||||
trace = []
|
||||
@ -68,6 +70,18 @@ uuid = { version = "1.13.1", default-features = false, features = ["js"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
notify-debouncer-full = { version = "0.5.0", optional = true }
|
||||
# updating ureq: while ureq is semver stable rustls is not, meaning unlikely but possible breaking changes on minor releases. https://github.com/bevyengine/bevy/pull/16366#issuecomment-2572890794
|
||||
ureq = { version = "3.0.4", optional = true, default-features = false, features = [
|
||||
"rustls-no-provider",
|
||||
"gzip",
|
||||
"json",
|
||||
] }
|
||||
rustls = { version = "0.23", optional = true, default-features = false, features = [
|
||||
"aws_lc_rs",
|
||||
"logging",
|
||||
"std",
|
||||
"tls12",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
bevy_log = { path = "../bevy_log", version = "0.16.0-dev" }
|
||||
|
||||
272
crates/bevy_asset/src/http_source.rs
Normal file
272
crates/bevy_asset/src/http_source.rs
Normal file
@ -0,0 +1,272 @@
|
||||
use crate::io::{AssetReader, AssetReaderError, Reader};
|
||||
use crate::io::{AssetSource, PathStream};
|
||||
use crate::AssetApp;
|
||||
use alloc::boxed::Box;
|
||||
use bevy_app::App;
|
||||
use bevy_tasks::ConditionalSendFuture;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Adds the `http` and `https` asset sources to the app.
|
||||
/// Any asset path that begins with `http` or `https` will be loaded from the web
|
||||
/// via `fetch`(wasm) or `ureq`(native).
|
||||
pub fn http_source_plugin(app: &mut App) {
|
||||
app.register_asset_source(
|
||||
"http",
|
||||
AssetSource::build().with_reader(|| Box::new(HttpSourceAssetReader::Http)),
|
||||
);
|
||||
app.register_asset_source(
|
||||
"https",
|
||||
AssetSource::build().with_reader(|| Box::new(HttpSourceAssetReader::Https)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Asset reader that treats paths as urls to load assets from.
|
||||
pub enum HttpSourceAssetReader {
|
||||
/// Unencrypted connections.
|
||||
Http,
|
||||
/// Use TLS for setting up connections.
|
||||
Https,
|
||||
}
|
||||
|
||||
impl HttpSourceAssetReader {
|
||||
fn make_uri(&self, path: &Path) -> PathBuf {
|
||||
PathBuf::from(match self {
|
||||
Self::Http => "http://",
|
||||
Self::Https => "https://",
|
||||
})
|
||||
.join(path)
|
||||
}
|
||||
|
||||
/// See [`crate::io::get_meta_path`]
|
||||
fn make_meta_uri(&self, path: &Path) -> Option<PathBuf> {
|
||||
let mut uri = self.make_uri(path);
|
||||
let mut extension = path.extension()?.to_os_string();
|
||||
extension.push(".meta");
|
||||
uri.set_extension(extension);
|
||||
Some(uri)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
async fn get<'a>(path: PathBuf) -> Result<Box<dyn Reader>, AssetReaderError> {
|
||||
use crate::io::wasm::HttpWasmAssetReader;
|
||||
|
||||
HttpWasmAssetReader::new("")
|
||||
.fetch_bytes(path)
|
||||
.await
|
||||
.map(|r| Box::new(r) as Box<dyn Reader>)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
async fn get(path: PathBuf) -> Result<Box<dyn Reader>, AssetReaderError> {
|
||||
use crate::io::VecReader;
|
||||
use alloc::{boxed::Box, vec::Vec};
|
||||
use bevy_platform_support::sync::LazyLock;
|
||||
use std::io::{self, BufReader, Read};
|
||||
|
||||
let str_path = path.to_str().ok_or_else(|| {
|
||||
AssetReaderError::Io(
|
||||
io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
std::format!("non-utf8 path: {}", path.display()),
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
#[cfg(feature = "http_source_cache")]
|
||||
if let Some(data) = http_asset_cache::try_load_from_cache(str_path)? {
|
||||
return Ok(Box::new(VecReader::new(data)));
|
||||
}
|
||||
use ureq::Agent;
|
||||
|
||||
static AGENT: LazyLock<Agent> = LazyLock::new(|| {
|
||||
use alloc::sync::Arc;
|
||||
use ureq::{
|
||||
tls::{TlsConfig, TlsProvider},
|
||||
Agent,
|
||||
};
|
||||
|
||||
let crypto = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||
Agent::config_builder()
|
||||
.tls_config(
|
||||
TlsConfig::builder()
|
||||
.provider(TlsProvider::Rustls)
|
||||
// requires rustls or rustls-no-provider feature
|
||||
.unversioned_rustls_crypto_provider(crypto)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
.new_agent()
|
||||
});
|
||||
|
||||
match AGENT.get(str_path).call() {
|
||||
Ok(mut response) => {
|
||||
// let mut reader = response.into_reader();
|
||||
let mut reader = BufReader::new(response.body_mut().with_config().reader());
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
reader.read_to_end(&mut buffer)?;
|
||||
|
||||
#[cfg(feature = "http_source_cache")]
|
||||
http_asset_cache::save_to_cache(str_path, &buffer)?;
|
||||
|
||||
Ok(Box::new(VecReader::new(buffer)))
|
||||
}
|
||||
// ureq considers all >=400 status codes as errors
|
||||
Err(ureq::Error::StatusCode(code)) => {
|
||||
if code == 404 {
|
||||
Err(AssetReaderError::NotFound(path))
|
||||
} else {
|
||||
Err(AssetReaderError::HttpError(code))
|
||||
}
|
||||
}
|
||||
Err(err) => Err(AssetReaderError::Io(
|
||||
io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
std::format!(
|
||||
"unexpected error while loading asset {}: {}",
|
||||
path.display(),
|
||||
err
|
||||
),
|
||||
)
|
||||
.into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
impl AssetReader for HttpSourceAssetReader {
|
||||
fn read<'a>(
|
||||
&'a self,
|
||||
path: &'a Path,
|
||||
) -> impl ConditionalSendFuture<Output = Result<Box<dyn Reader>, AssetReaderError>> {
|
||||
get(self.make_uri(path))
|
||||
}
|
||||
|
||||
async fn read_meta<'a>(&'a self, path: &'a Path) -> Result<Box<dyn Reader>, AssetReaderError> {
|
||||
match self.make_meta_uri(path) {
|
||||
Some(uri) => get(uri).await,
|
||||
None => Err(AssetReaderError::NotFound(
|
||||
"source path has no extension".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_directory<'a>(&'a self, _path: &'a Path) -> Result<bool, AssetReaderError> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn read_directory<'a>(
|
||||
&'a self,
|
||||
path: &'a Path,
|
||||
) -> Result<Box<PathStream>, AssetReaderError> {
|
||||
Err(AssetReaderError::NotFound(self.make_uri(path)))
|
||||
}
|
||||
}
|
||||
|
||||
/// A naive implementation of an HTTP asset cache that never invalidates.
|
||||
/// `ureq` currently does not support caching, so this is a simple workaround.
|
||||
/// It should eventually be replaced by `http-cache` or similar, see [tracking issue](https://github.com/06chaynes/http-cache/issues/91)
|
||||
#[cfg(feature = "http_source_cache")]
|
||||
mod http_asset_cache {
|
||||
use alloc::string::String;
|
||||
use alloc::vec::Vec;
|
||||
use core::hash::{Hash, Hasher};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const CACHE_DIR: &str = ".http-asset-cache";
|
||||
|
||||
fn url_to_hash(url: &str) -> String {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
url.hash(&mut hasher);
|
||||
std::format!("{:x}", hasher.finish())
|
||||
}
|
||||
|
||||
pub fn try_load_from_cache(url: &str) -> Result<Option<Vec<u8>>, io::Error> {
|
||||
let filename = url_to_hash(url);
|
||||
let cache_path = PathBuf::from(CACHE_DIR).join(&filename);
|
||||
|
||||
if cache_path.exists() {
|
||||
let mut file = File::open(&cache_path)?;
|
||||
let mut buffer = Vec::new();
|
||||
file.read_to_end(&mut buffer)?;
|
||||
Ok(Some(buffer))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_to_cache(url: &str, data: &[u8]) -> Result<(), io::Error> {
|
||||
let filename = url_to_hash(url);
|
||||
let cache_path = PathBuf::from(CACHE_DIR).join(&filename);
|
||||
|
||||
fs::create_dir_all(CACHE_DIR).ok();
|
||||
|
||||
let mut cache_file = File::create(&cache_path)?;
|
||||
cache_file.write_all(data)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn make_http_uri() {
|
||||
assert_eq!(
|
||||
HttpSourceAssetReader::Http
|
||||
.make_uri(Path::new("example.com/favicon.png"))
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"http://example.com/favicon.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_https_uri() {
|
||||
assert_eq!(
|
||||
HttpSourceAssetReader::Https
|
||||
.make_uri(Path::new("example.com/favicon.png"))
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"https://example.com/favicon.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_http_meta_uri() {
|
||||
assert_eq!(
|
||||
HttpSourceAssetReader::Http
|
||||
.make_meta_uri(Path::new("example.com/favicon.png"))
|
||||
.expect("cannot create meta uri")
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"http://example.com/favicon.png.meta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_https_meta_uri() {
|
||||
assert_eq!(
|
||||
HttpSourceAssetReader::Https
|
||||
.make_meta_uri(Path::new("example.com/favicon.png"))
|
||||
.expect("cannot create meta uri")
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"https://example.com/favicon.png.meta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_https_without_extension_meta_uri() {
|
||||
assert_eq!(
|
||||
HttpSourceAssetReader::Https.make_meta_uri(Path::new("example.com/favicon")),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -46,7 +46,8 @@ pub enum AssetReaderError {
|
||||
Io(Arc<std::io::Error>),
|
||||
|
||||
/// The HTTP request completed but returned an unhandled [HTTP response status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status).
|
||||
/// If the request fails before getting a status code (e.g. request timeout, interrupted connection, etc), expect [`AssetReaderError::Io`].
|
||||
/// - If the request returns a 404 error, expect [`AssetReaderError::NotFound`].
|
||||
/// - If the request fails before getting a status code (e.g. request timeout, interrupted connection, etc), expect [`AssetReaderError::Io`].
|
||||
#[error("Encountered HTTP status {0:?} when loading asset")]
|
||||
HttpError(u16),
|
||||
}
|
||||
|
||||
@ -52,7 +52,11 @@ fn js_value_to_err(context: &str) -> impl FnOnce(JsValue) -> std::io::Error + '_
|
||||
}
|
||||
|
||||
impl HttpWasmAssetReader {
|
||||
async fn fetch_bytes<'a>(&self, path: PathBuf) -> Result<impl Reader, AssetReaderError> {
|
||||
// Also used by HttpSourceAssetReader
|
||||
pub(crate) async fn fetch_bytes<'a>(
|
||||
&self,
|
||||
path: PathBuf,
|
||||
) -> Result<impl Reader, AssetReaderError> {
|
||||
// The JS global scope includes a self-reference via a specializing name, which can be used to determine the type of global context available.
|
||||
let global: Global = js_sys::global().unchecked_into();
|
||||
let promise = if !global.window().is_undefined() {
|
||||
|
||||
@ -186,6 +186,9 @@ mod reflect;
|
||||
mod render_asset;
|
||||
mod server;
|
||||
|
||||
#[cfg(feature = "http_source")]
|
||||
mod http_source;
|
||||
|
||||
pub use assets::*;
|
||||
pub use bevy_asset_macros::Asset;
|
||||
pub use direct_access_ext::DirectAssetAccessExt;
|
||||
@ -324,6 +327,9 @@ impl AssetPlugin {
|
||||
|
||||
impl Plugin for AssetPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
#[cfg(feature = "http_source")]
|
||||
app.add_plugins(http_source::http_source_plugin);
|
||||
|
||||
let embedded = EmbeddedAssetRegistry::default();
|
||||
{
|
||||
let mut sources = app
|
||||
|
||||
@ -205,6 +205,12 @@ debug_glam_assert = ["bevy_math/debug_glam_assert"]
|
||||
|
||||
default_font = ["bevy_text?/default_font"]
|
||||
|
||||
# Enables using assets from HTTP sources
|
||||
http_source = ["bevy_asset?/http_source"]
|
||||
|
||||
# Assets downloaded from HTTP sources are cached
|
||||
http_source_cache = ["bevy_asset?/http_source_cache"]
|
||||
|
||||
# Enables the built-in asset processor for processed assets.
|
||||
asset_processor = ["bevy_asset?/asset_processor"]
|
||||
|
||||
|
||||
@ -18,7 +18,9 @@ allow = [
|
||||
"ISC",
|
||||
"MIT",
|
||||
"MIT-0",
|
||||
"MPL-2.0",
|
||||
"Unlicense",
|
||||
"OpenSSL",
|
||||
"Zlib",
|
||||
]
|
||||
|
||||
|
||||
@ -78,6 +78,8 @@ The default feature set enables most of the expected features of a game engine,
|
||||
|ghost_nodes|Experimental support for nodes that are ignored for UI layouting|
|
||||
|gif|GIF image format support|
|
||||
|glam_assert|Enable assertions to check the validity of parameters passed to glam|
|
||||
|http_source|Enables using assets from HTTP sources|
|
||||
|http_source_cache|Assets downloaded from HTTP sources are cached|
|
||||
|ico|ICO image format support|
|
||||
|ios_simulator|Enable support for the ios_simulator by downgrading some rendering capabilities|
|
||||
|jpeg|JPEG image format support|
|
||||
|
||||
@ -248,7 +248,8 @@ Example | Description
|
||||
[Custom Asset](../examples/asset/custom_asset.rs) | Implements a custom asset loader
|
||||
[Custom Asset IO](../examples/asset/custom_asset_reader.rs) | Implements a custom AssetReader
|
||||
[Embedded Asset](../examples/asset/embedded_asset.rs) | Embed an asset in the application binary and load it
|
||||
[Extra asset source](../examples/asset/extra_source.rs) | Load an asset from a non-standard asset source
|
||||
[Extra Asset Source](../examples/asset/extra_source.rs) | Load an asset from a non-standard asset source
|
||||
[HTTP Asset Source](../examples/asset/http_source.rs) | Load an asset from a http source
|
||||
[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
|
||||
|
||||
22
examples/asset/http_source.rs
Normal file
22
examples/asset/http_source.rs
Normal file
@ -0,0 +1,22 @@
|
||||
//! Example usage of the `http` asset source to load assets from the web.
|
||||
//!
|
||||
//! Run with the feature `http_source`, and optionally `http_source_cache`
|
||||
//! for a simple caching mechanism that never invalidates.
|
||||
//!
|
||||
use bevy::prelude::*;
|
||||
|
||||
fn main() {
|
||||
App::new()
|
||||
.add_plugins(DefaultPlugins)
|
||||
.add_systems(Startup, setup)
|
||||
.run();
|
||||
}
|
||||
|
||||
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
|
||||
commands.spawn(Camera2d);
|
||||
|
||||
commands.spawn(
|
||||
// Simply use a url where you would normally use an asset folder relative path
|
||||
Sprite::from_image(asset_server.load("https://raw.githubusercontent.com/bevyengine/bevy/refs/heads/main/assets/branding/bevy_bird_dark.png"))
|
||||
);
|
||||
}
|
||||
@ -49,7 +49,7 @@ fn main() -> AnyhowResult<()> {
|
||||
let req = BrpRequest {
|
||||
jsonrpc: String::from("2.0"),
|
||||
method: String::from(BRP_QUERY_METHOD),
|
||||
id: Some(ureq::json!(1)),
|
||||
id: Some(serde_json::json!(1)),
|
||||
params: Some(
|
||||
serde_json::to_value(BrpQueryParams {
|
||||
data: BrpQuery {
|
||||
@ -66,7 +66,8 @@ fn main() -> AnyhowResult<()> {
|
||||
|
||||
let res = ureq::post(&url)
|
||||
.send_json(req)?
|
||||
.into_json::<serde_json::Value>()?;
|
||||
.body_mut()
|
||||
.read_json::<serde_json::Value>()?;
|
||||
|
||||
println!("{:#}", res);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user