From 47a829e6796a8b949d126cae124aec605686fb53 Mon Sep 17 00:00:00 2001 From: "Amar Sood (tekacs)" Date: Sun, 12 Oct 2025 17:48:18 -0400 Subject: [PATCH] Support custom asset mount paths - add optional mount_path to manganis AssetOptions + builders - have CLI honor mount paths when copying assets and generating URLs - update HTML preload injection, docs, and hotreload notifications - honor AssetOptions mount_path across every bundle type - serve native assets from mount directories via resolver fallback - ensure Android hotreload pushes files into mount-aware locations Tests: cargo test -p manganis-core; cargo check -p dioxus-cli --- packages/asset-resolver/src/native.rs | 17 +++- packages/cli/src/build/builder.rs | 41 +++++--- packages/cli/src/build/request.rs | 95 +++++++++++++++++-- packages/cli/src/serve/runner.rs | 2 +- packages/manganis/manganis-core/src/asset.rs | 44 +++++---- packages/manganis/manganis-core/src/css.rs | 11 ++- .../manganis/manganis-core/src/css_module.rs | 9 +- packages/manganis/manganis-core/src/folder.rs | 10 +- packages/manganis/manganis-core/src/images.rs | 10 +- packages/manganis/manganis-core/src/js.rs | 10 +- .../manganis/manganis-core/src/options.rs | 61 +++++++++++- packages/manganis/manganis/README.md | 17 ++++ 12 files changed, 274 insertions(+), 53 deletions(-) diff --git a/packages/asset-resolver/src/native.rs b/packages/asset-resolver/src/native.rs index 55b2b43a9e..ecd61b00df 100644 --- a/packages/asset-resolver/src/native.rs +++ b/packages/asset-resolver/src/native.rs @@ -40,7 +40,7 @@ pub(crate) fn resolve_native_asset_path(path: &str) -> Result 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( + let uri_path = PathBuf::from( percent_encoding::percent_decode_str(path) .decode_utf8() .expect("expected URL to be UTF-8 encoded") @@ -55,8 +55,19 @@ fn resolve_asset_path_from_filesystem(path: &str) -> Option { // 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); + let relative_path = uri_path.strip_prefix("/").unwrap_or(&uri_path); + + // First attempt: resolve directly relative to the bundle root (eg `.well-known/...`). + let direct_candidate = bundle_root.join(relative_path); + if direct_candidate.exists() { + return Some(direct_candidate); + } + + // Fallback: look inside the conventional `assets/` directory. + let assets_candidate = bundle_root.join("assets").join(relative_path); + if assets_candidate.exists() { + return Some(assets_candidate); + } } // If the asset exists, return it diff --git a/packages/cli/src/build/builder.rs b/packages/cli/src/build/builder.rs index efaf87d5c8..bae8026cef 100644 --- a/packages/cli/src/build/builder.rs +++ b/packages/cli/src/build/builder.rs @@ -645,8 +645,6 @@ impl AppBuilder { let original = self.build.main_exe(); let new = self.build.patch_exe(res.time_start); let triple = self.build.triple.clone(); - let asset_dir = self.build.asset_dir(); - // Hotpatch asset!() calls for bundled in res.assets.unique_assets() { let original_artifacts = self @@ -663,7 +661,7 @@ impl AppBuilder { let from = dunce::canonicalize(PathBuf::from(bundled.absolute_source_path()))?; - let to = asset_dir.join(bundled.bundled_path()); + let to = self.build.asset_destination_path(bundled); tracing::debug!("Copying asset from patch: {}", from.display()); if let Err(e) = dioxus_cli_opt::process_file_to(bundled.options(), &from, &to) { @@ -756,11 +754,6 @@ impl AppBuilder { // Use the build dir if there's no runtime asset dir as the override. For the case of ios apps, // we won't actually be using the build dir. - let asset_dir = match self.runtime_asset_dir.as_ref() { - Some(dir) => dir.to_path_buf().join("assets/"), - None => self.build.asset_dir(), - }; - // Canonicalize the path as Windows may use long-form paths "\\\\?\\C:\\". let changed_file = dunce::canonicalize(changed_file) .inspect_err(|e| tracing::debug!("Failed to canonicalize hotreloaded asset: {e}")) @@ -770,9 +763,19 @@ impl AppBuilder { let resources = artifacts.assets.get_assets_for_source(&changed_file)?; let mut bundled_names = Vec::new(); for resource in resources { - let output_path = asset_dir.join(resource.bundled_path()); + let output_path = if let Some(runtime_dir) = self.runtime_asset_dir.as_ref() { + let mut base = runtime_dir.to_path_buf().join("assets"); + if let Some(mount) = self.build.normalized_mount_path(resource.options()) { + if !mount.as_os_str().is_empty() { + base = base.join(mount); + } + } + base.join(resource.bundled_path()) + } else { + self.build.asset_destination_path(resource) + }; - tracing::debug!("Hotreloading asset {changed_file:?} in target {asset_dir:?}"); + tracing::debug!("Hotreloading asset {changed_file:?} into {output_path:?}"); // Remove the old asset if it exists _ = std::fs::remove_file(&output_path); @@ -782,15 +785,27 @@ impl AppBuilder { // hotreloading, we need to use the old asset location it was originally written to. let options = *resource.options(); let res = process_file_to(&options, &changed_file, &output_path); - let bundled_name = PathBuf::from(resource.bundled_path()); + let bundled_name = self.build.asset_public_path(resource); if let Err(e) = res { tracing::debug!("Failed to hotreload asset {e}"); } // If the emulator is android, we need to copy the asset to the device with `adb push asset /data/local/tmp/dx/assets/filename.ext` if self.build.bundle == BundleFormat::Android { - _ = self - .copy_file_to_android_tmp(&changed_file, &bundled_name) + let mut android_relative = bundled_name.clone(); + while android_relative.has_root() { + android_relative = android_relative + .strip_prefix(Path::new("/")) + .unwrap_or(android_relative.as_path()) + .to_path_buf(); + } + + if let Ok(stripped) = android_relative.strip_prefix("assets") { + android_relative = stripped.to_path_buf(); + } + + let _ = self + .copy_file_to_android_tmp(&changed_file, &android_relative) .await; } bundled_names.push(bundled_name); diff --git a/packages/cli/src/build/request.rs b/packages/cli/src/build/request.rs index 178dc26f4c..5d984e3f7f 100644 --- a/packages/cli/src/build/request.rs +++ b/packages/cli/src/build/request.rs @@ -333,7 +333,7 @@ use dioxus_cli_config::{APP_TITLE_ENV, ASSET_ROOT_ENV}; use dioxus_cli_opt::{process_file_to, AssetManifest}; use itertools::Itertools; use krates::{cm::TargetKind, NodeId}; -use manganis::AssetOptions; +use manganis::{AssetOptions, BundledAsset}; use manganis_core::AssetVariant; use rayon::prelude::{IntoParallelRefIterator, ParallelIterator}; use serde::{Deserialize, Serialize}; @@ -341,7 +341,7 @@ use std::{borrow::Cow, ffi::OsString}; use std::{ collections::{BTreeMap, HashSet}, io::Write, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, process::Stdio, sync::{ atomic::{AtomicUsize, Ordering}, @@ -1471,7 +1471,7 @@ impl BuildRequest { // Create a set of all the paths that new files will be bundled to let mut keep_bundled_output_paths: HashSet<_> = assets .unique_assets() - .map(|a| asset_dir.join(a.bundled_path())) + .map(|asset| self.asset_destination_path(asset)) .collect(); // The CLI creates a .version file in the asset dir to keep track of what version of the optimizer @@ -1511,7 +1511,7 @@ impl BuildRequest { // Queue the bundled assets for bundled in assets.unique_assets() { let from = PathBuf::from(bundled.absolute_source_path()); - let to = asset_dir.join(bundled.bundled_path()); + let to = self.asset_destination_path(bundled); // prefer to log using a shorter path relative to the workspace dir by trimming the workspace dir let from_ = from @@ -4409,6 +4409,84 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ } } + pub(crate) fn normalized_mount_path(&self, options: &AssetOptions) -> Option { + let raw = options.mount_path()?; + + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Some(PathBuf::new()); + } + + let slashes_trimmed = trimmed.trim_matches('/'); + if slashes_trimmed.is_empty() { + return Some(PathBuf::new()); + } + + let candidate = PathBuf::from(slashes_trimmed); + if candidate + .components() + .any(|component| matches!(component, Component::ParentDir | Component::Prefix(_))) + { + tracing::warn!("Ignoring invalid asset mount path {raw:?}; falling back to /assets"); + return None; + } + + Some(candidate) + } + + pub(crate) fn asset_destination_path(&self, bundled: &BundledAsset) -> PathBuf { + if let Some(mount) = self.normalized_mount_path(bundled.options()) { + let mut base = if self.bundle == BundleFormat::Web { + self.root_dir() + } else { + self.asset_dir() + }; + + if !mount.as_os_str().is_empty() { + base = base.join(&mount); + } + + base.join(bundled.bundled_path()) + } else { + self.asset_dir().join(bundled.bundled_path()) + } + } + + pub(crate) fn asset_public_path(&self, bundled: &BundledAsset) -> PathBuf { + let mount = self.normalized_mount_path(bundled.options()); + + if self.bundle == BundleFormat::Web { + let mut path = PathBuf::from("/"); + if let Some(base) = self.base_path() { + let trimmed = base.trim_matches('/'); + if !trimmed.is_empty() { + path = path.join(trimmed); + } + } + + if let Some(ref mount_path) = mount { + if !mount_path.as_os_str().is_empty() { + path = path.join(mount_path); + } + } else { + path = path.join("assets"); + } + + return path.join(bundled.bundled_path()); + } + + let mut path = PathBuf::from("/"); + if let Some(ref mount_path) = mount { + if !mount_path.as_os_str().is_empty() { + path = path.join(mount_path); + } + } else { + path = path.join("assets"); + } + + path.join(bundled.bundled_path()) + } + /// The directory in which we'll put the main exe /// /// Mac, Android, Web are a little weird @@ -4777,26 +4855,27 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ // Inject any resources from manganis into the head for asset in assets.unique_assets() { - let asset_path = asset.bundled_path(); + let public_path = self.asset_public_path(asset); + let public_path = public_path.to_string_lossy().replace('\\', "/"); match asset.options().variant() { AssetVariant::Css(css_options) => { if css_options.preloaded() { head_resources.push_str(&format!( - "" + "" )) } } AssetVariant::Image(image_options) => { if image_options.preloaded() { head_resources.push_str(&format!( - "" + "" )) } } AssetVariant::Js(js_options) => { if js_options.preloaded() { head_resources.push_str(&format!( - "" + "" )) } } diff --git a/packages/cli/src/serve/runner.rs b/packages/cli/src/serve/runner.rs index e34042b969..d9698ec3dc 100644 --- a/packages/cli/src/serve/runner.rs +++ b/packages/cli/src/serve/runner.rs @@ -375,7 +375,7 @@ impl AppServer { // todo(jon): don't hardcode this here if let Some(bundled_names) = self.client.hotreload_bundled_assets(path).await { for bundled_name in bundled_names { - assets.push(PathBuf::from("/assets/").join(bundled_name)); + assets.push(bundled_name); } } diff --git a/packages/manganis/manganis-core/src/asset.rs b/packages/manganis/manganis-core/src/asset.rs index 92c543599a..9c7f0f3e18 100644 --- a/packages/manganis/manganis-core/src/asset.rs +++ b/packages/manganis/manganis-core/src/asset.rs @@ -155,25 +155,35 @@ impl Asset { return PathBuf::from(self.bundled().absolute_source_path.as_str()); } + let mut bundle_root = PathBuf::from("/"); + #[cfg(feature = "dioxus")] - let bundle_root = { + { let base_path = dioxus_cli_config::base_path(); - let base_path = base_path - .as_deref() - .map(|base_path| { - let trimmed = base_path.trim_matches('/'); - format!("/{trimmed}") - }) - .unwrap_or_default(); - PathBuf::from(format!("{base_path}/assets/")) - }; - #[cfg(not(feature = "dioxus"))] - let bundle_root = PathBuf::from("/assets/"); - - // Otherwise presumably we're bundled and we can use the bundled path - bundle_root.join(PathBuf::from( - self.bundled().bundled_path.as_str().trim_start_matches('/'), - )) + if let Some(base) = base_path.as_deref() { + let trimmed = base.trim_matches('/'); + if !trimmed.is_empty() { + bundle_root = bundle_root.join(trimmed); + } + } + } + + let bundled = self.bundled(); + let bundled_options = bundled.options(); + + match bundled_options.mount_path() { + Some(raw_mount) => { + let trimmed = raw_mount.trim_matches('/'); + if !trimmed.is_empty() { + bundle_root = bundle_root.join(trimmed); + } + } + None => { + bundle_root = bundle_root.join("assets"); + } + } + + bundle_root.join(bundled.bundled_path().trim_start_matches('/')) } } diff --git a/packages/manganis/manganis-core/src/css.rs b/packages/manganis/manganis-core/src/css.rs index 67166409da..dc9afd7d3c 100644 --- a/packages/manganis/manganis-core/src/css.rs +++ b/packages/manganis/manganis-core/src/css.rs @@ -1,5 +1,5 @@ use crate::{AssetOptions, AssetOptionsBuilder, AssetVariant}; -use const_serialize::SerializeConst; +use const_serialize::{ConstStr, SerializeConst}; /// Options for a css asset #[derive( @@ -91,9 +91,16 @@ impl AssetOptionsBuilder { /// Convert the options into options for a generic asset pub const fn into_asset_options(self) -> AssetOptions { + let (mount_path, has_mount_path) = match self.mount_path { + Some(path) => (ConstStr::new(path), true), + None => (ConstStr::new(""), false), + }; + AssetOptions { - add_hash: true, + add_hash: self.add_hash, variant: AssetVariant::Css(self.variant), + mount_path, + has_mount_path, } } } diff --git a/packages/manganis/manganis-core/src/css_module.rs b/packages/manganis/manganis-core/src/css_module.rs index f46ec41c23..69862e2a5a 100644 --- a/packages/manganis/manganis-core/src/css_module.rs +++ b/packages/manganis/manganis-core/src/css_module.rs @@ -1,5 +1,5 @@ use crate::{AssetOptions, AssetOptionsBuilder, AssetVariant}; -use const_serialize::SerializeConst; +use const_serialize::{ConstStr, SerializeConst}; use std::collections::HashSet; /// Options for a css module asset @@ -84,9 +84,16 @@ impl AssetOptionsBuilder { /// Convert the options into options for a generic asset pub const fn into_asset_options(self) -> AssetOptions { + let (mount_path, has_mount_path) = match self.mount_path { + Some(path) => (ConstStr::new(path), true), + None => (ConstStr::new(""), false), + }; + AssetOptions { add_hash: self.add_hash, variant: AssetVariant::CssModule(self.variant), + mount_path, + has_mount_path, } } } diff --git a/packages/manganis/manganis-core/src/folder.rs b/packages/manganis/manganis-core/src/folder.rs index 6dbfb6851c..7e5e415a66 100644 --- a/packages/manganis/manganis-core/src/folder.rs +++ b/packages/manganis/manganis-core/src/folder.rs @@ -1,6 +1,5 @@ -use const_serialize::SerializeConst; - use crate::{AssetOptions, AssetOptionsBuilder}; +use const_serialize::{ConstStr, SerializeConst}; /// The builder for a folder asset. #[derive( @@ -50,9 +49,16 @@ impl AssetOptions { impl AssetOptionsBuilder { /// Convert the options into options for a generic asset pub const fn into_asset_options(self) -> AssetOptions { + let (mount_path, has_mount_path) = match self.mount_path { + Some(path) => (ConstStr::new(path), true), + None => (ConstStr::new(""), false), + }; + AssetOptions { add_hash: false, variant: crate::AssetVariant::Folder(self.variant), + mount_path, + has_mount_path, } } } diff --git a/packages/manganis/manganis-core/src/images.rs b/packages/manganis/manganis-core/src/images.rs index f3a4f00a36..6101e9b6cf 100644 --- a/packages/manganis/manganis-core/src/images.rs +++ b/packages/manganis/manganis-core/src/images.rs @@ -1,6 +1,5 @@ -use const_serialize::SerializeConst; - use crate::{AssetOptions, AssetOptionsBuilder, AssetVariant}; +use const_serialize::{ConstStr, SerializeConst}; /// The type of an image. You can read more about the tradeoffs between image formats [here](https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types) #[derive( @@ -227,9 +226,16 @@ impl AssetOptionsBuilder { /// Convert the options into options for a generic asset pub const fn into_asset_options(self) -> AssetOptions { + let (mount_path, has_mount_path) = match self.mount_path { + Some(path) => (ConstStr::new(path), true), + None => (ConstStr::new(""), false), + }; + AssetOptions { add_hash: self.add_hash, variant: AssetVariant::Image(self.variant), + mount_path, + has_mount_path, } } } diff --git a/packages/manganis/manganis-core/src/js.rs b/packages/manganis/manganis-core/src/js.rs index 8ac9613d6d..02b0f04b1e 100644 --- a/packages/manganis/manganis-core/src/js.rs +++ b/packages/manganis/manganis-core/src/js.rs @@ -1,6 +1,5 @@ -use const_serialize::SerializeConst; - use crate::{AssetOptions, AssetOptionsBuilder, AssetVariant}; +use const_serialize::{ConstStr, SerializeConst}; /// Options for a javascript asset #[derive( @@ -94,9 +93,16 @@ impl AssetOptionsBuilder { /// Convert the builder into asset options with the given variant pub const fn into_asset_options(self) -> AssetOptions { + let (mount_path, has_mount_path) = match self.mount_path { + Some(path) => (ConstStr::new(path), true), + None => (ConstStr::new(""), false), + }; + AssetOptions { add_hash: self.add_hash, variant: AssetVariant::Js(self.variant), + mount_path, + has_mount_path, } } } diff --git a/packages/manganis/manganis-core/src/options.rs b/packages/manganis/manganis-core/src/options.rs index dd383ab4d8..b7b92232b1 100644 --- a/packages/manganis/manganis-core/src/options.rs +++ b/packages/manganis/manganis-core/src/options.rs @@ -1,4 +1,4 @@ -use const_serialize::SerializeConst; +use const_serialize::{ConstStr, SerializeConst}; use crate::{ CssAssetOptions, CssModuleAssetOptions, FolderAssetOptions, ImageAssetOptions, JsAssetOptions, @@ -23,6 +23,9 @@ pub struct AssetOptions { pub(crate) add_hash: bool, /// The variant of the asset pub(crate) variant: AssetVariant, + /// Mount path relative to the bundle root + pub(crate) mount_path: ConstStr, + pub(crate) has_mount_path: bool, } impl AssetOptions { @@ -41,6 +44,14 @@ impl AssetOptions { self.add_hash } + pub const fn mount_path(&self) -> Option<&str> { + if self.has_mount_path { + Some(self.mount_path.as_str()) + } else { + None + } + } + /// Try to get the extension for the asset. If the asset options don't define an extension, this will return None pub const fn extension(&self) -> Option<&'static str> { match self.variant { @@ -72,6 +83,8 @@ impl AssetOptions { pub struct AssetOptionsBuilder { /// If a hash should be added to the asset path pub(crate) add_hash: bool, + /// Optional mount path relative to the bundle root + pub(crate) mount_path: Option<&'static str>, /// The variant of the asset pub(crate) variant: T, } @@ -87,6 +100,7 @@ impl AssetOptionsBuilder<()> { pub const fn new() -> Self { Self { add_hash: true, + mount_path: None, variant: (), } } @@ -98,9 +112,16 @@ impl AssetOptionsBuilder<()> { /// Convert the builder into asset options with the given variant pub const fn into_asset_options(self) -> AssetOptions { + let (mount_path, has_mount_path) = match self.mount_path { + Some(path) => (ConstStr::new(path), true), + None => (ConstStr::new(""), false), + }; + AssetOptions { add_hash: self.add_hash, variant: AssetVariant::Unknown, + mount_path, + has_mount_path, } } } @@ -110,6 +131,7 @@ impl AssetOptionsBuilder { pub(crate) const fn variant(variant: T) -> Self { Self { add_hash: true, + mount_path: None, variant, } } @@ -127,7 +149,7 @@ impl AssetOptionsBuilder { /// If you are using an asset outside of rust code where you know what the asset hash will be, you must use the /// `#[used]` attribute to ensure the asset is included in the binary even if it is not referenced in the code. /// - /// ```rust + /// ```rust,ignore /// #[used] /// static ASSET: manganis::Asset = manganis::asset!( /// "/assets/style.css", @@ -141,6 +163,22 @@ impl AssetOptionsBuilder { self.add_hash = add_hash; self } + + /// Override the mount path for the bundled asset relative to the bundle root. + /// + /// ```rust,ignore + /// # use manganis::{asset, Asset, AssetOptions}; + /// const _: Asset = asset!( + /// "/assets/security.txt", + /// AssetOptions::builder() + /// .with_hash_suffix(false) + /// .with_mount_path("/.well-known") + /// ); + /// ``` + pub const fn with_mount_path(mut self, mount_path: &'static str) -> Self { + self.mount_path = Some(mount_path); + self + } } /// Settings for a specific type of asset @@ -172,3 +210,22 @@ pub enum AssetVariant { /// An unknown asset Unknown, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_builder_has_no_mount_path() { + let opts = AssetOptions::builder().into_asset_options(); + assert!(opts.mount_path().is_none()); + } + + #[test] + fn builder_with_mount_path_is_preserved() { + let opts = AssetOptions::builder() + .with_mount_path("/.well-known") + .into_asset_options(); + assert_eq!(opts.mount_path(), Some("/.well-known")); + } +} diff --git a/packages/manganis/manganis/README.md b/packages/manganis/manganis/README.md index 81a5486dd7..98a952be57 100644 --- a/packages/manganis/manganis/README.md +++ b/packages/manganis/manganis/README.md @@ -26,6 +26,23 @@ pub const RESIZED_PNG_ASSET: Asset = pub const AVIF_ASSET: Asset = asset!("/assets/image.png", AssetOptions::image().with_format(ImageFormat::Avif)); ``` +## Custom mount paths + +By default, bundled assets are emitted under `/assets/…`. You can override the served location with `with_mount_path`, which is especially useful for well-known files: + +```rust +use manganis::{asset, Asset, AssetOptions}; + +const SECURITY_TXT: Asset = asset!( + "/assets/security.txt", + AssetOptions::builder() + .with_hash_suffix(false) + .with_mount_path("/.well-known") +); +``` + +When paired with the updated CLI, the file above will be written to `/.well-known/security.txt` in web builds, and requests to that URL will resolve correctly at runtime. + ## Adding Support to Your CLI To add support for your CLI, you need to integrate with the [manganis_cli_support](https://github.com/DioxusLabs/manganis/tree/main/cli-support) crate. This crate provides utilities to collect assets that integrate with the Manganis macro. It makes it easy to integrate an asset collection and optimization system into a build tool.