diff --git a/packages/cli-opt/src/lib.rs b/packages/cli-opt/src/lib.rs index b84252a21..45c8c84f8 100644 --- a/packages/cli-opt/src/lib.rs +++ b/packages/cli-opt/src/lib.rs @@ -3,7 +3,7 @@ use manganis::AssetOptions; use manganis_core::BundledAsset; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; @@ -25,7 +25,7 @@ pub use hash::add_hash_to_asset; #[derive(Debug, PartialEq, Default, Clone, Serialize, Deserialize)] pub struct AssetManifest { /// Map of bundled asset name to the asset itself - assets: HashMap>, + assets: BTreeMap>, } impl AssetManifest { diff --git a/packages/cli/Dioxus.toml b/packages/cli/Dioxus.toml index 02b61ba69..103fe1d9c 100644 --- a/packages/cli/Dioxus.toml +++ b/packages/cli/Dioxus.toml @@ -6,8 +6,8 @@ name = "project_name" # `build` & `serve` output path out_dir = "dist" -# The static resource path -asset_dir = "public" +# Static files copied verbatim into the built app (set to enable, e.g. "public") +public_dir = "public" [web.app] diff --git a/packages/cli/README.md b/packages/cli/README.md index 5913d9f2c..472622588 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -60,9 +60,11 @@ name = "project-name" # Currently supported platforms: web, desktop default_platform = "web" +# Optional: enable copying from a static directory (e.g. "public") +public_dir = "public" + [web.app] title = "Hello" [web.resource.dev] ``` - diff --git a/packages/cli/src/build/manifest.rs b/packages/cli/src/build/manifest.rs new file mode 100644 index 000000000..2d1d3bd26 --- /dev/null +++ b/packages/cli/src/build/manifest.rs @@ -0,0 +1,24 @@ +//! The build manifest for `dx` applications, containing metadata about the build including +//! the CLI version, Rust version, and all bundled assets. +//! +//! We eventually plan to use this manifest to support tighter integration with deployment platforms +//! and CDNs. +//! +//! This manifest contains the list of assets, rust version, and cli version used to build the app. +//! Eventually, we might want to expand this to include more metadata about the build, including +//! build time, target platform, etc. + +use dioxus_cli_opt::AssetManifest; +use serde::{Deserialize, Serialize}; + +#[derive(Default, Serialize, Deserialize)] +pub struct AppManifest { + /// Stable since 0.7.0 + pub cli_version: String, + + /// Stable since 0.7.0 + pub rust_version: String, + + /// Stable since 0.7.0 + pub assets: AssetManifest, +} diff --git a/packages/cli/src/build/mod.rs b/packages/cli/src/build/mod.rs index a31ba8dbf..c1cf1fcfa 100644 --- a/packages/cli/src/build/mod.rs +++ b/packages/cli/src/build/mod.rs @@ -11,6 +11,7 @@ mod assets; mod builder; mod context; +mod manifest; mod patch; mod pre_render; mod request; @@ -19,6 +20,7 @@ mod tools; pub(crate) use assets::*; pub(crate) use builder::*; pub(crate) use context::*; +pub(crate) use manifest::*; pub(crate) use patch::*; pub(crate) use pre_render::*; pub(crate) use request::*; diff --git a/packages/cli/src/build/request.rs b/packages/cli/src/build/request.rs index 52db3b3ac..a95babef4 100644 --- a/packages/cli/src/build/request.rs +++ b/packages/cli/src/build/request.rs @@ -320,9 +320,9 @@ //! - xbuild: use crate::{ - AndroidTools, BuildContext, BuildId, BundleFormat, DioxusConfig, Error, LinkAction, - LinkerFlavor, Platform, Renderer, Result, RustcArgs, TargetArgs, TraceSrc, WasmBindgen, - WasmOptConfig, Workspace, DX_RUSTC_WRAPPER_ENV_VAR, + AndroidTools, AppManifest, BuildContext, BuildId, BundleFormat, DioxusConfig, Error, + LinkAction, LinkerFlavor, Platform, Renderer, Result, RustcArgs, TargetArgs, TraceSrc, + WasmBindgen, WasmOptConfig, Workspace, DX_RUSTC_WRAPPER_ENV_VAR, }; use anyhow::{bail, Context}; use cargo_metadata::diagnostic::Diagnostic; @@ -333,8 +333,8 @@ 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_core::AssetVariant; +use manganis::{AssetOptions, BundledAsset}; +use manganis_core::{AssetOptionsBuilder, AssetVariant}; use rayon::prelude::{IntoParallelRefIterator, ParallelIterator}; use serde::{Deserialize, Serialize}; use std::{borrow::Cow, ffi::OsString}; @@ -1295,13 +1295,40 @@ impl BuildRequest { /// Collect the assets from the final executable and modify the binary in place to point to the right /// hashed asset location. async fn collect_assets(&self, exe: &Path, ctx: &BuildContext) -> Result { - // walk every file in the incremental cache dir, reading and inserting items into the manifest. - let mut manifest = AssetManifest::default(); - // And then add from the exe directly, just in case it's LTO compiled and has no incremental cache - if !self.skip_assets { - ctx.status_extracting_assets(); - manifest = super::assets::extract_assets_from_file(exe).await?; + if self.skip_assets { + return Ok(AssetManifest::default()); + } + + ctx.status_extracting_assets(); + + let mut manifest = super::assets::extract_assets_from_file(exe).await?; + + // If the user has a public dir, we submit all the entries there as assets too + // + // These don't receive a hash in their filename, since they're user-provided static assets + // We only do this for web builds + if matches!(self.bundle, BundleFormat::Web) + && matches!(ctx.mode, BuildMode::Base { .. } | BuildMode::Fat) + { + if let Some(dir) = self.user_public_dir() { + for entry in walkdir::WalkDir::new(&dir) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + { + let from = entry.path().to_path_buf(); + let relative_path = from.strip_prefix(&dir).unwrap(); + let to = format!("../{}", relative_path.display()); + manifest.insert_asset(BundledAsset::new( + from.to_string_lossy().as_ref(), + to.as_str(), + AssetOptionsBuilder::new() + .with_hash_suffix(false) + .into_asset_options(), + )); + } + } } Ok(manifest) @@ -1494,14 +1521,13 @@ impl BuildRequest { .map(|a| asset_dir.join(a.bundled_path())) .collect(); - // The CLI creates a .version file in the asset dir to keep track of what version of the optimizer - // the asset was processed. If that version doesn't match the CLI version, we need to re-optimize - // all assets. - let version_file = self.asset_optimizer_version_file(); - let clear_cache = std::fs::read_to_string(&version_file) - .ok() - .filter(|s| s == crate::VERSION.as_str()) - .is_none(); + // The CLI creates a .manifest.json file in the asset dir to keep track of the assets and + // other build metadata. If we can't parse this file (or the CLI version changed), then we + // want to re-copy all the assets rather than trying to do an incremental update. + let clear_cache = self + .load_manifest() + .map(|manifest| manifest.cli_version != crate::VERSION.as_str()) + .unwrap_or(true); if clear_cache { keep_bundled_output_paths.clear(); } @@ -1511,20 +1537,6 @@ impl BuildRequest { keep_bundled_output_paths ); - // use walkdir::WalkDir; - // for item in WalkDir::new(&asset_dir).into_iter().flatten() { - // // If this asset is in the manifest, we don't need to remove it - // let canonicalized = dunce::canonicalize(item.path())?; - // if !keep_bundled_output_paths.contains(canonicalized.as_path()) { - // // Remove empty dirs, remove files not in the manifest - // if item.file_type().is_dir() && item.path().read_dir()?.next().is_none() { - // std::fs::remove_dir(item.path())?; - // } else { - // std::fs::remove_file(item.path())?; - // } - // } - // } - // todo(jon): we also want to eventually include options for each asset's optimization and compression, which we currently aren't let mut assets_to_transfer = vec![]; @@ -1589,7 +1601,7 @@ impl BuildRequest { } // Write the version file so we know what version of the optimizer we used - std::fs::write(self.asset_optimizer_version_file(), crate::VERSION.as_str())?; + self.write_app_manifest(assets).await?; Ok(()) } @@ -3116,8 +3128,18 @@ impl BuildRequest { } } + /// Create a workdir for the given platform + /// This can be used as a temporary directory for the build, but in an observable way such that + /// you can see the files in the directory via `target` + /// + /// target/dx/build/app/web/ + /// target/dx/build/app/web/public/ + /// target/dx/build/app/web/server.exe fn platform_dir(&self) -> PathBuf { - self.build_dir(self.bundle, self.release) + self.internal_out_dir() + .join(&self.main_target) + .join(if self.release { "release" } else { "debug" }) + .join(self.bundle.build_folder_name()) } fn platform_exe_name(&self) -> String { @@ -3416,20 +3438,6 @@ impl BuildRequest { dir } - /// Create a workdir for the given platform - /// This can be used as a temporary directory for the build, but in an observable way such that - /// you can see the files in the directory via `target` - /// - /// target/dx/build/app/web/ - /// target/dx/build/app/web/public/ - /// target/dx/build/app/web/server.exe - pub(crate) fn build_dir(&self, bundle: BundleFormat, release: bool) -> PathBuf { - self.internal_out_dir() - .join(&self.main_target) - .join(if release { "release" } else { "debug" }) - .join(bundle.build_folder_name()) - } - /// target/dx/bundle/app/ /// target/dx/bundle/app/blah.app /// target/dx/bundle/app/blah.exe @@ -4422,12 +4430,13 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ .join("main") .join("assets"), + // We put assets in public/assets for server apps + BundleFormat::Server => self.root_dir().join("public").join("assets"), + // everyone else is soooo normal, just app/assets :) - BundleFormat::Web - | BundleFormat::Ios - | BundleFormat::Windows - | BundleFormat::Linux - | BundleFormat::Server => self.root_dir().join("assets"), + BundleFormat::Web | BundleFormat::Ios | BundleFormat::Windows | BundleFormat::Linux => { + self.root_dir().join("assets") + } } } @@ -4482,9 +4491,21 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ .with_extension("wasm") } - /// Get the path to the asset optimizer version file - pub(crate) fn asset_optimizer_version_file(&self) -> PathBuf { - self.platform_dir().join(".cli-version") + /// Get the path to the app manifest file + /// + /// This includes metadata about the build such as the bundle format, target triple, features, etc. + /// Manifests are only written by the `PRIMARY` build. + pub(crate) fn app_manifest(&self) -> PathBuf { + self.platform_dir().join(".manifest.json") + } + + pub(crate) fn load_manifest(&self) -> Result { + let manifest_path = self.app_manifest(); + let manifest_data = std::fs::read_to_string(&manifest_path) + .with_context(|| format!("Failed to read manifest at {:?}", &manifest_path))?; + let manifest: AppManifest = serde_json::from_str(&manifest_data) + .with_context(|| format!("Failed to parse manifest at {:?}", &manifest_path))?; + Ok(manifest) } /// Check for tooling that might be required for this build. @@ -4882,6 +4903,34 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ } } + /// Resolve the configured public directory relative to the crate, if any. + pub(crate) fn user_public_dir(&self) -> Option { + let path = self.config.application.public_dir.as_ref()?; + + if path.as_os_str().is_empty() { + return None; + } + + Some(if path.is_absolute() { + path.clone() + } else { + self.crate_dir().join(path) + }) + } + + pub(crate) fn path_is_in_public_dir(&self, path: &Path) -> bool { + let Some(static_dir) = self.user_public_dir() else { + return false; + }; + + // Canonicalize when possible so we work with editors that use tmp files + let canonical_static = + dunce::canonicalize(&static_dir).unwrap_or_else(|_| static_dir.clone()); + let canonical_path = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + + canonical_path.starts_with(&canonical_static) + } + /// Get the base path from the config or None if this is not a web or server build pub(crate) fn base_path(&self) -> Option<&str> { self.base_path @@ -5289,4 +5338,17 @@ We checked the folders: TEAM_IDENTIFIER = mbfile.team_identifier[0], )) } + + async fn write_app_manifest(&self, assets: &AssetManifest) -> Result<()> { + let manifest = AppManifest { + assets: assets.clone(), + cli_version: crate::VERSION.to_string(), + rust_version: self.workspace.rustc_version.clone(), + }; + + let manifest_path = self.app_manifest(); + std::fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)?; + + Ok(()) + } } diff --git a/packages/cli/src/config/app.rs b/packages/cli/src/config/app.rs index 4ffde0a68..8e17d9c2b 100644 --- a/packages/cli/src/config/app.rs +++ b/packages/cli/src/config/app.rs @@ -6,6 +6,10 @@ pub(crate) struct ApplicationConfig { #[serde(default)] pub(crate) out_dir: Option, + #[serde(default = "public_dir_default")] + #[serde(deserialize_with = "empty_string_is_none")] + pub(crate) public_dir: Option, + #[serde(default)] pub(crate) tailwind_input: Option, @@ -43,3 +47,19 @@ pub(crate) struct ApplicationConfig { #[serde(default)] pub(crate) android_min_sdk_version: Option, } + +fn public_dir_default() -> Option { + Some("public".into()) +} + +fn empty_string_is_none<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let opt: Option = Option::deserialize(deserializer)?; + match opt { + Some(s) if s.is_empty() => Ok(None), + Some(s) => Ok(Some(PathBuf::from(s))), + None => Ok(None), + } +} diff --git a/packages/cli/src/config/dioxus_config.rs b/packages/cli/src/config/dioxus_config.rs index 30548d0b9..385457ce8 100644 --- a/packages/cli/src/config/dioxus_config.rs +++ b/packages/cli/src/config/dioxus_config.rs @@ -17,6 +17,7 @@ impl Default for DioxusConfig { Self { application: ApplicationConfig { out_dir: None, + public_dir: Some("public".into()), tailwind_input: None, tailwind_output: None, ios_info_plist: None, @@ -55,3 +56,42 @@ impl Default for DioxusConfig { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn static_dir_defaults_to_public() { + let config = DioxusConfig::default(); + assert_eq!( + config.application.public_dir, + Some(std::path::PathBuf::from("public")) + ); + } + + #[test] + fn static_dir_can_be_overridden() { + let source = r#" + [application] + public_dir = "public2" + "#; + + let config: DioxusConfig = toml::from_str(source).expect("parse config"); + assert_eq!( + config.application.public_dir.as_deref(), + Some(std::path::Path::new("public2")) + ); + } + + #[test] + fn static_dir_can_be_disabled() { + let source = r#" + [application] + public_dir = "" + "#; + + let config: DioxusConfig = toml::from_str(source).expect("parse config"); + assert_eq!(config.application.public_dir.as_deref(), None); + } +} diff --git a/packages/cli/src/serve/runner.rs b/packages/cli/src/serve/runner.rs index e34042b96..4d06ae713 100644 --- a/packages/cli/src/serve/runner.rs +++ b/packages/cli/src/serve/runner.rs @@ -379,6 +379,12 @@ impl AppServer { } } + // If it's in the public dir, we sync it and trigger a full rebuild + if self.client.build.path_is_in_public_dir(path) { + needs_full_rebuild = true; + continue; + } + // If it's a rust file, we want to hotreload it using the filemap if ext == "rs" { // And grabout the contents @@ -1028,6 +1034,15 @@ impl AppServer { let mut watched_crates = self.local_dependencies(crate_package); watched_crates.push(crate_dir); + // Watch the `public` directory if this is the client crate + if self.client.build.crate_package == crate_package { + if let Some(public_dir) = self.client.build.user_public_dir() { + if public_dir.exists() { + watched_paths.push(public_dir); + } + } + } + // Now, watch all the folders in the crates, but respecting their respective ignore files for krate_root in watched_crates { // Build the ignore builder for this crate, but with our default ignore list as well diff --git a/packages/const-serialize/src/lib.rs b/packages/const-serialize/src/lib.rs index ac27500d0..4cc5dcff1 100644 --- a/packages/const-serialize/src/lib.rs +++ b/packages/const-serialize/src/lib.rs @@ -222,32 +222,34 @@ const MAX_STR_SIZE: usize = 256; /// A string that is stored in a constant sized buffer that can be serialized and deserialized at compile time #[derive(Eq, PartialEq, PartialOrd, Clone, Copy, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct ConstStr { - #[cfg_attr(feature = "serde", serde(with = "serde_bytes"))] bytes: [u8; MAX_STR_SIZE], len: u32, } #[cfg(feature = "serde")] mod serde_bytes { - use serde::{Deserialize, Serializer}; + use serde::{Deserialize, Serialize, Serializer}; - pub fn serialize(bytes: &[u8], serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_bytes(bytes) + use crate::ConstStr; + + impl Serialize for ConstStr { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } } - pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; super::MAX_STR_SIZE], D::Error> - where - D: serde::Deserializer<'de>, - { - let bytes = Vec::::deserialize(deserializer)?; - bytes - .try_into() - .map_err(|_| serde::de::Error::custom("Failed to convert bytes to a fixed size array")) + impl<'de> Deserialize<'de> for ConstStr { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(ConstStr::new(&s)) + } } }