diff --git a/Cargo.lock b/Cargo.lock index 8074b12b17..f348ac63d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4966,6 +4966,7 @@ version = "0.7.0-alpha.3" dependencies = [ "criterion", "dioxus", + "dioxus-asset-resolver", "dioxus-cli-config", "dioxus-config-macro", "dioxus-config-macros", @@ -5005,15 +5006,22 @@ dependencies = [ name = "dioxus-asset-resolver" version = "0.7.0-alpha.3" dependencies = [ + "dioxus", "dioxus-cli-config", "http 1.3.1", "infer", "jni 0.21.1", + "js-sys", + "manganis-core", "ndk 0.9.0", "ndk-context", "ndk-sys 0.6.0+11769913", "percent-encoding", + "serde_json", "thiserror 2.0.12", + "tokio", + "wasm-bindgen-futures", + "web-sys", ] [[package]] @@ -5218,6 +5226,8 @@ dependencies = [ "dioxus", "flate2", "reqwest 0.12.22", + "serde", + "serde_json", "tar", ] diff --git a/packages/asset-resolver/Cargo.toml b/packages/asset-resolver/Cargo.toml index 784a87eec0..d5cf98ad98 100644 --- a/packages/asset-resolver/Cargo.toml +++ b/packages/asset-resolver/Cargo.toml @@ -11,14 +11,32 @@ keywords = ["web", "desktop", "mobile", "gui", "wasm"] rust-version = "1.79.0" [dependencies] -http = { workspace = true } -percent-encoding = { workspace = true } -infer = { workspace = true } thiserror = { workspace = true } -dioxus-cli-config = { workspace = true } +manganis-core = { workspace = true } + +# native/fs resolver dependencies +http = { workspace = true, optional = true } +percent-encoding = { workspace = true, optional = true } +infer = { workspace = true, optional = true } +dioxus-cli-config = { workspace = true, optional = true } +tokio = { workspace = true, features = ["rt"], optional = true } + +# browser resolver dependencies +web-sys = { workspace = true, features = ['Headers', 'Request', 'RequestInit', 'Response', 'Window'], optional = true } +js-sys = { workspace = true, optional = true } +wasm-bindgen-futures = { workspace = true, optional = true } [target.'cfg(target_os = "android")'.dependencies] -jni = "0.21.1" +jni = { version = "0.21.1" } ndk = { version = "0.9.0" } ndk-sys = { version = "0.6.0" } ndk-context = { version = "0.1.1" } + +[dev-dependencies] +dioxus = { workspace = true } +serde_json = { workspace = true } + +[features] +default = [] +web = ["dep:web-sys", "dep:js-sys", "dep:wasm-bindgen-futures"] +native = ["dep:http", "dep:percent-encoding", "dep:infer", "dep:dioxus-cli-config", "dep:tokio"] diff --git a/packages/asset-resolver/assets/data.json b/packages/asset-resolver/assets/data.json new file mode 100644 index 0000000000..7a9e864415 --- /dev/null +++ b/packages/asset-resolver/assets/data.json @@ -0,0 +1,3 @@ +{ + "key": "value" +} diff --git a/packages/asset-resolver/src/lib.rs b/packages/asset-resolver/src/lib.rs index aa442873f8..c54eaf3151 100644 --- a/packages/asset-resolver/src/lib.rs +++ b/packages/asset-resolver/src/lib.rs @@ -1,209 +1,196 @@ -use http::{status::StatusCode, Response}; -use std::path::{Path, PathBuf}; - +#![warn(missing_docs)] +//! The asset resolver for the Dioxus bundle format. Each platform has its own way of resolving assets. This crate handles +//! resolving assets in a cross-platform way. +//! +//! There are two broad locations for assets depending on the platform: +//! - **Web**: Assets are stored on a remote server and fetched via HTTP requests. +//! - **Native**: Assets are read from the local bundle. Each platform has its own bundle structure which may store assets +//! as a file at a specific path or in an opaque format like Android's AssetManager. +//! +//! [`read_asset_bytes`]( abstracts over both of these methods, allowing you to read the bytes of an asset +//! regardless of the platform. +//! +//! If you know you are on a desktop platform, you can use [`asset_path`] to resolve the path of an asset and read +//! the contents with [`std::fs`]. +//! +//! ## Example +//! ```rust +//! # async fn asset_example() { +//! use dioxus::prelude::*; +//! +//! // Bundle the static JSON asset into the application +//! static JSON_ASSET: Asset = asset!("assets/data.json"); +//! +//! // Read the bytes of the JSON asset +//! let bytes = dioxus::asset_resolver::read_asset_bytes(&JSON_ASSET).await.unwrap(); +//! +//! // Deserialize the JSON data +//! let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); +//! assert_eq!(json["key"].as_str(), Some("value")); +//! # } +//! ``` + +use manganis_core::Asset; +use std::{fmt::Debug, path::PathBuf}; + +#[cfg(feature = "native")] +pub mod native; + +#[cfg(feature = "web")] +mod web; + +/// An error that can occur when resolving an asset to a path. Not all platforms can represent assets as paths, +/// an error may mean that the asset doesn't exist or it cannot be represented as a path. #[non_exhaustive] #[derive(Debug, thiserror::Error)] -pub enum AssetServeError { - #[error("Failed to infer mime type for asset: {0}")] - InferringMimeType(std::io::Error), - - #[error("Failed to serve asset: {0}")] - IoError(#[from] std::io::Error), - - #[error("Failed to construct response: {0}")] - ResponseError(#[from] http::Error), +pub enum AssetPathError { + /// The asset was not found by the resolver. + #[error("Failed to find the path in the asset directory")] + NotFound, + + /// The asset may exist, but it cannot be represented as a path. + #[error("Asset cannot be represented as a path")] + CannotRepresentAsPath, } -/// Serve an asset from the filesystem or a custom asset handler. +/// Tries to resolve the path of an asset from a given URI path. Depending on the platform, this may +/// return an error even if the asset exists because some platforms cannot represent assets as paths. +/// You should prefer [`read_asset_bytes`] to read the asset bytes directly +/// for cross-platform compatibility. /// -/// This method properly accesses the asset directory based on the platform and serves the asset -/// wrapped in an HTTP response. +/// ## Platform specific behavior /// -/// Platform specifics: -/// - On the web, this returns AssetServerError since there's no filesystem access. Use `fetch` instead. -/// - On Android, it attempts to load assets using the Android AssetManager. -/// - On other platforms, it serves assets from the filesystem. -pub fn serve_asset(path: &str) -> Result>, AssetServeError> { - // If the user provided a custom asset handler, then call it and return the response if the request was handled. - // The path is the first part of the URI, so we need to trim the leading slash. - let mut uri_path = PathBuf::from( - percent_encoding::percent_decode_str(path) - .decode_utf8() - .expect("expected URL to be UTF-8 encoded") - .as_ref(), - ); - - // Attempt to serve from the asset dir on android using its loader - #[cfg(target_os = "android")] - { - if let Some(asset) = to_java_load_asset(path) { - return Ok(Response::builder() - .header("Content-Type", get_mime_by_ext(&uri_path)) - .header("Access-Control-Allow-Origin", "*") - .body(asset)?); - } - } - - // If the asset doesn't exist, or starts with `/assets/`, then we'll try to serve out of the bundle - // This lets us handle both absolute and relative paths without being too "special" - // It just means that our macos bundle is a little "special" because we need to place an `assets` - // dir in the `Resources` dir. - // - // If there's no asset root, we use the cargo manifest dir as the root, or the current dir - if !uri_path.exists() || uri_path.starts_with("/assets/") { - let bundle_root = get_asset_root(); - let relative_path = uri_path.strip_prefix("/").unwrap(); - uri_path = bundle_root.join(relative_path); - } - - // If the asset exists, then we can serve it! - if uri_path.exists() { - let mime_type = - get_mime_from_path(&uri_path).map_err(AssetServeError::InferringMimeType)?; - let body = std::fs::read(uri_path)?; - return Ok(Response::builder() - .header("Content-Type", mime_type) - .header("Access-Control-Allow-Origin", "*") - .body(body)?); - } +/// This function will only work on desktop platforms. It will always return an error in web and Android +/// bundles. On Android assets are bundled in the APK, and cannot be represented as paths. In web bundles, +/// Assets are fetched via HTTP requests and don't have a filesystem path. +/// +/// ## Example +/// ```rust +/// use dioxus::prelude::*; +/// +/// // Bundle the static JSON asset into the application +/// static JSON_ASSET: Asset = asset!("assets/data.json"); +/// +/// // Resolve the path of the asset. This will not work in web or Android bundles +/// let path = dioxus::asset_resolver::asset_path(&JSON_ASSET).unwrap(); +/// +/// println!("Asset path: {:?}", path); +/// +/// // Read the bytes of the JSON asset +/// let bytes = std::fs::read(path).unwrap(); +/// +/// // Deserialize the JSON data +/// let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); +/// assert_eq!(json["key"].as_str(), Some("value")); +/// ``` +#[allow(unused)] +pub fn asset_path(asset: &Asset) -> Result { + #[cfg(all(feature = "web", target_arch = "wasm32"))] + return Err(AssetPathError::CannotRepresentAsPath); + + #[cfg(feature = "native")] + return native::resolve_native_asset_path(asset.to_string().as_str()); + + Err(AssetPathError::NotFound) +} - Ok(Response::builder() - .status(StatusCode::NOT_FOUND) - .body(String::from("Not Found").into_bytes())?) +/// An error that can occur when resolving an asset. +#[non_exhaustive] +#[derive(Debug, thiserror::Error)] +pub enum AssetResolveError { + /// An error occurred while resolving a native asset. + #[error("Failed to resolve native asset: {0}")] + Native(#[from] NativeAssetResolveError), + + /// An error occurred while resolving a web asset. + #[error("Failed to resolve web asset: {0}")] + Web(#[from] WebAssetResolveError), + + /// An error that occurs when no asset resolver is available for the current platform. + #[error("Asset resolution is not supported on this platform")] + UnsupportedPlatform, } -/// Get the asset directory, following tauri/cargo-bundles directory discovery approach +/// Read the bytes of an asset. This will work on both web and native platforms. On the web, +/// it will fetch the asset via HTTP, and on native platforms, it will read the asset from the filesystem or bundle. /// -/// Currently supports: -/// - [x] macOS -/// - [x] iOS -/// - [x] Windows -/// - [x] Linux (appimage) -/// - [ ] Linux (rpm) -/// - [x] Linux (deb) -/// - [ ] Android -#[allow(unreachable_code)] -fn get_asset_root() -> PathBuf { - let cur_exe = std::env::current_exe().unwrap(); - - #[cfg(target_os = "macos")] - { - return cur_exe - .parent() - .unwrap() - .parent() - .unwrap() - .join("Resources"); - } - - #[cfg(target_os = "linux")] - { - // In linux bundles, the assets are placed in the lib/$product_name directory - // bin/ - // main - // lib/ - // $product_name/ - // assets/ - if let Some(product_name) = dioxus_cli_config::product_name() { - let lib_asset_path = || { - let path = cur_exe.parent()?.parent()?.join("lib").join(product_name); - path.exists().then_some(path) - }; - if let Some(asset_dir) = lib_asset_path() { - return asset_dir; - } - } - } - - // For all others, the structure looks like this: - // app.(exe/appimage) - // main.exe - // assets/ - cur_exe.parent().unwrap().to_path_buf() +/// ## Errors +/// This function will return an error if the asset cannot be found or if it fails to read which may be due to I/O errors or +/// network issues. +/// +/// ## Example +/// +/// ```rust +/// # async fn asset_example() { +/// use dioxus::prelude::*; +/// +/// // Bundle the static JSON asset into the application +/// static JSON_ASSET: Asset = asset!("assets/data.json"); +/// +/// // Read the bytes of the JSON asset +/// let bytes = dioxus::asset_resolver::read_asset_bytes(&JSON_ASSET).await.unwrap(); +/// +/// // Deserialize the JSON data +/// let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); +/// assert_eq!(json["key"].as_str(), Some("value")); +/// # } +/// ``` +#[allow(unused)] +pub async fn read_asset_bytes(asset: &Asset) -> Result, AssetResolveError> { + let path = asset.to_string(); + + #[cfg(feature = "web")] + return web::resolve_web_asset(&path) + .await + .map_err(AssetResolveError::Web); + + #[cfg(feature = "native")] + return tokio::task::spawn_blocking(move || native::resolve_native_asset(&path)) + .await + .map_err(|err| AssetResolveError::Native(NativeAssetResolveError::JoinError(err))) + .and_then(|result| result.map_err(AssetResolveError::Native)); + + Err(AssetResolveError::UnsupportedPlatform) } -/// Get the mime type from a path-like string -fn get_mime_from_path(asset: &Path) -> std::io::Result<&'static str> { - if asset.extension().is_some_and(|ext| ext == "svg") { - return Ok("image/svg+xml"); - } +/// An error that occurs when resolving a native asset. +#[non_exhaustive] +#[derive(Debug, thiserror::Error)] +pub enum NativeAssetResolveError { + /// An I/O error occurred while reading the asset from the filesystem. + #[error("Failed to read asset: {0}")] + IoError(#[from] std::io::Error), - match infer::get_from_path(asset)?.map(|f| f.mime_type()) { - Some(f) if f != "text/plain" => Ok(f), - _other => Ok(get_mime_by_ext(asset)), - } + /// The asset resolver failed to complete and could not be joined. + #[cfg(feature = "native")] + #[error("Asset resolver join failed: {0}")] + JoinError(tokio::task::JoinError), } -/// Get the mime type from a URI using its extension -fn get_mime_by_ext(trimmed: &Path) -> &'static str { - match trimmed.extension().and_then(|e| e.to_str()) { - // The common assets are all utf-8 encoded - Some("js") => "text/javascript; charset=utf-8", - Some("css") => "text/css; charset=utf-8", - Some("json") => "application/json; charset=utf-8", - Some("svg") => "image/svg+xml; charset=utf-8", - Some("html") => "text/html; charset=utf-8", - - // the rest... idk? probably not - Some("mjs") => "text/javascript; charset=utf-8", - Some("bin") => "application/octet-stream", - Some("csv") => "text/csv", - Some("ico") => "image/vnd.microsoft.icon", - Some("jsonld") => "application/ld+json", - Some("rtf") => "application/rtf", - Some("mp4") => "video/mp4", - // Assume HTML when a TLD is found for eg. `dioxus:://dioxuslabs.app` | `dioxus://hello.com` - Some(_) => "text/html; charset=utf-8", - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types - // using octet stream according to this: - None => "application/octet-stream", - } +/// An error that occurs when resolving an asset on the web. +pub struct WebAssetResolveError { + #[cfg(feature = "web")] + error: js_sys::Error, } -#[cfg(target_os = "android")] -pub(crate) fn to_java_load_asset(filepath: &str) -> Option> { - let normalized = filepath - .trim_start_matches("/assets/") - .trim_start_matches('/'); - - // in debug mode, the asset might be under `/data/local/tmp/dx/` - attempt to read it from there if it exists - #[cfg(debug_assertions)] - { - let path = dioxus_cli_config::android_session_cache_dir().join(normalized); - if path.exists() { - return std::fs::read(path).ok(); - } +impl Debug for WebAssetResolveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut debug = f.debug_struct("WebAssetResolveError"); + #[cfg(feature = "web")] + debug.field("name", &self.error.name()); + #[cfg(feature = "web")] + debug.field("message", &self.error.message()); + debug.finish() } +} - use std::ptr::NonNull; - - let ctx = ndk_context::android_context(); - let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.unwrap(); - let mut env = vm.attach_current_thread().unwrap(); - - // Query the Asset Manager - let asset_manager_ptr = env - .call_method( - unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) }, - "getAssets", - "()Landroid/content/res/AssetManager;", - &[], - ) - .expect("Failed to get asset manager") - .l() - .expect("Failed to get asset manager as object"); - - unsafe { - let asset_manager = - ndk_sys::AAssetManager_fromJava(env.get_native_interface(), *asset_manager_ptr); - - let asset_manager = ndk::asset::AssetManager::from_ptr( - NonNull::new(asset_manager).expect("Invalid asset manager"), - ); - - let cstr = std::ffi::CString::new(normalized).unwrap(); - - let mut asset = asset_manager.open(&cstr)?; - Some(asset.buffer().unwrap().to_vec()) +impl std::fmt::Display for WebAssetResolveError { + #[allow(unreachable_code)] + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + #[cfg(feature = "web")] + return write!(f, "{}", self.error.message()); + write!(f, "WebAssetResolveError") } } + +impl std::error::Error for WebAssetResolveError {} diff --git a/packages/asset-resolver/src/native.rs b/packages/asset-resolver/src/native.rs new file mode 100644 index 0000000000..3f27848916 --- /dev/null +++ b/packages/asset-resolver/src/native.rs @@ -0,0 +1,281 @@ +//! Native specific utilities for resolving assets in a bundle. This module is intended for use in renderers that +//! need to resolve asset bundles for resources like images, and fonts. + +use http::{status::StatusCode, Response}; +use std::path::{Path, PathBuf}; + +use crate::{AssetPathError, NativeAssetResolveError}; + +/// An error that can occur when serving an asset. +#[non_exhaustive] +#[derive(Debug, thiserror::Error)] +pub enum AssetServeError { + /// The asset path could not be resolved. + #[error("Failed to resolve asset: {0}")] + ResolveError(#[from] NativeAssetResolveError), + + /// An error occurred while constructing the HTTP response. + #[error("Failed to construct response: {0}")] + ResponseError(#[from] http::Error), +} + +/// Try to resolve the path of an asset from a given URI path. +pub(crate) fn resolve_native_asset_path(path: &str) -> Result { + #[allow(clippy::unnecessary_lazy_evaluations)] + resolve_asset_path_from_filesystem(path).ok_or_else(|| { + #[cfg(target_os = "android")] + { + // If the asset exists in the Android asset manager, return the can't be represented as a path + // error instead + if to_java_load_asset(path).is_some() { + return AssetPathError::CannotRepresentAsPath; + } + } + + AssetPathError::NotFound + }) +} + +/// Try to resolve the path of an asset from a given URI path. +fn resolve_asset_path_from_filesystem(path: &str) -> Option { + // If the user provided a custom asset handler, then call it and return the response if the request was handled. + // The path is the first part of the URI, so we need to trim the leading slash. + let mut uri_path = PathBuf::from( + percent_encoding::percent_decode_str(path) + .decode_utf8() + .expect("expected URL to be UTF-8 encoded") + .as_ref(), + ); + + // If the asset doesn't exist, or starts with `/assets/`, then we'll try to serve out of the bundle + // This lets us handle both absolute and relative paths without being too "special" + // It just means that our macos bundle is a little "special" because we need to place an `assets` + // dir in the `Resources` dir. + // + // If there's no asset root, we use the cargo manifest dir as the root, or the current dir + if !uri_path.exists() || uri_path.starts_with("/assets/") { + let bundle_root = get_asset_root(); + let relative_path = uri_path.strip_prefix("/").unwrap(); + uri_path = bundle_root.join(relative_path); + } + + // If the asset exists, return it + uri_path.exists().then_some(uri_path) +} + +struct ResolvedAsset { + mime_type: &'static str, + body: Vec, +} + +impl ResolvedAsset { + fn new(mime_type: &'static str, body: Vec) -> Self { + Self { mime_type, body } + } + + fn into_response(self) -> Result>, AssetServeError> { + Ok(Response::builder() + .header("Content-Type", self.mime_type) + .header("Access-Control-Allow-Origin", "*") + .body(self.body)?) + } +} + +/// Read the bytes for an asset +pub(crate) fn resolve_native_asset(path: &str) -> Result, NativeAssetResolveError> { + // Attempt to serve from the asset dir on android using its loader + #[cfg(target_os = "android")] + { + if let Some(asset) = to_java_load_asset(path) { + return Ok(asset); + } + } + + let Some(uri_path) = resolve_asset_path_from_filesystem(path) else { + return Err(NativeAssetResolveError::IoError(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Asset not found", + ))); + }; + Ok(std::fs::read(uri_path)?) +} + +/// Resolve the asset and its mime type +fn resolve_asset(path: &str) -> Result, NativeAssetResolveError> { + // Attempt to serve from the asset dir on android using its loader + #[cfg(target_os = "android")] + { + if let Some(asset) = to_java_load_asset(path) { + let extension = path.rsplit_once('.').and_then(|(_, ext)| Some(ext)); + let mime_type = get_mime_from_ext(extension); + return Ok(Some(ResolvedAsset::new(mime_type, asset))); + } + } + + let Some(uri_path) = resolve_asset_path_from_filesystem(path) else { + return Ok(None); + }; + let mime_type = get_mime_from_path(&uri_path)?; + let body = std::fs::read(uri_path)?; + Ok(Some(ResolvedAsset::new(mime_type, body))) +} + +/// Serve an asset from the filesystem or a custom asset handler. +/// +/// This method properly accesses the asset directory based on the platform and serves the asset +/// wrapped in an HTTP response. +/// +/// Platform specifics: +/// - On the web, this returns AssetServerError since there's no filesystem access. Use `fetch` instead. +/// - On Android, it attempts to load assets using the Android AssetManager. +/// - On other platforms, it serves assets from the filesystem. +pub fn serve_asset(path: &str) -> Result>, AssetServeError> { + match resolve_asset(path)? { + Some(asset) => asset.into_response(), + None => Ok(Response::builder() + .status(StatusCode::NOT_FOUND) + .body(String::from("Not Found").into_bytes())?), + } +} + +/// Get the asset directory, following tauri/cargo-bundles directory discovery approach +/// +/// Currently supports: +/// - [x] macOS +/// - [x] iOS +/// - [x] Windows +/// - [x] Linux (appimage) +/// - [ ] Linux (rpm) +/// - [x] Linux (deb) +/// - [ ] Android +#[allow(unreachable_code)] +fn get_asset_root() -> PathBuf { + let cur_exe = std::env::current_exe().unwrap(); + + #[cfg(target_os = "macos")] + { + return cur_exe + .parent() + .unwrap() + .parent() + .unwrap() + .join("Resources"); + } + + #[cfg(target_os = "linux")] + { + // In linux bundles, the assets are placed in the lib/$product_name directory + // bin/ + // main + // lib/ + // $product_name/ + // assets/ + if let Some(product_name) = dioxus_cli_config::product_name() { + let lib_asset_path = || { + let path = cur_exe.parent()?.parent()?.join("lib").join(product_name); + path.exists().then_some(path) + }; + if let Some(asset_dir) = lib_asset_path() { + return asset_dir; + } + } + } + + // For all others, the structure looks like this: + // app.(exe/appimage) + // main.exe + // assets/ + cur_exe.parent().unwrap().to_path_buf() +} + +/// Get the mime type from a path-like string +fn get_mime_from_path(asset: &Path) -> std::io::Result<&'static str> { + if asset.extension().is_some_and(|ext| ext == "svg") { + return Ok("image/svg+xml"); + } + + match infer::get_from_path(asset)?.map(|f| f.mime_type()) { + Some(f) if f != "text/plain" => Ok(f), + _other => Ok(get_mime_by_ext(asset)), + } +} + +/// Get the mime type from a URI using its extension +fn get_mime_by_ext(trimmed: &Path) -> &'static str { + let ext = trimmed.extension().as_ref().and_then(|ext| ext.to_str()); + get_mime_from_ext(ext) +} + +/// Get the mime type from a URI using its extension +fn get_mime_from_ext(ext: Option<&str>) -> &'static str { + match ext { + // The common assets are all utf-8 encoded + Some("js") => "text/javascript; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("json") => "application/json; charset=utf-8", + Some("svg") => "image/svg+xml; charset=utf-8", + Some("html") => "text/html; charset=utf-8", + + // the rest... idk? probably not + Some("mjs") => "text/javascript; charset=utf-8", + Some("bin") => "application/octet-stream", + Some("csv") => "text/csv", + Some("ico") => "image/vnd.microsoft.icon", + Some("jsonld") => "application/ld+json", + Some("rtf") => "application/rtf", + Some("mp4") => "video/mp4", + // Assume HTML when a TLD is found for eg. `dioxus:://dioxuslabs.app` | `dioxus://hello.com` + Some(_) => "text/html; charset=utf-8", + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types + // using octet stream according to this: + None => "application/octet-stream", + } +} + +#[cfg(target_os = "android")] +pub(crate) fn to_java_load_asset(filepath: &str) -> Option> { + let normalized = filepath + .trim_start_matches("/assets/") + .trim_start_matches('/'); + + // in debug mode, the asset might be under `/data/local/tmp/dx/` - attempt to read it from there if it exists + #[cfg(debug_assertions)] + { + let path = dioxus_cli_config::android_session_cache_dir().join(normalized); + if path.exists() { + return std::fs::read(path).ok(); + } + } + + use std::ptr::NonNull; + + let ctx = ndk_context::android_context(); + let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.unwrap(); + let mut env = vm.attach_current_thread().unwrap(); + + // Query the Asset Manager + let asset_manager_ptr = env + .call_method( + unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) }, + "getAssets", + "()Landroid/content/res/AssetManager;", + &[], + ) + .expect("Failed to get asset manager") + .l() + .expect("Failed to get asset manager as object"); + + unsafe { + let asset_manager = + ndk_sys::AAssetManager_fromJava(env.get_native_interface(), *asset_manager_ptr); + + let asset_manager = ndk::asset::AssetManager::from_ptr( + NonNull::new(asset_manager).expect("Invalid asset manager"), + ); + + let cstr = std::ffi::CString::new(normalized).unwrap(); + + let mut asset = asset_manager.open(&cstr)?; + Some(asset.buffer().unwrap().to_vec()) + } +} diff --git a/packages/asset-resolver/src/web.rs b/packages/asset-resolver/src/web.rs new file mode 100644 index 0000000000..f0ba8510d9 --- /dev/null +++ b/packages/asset-resolver/src/web.rs @@ -0,0 +1,50 @@ +use js_sys::{ + wasm_bindgen::{JsCast, JsValue}, + ArrayBuffer, Uint8Array, +}; +use wasm_bindgen_futures::JsFuture; +use web_sys::{Request, Response}; + +use crate::WebAssetResolveError; + +impl From for WebAssetResolveError { + fn from(error: js_sys::Error) -> Self { + WebAssetResolveError { error } + } +} + +impl WebAssetResolveError { + fn from_js_value(value: JsValue) -> Self { + if let Some(error) = value.dyn_ref::() { + WebAssetResolveError::from(error.clone()) + } else { + unreachable!("Expected a js_sys::Error, got: {:?}", value) + } + } +} + +pub(crate) async fn resolve_web_asset(path: &str) -> Result, WebAssetResolveError> { + let url = if path.starts_with("/") { + path.to_string() + } else { + format!("/{path}") + }; + + let request = Request::new_with_str(&url).map_err(WebAssetResolveError::from_js_value)?; + + let window = web_sys::window().unwrap(); + let response_promise = JsFuture::from(window.fetch_with_request(&request)) + .await + .map_err(WebAssetResolveError::from_js_value)?; + let response = response_promise.unchecked_into::(); + + let array_buffer_promise = response + .array_buffer() + .map_err(WebAssetResolveError::from_js_value)?; + let array_buffer: ArrayBuffer = JsFuture::from(array_buffer_promise) + .await + .map_err(WebAssetResolveError::from_js_value)? + .unchecked_into(); + let bytes = Uint8Array::new(&array_buffer); + Ok(bytes.to_vec()) +} diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml index b77f52b738..c136dadc9e 100644 --- a/packages/desktop/Cargo.toml +++ b/packages/desktop/Cargo.toml @@ -17,7 +17,7 @@ dioxus-document = { workspace = true } dioxus-signals = { workspace = true, optional = true } dioxus-interpreter-js = { workspace = true, features = ["binary-protocol", "serialize"] } dioxus-cli-config = { workspace = true } -dioxus-asset-resolver = { workspace = true } +dioxus-asset-resolver = { workspace = true, features = ["native"] } generational-box = { workspace = true } dioxus-devtools = { workspace = true, optional = true } diff --git a/packages/desktop/src/protocol.rs b/packages/desktop/src/protocol.rs index c35a293069..f4b69f8e4f 100644 --- a/packages/desktop/src/protocol.rs +++ b/packages/desktop/src/protocol.rs @@ -66,7 +66,7 @@ pub(super) fn desktop_handler( } } - match dioxus_asset_resolver::serve_asset(request.uri().path()) { + match dioxus_asset_resolver::native::serve_asset(request.uri().path()) { Ok(res) => responder.respond(res), Err(_e) => responder.respond( Response::builder() diff --git a/packages/dioxus/Cargo.toml b/packages/dioxus/Cargo.toml index 7c62efe342..715be9907a 100644 --- a/packages/dioxus/Cargo.toml +++ b/packages/dioxus/Cargo.toml @@ -30,6 +30,7 @@ dioxus-server = { workspace = true, optional = true } dioxus-ssr = { workspace = true, optional = true } dioxus-native = { workspace = true, optional = true } dioxus_server_macro = { workspace = true, optional = true } +dioxus-asset-resolver = { workspace = true, optional = true } manganis = { workspace = true, features = ["dioxus"], optional = true } dioxus-logger = { workspace = true, optional = true } warnings = { workspace = true, optional = true } @@ -69,7 +70,7 @@ hooks = ["dep:dioxus-hooks"] devtools = ["dep:dioxus-devtools", "dioxus-web?/devtools", "dioxus-fullstack?/devtools"] mounted = ["dioxus-web?/mounted"] file_engine = ["dioxus-web?/file_engine"] -asset = ["dep:manganis"] +asset = ["dep:manganis", "dep:dioxus-asset-resolver"] document = ["dioxus-web?/document", "dep:dioxus-document", "dep:dioxus-history"] logger = ["dep:dioxus-logger"] cli-config = ["dep:dioxus-cli-config"] @@ -92,6 +93,7 @@ web = [ "dioxus-config-macro/web", "dep:dioxus-cli-config", "dioxus-cli-config?/web", + "dioxus-asset-resolver?/web", ] ssr = ["dep:dioxus-ssr", "dioxus-config-macro/ssr"] liveview = ["dep:dioxus-liveview", "dioxus-config-macro/liveview"] diff --git a/packages/dioxus/src/lib.rs b/packages/dioxus/src/lib.rs index 9a200476f6..c64edd25eb 100644 --- a/packages/dioxus/src/lib.rs +++ b/packages/dioxus/src/lib.rs @@ -129,6 +129,11 @@ pub use wasm_splitter as wasm_split; pub use subsecond; +#[cfg(feature = "asset")] +#[cfg_attr(docsrs, doc(cfg(feature = "asset")))] +#[doc(inline)] +pub use dioxus_asset_resolver as asset_resolver; + pub mod prelude { #[cfg(feature = "document")] #[cfg_attr(docsrs, doc(cfg(feature = "document")))] diff --git a/packages/native/Cargo.toml b/packages/native/Cargo.toml index a02b0936e0..9cd95b4ef0 100644 --- a/packages/native/Cargo.toml +++ b/packages/native/Cargo.toml @@ -33,7 +33,7 @@ blitz-shell = { workspace = true } dioxus-core = { workspace = true } dioxus-html = { workspace = true } dioxus-native-dom = { workspace = true } -dioxus-asset-resolver = { workspace = true } +dioxus-asset-resolver = { workspace = true, features = ["native"] } dioxus-cli-config = { workspace = true, optional = true } dioxus-devtools = { workspace = true, optional = true } dioxus-history = { workspace = true } diff --git a/packages/native/src/assets.rs b/packages/native/src/assets.rs index 3e1aae1fe6..12e308abc4 100644 --- a/packages/native/src/assets.rs +++ b/packages/native/src/assets.rs @@ -33,7 +33,7 @@ impl NetProvider for DioxusNativeNetProvider { handler: blitz_traits::net::BoxedHandler, ) { if request.url.scheme() == "dioxus" { - match dioxus_asset_resolver::serve_asset(request.url.path()) { + match dioxus_asset_resolver::native::serve_asset(request.url.path()) { Ok(res) => { tracing::trace!("fetching asset from file system success {request:#?}"); handler.bytes(doc_id, res.into_body().into(), self.callback.clone()) diff --git a/packages/playwright-tests/cli-optimization.spec.js b/packages/playwright-tests/cli-optimization.spec.js index f5c890fcb8..9cad95fc61 100644 --- a/packages/playwright-tests/cli-optimization.spec.js +++ b/packages/playwright-tests/cli-optimization.spec.js @@ -45,3 +45,15 @@ test("unused external assets are bundled", async ({ page }) => { // make sure the response is an image expect(response.headers()["content-type"]).toBe("image/png"); }); + +test("assets are resolved", async ({ page }) => { + await page.goto("http://localhost:8989"); + + // Expect the page to contain an element with the id "resolved-data" + const resolvedData = page.locator("#resolved-data"); + await expect(resolvedData).toBeVisible(); + // Expect the element to contain the text "List: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]" + await expect(resolvedData).toContainText( + "List: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]" + ); +}); diff --git a/packages/playwright-tests/cli-optimization/Cargo.toml b/packages/playwright-tests/cli-optimization/Cargo.toml index 743b3bcc0e..9fed023320 100644 --- a/packages/playwright-tests/cli-optimization/Cargo.toml +++ b/packages/playwright-tests/cli-optimization/Cargo.toml @@ -8,6 +8,8 @@ publish = false [dependencies] dioxus = { workspace = true, features = ["web"] } +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true [build-dependencies] reqwest = { workspace = true, features = ["blocking"] } diff --git a/packages/playwright-tests/cli-optimization/assets/data.json b/packages/playwright-tests/cli-optimization/assets/data.json new file mode 100644 index 0000000000..f28e5498c8 --- /dev/null +++ b/packages/playwright-tests/cli-optimization/assets/data.json @@ -0,0 +1,3 @@ +{ + "list": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] +} diff --git a/packages/playwright-tests/cli-optimization/src/main.rs b/packages/playwright-tests/cli-optimization/src/main.rs index 1e93b7c9ca..f47da783d1 100644 --- a/packages/playwright-tests/cli-optimization/src/main.rs +++ b/packages/playwright-tests/cli-optimization/src/main.rs @@ -56,5 +56,36 @@ fn App() -> Element { id: "some_image_without_hash", src: "{SOME_IMAGE_WITHOUT_HASH}" } + LoadsAsset {} + } +} + +const JSON: Asset = asset!("/assets/data.json"); + +#[derive(Debug, Clone, serde::Deserialize)] +struct Data { + list: Vec, +} + +#[component] +fn LoadsAsset() -> Element { + let data = use_resource(|| async { + let bytes = dioxus::asset_resolver::read_asset_bytes(&JSON) + .await + .unwrap(); + serde_json::from_slice::(&bytes).unwrap() + }); + match data() { + Some(data) => rsx! { + div { + id: "resolved-data", + "List: {data.list:?}" + } + }, + None => rsx! { + div { + "Loading..." + } + }, } }