From 20e242628d22c29865826e5359b574a878e60e7b Mon Sep 17 00:00:00 2001 From: Jonathan Kelley Date: Thu, 26 Mar 2026 13:05:11 -0700 Subject: [PATCH] fix stutter in bundle, fix nsis, fix msi bundling (#5412) * fix stutter in bundle, fix nsis, fix msi bundling * fmt * rename android libname to `libmain`, rename server exe to `server` * fix hot-dog url handling * set linux product name * update schema --- examples/01-app-demos/hotdog/Cargo.toml | 1 + examples/01-app-demos/hotdog/Dockerfile | 2 +- examples/01-app-demos/hotdog/src/backend.rs | 3 +- examples/01-app-demos/hotdog/src/main.rs | 3 +- packages/asset-resolver/src/native.rs | 29 +++- .../gen/app/src/main/AndroidManifest.xml.hbs | 2 +- packages/cli/schema.json | 7 + packages/cli/src/build/request.rs | 54 ++++---- packages/cli/src/bundler/ios.rs | 2 +- packages/cli/src/bundler/linux.rs | 28 ++-- packages/cli/src/bundler/updater.rs | 2 +- packages/cli/src/bundler/windows.rs | 128 +++++++++--------- packages/cli/src/config/manifest.rs | 5 + packages/cli/src/wasm_bindgen.rs | 3 + 14 files changed, 157 insertions(+), 112 deletions(-) diff --git a/examples/01-app-demos/hotdog/Cargo.toml b/examples/01-app-demos/hotdog/Cargo.toml index f29b3e8a1..ecd656bbc 100644 --- a/examples/01-app-demos/hotdog/Cargo.toml +++ b/examples/01-app-demos/hotdog/Cargo.toml @@ -20,3 +20,4 @@ desktop = ["dioxus/desktop"] native = ["dioxus/native"] mobile = ["dioxus/mobile"] server = ["dioxus/server", "dep:rusqlite"] +production = [] diff --git a/examples/01-app-demos/hotdog/Dockerfile b/examples/01-app-demos/hotdog/Dockerfile index 897ce6492..278d858e3 100644 --- a/examples/01-app-demos/hotdog/Dockerfile +++ b/examples/01-app-demos/hotdog/Dockerfile @@ -11,7 +11,7 @@ COPY --from=planner /app/recipe.json recipe.json RUN cargo chef cook --release --recipe-path recipe.json COPY . . RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/DioxusLabs/dioxus/refs/heads/main/.github/install.sh | bash -RUN /.cargo/bin/dx bundle --platform web +RUN /.cargo/bin/dx bundle --platform web --features production FROM chef AS runtime COPY --from=builder /app/target/dx/hotdog/release/web/ /usr/local/app diff --git a/examples/01-app-demos/hotdog/src/backend.rs b/examples/01-app-demos/hotdog/src/backend.rs index 301c4e023..9b8fcc4e8 100644 --- a/examples/01-app-demos/hotdog/src/backend.rs +++ b/examples/01-app-demos/hotdog/src/backend.rs @@ -4,8 +4,7 @@ use dioxus::prelude::*; #[cfg(feature = "server")] thread_local! { static DB: std::sync::LazyLock = std::sync::LazyLock::new(|| { - std::fs::create_dir("hotdogdb").unwrap(); - let conn = rusqlite::Connection::open("hotdogdb/hotdog.db").expect("Failed to open database"); + let conn = rusqlite::Connection::open(":memory:").expect("Failed to open database"); conn.execute_batch( "CREATE TABLE IF NOT EXISTS dogs ( diff --git a/examples/01-app-demos/hotdog/src/main.rs b/examples/01-app-demos/hotdog/src/main.rs index ad0791660..8adfa2fe6 100644 --- a/examples/01-app-demos/hotdog/src/main.rs +++ b/examples/01-app-demos/hotdog/src/main.rs @@ -15,7 +15,8 @@ enum Route { } fn main() { - #[cfg(not(feature = "server"))] + // only in production should we set the URL, otherwise let `dx` do the work + #[cfg(all(not(feature = "server"), feature = "production"))] dioxus::fullstack::set_server_url("https://hot-dog.fly.dev"); dioxus::launch(app); diff --git a/packages/asset-resolver/src/native.rs b/packages/asset-resolver/src/native.rs index 954c60988..6b5fdc3fd 100644 --- a/packages/asset-resolver/src/native.rs +++ b/packages/asset-resolver/src/native.rs @@ -169,13 +169,28 @@ fn get_asset_root() -> PathBuf { // $product_name/ // assets/ if cfg!(target_os = "linux") { - if let Some(product_name) = dioxus_cli_config::product_name() { - let path = cur_exe - .parent() - .and_then(|parent| parent.parent()) - .map(|root| root.join("lib").join(product_name)); - if let Some(asset_dir) = path.filter(|path| path.exists()) { - return asset_dir; + if let Some(lib_dir) = cur_exe + .parent() + .and_then(|parent| parent.parent()) + .map(|root| root.join("lib")) + { + // Try the product name from the compile-time env var first + if let Some(product_name) = dioxus_cli_config::product_name() { + let path = lib_dir.join(&product_name); + if path.exists() { + return path; + } + } + + // Fall back to scanning lib/ for a directory containing an `assets/` subfolder. + // This handles debug builds where DIOXUS_PRODUCT_NAME isn't baked in. + if let Ok(entries) = std::fs::read_dir(&lib_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && path.join("assets").is_dir() { + return path; + } + } } } } diff --git a/packages/cli/assets/android/gen/app/src/main/AndroidManifest.xml.hbs b/packages/cli/assets/android/gen/app/src/main/AndroidManifest.xml.hbs index 483aab06e..c04bd1e09 100644 --- a/packages/cli/assets/android/gen/app/src/main/AndroidManifest.xml.hbs +++ b/packages/cli/assets/android/gen/app/src/main/AndroidManifest.xml.hbs @@ -27,7 +27,7 @@ android:networkSecurityConfig="@xml/network_security_config"> - + diff --git a/packages/cli/schema.json b/packages/cli/schema.json index f432250f9..73a61448f 100644 --- a/packages/cli/schema.json +++ b/packages/cli/schema.json @@ -165,6 +165,13 @@ "$ref": "#/definitions/AndroidIntentFilter" } }, + "lib_name": { + "description": "Native library name loaded by NativeActivity (without `lib` prefix and `.so` extension). Defaults to `\"main\"` (i.e. `libmain.so`). Override if you need a custom library name.", + "type": [ + "string", + "null" + ] + }, "long_description": { "description": "Long description. Overrides `bundle.long_description` for Android builds.", "type": [ diff --git a/packages/cli/src/build/request.rs b/packages/cli/src/build/request.rs index b0e9d883a..9497c581e 100644 --- a/packages/cli/src/build/request.rs +++ b/packages/cli/src/build/request.rs @@ -3611,6 +3611,10 @@ impl BuildRequest { env_vars.extend(self.android_env_vars()?); }; + // Always bake the product name into the binary so bundled apps can find their assets + // at runtime regardless of build profile (the asset directory structure uses the product name). + env_vars.push((PRODUCT_NAME_ENV.into(), self.bundled_app_name().into())); + // If this is a release build, bake the base path and title into the binary with env vars. // todo: should we even be doing this? might be better being a build.rs or something else. if self.release { @@ -3621,7 +3625,6 @@ impl BuildRequest { APP_TITLE_ENV.into(), self.config.web.app.title.clone().into(), )); - env_vars.push((PRODUCT_NAME_ENV.into(), self.bundled_app_name().into())); } // Assemble the rustflags by peering into the `.cargo/config.toml` file @@ -3739,7 +3742,6 @@ impl BuildRequest { let java_home = tools.java_home(); let ndk_home = tools.ndk.clone(); let sdk_root = tools.sdk(); - let artifact_dir = self.android_artifact_dir()?; tracing::debug!( r#"Using android: min_sdk_version: {min_sdk_version} @@ -3749,7 +3751,6 @@ impl BuildRequest { target_cxx: {target_cxx:?} java_home: {java_home:?} sdk_root: {sdk_root:?} - artifact_dir: {artifact_dir:?} "# ); @@ -3762,10 +3763,6 @@ impl BuildRequest { )); } - env_vars.push(( - "DX_ANDROID_ARTIFACT_DIR".into(), - artifact_dir.into_os_string(), - )); env_vars.push(( "DX_ANDROID_NDK_HOME".into(), ndk_home.clone().into_os_string(), @@ -3917,7 +3914,7 @@ impl BuildRequest { ), ( "WRY_ANDROID_LIBRARY".to_string(), - "dioxusmain".to_string().into(), + self.android_lib_name().into(), ), ("WRY_ANDROID_KOTLIN_FILES_OUT_DIR".to_string(), { let kotlin_dir = self.wry_android_kotlin_files_out_dir(); @@ -3951,17 +3948,6 @@ impl BuildRequest { Ok(env_vars) } - fn android_artifact_dir(&self) -> Result { - let dir = self - .internal_out_dir() - .join(&self.main_target) - .join(if self.release { "release" } else { "debug" }) - .join("android-artifacts") - .join(self.triple.to_string()); - std::fs::create_dir_all(&dir)?; - Ok(dir) - } - /// Get an estimate of the number of units in the crate. If nightly rustc is not available, this /// will return an estimate of the number of units in the crate based on cargo metadata. /// @@ -4092,15 +4078,24 @@ impl BuildRequest { // mac/ios are unixy and dont have an exe extension BundleFormat::MacOS | BundleFormat::Ios => self.executable_name().to_string(), - // "server" and windows can be the same - BundleFormat::Server | BundleFormat::Windows => match self.triple.operating_system { + // the server binary is always called "server" to avoid antivirus issues when the + // binary name changes between builds (the folder name already identifies the project) + BundleFormat::Server => match self.triple.operating_system { + OperatingSystem::Windows => "server.exe".to_string(), + _ => "server".to_string(), + }, + + BundleFormat::Windows => match self.triple.operating_system { OperatingSystem::Windows => format!("{}.exe", self.executable_name()), _ => self.executable_name().to_string(), }, // from the apk spec, the root exe is a shared library - // we include the user's rust code as a shared library with a fixed namespace - BundleFormat::Android => "libdioxusmain.so".to_string(), + // defaults to "main" (libmain.so) per NativeActivity convention, overridable in Dioxus.toml + BundleFormat::Android => { + let lib_name = self.android_lib_name(); + format!("lib{lib_name}.so") + } // this will be wrong, I think, but not important? BundleFormat::Web => format!("{}_bg.wasm", self.executable_name()), @@ -4183,6 +4178,8 @@ impl BuildRequest { app_theme: Option, supports_rtl: Option, large_heap: Option, + /// Native library name (without lib prefix and .so extension) + lib_name: String, } // Get permission mapper from config @@ -4231,6 +4228,7 @@ impl BuildRequest { app_theme: self.config.android.application.theme.clone(), supports_rtl: self.config.android.application.supports_rtl, large_heap: self.config.android.application.large_heap, + lib_name: self.android_lib_name(), }; let hbs = handlebars::Handlebars::new(); @@ -4534,6 +4532,16 @@ impl BuildRequest { &self.crate_target.name } + /// Android native library name (without `lib` prefix and `.so` extension). + /// Defaults to `"main"` per NativeActivity convention, overridable via `[android] lib_name`. + fn android_lib_name(&self) -> String { + self.config + .android + .lib_name + .clone() + .unwrap_or_else(|| "main".to_string()) + } + /// Get the type of executable we are compiling pub(crate) fn executable_type(&self) -> TargetKind { self.crate_target.kind[0].clone() diff --git a/packages/cli/src/bundler/ios.rs b/packages/cli/src/bundler/ios.rs index d7bce341e..62362b15f 100644 --- a/packages/cli/src/bundler/ios.rs +++ b/packages/cli/src/bundler/ios.rs @@ -52,7 +52,7 @@ impl BundleContext<'_> { verify_codesigned_app(app_path).await?; - let output_dir = self.project_out_directory().join("bundle").join("ipa"); + let output_dir = self.project_out_directory().join("ipa"); std::fs::create_dir_all(&output_dir)?; let ipa_name = format!( diff --git a/packages/cli/src/bundler/linux.rs b/packages/cli/src/bundler/linux.rs index 9f1556d2e..5db54f159 100644 --- a/packages/cli/src/bundler/linux.rs +++ b/packages/cli/src/bundler/linux.rs @@ -43,7 +43,7 @@ impl BundleContext<'_> { let arch = self.binary_arch(); let arch_str = arch.appimage_arch(); - let output_dir = self.project_out_directory().join("bundle").join("appimage"); + let output_dir = self.project_out_directory().join("appimage"); fs::create_dir_all(&output_dir)?; let appimage_filename = format!("{name}_{version}_{arch_str}.AppImage"); @@ -150,7 +150,7 @@ impl BundleContext<'_> { let package_name = self.deb_package_name(); let version = self.version_string(); - let output_dir = self.project_out_directory().join("bundle").join("deb"); + let output_dir = self.project_out_directory().join("deb"); fs::create_dir_all(&output_dir)?; let deb_filename = format!("{package_name}_{version}_{arch}.deb"); @@ -234,7 +234,7 @@ impl BundleContext<'_> { let description = self.short_description(); let resource_dir_name = self.linux_resource_dir_name(); - let output_dir = self.project_out_directory().join("bundle").join("rpm"); + let output_dir = self.project_out_directory().join("rpm"); fs::create_dir_all(&output_dir)?; let rpm_filename = format!("{name}-{version}-1.{arch}.rpm"); @@ -864,16 +864,20 @@ Type=Application control.push_str(&format!("Installed-Size: {installed_size}\n")); let description = self.short_description(); - if !description.is_empty() { - control.push_str(&format!("Description: {description}\n")); + // Description is a required field in Debian control files - use the product name as fallback + let description = if description.is_empty() { + self.product_name() + } else { + description + }; + control.push_str(&format!("Description: {description}\n")); - if let Some(long_desc) = self.long_description() { - for line in long_desc.lines() { - if line.is_empty() { - control.push_str(" .\n"); - } else { - control.push_str(&format!(" {line}\n")); - } + if let Some(long_desc) = self.long_description() { + for line in long_desc.lines() { + if line.is_empty() { + control.push_str(" .\n"); + } else { + control.push_str(&format!(" {line}\n")); } } } diff --git a/packages/cli/src/bundler/updater.rs b/packages/cli/src/bundler/updater.rs index 78601c7f3..182b538c9 100644 --- a/packages/cli/src/bundler/updater.rs +++ b/packages/cli/src/bundler/updater.rs @@ -32,7 +32,7 @@ impl BundleContext<'_> { /// archive container. pub(crate) async fn bundle_updater(&self, bundles: &[Bundle]) -> Result> { let mut updater_paths = Vec::new(); - let output_dir = self.project_out_directory().join("bundle").join("updater"); + let output_dir = self.project_out_directory().join("updater"); std::fs::create_dir_all(&output_dir)?; for bundle in bundles { diff --git a/packages/cli/src/bundler/windows.rs b/packages/cli/src/bundler/windows.rs index 796f48468..138483922 100644 --- a/packages/cli/src/bundler/windows.rs +++ b/packages/cli/src/bundler/windows.rs @@ -34,7 +34,7 @@ impl BundleContext<'_> { /// Intermediate `.wxs`, `.wixobj`, and `_staging` files are intentionally left in /// place because they are useful when diagnosing packaging failures. pub(crate) async fn bundle_windows_msi(&self) -> Result> { - let output_dir = self.project_out_directory().join("bundle").join("msi"); + let output_dir = self.project_out_directory().join("msi"); std::fs::create_dir_all(&output_dir).context("Failed to create MSI output directory")?; let wix_settings = self.windows().wix.unwrap_or_default(); @@ -86,9 +86,7 @@ impl BundleContext<'_> { ) })?; - let resources_dir = staging_dir.join("resources"); - std::fs::create_dir_all(&resources_dir)?; - self.copy_resources(&resources_dir)?; + self.copy_resources(&staging_dir)?; self.copy_external_binaries(&staging_dir)?; let staged_files = collect_staged_files(&staging_dir, Some(&main_binary_dest))?; let (install_tree, component_refs_xml) = render_wix_install_tree(&staged_files); @@ -213,28 +211,20 @@ impl BundleContext<'_> { serde_json::Value::String(component_refs_xml), ); - let mut hbs = Handlebars::new(); - hbs.set_strict_mode(false); - hbs.register_escape_fn(|s: &str| s.to_string()); - hbs.register_template_string( - "wix", - if let Some(custom_template) = &wix_settings.template { - let template_path = self.crate_dir().join(custom_template); - std::fs::read_to_string(&template_path).with_context(|| { - format!( - "Failed to read custom WiX template: {}", - template_path.display() - ) - })? - } else { - WIX_TEMPLATE.to_string() - }, - ) - .context("Failed to parse WiX template")?; + let wix_template = if let Some(custom_template) = &wix_settings.template { + let template_path = self.crate_dir().join(custom_template); + std::fs::read_to_string(&template_path).with_context(|| { + format!( + "Failed to read custom WiX template: {}", + template_path.display() + ) + })? + } else { + WIX_TEMPLATE.to_string() + }; - let wxs_content = hbs - .render("wix", &data) - .context("Failed to render WiX template")?; + let wxs_content = + render_template(&wix_template, &data).context("Failed to render WiX template")?; let wxs_path = output_dir.join(format!("{product_name}.wxs")); std::fs::write(&wxs_path, &wxs_content).context("Failed to write WiX source file")?; @@ -376,7 +366,7 @@ impl BundleContext<'_> { /// As with the MSI flow, the script and staging directory are preserved for /// debugging rather than treated as disposable hidden state. pub(crate) async fn bundle_windows_nsis(&self) -> Result> { - let output_dir = self.project_out_directory().join("bundle").join("nsis"); + let output_dir = self.project_out_directory().join("nsis"); std::fs::create_dir_all(&output_dir).context("Failed to create NSIS output directory")?; let nsis_settings = self.windows().nsis.unwrap_or_default(); @@ -422,9 +412,7 @@ impl BundleContext<'_> { ) })?; - let resources_dir = staging_dir.join("resources"); - std::fs::create_dir_all(&resources_dir)?; - self.copy_resources(&resources_dir)?; + self.copy_resources(&staging_dir)?; self.copy_external_binaries(&staging_dir)?; let staged_files = collect_staged_files(&staging_dir, Some(&main_binary_dest))?; @@ -914,14 +902,25 @@ async fn run_signtool_sign( } /// Render a Handlebars template with the given data. +/// +/// Handlebars treats `\{{` as an escape sequence that outputs a literal `{{`. +/// This is a problem for Windows paths like `$INSTDIR\{{product_name}}` where +/// the backslash is a path separator, not an escape. We work around this by +/// replacing `\{{` with a placeholder before Handlebars parses the template, +/// then restoring the backslash in the rendered output. fn render_template(template: &str, data: &BTreeMap) -> Result { + const BACKSLASH_PLACEHOLDER: &str = "\x00BSEP\x00"; + let replacement = String::from(BACKSLASH_PLACEHOLDER) + "{{"; + let safe_template = template.replace("\\{{", &replacement); let mut hbs = Handlebars::new(); hbs.set_strict_mode(false); hbs.register_escape_fn(|s: &str| s.to_string()); - hbs.register_template_string("nsis", template) - .context("Failed to parse NSIS template")?; - hbs.render("nsis", data) - .context("Failed to render NSIS template") + hbs.register_template_string("template", &safe_template) + .context("Failed to parse template")?; + let rendered = hbs + .render("template", data) + .context("Failed to render template")?; + Ok(rendered.replace(BACKSLASH_PLACEHOLDER, "\\")) } /// Convert a semver version string to a WiX-compatible version. @@ -1015,7 +1014,15 @@ const WIX_TEMPLATE: &str = r#" {{/if}} + {{#if license}} + {{else}} + + + 1 + 1 + + {{/if}} @@ -1244,10 +1251,10 @@ SectionEnd #[cfg(test)] mod tests { - use super::WIX_TEMPLATE; + use super::{render_template, WIX_TEMPLATE}; use crate::bundler::Arch; - use handlebars::Handlebars; use serde_json::json; + use std::collections::BTreeMap; #[test] fn wix_program_files_folder_matches_architecture() { @@ -1264,37 +1271,32 @@ mod tests { #[test] fn wix_template_uses_arch_specific_program_files_folder() { - let mut hbs = Handlebars::new(); - hbs.set_strict_mode(false); - hbs.register_escape_fn(|s: &str| s.to_string()); - hbs.register_template_string("wix", WIX_TEMPLATE).unwrap(); + let data: BTreeMap = serde_json::from_value(json!({ + "product_name": "Hotdog", + "upgrade_code": "00000000-0000-0000-0000-000000000000", + "version": "0.1.0", + "publisher": "Dioxus Labs", + "main_binary_name": "hotdog.exe", + "main_binary_path": "C:\\staging\\hotdog.exe", + "short_description": "Hotdog app", + "allow_downgrades": false, + "fips_compliant": false, + "install_tree": "", + "component_refs_xml": "", + "component_group_refs": [], + "component_refs": [], + "feature_group_refs": [], + "feature_refs": [], + "merge_refs": [], + "wix_program_files_folder": "ProgramFiles64Folder" + })) + .unwrap(); - let rendered = hbs - .render( - "wix", - &json!({ - "product_name": "Hotdog", - "upgrade_code": "00000000-0000-0000-0000-000000000000", - "version": "0.1.0", - "publisher": "Dioxus Labs", - "main_binary_name": "hotdog.exe", - "main_binary_path": "C:\\staging\\hotdog.exe", - "short_description": "Hotdog app", - "allow_downgrades": false, - "fips_compliant": false, - "install_tree": "", - "component_refs_xml": "", - "component_group_refs": [], - "component_refs": [], - "feature_group_refs": [], - "feature_refs": [], - "merge_refs": [], - "wix_program_files_folder": "ProgramFiles64Folder" - }), - ) - .unwrap(); + let rendered = render_template(WIX_TEMPLATE, &data).unwrap(); assert!(rendered.contains("")); assert!(!rendered.contains("")); + // Verify \{{ substitution works: registry key should contain actual values + assert!(rendered.contains("Software\\Dioxus Labs\\Hotdog")); } } diff --git a/packages/cli/src/config/manifest.rs b/packages/cli/src/config/manifest.rs index 1eefbe57b..2d133fd84 100644 --- a/packages/cli/src/config/manifest.rs +++ b/packages/cli/src/config/manifest.rs @@ -602,6 +602,11 @@ pub struct AndroidConfig { #[serde(default)] pub compile_sdk: Option, + /// Native library name loaded by NativeActivity (without `lib` prefix and `.so` extension). + /// Defaults to `"main"` (i.e. `libmain.so`). Override if you need a custom library name. + #[serde(default)] + pub lib_name: Option, + /// Hardware/software features required. #[serde(default)] pub features: Vec, diff --git a/packages/cli/src/wasm_bindgen.rs b/packages/cli/src/wasm_bindgen.rs index 0e5ec3155..19deb9c55 100644 --- a/packages/cli/src/wasm_bindgen.rs +++ b/packages/cli/src/wasm_bindgen.rs @@ -428,6 +428,9 @@ impl WasmBindgen { fn git_install_url(&self) -> Option { let platform = if cfg!(all(target_os = "windows", target_arch = "x86_64")) { "x86_64-pc-windows-msvc" + } else if cfg!(all(target_os = "windows", target_arch = "aarch64")) { + // No native ARM64 binary available; the x86_64 binary runs fine under emulation. + "x86_64-pc-windows-msvc" } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) { "x86_64-unknown-linux-musl" } else if cfg!(all(target_os = "linux", target_arch = "aarch64")) {