Cross platform asset resolver (#4521)
* add initial web resolver * re-export resolver and add playwright test * add documentation * fix clippy * fix docs + clippy
This commit is contained in:
1 parent
b72b142b53
commit
68645e7f0c
16 files changed
+607
-203
No files matched your search
Generated
+10
@@ -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]]
|
||||
@@ -5223,6 +5231,8 @@ dependencies = [
|
||||
"dioxus",
|
||||
"flate2",
|
||||
"reqwest 0.12.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"key": "value"
|
||||
}
|
||||
+180
-193
@@ -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),
|
||||
pub enum AssetPathError {
|
||||
/// The asset was not found by the resolver.
|
||||
#[error("Failed to find the path in the asset directory")]
|
||||
NotFound,
|
||||
|
||||
#[error("Failed to serve asset: {0}")]
|
||||
/// The asset may exist, but it cannot be represented as a path.
|
||||
#[error("Asset cannot be represented as a path")]
|
||||
CannotRepresentAsPath,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// ## Platform specific behavior
|
||||
///
|
||||
/// 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<PathBuf, AssetPathError> {
|
||||
#[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)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// ## 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<Vec<u8>, 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)
|
||||
}
|
||||
|
||||
/// 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),
|
||||
|
||||
#[error("Failed to construct response: {0}")]
|
||||
ResponseError(#[from] http::Error),
|
||||
/// The asset resolver failed to complete and could not be joined.
|
||||
#[cfg(feature = "native")]
|
||||
#[error("Asset resolver join failed: {0}")]
|
||||
JoinError(tokio::task::JoinError),
|
||||
}
|
||||
|
||||
/// 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<Response<Vec<u8>>, 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)?);
|
||||
}
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(String::from("Not Found").into_bytes())?)
|
||||
/// An error that occurs when resolving an asset on the web.
|
||||
pub struct WebAssetResolveError {
|
||||
#[cfg(feature = "web")]
|
||||
error: js_sys::Error,
|
||||
}
|
||||
|
||||
/// 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)),
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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",
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) fn to_java_load_asset(filepath: &str) -> Option<Vec<u8>> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
impl std::error::Error for WebAssetResolveError {}
|
||||
@@ -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<PathBuf, AssetPathError> {
|
||||
#[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<PathBuf> {
|
||||
// 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<u8>,
|
||||
}
|
||||
|
||||
impl ResolvedAsset {
|
||||
fn new(mime_type: &'static str, body: Vec<u8>) -> Self {
|
||||
Self { mime_type, body }
|
||||
}
|
||||
|
||||
fn into_response(self) -> Result<Response<Vec<u8>>, 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<Vec<u8>, 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<Option<ResolvedAsset>, 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<Response<Vec<u8>>, 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<Vec<u8>> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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<js_sys::Error> 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::<js_sys::Error>() {
|
||||
WebAssetResolveError::from(error.clone())
|
||||
} else {
|
||||
unreachable!("Expected a js_sys::Error, got: {:?}", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_web_asset(path: &str) -> Result<Vec<u8>, 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::<Response>();
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")))]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -33,7 +33,7 @@ impl NetProvider<Resource> for DioxusNativeNetProvider {
|
||||
handler: blitz_traits::net::BoxedHandler<Resource>,
|
||||
) {
|
||||
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())
|
||||
|
||||
@@ -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]"
|
||||
);
|
||||
});
|
||||
@@ -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"] }
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"list": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
}
|
||||
@@ -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<i32>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn LoadsAsset() -> Element {
|
||||
let data = use_resource(|| async {
|
||||
let bytes = dioxus::asset_resolver::read_asset_bytes(&JSON)
|
||||
.await
|
||||
.unwrap();
|
||||
serde_json::from_slice::<Data>(&bytes).unwrap()
|
||||
});
|
||||
match data() {
|
||||
Some(data) => rsx! {
|
||||
div {
|
||||
id: "resolved-data",
|
||||
"List: {data.list:?}"
|
||||
}
|
||||
},
|
||||
None => rsx! {
|
||||
div {
|
||||
"Loading..."
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user