diff --git a/packages/cli/src/build/assets.rs b/packages/cli/src/build/assets.rs index 96faf4d048..89cd7f1a5b 100644 --- a/packages/cli/src/build/assets.rs +++ b/packages/cli/src/build/assets.rs @@ -293,7 +293,7 @@ fn find_wasm_symbol_offsets<'a, R: ReadRef<'a>>( /// Find all assets in the given file, hash them, and write them back to the file. /// Then return an `AssetManifest` containing all the assets found in the file. -pub(crate) fn extract_assets_from_file(path: impl AsRef) -> Result { +pub fn extract_assets_from_file(path: impl AsRef) -> Result { let path = path.as_ref(); let mut file = std::fs::File::options().write(true).read(true).open(path)?; let mut file_contents = Vec::new(); diff --git a/packages/cli/src/build/ejected_assets.rs b/packages/cli/src/build/ejected_assets.rs new file mode 100644 index 0000000000..03cd740ff6 --- /dev/null +++ b/packages/cli/src/build/ejected_assets.rs @@ -0,0 +1,106 @@ +use std::path::{Path, PathBuf}; + +/// Utility struct for working with ejected assets +pub struct EjectedAssets { + project_dir: PathBuf, +} + +impl EjectedAssets { + /// Create a new EjectedAssets instance with a specific project directory + pub fn with_project_dir(project_dir: PathBuf) -> Self { + Self { project_dir } + } + + /// Get the ejected path for an asset if it exists + pub fn get_ejected_path(&self, asset_path: &str) -> Option { + self.resolve_asset_path(asset_path) + } + + /// Resolve the asset path + pub fn resolve_asset_path(&self, asset_path: &str) -> Option { + if asset_path.contains("android") { + if let Some(android_dir) = Self::android_assets_dir(&self.project_dir) { + let relative_path = asset_path.split('/').last()?; + let ejected_path = android_dir.join(relative_path); + if ejected_path.exists() { + tracing::info!(dx_src = ?crate::logging::TraceSrc::Dev, "Using ejected Android asset: {}", ejected_path.display()); + return Some(ejected_path); + } + } + } else if asset_path.contains("ios") { + if let Some(ios_dir) = Self::ios_assets_dir(&self.project_dir) { + let relative_path = asset_path.split('/').last()?; + let ejected_path = ios_dir.join(relative_path); + if ejected_path.exists() { + tracing::info!(dx_src = ?crate::logging::TraceSrc::Dev, "Using ejected iOS asset: {}", ejected_path.display()); + return Some(ejected_path); + } + } + } + + tracing::info!(dx_src = ?crate::logging::TraceSrc::Dev, "Using internal asset: {}", asset_path); + + None + } + + /// Check if there are ejected assets in the project directory + pub fn has_ejected_assets(project_dir: &Path) -> bool { + Self::android_assets_dir(project_dir).is_some() || Self::ios_assets_dir(project_dir).is_some() + } + + /// Check if there are ejected Android assets in the project directory + pub fn android_assets_dir(project_dir: &Path) -> Option { + // First check for root-level android folder + let android_dir_root = project_dir.join("android"); + if android_dir_root.exists() && android_dir_root.is_dir() { + return Some(android_dir_root); + } + + // Fall back to assets/android for backward compatibility + let android_dir = project_dir.join("assets").join("android"); + if android_dir.exists() && android_dir.is_dir() { + Some(android_dir) + } else { + None + } + } + + /// Check if there are ejected iOS assets in the project directory + pub fn ios_assets_dir(project_dir: &Path) -> Option { + // First check for root-level ios folder + let ios_dir_root = project_dir.join("ios"); + if ios_dir_root.exists() && ios_dir_root.is_dir() { + return Some(ios_dir_root); + } + + // Fall back to assets/ios for backward compatibility + let ios_dir = project_dir.join("assets").join("ios"); + if ios_dir.exists() && ios_dir.is_dir() { + Some(ios_dir) + } else { + None + } + } + + /// Get the ejected assets directory for a specific platform + /// + /// # Arguments + /// + /// * `project_dir` - The project directory + /// * `platform` - The platform name ("android" or "ios") + pub fn platform_assets_dir(project_dir: &Path, platform: &str) -> Option { + // First check for root-level platform folder + let platform_dir_root = project_dir.join(platform); + if platform_dir_root.exists() && platform_dir_root.is_dir() { + return Some(platform_dir_root); + } + + // Fall back to assets/platform for backward compatibility + let platform_dir = project_dir.join("assets").join(platform); + if platform_dir.exists() && platform_dir.is_dir() { + Some(platform_dir) + } else { + None + } + } +} diff --git a/packages/cli/src/build/mod.rs b/packages/cli/src/build/mod.rs index a31ba8dbfe..29c849c4d6 100644 --- a/packages/cli/src/build/mod.rs +++ b/packages/cli/src/build/mod.rs @@ -11,15 +11,17 @@ mod assets; mod builder; mod context; +pub mod ejected_assets; mod patch; mod pre_render; mod request; mod tools; -pub(crate) use assets::*; pub(crate) use builder::*; pub(crate) use context::*; +pub(crate) use ejected_assets::EjectedAssets; pub(crate) use patch::*; pub(crate) use pre_render::*; pub(crate) use request::*; pub(crate) use tools::*; +pub use assets::extract_assets_from_file; diff --git a/packages/cli/src/build/request.rs b/packages/cli/src/build/request.rs index 3d99651947..e023333985 100644 --- a/packages/cli/src/build/request.rs +++ b/packages/cli/src/build/request.rs @@ -2572,12 +2572,19 @@ impl BuildRequest { use std::fs::{create_dir_all, write}; let root = self.root_dir(); + // Get the project directory for ejected assets + let project_dir = self.workspace.workspace_root(); + let ejected_android_dir = project_dir.join("android"); + + tracing::info!(dx_src = ?crate::logging::TraceSrc::Dev, "Project directory: {}", project_dir.display()); + tracing::info!(dx_src = ?crate::logging::TraceSrc::Dev, "Checking for ejected Android assets at: {}", ejected_android_dir.display()); + // gradle let wrapper = root.join("gradle").join("wrapper"); create_dir_all(&wrapper)?; // app - let app = root.join("app"); + let app = self.android_app_dir(); let app_main = app.join("src").join("main"); let app_kotlin = app_main.join("kotlin"); let app_jnilibs = app_main.join("jniLibs"); @@ -2616,92 +2623,154 @@ impl BuildRequest { }; let hbs = handlebars::Handlebars::new(); + // Helper function to check for ejected file and use it if it exists + let copy_ejected_or_use_template = |rel_path: &str, + dest_path: &std::path::Path, + template_content: &[u8]| + -> Result<()> { + let ejected_path = ejected_android_dir.join(rel_path); + + if ejected_path.exists() { + tracing::info!(dx_src = ?crate::logging::TraceSrc::Dev, "Using ejected Android asset: {}", ejected_path.display()); + std::fs::copy(&ejected_path, dest_path)?; + } else { + tracing::info!(dx_src = ?crate::logging::TraceSrc::Dev, "Using internal Android asset template for: {}", rel_path); + write(dest_path, template_content)?; + } + Ok(()) + }; + + // Helper function for handlebars templates + let render_ejected_or_use_template = |rel_path: &str, + dest_path: &std::path::Path, + template_path: &str| + -> Result<()> { + let ejected_path = ejected_android_dir.join(rel_path); + + if ejected_path.exists() { + tracing::info!(dx_src = ?crate::logging::TraceSrc::Dev, "Using ejected Android asset: {}", ejected_path.display()); + let content = std::fs::read_to_string(&ejected_path).with_context(|| { + format!("Failed to read ejected file: {}", ejected_path.display()) + })?; + write(dest_path, content)?; + } else { + tracing::info!(dx_src = ?crate::logging::TraceSrc::Dev, "Using internal Android asset template for: {}", rel_path); + write(dest_path, hbs.render_template(template_path, &hbs_data)?)?; + } + Ok(()) + }; + // Top-level gradle config - write( - root.join("build.gradle.kts"), + copy_ejected_or_use_template( + "gen/build.gradle.kts", + &root.join("build.gradle.kts"), include_bytes!("../../assets/android/gen/build.gradle.kts"), )?; - write( - root.join("gradle.properties"), + + copy_ejected_or_use_template( + "gen/gradle.properties", + &root.join("gradle.properties"), include_bytes!("../../assets/android/gen/gradle.properties"), )?; - write( - root.join("gradlew"), + + copy_ejected_or_use_template( + "gen/gradlew", + &root.join("gradlew"), include_bytes!("../../assets/android/gen/gradlew"), )?; - write( - root.join("gradlew.bat"), + + copy_ejected_or_use_template( + "gen/gradlew.bat", + &root.join("gradlew.bat"), include_bytes!("../../assets/android/gen/gradlew.bat"), )?; - write( - root.join("settings.gradle"), + + copy_ejected_or_use_template( + "gen/settings.gradle", + &root.join("settings.gradle"), include_bytes!("../../assets/android/gen/settings.gradle"), )?; // Then the wrapper and its properties - write( - wrapper.join("gradle-wrapper.properties"), + copy_ejected_or_use_template( + "gen/gradle/wrapper/gradle-wrapper.properties", + &wrapper.join("gradle-wrapper.properties"), include_bytes!("../../assets/android/gen/gradle/wrapper/gradle-wrapper.properties"), )?; - write( - wrapper.join("gradle-wrapper.jar"), + + copy_ejected_or_use_template( + "gen/gradle/wrapper/gradle-wrapper.jar", + &wrapper.join("gradle-wrapper.jar"), include_bytes!("../../assets/android/gen/gradle/wrapper/gradle-wrapper.jar"), )?; // Now the app directory - write( - app.join("build.gradle.kts"), - hbs.render_template( - include_str!("../../assets/android/gen/app/build.gradle.kts.hbs"), - &hbs_data, - )?, + render_ejected_or_use_template( + "gen/app/build.gradle.kts", + &app.join("build.gradle.kts"), + include_str!("../../assets/android/gen/app/build.gradle.kts.hbs"), )?; - write( - app.join("proguard-rules.pro"), + + copy_ejected_or_use_template( + "gen/app/proguard-rules.pro", + &app.join("proguard-rules.pro"), include_bytes!("../../assets/android/gen/app/proguard-rules.pro"), )?; - let manifest_xml = match self.config.application.android_manifest.as_deref() { - Some(manifest) => std::fs::read_to_string(self.package_manifest_dir().join(manifest)) - .context("Failed to locate custom AndroidManifest.xml")?, - _ => hbs.render_template( - include_str!("../../assets/android/gen/app/src/main/AndroidManifest.xml.hbs"), - &hbs_data, - )?, - }; - - write( - app.join("src").join("main").join("AndroidManifest.xml"), - manifest_xml, - )?; + // Handle AndroidManifest.xml with special case for config-specified manifest + let manifest_dest = app.join("src").join("main").join("AndroidManifest.xml"); + let ejected_manifest = ejected_android_dir.join("gen/app/src/main/AndroidManifest.xml"); + + if ejected_manifest.exists() { + tracing::info!(dx_src = ?crate::logging::TraceSrc::Dev, "Using ejected AndroidManifest.xml: {}", ejected_manifest.display()); + let _ = std::fs::copy(&ejected_manifest, &manifest_dest)?; + } else if let Some(manifest) = self.config.application.android_manifest.as_deref() { + tracing::info!(dx_src = ?crate::logging::TraceSrc::Dev, "Using custom AndroidManifest.xml from config"); + let custom_manifest = + std::fs::read_to_string(self.package_manifest_dir().join(manifest)) + .context("Failed to locate custom AndroidManifest.xml")?; + write(&manifest_dest, custom_manifest)?; + } else { + tracing::info!(dx_src = ?crate::logging::TraceSrc::Dev, "Using internal AndroidManifest.xml template"); + write( + &manifest_dest, + hbs.render_template( + include_str!("../../assets/android/gen/app/src/main/AndroidManifest.xml.hbs"), + &hbs_data, + )?, + )?; + } - // Write the main activity manually since tao dropped support for it - write( - self.wry_android_kotlin_files_out_dir() + // Write the main activity using the render helper + render_ejected_or_use_template( + "MainActivity.kt", + &self + .wry_android_kotlin_files_out_dir() .join("MainActivity.kt"), - hbs.render_template( - include_str!("../../assets/android/MainActivity.kt.hbs"), - &hbs_data, - )?, + include_str!("../../assets/android/MainActivity.kt.hbs"), )?; // Write the res folder, containing stuff like default icons, colors, and menubars. let res = app_main.join("res"); create_dir_all(&res)?; create_dir_all(res.join("values"))?; - write( - res.join("values").join("strings.xml"), - hbs.render_template( - include_str!("../../assets/android/gen/app/src/main/res/values/strings.xml.hbs"), - &hbs_data, - )?, + + // Handle strings.xml with the render helper + render_ejected_or_use_template( + "gen/app/src/main/res/values/strings.xml", + &res.join("values").join("strings.xml"), + include_str!("../../assets/android/gen/app/src/main/res/values/strings.xml.hbs"), )?; - write( - res.join("values").join("colors.xml"), + // Handle colors.xml with the copy helper + copy_ejected_or_use_template( + "gen/app/src/main/res/values/colors.xml", + &res.join("values").join("colors.xml"), include_bytes!("../../assets/android/gen/app/src/main/res/values/colors.xml"), )?; - write( - res.join("values").join("styles.xml"), + // Handle styles.xml with the copy helper + copy_ejected_or_use_template( + "gen/app/src/main/res/values/styles.xml", + &res.join("values").join("styles.xml"), include_bytes!("../../assets/android/gen/app/src/main/res/values/styles.xml"), )?; @@ -2714,57 +2783,69 @@ impl BuildRequest { )?; create_dir_all(res.join("drawable"))?; - write( - res.join("drawable").join("ic_launcher_background.xml"), + + // Handle ic_launcher_background.xml with the copy helper + copy_ejected_or_use_template( + "gen/app/src/main/res/drawable/ic_launcher_background.xml", + &res.join("drawable").join("ic_launcher_background.xml"), include_bytes!( "../../assets/android/gen/app/src/main/res/drawable/ic_launcher_background.xml" ), )?; create_dir_all(res.join("drawable-v24"))?; - write( - res.join("drawable-v24").join("ic_launcher_foreground.xml"), + + // Handle ic_launcher_foreground.xml with the copy helper + copy_ejected_or_use_template( + "gen/app/src/main/res/drawable-v24/ic_launcher_foreground.xml", + &res.join("drawable-v24").join("ic_launcher_foreground.xml"), include_bytes!( "../../assets/android/gen/app/src/main/res/drawable-v24/ic_launcher_foreground.xml" ), )?; create_dir_all(res.join("mipmap-anydpi-v26"))?; - write( - res.join("mipmap-anydpi-v26").join("ic_launcher.xml"), + copy_ejected_or_use_template( + "gen/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml", + &res.join("mipmap-anydpi-v26").join("ic_launcher.xml"), include_bytes!( "../../assets/android/gen/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml" ), )?; create_dir_all(res.join("mipmap-hdpi"))?; - write( - res.join("mipmap-hdpi").join("ic_launcher.webp"), + copy_ejected_or_use_template( + "gen/app/src/main/res/mipmap-hdpi/ic_launcher.webp", + &res.join("mipmap-hdpi").join("ic_launcher.webp"), include_bytes!( "../../assets/android/gen/app/src/main/res/mipmap-hdpi/ic_launcher.webp" ), )?; create_dir_all(res.join("mipmap-mdpi"))?; - write( - res.join("mipmap-mdpi").join("ic_launcher.webp"), + copy_ejected_or_use_template( + "gen/app/src/main/res/mipmap-mdpi/ic_launcher.webp", + &res.join("mipmap-mdpi").join("ic_launcher.webp"), include_bytes!( "../../assets/android/gen/app/src/main/res/mipmap-mdpi/ic_launcher.webp" ), )?; create_dir_all(res.join("mipmap-xhdpi"))?; - write( - res.join("mipmap-xhdpi").join("ic_launcher.webp"), + copy_ejected_or_use_template( + "gen/app/src/main/res/mipmap-xhdpi/ic_launcher.webp", + &res.join("mipmap-xhdpi").join("ic_launcher.webp"), include_bytes!( "../../assets/android/gen/app/src/main/res/mipmap-xhdpi/ic_launcher.webp" ), )?; create_dir_all(res.join("mipmap-xxhdpi"))?; - write( - res.join("mipmap-xxhdpi").join("ic_launcher.webp"), + copy_ejected_or_use_template( + "gen/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp", + &res.join("mipmap-xxhdpi").join("ic_launcher.webp"), include_bytes!( "../../assets/android/gen/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp" ), )?; create_dir_all(res.join("mipmap-xxxhdpi"))?; - write( - res.join("mipmap-xxxhdpi").join("ic_launcher.webp"), + copy_ejected_or_use_template( + "gen/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp", + &res.join("mipmap-xxxhdpi").join("ic_launcher.webp"), include_bytes!( "../../assets/android/gen/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp" ), @@ -2773,10 +2854,22 @@ impl BuildRequest { Ok(()) } + /// Helper method to get the Android app directory, handling ejected assets correctly + fn android_app_dir(&self) -> PathBuf { + let project_dir = self.workspace.workspace_root(); + let ejected_android_dir = project_dir.join("android"); + let using_ejected_assets = ejected_android_dir.exists(); + + if using_ejected_assets { + self.root_dir() + } else { + self.root_dir().join("app") + } + } + fn wry_android_kotlin_files_out_dir(&self) -> PathBuf { let mut kotlin_dir = self - .root_dir() - .join("app") + .android_app_dir() .join("src") .join("main") .join("kotlin"); @@ -3137,8 +3230,22 @@ impl BuildRequest { Platform::Ios => { let dest = self.root_dir().join("Info.plist"); - let plist = self.info_plist_contents(self.platform)?; - std::fs::write(dest, plist)?; + + // Check for ejected iOS Info.plist + let project_dir = self.workspace_dir(); + let ejected_ios_dir = project_dir.join("ios"); + let ejected_plist_path = ejected_ios_dir.join("Info.plist"); + + tracing::info!(dx_src = ?crate::logging::TraceSrc::Dev, "Checking for ejected iOS Info.plist at: {}", ejected_plist_path.display()); + + if ejected_plist_path.exists() { + tracing::info!(dx_src = ?crate::logging::TraceSrc::Dev, "Using ejected iOS Info.plist: {}", ejected_plist_path.display()); + let _ = std::fs::copy(&ejected_plist_path, &dest)?; + } else { + tracing::info!(dx_src = ?crate::logging::TraceSrc::Dev, "Using internal iOS Info.plist template"); + let plist = self.info_plist_contents(self.platform)?; + std::fs::write(dest, plist)?; + } } // AndroidManifest.xml @@ -3597,8 +3704,7 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ } let app_release = self - .root_dir() - .join("app") + .android_app_dir() .join("build") .join("outputs") .join("bundle") @@ -3633,8 +3739,7 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ } pub(crate) fn debug_apk_path(&self) -> PathBuf { - self.root_dir() - .join("app") + self.android_app_dir() .join("build") .join("outputs") .join("apk") @@ -3694,7 +3799,27 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ Ok(()) } + /// Check if there are ejected assets for the current platform + /// and return the path to the ejected assets directory if found + pub(crate) fn ejected_assets_dir(&self) -> Option { + use crate::build::EjectedAssets; + + let workspace_dir = self.workspace_dir(); + + match self.platform { + Platform::Android => EjectedAssets::android_assets_dir(&workspace_dir), + Platform::Ios => EjectedAssets::ios_assets_dir(&workspace_dir), + _ => None, // No ejected assets for other platforms yet + } + } + pub(crate) fn asset_dir(&self) -> PathBuf { + // First check if there are ejected assets for this platform + if let Some(ejected_dir) = self.ejected_assets_dir() { + return ejected_dir; + } + + // If no ejected assets, use the default asset directory match self.platform { Platform::MacOS => self .root_dir() @@ -3703,8 +3828,7 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ .join("assets"), Platform::Android => self - .root_dir() - .join("app") + .android_app_dir() .join("src") .join("main") .join("assets"), @@ -3736,8 +3860,7 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ // Android has a whole build structure to it Platform::Android => self - .root_dir() - .join("app") + .android_app_dir() .join("src") .join("main") .join("jniLibs") diff --git a/packages/cli/src/cli/build_assets.rs b/packages/cli/src/cli/build_assets.rs index f8688b2caf..f5bc86276b 100644 --- a/packages/cli/src/cli/build_assets.rs +++ b/packages/cli/src/cli/build_assets.rs @@ -1,6 +1,6 @@ use std::{fs::create_dir_all, path::PathBuf}; -use crate::{extract_assets_from_file, Result, StructuredOutput}; +use crate::{build::extract_assets_from_file, build::ejected_assets::EjectedAssets, Result, StructuredOutput}; use clap::Parser; use dioxus_cli_opt::process_file_to; use tracing::debug; @@ -15,19 +15,41 @@ pub struct BuildAssets { } impl BuildAssets { - pub async fn run(self) -> Result { + pub async fn run(&self) -> Result { + // Extract assets from the executable let manifest = extract_assets_from_file(&self.executable)?; + // Create the output directory if it doesn't exist create_dir_all(&self.destination)?; + + // Check for ejected assets + let current_dir = std::env::current_dir()?; + let ejected_assets = EjectedAssets::with_project_dir(current_dir); + for asset in manifest.assets() { - let source_path = PathBuf::from(asset.absolute_source_path()); + let mut source_path = PathBuf::from(asset.absolute_source_path()); let destination_path = self.destination.join(asset.bundled_path()); + + // Check if this asset has been ejected + if let Some(ejected_path) = ejected_assets.get_ejected_path(asset.bundled_path()) { + if ejected_path.exists() { + // Use the ejected asset instead + source_path = ejected_path; + debug!("Using ejected asset: {}", source_path.display()); + } + } + + if let Some(parent) = destination_path.parent() { + create_dir_all(parent)?; + } + debug!( "Processing asset {} --> {} {:#?}", source_path.display(), destination_path.display(), asset ); + process_file_to(asset.options(), &source_path, &destination_path)?; } diff --git a/packages/cli/src/cli/eject.rs b/packages/cli/src/cli/eject.rs new file mode 100644 index 0000000000..6eb07ae89d --- /dev/null +++ b/packages/cli/src/cli/eject.rs @@ -0,0 +1,248 @@ +use std::fs::{self, create_dir_all}; +use std::path::{Path, PathBuf}; + +use clap::Parser; +use handlebars::Handlebars; +use serde_json::json; + +use crate::{Result, StructuredOutput, Workspace}; + +/// Eject Android and iOS assets from the CLI to a local directory for customization +#[derive(Parser, Debug)] +pub struct Eject { + /// Output directory for ejected assets (defaults to current directory) + #[clap(short, long)] + pub output_dir: Option, + + /// Eject Android assets + #[clap(long, default_value = "true")] + pub android: bool, + + /// Eject iOS assets + #[clap(long, default_value = "true")] + pub ios: bool, + + /// Force overwrite existing files + #[clap(short, long)] + pub force: bool, +} + +impl Eject { + pub async fn eject(&self) -> Result { + // Check if we're in a Dioxus project + let _workspace = Workspace::current().await?; + if !self.is_dioxus_project(&_workspace) { + return Err( + "Not in a Dioxus project. Please run this command from a Dioxus project directory." + .into(), + ); + } + + // Check if assets are already ejected + let current_dir = std::env::current_dir()?; + + // Use the static has_ejected_assets method to check if assets are already ejected + if crate::build::ejected_assets::EjectedAssets::has_ejected_assets(¤t_dir) && !self.force { + return Err(format!("Assets are already ejected. Use --force to overwrite.").into()); + } + + let output_dir = self + .output_dir + .clone() + .unwrap_or_else(|| PathBuf::from(".")); + + println!("Ejecting assets to {}", output_dir.display()); + + // Eject Android assets if requested + if self.android { + self.eject_android_assets(&output_dir).await?; + } + + // Eject iOS assets if requested + if self.ios { + self.eject_ios_assets(&output_dir).await?; + } + + println!("Successfully ejected assets to {}", output_dir.display()); + Ok(StructuredOutput::Success) + } + + /// Check if the current directory is a Dioxus project + fn is_dioxus_project(&self, workspace: &Workspace) -> bool { + // Check for dioxus dependencies in the workspace + !workspace.dioxus_versions().is_empty() + } + + /// Eject Android assets + async fn eject_android_assets(&self, output_dir: &Path) -> Result<()> { + let android_dir = output_dir.join("android"); + create_dir_all(&android_dir)?; + + // Check if android assets directory already exists using platform_assets_dir + let project_dir = std::env::current_dir()?; + if crate::build::ejected_assets::EjectedAssets::platform_assets_dir(&project_dir, "android").is_some() && !self.force { + println!("Using existing ejected Android assets in {}", android_dir.display()); + } else { + // Copy assets from CLI package to output directory + self.copy_assets_with_rendering(Path::new("assets/android"), &android_dir).await? + } + + Ok(()) + } + + /// Eject iOS assets + async fn eject_ios_assets(&self, output_dir: &Path) -> Result<()> { + let ios_dir = output_dir.join("ios"); + create_dir_all(&ios_dir)?; + + // Check if ios assets directory already exists using platform_assets_dir + let project_dir = std::env::current_dir()?; + if crate::build::ejected_assets::EjectedAssets::platform_assets_dir(&project_dir, "ios").is_some() && !self.force { + println!("Using existing ejected iOS assets in {}", ios_dir.display()); + } else { + // Copy assets from CLI package to output directory + self.copy_assets_with_rendering(Path::new("assets/ios"), &ios_dir).await? + } + + Ok(()) + } + + /// Copy assets from source to destination, rendering HBS templates + async fn copy_assets_with_rendering(&self, src_dir: &Path, dest_dir: &Path) -> Result<()> { + let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(src_dir); + println!("Source directory: {}", src_dir.display()); + println!("Destination directory: {}", dest_dir.display()); + + if !src_dir.exists() { + return Err(format!("Source directory {} does not exist", src_dir.display()).into()); + } + + // List files in source directory for debugging + println!("Files in source directory:"); + if let Ok(entries) = fs::read_dir(&src_dir) { + for entry in entries { + if let Ok(entry) = entry { + println!(" {}", entry.path().display()); + } + } + } else { + println!(" Failed to read directory contents"); + } + + // Get the workspace for project information + let _workspace = Workspace::current().await?; + + // Setup Handlebars for template rendering + let mut hbs = Handlebars::new(); + hbs.set_strict_mode(true); + + // Get the package name from the workspace + let package_name = if let Ok(dir) = std::env::current_dir() { + if let Some(name) = dir.file_name() { + if let Some(name_str) = name.to_str() { + name_str.to_string() + } else { + "dioxus-app".to_string() + } + } else { + "dioxus-app".to_string() + } + } else { + "dioxus-app".to_string() + }; + + // Platform-specific template data + let hbs_data = if src_dir.ends_with("android") { + // Android template data - match AndroidHandlebarsObjects in request.rs + let application_id = format!("com.example.{}", package_name.replace("-", "_")); + + json!({ + "application_id": application_id, + "app_name": package_name, + "android_bundle": null + }) + } else if src_dir.ends_with("ios") { + // iOS template data + let bundle_id = format!("com.example.{}", package_name.replace("-", "_")); + + json!({ + "display_name": package_name, + "bundle_name": package_name, + "executable_name": package_name, + "bundle_identifier": bundle_id + }) + } else { + // Generic template data for other platforms + json!({ + "app_name": package_name + }) + }; + + // Walk the source directory and copy files + self.copy_dir_recursive(&src_dir, dest_dir, &hbs, &hbs_data)?; + + Ok(()) + } + + /// Recursively copy a directory, rendering HBS templates + fn copy_dir_recursive( + &self, + src: &Path, + dest: &Path, + hbs: &Handlebars, + hbs_data: &serde_json::Value, + ) -> Result<()> { + println!("Copying directory: {} -> {}", src.display(), dest.display()); + + if !dest.exists() { + println!("Creating destination directory: {}", dest.display()); + create_dir_all(dest)?; + } + + for entry in fs::read_dir(src)? { + let entry = entry?; + let file_type = entry.file_type()?; + let src_path = entry.path(); + let file_name = entry.file_name(); + let dest_path = dest.join(&file_name); + + if file_type.is_dir() { + // Recursively copy subdirectories + self.copy_dir_recursive(&src_path, &dest_path, hbs, hbs_data)?; + } else { + // Skip existing files unless force is specified + if dest_path.exists() && !self.force { + println!("Skipping existing file: {}", dest_path.display()); + continue; + } + + // Check if this is an HBS template + if src_path.extension().map_or(false, |ext| ext == "hbs") { + // Render the template + let template_content = fs::read_to_string(&src_path)?; + let rendered = hbs.render_template(&template_content, hbs_data)?; + + // Write the rendered content to a file without the .hbs extension + let dest_path_without_hbs = + dest_path.with_file_name(file_name.to_string_lossy().replace(".hbs", "")); + fs::write(&dest_path_without_hbs, rendered)?; + println!( + "Rendered template: {} -> {}", + src_path.display(), + dest_path_without_hbs.display() + ); + } else { + // Regular file, just copy it + fs::copy(&src_path, &dest_path)?; + println!( + "Copied file: {} -> {}", + src_path.display(), + dest_path.display() + ); + } + } + } + + Ok(()) + } +} diff --git a/packages/cli/src/cli/mod.rs b/packages/cli/src/cli/mod.rs index 9a05342e09..6449545525 100644 --- a/packages/cli/src/cli/mod.rs +++ b/packages/cli/src/cli/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod check; pub(crate) mod clean; pub(crate) mod config; pub(crate) mod create; +pub(crate) mod eject; pub(crate) mod init; pub(crate) mod link; pub(crate) mod platform_override; @@ -100,6 +101,10 @@ pub(crate) enum Commands { #[clap(name = "self-update")] SelfUpdate(update::SelfUpdate), + /// Eject Android and iOS assets from the CLI to a local directory for customization. + #[clap(name = "eject")] + Eject(eject::Eject), + /// Run a dioxus build tool. IE `build-assets`, etc #[clap(name = "tools")] #[clap(subcommand)] @@ -127,6 +132,7 @@ impl Display for Commands { Commands::Check(_) => write!(f, "check"), Commands::Bundle(_) => write!(f, "bundle"), Commands::Run(_) => write!(f, "run"), + Commands::Eject(_) => write!(f, "eject"), Commands::SelfUpdate(_) => write!(f, "self-update"), Commands::Tools(_) => write!(f, "tools"), } diff --git a/packages/cli/src/main.rs b/packages/cli/src/main.rs index 4ba8f26690..cd46a516fb 100644 --- a/packages/cli/src/main.rs +++ b/packages/cli/src/main.rs @@ -62,6 +62,7 @@ async fn main() { Commands::Bundle(opts) => opts.bundle().await, Commands::Run(opts) => opts.run().await, Commands::SelfUpdate(opts) => opts.self_update().await, + Commands::Eject(opts) => opts.eject().await, Commands::Tools(BuildTools::BuildAssets(opts)) => opts.run().await, }; diff --git a/packages/cli/src/serve/runner.rs b/packages/cli/src/serve/runner.rs index a00587d405..4ca8305d0f 100644 --- a/packages/cli/src/serve/runner.rs +++ b/packages/cli/src/serve/runner.rs @@ -353,6 +353,21 @@ impl AppServer { // We attempt to hotreload rsx blocks without a full rebuild for path in files { + // Check if the file is in an ejected asset directory + let path_str = path.to_string_lossy(); + + // Check for ejected Android or iOS assets + let is_ejected_android = path_str.contains("/android/"); + let is_ejected_ios = path_str.contains("/ios/"); + + if is_ejected_android || is_ejected_ios { + tracing::info!("Detected change in ejected asset: {}", path.display()); + // For ejected assets, we'll trigger a reload of the app + // This ensures changes to ejected assets are immediately visible + needs_full_rebuild = true; + break; + } + // for various assets that might be linked in, we just try to hotreloading them forcefully // That is, unless they appear in an include! macro, in which case we need to a full rebuild.... let Some(ext) = path.extension().and_then(|v| v.to_str()) else { @@ -934,6 +949,27 @@ impl AppServer { ) { handle_notify_error(err); } + + // Watch ejected asset directories for Android and iOS + let workspace_root = self.workspace.krates.workspace_root().as_std_path().to_path_buf(); + + // Watch Android ejected assets directory + let android_ejected_dir = workspace_root.join("android"); + if android_ejected_dir.exists() { + tracing::info!("Watching ejected Android assets directory: {}", android_ejected_dir.display()); + if let Err(err) = self.watcher.watch(&android_ejected_dir, RecursiveMode::Recursive) { + handle_notify_error(err); + } + } + + // Watch iOS ejected assets directory + let ios_ejected_dir = workspace_root.join("ios"); + if ios_ejected_dir.exists() { + tracing::info!("Watching ejected iOS assets directory: {}", ios_ejected_dir.display()); + if let Err(err) = self.watcher.watch(&ios_ejected_dir, RecursiveMode::Recursive) { + handle_notify_error(err); + } + } } /// Return the list of paths that we should watch for changes. diff --git a/packages/cli/src/serve/server.rs b/packages/cli/src/serve/server.rs index f60eb99c35..5efdc17ed5 100644 --- a/packages/cli/src/serve/server.rs +++ b/packages/cli/src/serve/server.rs @@ -558,22 +558,50 @@ fn build_serve_dir(runner: &AppServer) -> axum::routing::MethodRouter { let app = &runner.client; let cfg = &runner.client.build.config; + // Get the workspace root directory to check for ejected assets + let workspace_root = runner.workspace.krates.workspace_root().as_std_path().to_path_buf(); + + // Check for ejected Android and iOS assets + let android_ejected_dir = workspace_root.join("android"); + let ios_ejected_dir = workspace_root.join("ios"); + + // Use the standard output directory as fallback let out_dir = app.build.root_dir(); let index_on_404: bool = cfg.web.watcher.index_on_404; - - get_service( - ServiceBuilder::new() - .override_response_header( - HeaderName::from_static("cross-origin-embedder-policy"), - coep, - ) - .override_response_header(HeaderName::from_static("cross-origin-opener-policy"), coop) - .and_then({ - let out_dir = out_dir.clone(); - move |response| async move { Ok(no_cache(index_on_404, &out_dir, response)) } - }) - .service(ServeDir::new(&out_dir)), - ) + + // Create a vector of directories to serve, prioritizing ejected assets + let mut serve_dirs = Vec::new(); + + // Add ejected asset directories if they exist + if android_ejected_dir.exists() { + tracing::info!("Serving ejected Android assets directly from: {}", android_ejected_dir.display()); + serve_dirs.push(android_ejected_dir); + } + + if ios_ejected_dir.exists() { + tracing::info!("Serving ejected iOS assets directly from: {}", ios_ejected_dir.display()); + serve_dirs.push(ios_ejected_dir); + } + + // Always add the standard output directory as fallback + serve_dirs.push(out_dir.clone()); + + // Create a service that tries each directory in order + let service_builder = ServiceBuilder::new() + .override_response_header( + HeaderName::from_static("cross-origin-embedder-policy"), + coep, + ) + .override_response_header(HeaderName::from_static("cross-origin-opener-policy"), coop) + .and_then({ + let out_dir = out_dir.clone(); + move |response| async move { Ok(no_cache(index_on_404, &out_dir, response)) } + }); + + // Create a service that serves from all directories + let service = service_builder.service(ServeDir::new(&serve_dirs[0])); + + get_service(service) .handle_error(|error: Infallible| async move { ( StatusCode::INTERNAL_SERVER_ERROR,