fix: workspace hotpatch cache thrashing, feat: add compile profiling, cleanup + refactor + document cli request.rs file (#5479)

* it works!

* fix wasm hotpatch error

* bump wasmopt

* rename wasmopt flag

* rename ldd_keep_exports

* add build profiling

* undo bundle ignore

* some cleanups

* more cleanups

* wip: hoist up telemetry to builder

* fix compile

* wip: flamegraph rewrite

* clean up flamegraph impl

* cleanup profiling engine

* add comment on spans obj

* cleanup more phases

* more cleanup

* small cleanups

* bit more cleanups

* cleanup phases

* sign with tokio, not std proc

* split out the specific platform helpers

* cleanup a bit

* more cleanups

* more cleanups

* more cleanups

* more cleanups

* pull out fingerprint logic

* wip... I think I have a slightly better approach. saving checkpoint

* hm I think it's better now?

* fix: also seems to work not in workspace mode juuust yet

* okay yay almost there

* clooose but not there yet

* fix: manually export __wasm_apply_data_relocs since got relocations weren't firing

WASM workspace hotpatching: export __wasm_apply_global_relocs to fix call_indirect type mismatch

When workspace crates are recompiled with -Crelocation-model=pic for hotpatching, wasm-ld generates GOT.func.internal globals that hold element-segment-relative offsets (e.g., MetricCardProps::memoize = 31). These need __table_base added at runtime by __wasm_apply_global_relocs to become absolute table indices.

wasm-ld generates __wasm_apply_global_relocs but treats it as an internal synthetic function -- neither --export nor --export-if-defined will export it. Without the export, the runtime's call silently failed (the old code used unchecked_into on undefined), leaving the globals unrelocated. Function pointers then pointed to index 31 in the original module's table (some unrelated function) instead of table_base + 31 (the patched function), causing call_indirect to a signature that does not match.

Three changes:

patch.rs: After walrus processing, find __wasm_apply_global_relocs by name and add it as an export before emitting the patched wasm.
lib.rs: Replace unchecked_into with dyn_into for calling relocation functions, so missing exports are handled gracefully instead of silently swallowed.
link.rs: Remove the ineffective --export-if-defined flags that were added during debugging.

* cleanup

* fix scope dir

* change up naming a bit

* more tidying up

* more cleanups

* ignore early return

* clean er up

* more comprehensive cache bustign

* bust fingerprints of dag

* move bust_fingerprint back to request.rs

* add better rustdoc

* more idiomatic

* more cleanups

* fix panic, typos

* more lints

* only delete workspace prints in fat mod

* clippy
This commit is contained in:
Jonathan Kelley authored and GitHub committed 2026-04-14 12:09:49 -07:00
1 parent 348208d1cb
commit fb855b7088
37 files changed
+7276 -6865

No files matched your search

+19 -23
View File
@@ -6,9 +6,25 @@ fn main() {
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/refs");
let full_hash = std::env::var("DIOXUS_CLI_GIT_SHA")
.ok()
.or_else(resolve_git_commit_hash);
let full_hash = std::env::var("DIOXUS_CLI_GIT_SHA").ok().or_else(|| {
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let hash = String::from_utf8(output.stdout).ok()?;
let hash = hash.trim().to_string();
if hash.is_empty() {
None
} else {
Some(hash)
}
});
if let Some(full_hash) = full_hash {
println!("cargo:rustc-env=DIOXUS_CLI_GIT_SHA={full_hash}");
@@ -20,23 +36,3 @@ fn main() {
println!("cargo:rustc-env=DIOXUS_CLI_GIT_SHA_SHORT={short_hash}");
}
}
fn resolve_git_commit_hash() -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let hash = String::from_utf8(output.stdout).ok()?;
let hash = hash.trim().to_string();
if hash.is_empty() {
None
} else {
Some(hash)
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+49 -61
View File
@@ -29,37 +29,27 @@
//! build system.
use std::{
fs::OpenOptions,
io::{Cursor, Read, Seek, Write},
path::{Path, PathBuf},
time::{Duration, Instant},
};
use crate::opt::AssetManifest;
use crate::opt::AppManifest;
use crate::Result;
use anyhow::{bail, Context};
use const_serialize::{deserialize_const, serialize_const, ConstVec};
use manganis::{AssetOptions, AssetVariant, BundledAsset, ImageFormat, ImageSize};
use manganis_core::{AndroidArtifactMetadata, SwiftPackageMetadata, SymbolData};
use manganis_core::SymbolData;
use object::{File, Object, ObjectSection, ObjectSymbol, ReadCache, ReadRef, Section, Symbol};
use pdb::FallibleIterator;
use rayon::iter::{IntoParallelRefMutIterator, ParallelIterator};
/// Extract all manganis symbols and their sections from the given object file.
fn manganis_symbols<'a, 'b, R: ReadRef<'a>>(
file: &'b File<'a, R>,
) -> impl Iterator<Item = (ManganisVersion, Symbol<'a, 'b, R>, Section<'a, 'b, R>)> + 'b {
file.symbols().filter_map(move |symbol| {
let name = symbol.name().ok()?;
let version = looks_like_manganis_symbol(name)?;
let section_index = symbol.section_index()?;
let section = file.section_by_index(section_index).ok()?;
Some((version, symbol, section))
})
}
#[derive(Copy, Clone)]
enum ManganisVersion {
/// The legacy version of the manganis format published with 0.7.0 and 0.7.1
Legacy,
/// The new version of the manganis format 0.7.2 onward
/// This now includes both assets (old BundledAsset format) and permissions (SymbolData format)
New,
@@ -696,37 +686,25 @@ fn find_wasm_symbol_offsets<'a, R: ReadRef<'a>>(
Ok(offsets)
}
/// Result of extracting symbols from a binary file
#[derive(Debug, Clone)]
pub(crate) struct SymbolExtractionResult {
/// Assets found in the binary
pub assets: Vec<BundledAsset>,
/// Android plugin artifacts discovered in the binary
pub android_artifacts: Vec<AndroidArtifactMetadata>,
/// Swift packages discovered in the binary
pub swift_packages: Vec<SwiftPackageMetadata>,
}
/// Find all assets in the given file, hash them, and write them back to the file.
/// Also extracts Android/Swift plugin metadata for FFI bindings.
pub(crate) async fn extract_symbols_from_file(
path: impl AsRef<Path>,
) -> Result<SymbolExtractionResult> {
pub(crate) async fn extract_symbols_from_file(path: impl AsRef<Path>) -> Result<AppManifest> {
let path = path.as_ref();
let mut file = open_file_for_writing_with_timeout(
path,
std::fs::OpenOptions::new().write(true).read(true),
)
.await?;
let mut file =
open_file_for_writing_with_timeout(path, OpenOptions::new().write(true).read(true)).await?;
let mut file_contents = Vec::new();
file.read_to_end(&mut file_contents)?;
let mut reader = Cursor::new(&file_contents);
let read_cache = ReadCache::new(&mut reader);
let object_file = object::File::parse(&read_cache)?;
let offsets = find_symbol_offsets(path, &file_contents, &object_file)?;
let (offsets, obj_format) = {
let mut reader = Cursor::new(&file_contents);
let read_cache = ReadCache::new(&mut reader);
let object_file = object::File::parse(&read_cache)?;
(
find_symbol_offsets(path, &file_contents, &object_file)?,
object_file.format(),
)
};
let mut assets = Vec::new();
let mut android_artifacts = Vec::new();
@@ -824,7 +802,11 @@ pub(crate) async fn extract_symbols_from_file(
match entry.representation {
AssetRepresentation::RawBundled => {
tracing::debug!("Writing asset to offset {offset}: {:?}", asset);
tracing::debug!(
"Writing asset to offset {offset}: {:?} - {:?}",
asset.absolute_source_path(),
asset.bundled_path()
);
let new_data = version.serialize_asset(&asset);
if new_data.len() > version.size() {
tracing::warn!(
@@ -861,14 +843,15 @@ pub(crate) async fn extract_symbols_from_file(
.context("Failed to sync file after writing assets")?;
// If the file is a macos binary, we need to re-sign the modified binary
if object_file.format() == object::BinaryFormat::MachO && !assets.is_empty() {
if obj_format == object::BinaryFormat::MachO && !assets.is_empty() {
// Spawn the codesign command to re-sign the binary
let output = std::process::Command::new("codesign")
let output = tokio::process::Command::new("codesign")
.arg("--force")
.arg("--sign")
.arg("-") // Sign with an empty identity
.arg(path)
.output()
.await
.context("Failed to run codesign - is `codesign` in your path?")?;
if !output.status.success() {
bail!(
@@ -878,23 +861,15 @@ pub(crate) async fn extract_symbols_from_file(
}
}
Ok(SymbolExtractionResult {
assets,
android_artifacts,
swift_packages,
})
}
let mut manifest = AppManifest::new();
/// 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.
///
/// This is a convenience function that extracts symbols and returns only assets.
pub(crate) async fn extract_assets_from_file(path: impl AsRef<Path>) -> Result<AssetManifest> {
let result = extract_symbols_from_file(path).await?;
let mut manifest = AssetManifest::default();
for asset in result.assets {
for asset in assets {
manifest.insert_asset(asset);
}
manifest.android_artifacts = android_artifacts;
manifest.swift_sources = swift_packages;
Ok(manifest)
}
@@ -903,10 +878,10 @@ pub(crate) async fn extract_assets_from_file(path: impl AsRef<Path>) -> Result<A
/// This is useful on windows where antivirus software might grab the executable before we have a chance to read it.
async fn open_file_for_writing_with_timeout(
file: &Path,
options: &mut std::fs::OpenOptions,
options: &mut OpenOptions,
) -> Result<std::fs::File> {
let start_time = std::time::Instant::now();
let timeout = std::time::Duration::from_secs(5);
let start_time = Instant::now();
let timeout = Duration::from_secs(5);
loop {
match options.open(file) {
Ok(file) => return Ok(file),
@@ -916,7 +891,7 @@ async fn open_file_for_writing_with_timeout(
tracing::trace!(
"Failed to open file because another process is using it. Retrying..."
);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
tokio::time::sleep(Duration::from_millis(50)).await;
} else {
return Err(e.into());
}
@@ -946,3 +921,16 @@ fn write_serialized_bytes(
Ok(())
}
/// Extract all manganis symbols and their sections from the given object file.
fn manganis_symbols<'a, 'b, R: ReadRef<'a>>(
file: &'b File<'a, R>,
) -> impl Iterator<Item = (ManganisVersion, Symbol<'a, 'b, R>, Section<'a, 'b, R>)> + 'b {
file.symbols().filter_map(move |symbol| {
let name = symbol.name().ok()?;
let version = looks_like_manganis_symbol(name)?;
let section_index = symbol.section_index()?;
let section = file.section_by_index(section_index).ok()?;
Some((version, symbol, section))
})
}
+469 -130
View File
@@ -1,8 +1,7 @@
use crate::opt::process_file_to;
use crate::{opt::process_file_to, BuildPhaseProfile};
use crate::{
build::cache::ObjectCache, serve::WebServer, verbosity_or_default, BuildArtifacts,
BuildRequest, BuildStage, BuilderUpdate, BundleFormat, ProgressRx, ProgressTx, Result,
RustcArgs, StructuredOutput,
serve::WebServer, verbosity_or_default, BuildArtifacts, BuildRequest, BuildStage,
BuilderUpdate, BundleFormat, ProgressRx, ProgressTx, Result, StructuredOutput,
};
use anyhow::{bail, Context, Error};
use futures_util::{future::OptionFuture, pin_mut, FutureExt};
@@ -10,7 +9,7 @@ use itertools::Itertools;
use std::{
collections::HashSet,
env,
time::{Duration, Instant, SystemTime},
time::{Duration, SystemTime},
};
use std::{
net::SocketAddr,
@@ -98,10 +97,10 @@ pub(crate) struct AppBuilder {
pub compiled_crates: usize,
pub expected_crates: usize,
pub bundling_progress: f64,
pub compile_start: Option<Instant>,
pub compile_end: Option<Instant>,
pub bundle_start: Option<Instant>,
pub bundle_end: Option<Instant>,
pub compile_start: Option<SystemTime>,
pub compile_end: Option<SystemTime>,
pub bundle_start: Option<SystemTime>,
pub bundle_end: Option<SystemTime>,
/// The debugger for the app - must be enabled with the `d` key
pub(crate) pid: Option<u32>,
@@ -110,8 +109,8 @@ pub(crate) struct AppBuilder {
/// Each patch includes objects from ALL crates in this set.
pub modified_crates: HashSet<String>,
/// Cache of the latest `.rcgu.o` files for each modified workspace crate.
pub object_cache: ObjectCache,
/// The build profiling spans for us to generate a flamegraph from.
pub profile_spans: Vec<BuildPhaseProfile>,
}
impl AppBuilder {
@@ -153,7 +152,7 @@ impl AppBuilder {
expected_crates: 1,
bundling_progress: 0.0,
builds_opened: 0,
compile_start: Some(Instant::now()),
compile_start: Some(SystemTime::now()),
aslr_reference: None,
compile_end: None,
bundle_start: None,
@@ -169,7 +168,7 @@ impl AppBuilder {
patch_cache: None,
pid: None,
modified_crates: HashSet::new(),
object_cache: ObjectCache::new(&request.session_cache_dir()),
profile_spans: Vec::new(),
})
}
@@ -181,19 +180,13 @@ impl AppBuilder {
}
pub(crate) fn start(&mut self, mode: BuildMode, build_id: BuildId) {
self.build_task = tokio::spawn({
let request = self.build.clone();
let tx = self.tx.clone();
async move {
let ctx = BuildContext {
mode,
build_id,
tx: tx.clone(),
};
request.verify_tooling(&ctx).await?;
request.prebuild(&ctx).await?;
request.build(&ctx).await
}
let request = self.build.clone();
let tx = self.tx.clone();
self.build_task = tokio::spawn(async move {
let ctx = BuildContext::new(tx, mode, build_id);
request.verify_tooling(&ctx).await?;
request.prebuild(&ctx).await?;
request.build(ctx).await
});
}
@@ -260,69 +253,88 @@ impl AppBuilder {
// doing so will cause the changes to be lost since this wait call is called under a cancellable task
// todo - move this handling to a separate function that won't be cancelled
match &update {
BuilderUpdate::Progress { stage } => {
BuilderUpdate::Progress { .. } if self.is_finished() => {
// Prevent updates from flowing in after the build has already finished
if !self.is_finished() {
self.stage = stage.clone();
}
BuilderUpdate::Progress { stage } => {
self.stage = stage.clone();
match stage {
BuildStage::Initializing => {
self.compiled_crates = 0;
self.bundling_progress = 0.0;
}
BuildStage::Starting { crate_count, .. } => {
self.expected_crates = *crate_count.max(&1);
}
BuildStage::InstallingTooling => {}
BuildStage::Compiling { current, total, .. } => {
self.compiled_crates = *current;
self.expected_crates = *total.max(&1);
if self.compile_start.is_none() {
self.compile_start = Some(Instant::now());
}
}
BuildStage::Bundling => {
self.complete_compile();
self.bundling_progress = 0.0;
self.bundle_start = Some(Instant::now());
}
BuildStage::OptimizingWasm => {}
BuildStage::CopyingAssets { current, total, .. } => {
self.bundling_progress = *current as f64 / *total as f64;
}
BuildStage::Success => {
self.compiled_crates = self.expected_crates;
self.bundling_progress = 1.0;
}
BuildStage::Failed => {
self.compiled_crates = self.expected_crates;
self.bundling_progress = 1.0;
}
BuildStage::Aborted => {}
BuildStage::Restarting => {
self.compiled_crates = 0;
self.expected_crates = 1;
self.bundling_progress = 0.0;
}
BuildStage::RunningBindgen => {}
_ => {}
match stage {
BuildStage::Initializing => {
self.profile_spans.clear();
self.compiled_crates = 0;
self.bundling_progress = 0.0;
}
BuildStage::Starting { crate_count, .. } => {
self.expected_crates = *crate_count.max(&1);
}
BuildStage::InstallingTooling => {}
BuildStage::Compiling { current, total, .. } => {
self.compiled_crates = *current;
self.expected_crates = *total.max(&1);
if self.compile_start.is_none() {
self.compile_start = Some(SystemTime::now());
}
}
BuildStage::Bundling => {
self.bundling_progress = 0.0;
self.bundle_start = Some(SystemTime::now());
if self.compile_end.is_none() {
self.compiled_crates = self.expected_crates;
self.compile_end = Some(SystemTime::now());
}
}
BuildStage::OptimizingWasm => {}
BuildStage::CopyingAssets { current, total, .. } => {
self.bundling_progress = *current as f64 / *total as f64;
}
BuildStage::Success => {
self.compiled_crates = self.expected_crates;
self.bundling_progress = 1.0;
}
BuildStage::Failed => {
self.compiled_crates = self.expected_crates;
self.bundling_progress = 1.0;
}
BuildStage::Aborted => {}
BuildStage::Restarting => {
self.compiled_crates = 0;
self.expected_crates = 1;
self.bundling_progress = 0.0;
}
BuildStage::RunningBindgen => {}
_ => {}
}
}
BuilderUpdate::CompilerMessage { .. } => {}
BuilderUpdate::BuildReady { .. } => {
BuilderUpdate::BuildReady { ref bundle } => {
// Log the build completion as a telemetry event + provide analytics on build phases
self.log_flamegraph_and_telemetry(bundle);
// And then update the build state
self.compiled_crates = self.expected_crates;
self.bundling_progress = 1.0;
self.stage = BuildStage::Success;
self.complete_compile();
self.bundle_end = Some(Instant::now());
self.bundle_end = Some(SystemTime::now());
if self.compile_end.is_none() {
self.compiled_crates = self.expected_crates;
self.compile_end = Some(SystemTime::now());
}
}
BuilderUpdate::BuildFailed { .. } => {
tracing::debug!("Setting builder to failed state");
self.stage = BuildStage::Failed;
}
BuilderUpdate::ProfilePhase { profile } => {
// Collapse consecutive entries with the same label so e.g. per-crate "Compiling"
// calls show up as a single span instead of hundreds.
if self.profile_spans.last().map(|p| p.label) != Some(profile.label) {
self.profile_spans.push(profile.clone());
}
}
BuilderUpdate::CompilerMessage { .. } => {}
StdoutReceived { .. } => {}
StderrReceived { .. } => {}
ProcessExited { .. } => {}
@@ -332,6 +344,23 @@ impl AppBuilder {
update
}
/// Kick off a thin (hotpatch) rebuild in response to a file change, replacing any in-flight
/// build with one that produces a relinkable patch dylib (later applied by [`AppBuilder::hotpatch`]).
///
/// Bails early if there's no prior fat build to link against, or if we're missing the ASLR
/// reference on a non-wasm target (which means no client is connected yet to report it).
///
/// Updates the cumulative `modified_crates` set: every patch is self-contained and contains
/// the latest version of *every* crate modified since the fat build, not just the one that
/// changed this iteration. We BFS `workspace_dependents_of` from `changed_crates` to catch
/// the cascade — e.g. editing a leaf crate forces parent crates' generic instantiations to
/// change too. (`compile_workspace_deps` does its own walk to decide what to *compile* now;
/// this set tracks what to *link* into the patch.)
///
/// Then aborts any in-flight build and spawns a fresh task with [`BuildMode::Thin`], handing
/// it the fat build's `workspace_rustc_args`/`artifact_paths` (so rustc gets re-invoked with
/// identical flags), the `HotpatchModuleCache` (symbol map for `create_jump_table`), and the
/// current `object_cache` (per-crate `.rcgu.o` files used for assembly diffing).
pub(crate) fn patch_rebuild(
&mut self,
changed_files: Vec<PathBuf>,
@@ -405,22 +434,22 @@ impl AppBuilder {
// Abort all the ongoing builds, cleaning up any loose artifacts and waiting to cleanly exit
self.abort_all(BuildStage::Restarting);
self.compile_start = Some(SystemTime::now());
self.profile_spans.clear();
self.build_task = tokio::spawn({
let request = self.build.clone();
let ctx = BuildContext {
build_id,
tx: self.tx.clone(),
mode: BuildMode::Thin {
let ctx = BuildContext::new(
self.tx.clone(),
BuildMode::Thin {
changed_files,
changed_crates,
modified_crates: self.modified_crates.clone(),
workspace_rustc_args: artifacts.workspace_rustc_args,
workspace_rustc_args: artifacts.workspace_rustc,
aslr_reference,
cache,
object_cache: self.object_cache.clone(),
},
};
async move { request.build(&ctx).await }
build_id,
);
async move { request.build(ctx).await }
});
}
@@ -434,15 +463,14 @@ impl AppBuilder {
// A full rebuild resets all accumulated hotpatch state — the fat binary is a clean baseline.
self.modified_crates.clear();
self.object_cache = ObjectCache::new(&self.build.session_cache_dir());
self.profile_spans.clear();
self.build_task = tokio::spawn({
let request = self.build.clone();
let ctx = BuildContext {
tx: self.tx.clone(),
mode,
build_id,
};
async move { request.build(&ctx).await }
let tx = self.tx.clone();
async move {
let ctx = BuildContext::new(tx, mode, build_id);
request.build(ctx).await
}
});
}
@@ -520,6 +548,7 @@ impl AppBuilder {
return Err(err);
}
BuilderUpdate::ProfilePhase { .. } => {}
BuilderUpdate::StdoutReceived { .. } => {}
BuilderUpdate::StderrReceived { .. } => {}
BuilderUpdate::ProcessExited { .. } => {}
@@ -536,7 +565,7 @@ impl AppBuilder {
///
/// Note that Dioxus apps *should not* rely on this vars being set, but libraries like Bevy do.
pub(crate) fn child_environment_variables(
&mut self,
&self,
devserver_ip: Option<SocketAddr>,
start_fullstack_on_address: Option<SocketAddr>,
always_on_top: bool,
@@ -605,22 +634,16 @@ impl AppBuilder {
));
}
// If there's any CARGO vars in the rustc_wrapper files, push those too.
// Read from any per-crate args file in the directory (they all share the same CARGO_ envs).
if let Ok(entries) = std::fs::read_dir(self.build.rustc_wrapper_args_dir()) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "json") {
if let Ok(contents) = std::fs::read_to_string(&path) {
if let Ok(args) = serde_json::from_str::<RustcArgs>(&contents) {
for (key, value) in args.envs {
if key.starts_with("CARGO_") {
envs.push((key, value));
}
}
break; // Only need one file for CARGO_ env vars
}
}
// If there's any CARGO vars in the captured rustc args, push those too.
// Any captured invocation is sufficient since the CARGO_ environment is shared.
if let Some(args) = self
.artifacts
.as_ref()
.and_then(|artifacts| artifacts.workspace_rustc.rustc_args.values().next())
{
for (key, value) in args.envs.iter().cloned() {
if key.starts_with("CARGO_") {
envs.push((key, value));
}
}
}
@@ -735,6 +758,56 @@ impl AppBuilder {
}
}
/// Apply the artifacts produced by a thin (hotpatch) build to the currently running app and
/// return a [`JumpTable`] that the runtime can use to swap function pointers in place.
///
/// This is the CLI-side counterpart to `subsecond`'s runtime patch loader. A thin build
/// produces a self-contained dylib (`patch_exe`) that links against the symbols already
/// resident in the running process. To make that dylib actually take effect, this function
/// has to do four things, in order:
///
/// 1. **Reconcile new assets.** Walks `res.assets` and, for any `asset!()` reference that
/// wasn't in the previous build, processes the source file (via `process_file_to`, which
/// runs the asset through the appropriate optimizer — esbuild for JS/CSS, image
/// re-encoding, etc.) and copies it into the live `asset_dir`. New assets are also
/// inserted into `self.artifacts.assets` so subsequent patches see them as already-known.
/// On Android the asset is additionally `adb push`'d to `/data/local/tmp/dx/assets/...`
/// so the on-device app can read it.
///
/// 2. **Extend the file watcher set.** New `include!()` / `include_str!()` / `include_bytes!()`
/// targets discovered in `res.depinfo.files` are appended to the existing artifacts'
/// depinfo so the file watcher will pick up future edits to them.
///
/// 3. **Build the jump table.** Calls [`BuildRequest::create_jump_table`] with the new dylib
/// and the [`HotpatchModuleCache`] (which holds the symbol map from the original fat
/// build). The cache is what lets us resolve "function `foo` in the new dylib" to "the
/// address of `foo` in the running process" so the runtime can patch the call sites. On
/// Android the dylib itself is also pushed to the device and `jump_table.lib` is
/// rewritten to point at the on-device path, since the runtime will `dlopen` it from
/// there rather than from the host filesystem.
///
/// 4. **Commit local state.** The jump table is appended to `self.patches` (the cumulative
/// history of patches applied to this run of the app — used so that a fresh client
/// connecting mid-session can be brought up to date by replaying every patch in order),
/// and `self.object_cache` is overwritten with `res.object_cache` so the next thin build
/// diffs against the objects this patch produced rather than the previous tip.
///
/// The returned [`JumpTable`] is what gets shipped to the runtime over the devserver
/// websocket; the runtime then `dlopen`s `jump_table.lib` and rewrites the trampolines
/// described by the table.
///
/// # Errors
///
/// Returns an error if there are no prior `artifacts` to patch against (i.e. no fat build
/// has completed yet), if `create_jump_table` fails (typically a symbol-resolution failure
/// indicating the patch references something that doesn't exist in the base binary), or if
/// the Android `adb push` of the dylib fails. Failures while copying individual assets are
/// logged and skipped rather than aborting the patch.
///
/// # Panics
///
/// Panics if `res.mode` is [`BuildMode::Thin`] but `changed_files` is empty — every thin
/// build is triggered by at least one file change, so this should be unreachable in practice.
pub(crate) async fn hotpatch(
&mut self,
res: &BuildArtifacts,
@@ -742,7 +815,7 @@ impl AppBuilder {
) -> Result<JumpTable> {
let original = self.build.main_exe();
let new = self.build.patch_exe(res.time_start);
let asset_dir = self.build.asset_dir();
let asset_dir = self.build.bundle_asset_dir();
// Hotpatch asset!() calls
for bundled in res.assets.unique_assets() {
@@ -822,9 +895,6 @@ impl AppBuilder {
// Commit this patch
self.patches.push(jump_table.clone());
// Sync the updated object cache back from the build artifacts.
self.object_cache = res.object_cache.clone();
Ok(jump_table)
}
@@ -850,7 +920,7 @@ impl AppBuilder {
// 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(),
None => self.build.bundle_asset_dir(),
};
// Canonicalize the path as Windows may use long-form paths "\\\\?\\C:\\".
@@ -1558,32 +1628,23 @@ impl AppBuilder {
main_exe
}
fn complete_compile(&mut self) {
if self.compile_end.is_none() {
self.compiled_crates = self.expected_crates;
self.compile_end = Some(Instant::now());
}
}
/// Get the total duration of the build, if all stages have completed
pub(crate) fn total_build_time(&self) -> Option<Duration> {
Some(self.compile_duration()? + self.bundle_duration()?)
}
pub(crate) fn compile_duration(&self) -> Option<Duration> {
Some(
self.compile_end
.unwrap_or_else(Instant::now)
.duration_since(self.compile_start?),
)
self.compile_end
.unwrap_or_else(SystemTime::now)
.duration_since(self.compile_start?)
.ok()
}
pub(crate) fn bundle_duration(&self) -> Option<Duration> {
Some(
self.bundle_end
.unwrap_or_else(Instant::now)
.duration_since(self.bundle_start?),
)
self.bundle_end
.unwrap_or_else(SystemTime::now)
.duration_since(self.bundle_start?)
.ok()
}
/// Return a number between 0 and 1 representing the progress of the app build
@@ -1868,4 +1929,282 @@ impl AppBuilder {
Ok(pid)
}
/// Log the profile flamegraph for this build run, and then emit a telemetry event.
///
/// This works by walking the profile_spans vec, calculating the deltas, and then rendering out
/// the flamegraph via a debug!()
///
/// The flamegraph looks something like this:
///
/// ```txt
/// 17:22:56 [dev] Flamegraph for fat - time taken: 11371ms
/// 0ms 0.0% Verify Tooling |█ |
/// 0ms 0.0% Installing Tooling |█ |
/// 100ms 0.9% Prebuild |██ |
/// 307ms 2.7% Workspace precompile | ███ |
/// 1400ms 12.3% Compiling | █████████████ |
/// 1325ms 11.7% Fat Linking | ████████████ |
/// 915ms 8.0% Extracting assets | █████████ |
/// 1529ms 13.4% Writing executable | ██████████████ |
/// 4685ms 41.2% Wasm Bindgen | █████████████████████████████████████████ |
/// 1061ms 9.3% Creating Patch Cache | █████████|
/// ```
fn log_flamegraph_and_telemetry(&self, bundle: &BuildArtifacts) {
let Some(start) = self.compile_start else {
tracing::debug!("no compile start?");
return;
};
// Record the build duration as a telemetry event. Capture `now` once and use
// it as the end of the final phase too, so the flamegraph bars stay
// internally consistent with `time_taken`.
let now = SystemTime::now();
let time_taken = now
.duration_since(start)
.unwrap_or_default()
.as_millis()
.max(1);
tracing::debug!(
telemetry = %serde_json::json!({
"event": "build_and_bundle_complete",
"time_taken": time_taken,
"mode": match bundle.mode {
BuildMode::Base { .. } => "base",
BuildMode::Fat => "fat",
BuildMode::Thin { .. } => "thin",
},
"blah": 123,
"triple": self.build.triple.to_string(),
"format": self.build.bundle.to_string(),
"num_dependencies": self.build.workspace.krates.len(),
}),
"Build completed in {time_taken}ms",
);
// Render the flamegraph out by walking the span history. This is pretty naive
// and doesn't support nested span contexts (yet!) - all phases live on one row
// and a phase ends where the next one begins.
use std::fmt::Write as _;
let total_ms = time_taken as usize;
let timeline_width = 96usize;
let max_label_width = self
.profile_spans
.iter()
.map(|phase| phase.label.len())
.max()
.unwrap_or(0)
.min(28);
// The final phase runs right up until `now`. Don't use `compile_end` here:
// it's set when the Bundling stage starts, so it predates the bundling phases
// themselves and would zero out the last bar.
let phase_end = now;
let mut flamegraph = format!(
"Flamegraph for {} - time taken: {}ms",
match bundle.mode {
BuildMode::Base { .. } => "base",
BuildMode::Fat => "fat",
BuildMode::Thin { .. } => "thin",
},
time_taken
);
let mut phase_iter = self.profile_spans.iter().peekable();
while let Some(phase) = phase_iter.next() {
let end_time = phase_iter.peek().map(|f| f.start).unwrap_or(phase_end);
let offset_ms = phase
.start
.duration_since(start)
.unwrap_or_default()
.as_millis() as usize;
let dur_ms = end_time
.duration_since(phase.start)
.unwrap_or_default()
.as_millis() as usize;
let pct = (dur_ms as f64) * 100.0 / (total_ms as f64);
let bar_start = ((offset_ms * timeline_width) / total_ms).min(timeline_width - 1);
let bar_end = (((offset_ms + dur_ms) * timeline_width).div_ceil(total_ms))
.max(bar_start + 1)
.min(timeline_width);
let mut bar = vec![' '; timeline_width];
for ch in &mut bar[bar_start..bar_end] {
*ch = '█';
}
let bar: String = bar.into_iter().collect();
// Labels are ASCII so byte slicing is safe; truncate so the bar column
// stays aligned across rows.
let label = if phase.label.len() > max_label_width {
&phase.label[..max_label_width]
} else {
phase.label
};
let _ = write!(
flamegraph,
"\n {:>6}ms {:>5.1}% {:<width$} |{}|",
dur_ms,
pct,
label,
bar,
width = max_label_width
);
}
tracing::debug!("{}", flamegraph);
}
/// Pre-render the static routes, performing static-site generation
pub(crate) async fn pre_render_static_routes(
&mut self,
devserver_ip: Option<SocketAddr>,
updates: Option<&futures_channel::mpsc::UnboundedSender<BuilderUpdate>>,
) -> anyhow::Result<()> {
use super::{BuildId, BuilderUpdate};
use anyhow::Context;
use dioxus_cli_config::{server_ip, server_port};
use dioxus_dx_wire_format::BuildStage;
use futures_util::{stream::FuturesUnordered, StreamExt};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::Duration,
};
use tokio::process::Command;
if let Some(updates) = updates {
updates
.unbounded_send(BuilderUpdate::Progress {
stage: BuildStage::Prerendering,
})
.unwrap();
}
let server_exe = self.build.main_exe();
// Use the address passed in through environment variables or default to localhost:9999. We need
// to default to a value that is different than the CLI default address to avoid conflicts
let ip = server_ip().unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
let port = server_port().unwrap_or(9999);
let fullstack_address = SocketAddr::new(ip, port);
let address = fullstack_address.ip().to_string();
let port = fullstack_address.port().to_string();
// Borrow port and address so we can easily move them into multiple tasks below
let address = &address;
let port = &port;
tracing::info!("Running SSG at http://{address}:{port} for {server_exe:?}");
let vars = self.child_environment_variables(
devserver_ip,
Some(fullstack_address),
false,
BuildId::SECONDARY,
);
// Run the server executable
let _child = Command::new(&server_exe)
.envs(vars)
.current_dir(server_exe.parent().unwrap())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.kill_on_drop(true)
.spawn()?;
// Borrow reqwest_client so we only move the reference into the futures
let reqwest_client = reqwest::Client::new();
let reqwest_client = &reqwest_client;
// Get the routes from the `/static_routes` endpoint
let mut routes = None;
// The server may take a few seconds to start up. Try fetching the route up to 5 times with a one second delay
const RETRY_ATTEMPTS: usize = 5;
for i in 0..=RETRY_ATTEMPTS {
tracing::debug!(
"Attempting to get static routes from server. Attempt {i} of {RETRY_ATTEMPTS}"
);
let request = reqwest_client
.post(format!("http://{address}:{port}/api/static_routes"))
.body("{}".to_string())
.send()
.await;
match request {
Ok(request) => {
routes = Some(request
.json::<Vec<String>>()
.await
.inspect(|text| tracing::debug!("Got static routes: {text:?}"))
.context("Failed to parse static routes from the server. Make sure your server function returns Vec<String> with the (default) json encoding")?);
break;
}
Err(err) => {
// If the request fails, try up to 5 times with a one second delay
// If it fails 5 times, return the error
if i == RETRY_ATTEMPTS {
return Err(err).context("Failed to get static routes from server. Make sure you have a server function at the `/api/static_routes` endpoint that returns Vec<String> of static routes.");
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
}
let routes = routes.expect(
"static routes should exist or an error should have been returned on the last attempt",
);
// Create a pool of futures that cache each route
let mut resolved_routes = routes
.into_iter()
.map(|route| async move {
tracing::info!("Rendering {route} for SSG");
// For each route, ping the server to force it to cache the response for ssg
let request = reqwest_client
.get(format!("http://{address}:{port}{route}"))
.header("Accept", "text/html")
.send()
.await?;
// If it takes longer than 30 seconds to resolve the route, log a warning
let warning_task = tokio::spawn({
let route = route.clone();
async move {
tokio::time::sleep(Duration::from_secs(30)).await;
tracing::warn!("Route {route} has been rendering for 30 seconds");
}
});
// Wait for the streaming response to completely finish before continuing. We don't use the html it returns directly
// because it may contain artifacts of intermediate streaming steps while the page is loading. The SSG app should write
// the final clean HTML to the disk automatically after the request completes.
let _html = request.text().await?;
// Cancel the warning task if it hasn't already run
warning_task.abort();
Ok::<_, reqwest::Error>(route)
})
.collect::<FuturesUnordered<_>>();
while let Some(route) = resolved_routes.next().await {
match route {
Ok(route) => tracing::debug!("ssg success: {route:?}"),
Err(err) => tracing::error!("ssg error: {err:?}"),
}
}
tracing::info!("SSG complete");
drop(_child);
Ok(())
}
}
-119
View File
@@ -1,119 +0,0 @@
//! Object file cache for workspace hotpatching.
//!
//! Maintains the latest `.rcgu.o` files for each crate in the cumulative `modified_crates` set.
//! Used for accumulated relinking: combining objects from all modified crates into a single
//! patch dylib.
//!
//! Objects are stored on disk under `session_cache_dir/object_cache/{crate_name}/` so they
//! persist across patches without holding file contents in memory. The session cache dir
//! lives in `/tmp/` and is cleaned up by the OS.
//!
//! - **Dep crates:** objects are extracted from their rlib in `target/deps/`.
//! - **Tip crate:** objects are copied from linker arg paths since incremental compilation
//! overwrites them in place.
use std::collections::HashMap;
use std::io::Read;
use std::path::{Path, PathBuf};
/// Cache of compiled object files on disk, keyed by crate name.
///
/// After each compilation, the cache is updated for the compiled crate by extracting
/// objects to `dir/{crate_name}/`. On relink, paths from all crates in `modified_crates`
/// are passed directly to the linker — no intermediate copy needed.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ObjectCache {
/// Root directory: `session_cache_dir/object_cache/`
dir: PathBuf,
/// crate_name -> object file paths on disk
objects: HashMap<String, Vec<PathBuf>>,
}
impl ObjectCache {
pub fn new(session_cache_dir: &Path) -> Self {
let dir = session_cache_dir.join("object_cache");
Self {
dir,
objects: HashMap::new(),
}
}
/// Extract `.rcgu.o` files from an rlib archive and write them to
/// `dir/{crate_name}/`. Replaces any previously cached objects for this crate.
pub fn cache_from_rlib(&mut self, crate_name: &str, rlib_path: &Path) -> anyhow::Result<()> {
let crate_dir = self.dir.join(crate_name);
// Clear previous objects for this crate
if crate_dir.exists() {
std::fs::remove_dir_all(&crate_dir)?;
}
std::fs::create_dir_all(&crate_dir)?;
let rlib_contents = std::fs::read(rlib_path)?;
let mut reader = ar::Archive::new(std::io::Cursor::new(rlib_contents));
let mut paths = Vec::new();
while let Some(Ok(mut entry)) = reader.next_entry() {
let name = std::str::from_utf8(entry.header().identifier())
.unwrap_or_default()
.to_string();
// Skip rmeta and empty entries
if name.ends_with(".rmeta") || entry.header().size() == 0 {
continue;
}
// Only keep object files
if !name.ends_with(".o") {
continue;
}
let mut data = Vec::with_capacity(entry.header().size() as usize);
entry.read_to_end(&mut data)?;
let obj_path = crate_dir.join(&name);
std::fs::write(&obj_path, &data)?;
paths.push(obj_path);
}
self.objects.insert(crate_name.to_string(), paths);
Ok(())
}
/// Cache tip crate objects by copying them from their filesystem paths.
///
/// Tip crate `.rcgu.o` files get overwritten on recompilation, so we copy
/// them into our cache directory for stable access.
pub fn cache_from_paths(
&mut self,
crate_name: &str,
object_paths: &[impl AsRef<Path>],
) -> anyhow::Result<()> {
let crate_dir = self.dir.join(crate_name);
if crate_dir.exists() {
std::fs::remove_dir_all(&crate_dir)?;
}
std::fs::create_dir_all(&crate_dir)?;
let mut paths = Vec::with_capacity(object_paths.len());
for path in object_paths {
let path = path.as_ref();
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
let dest = crate_dir.join(name);
std::fs::copy(path, &dest)?;
paths.push(dest);
}
self.objects.insert(crate_name.to_string(), paths);
Ok(())
}
/// Get cached object file paths for a crate.
pub fn get(&self, crate_name: &str) -> Option<&Vec<PathBuf>> {
self.objects.get(crate_name)
}
}
+42 -10
View File
@@ -5,7 +5,7 @@ use crate::{BuildArtifacts, BuildStage, Error, TraceSrc};
use cargo_metadata::diagnostic::Diagnostic;
use futures_channel::mpsc::{UnboundedReceiver, UnboundedSender};
use serde::{Deserialize, Serialize};
use std::{path::PathBuf, process::ExitStatus};
use std::{path::PathBuf, process::ExitStatus, time::SystemTime};
/// The context of the build process. While the BuildRequest is a "plan" for the build, the BuildContext
/// provides some dynamic configuration that is only known at runtime. For example, the Progress channel
@@ -35,6 +35,10 @@ pub enum BuilderUpdate {
stage: BuildStage,
},
ProfilePhase {
profile: BuildPhaseProfile,
},
CompilerMessage {
message: Diagnostic,
},
@@ -78,20 +82,41 @@ pub enum BuilderUpdate {
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuildPhaseProfile {
pub label: &'static str,
pub start: SystemTime,
}
impl BuildContext {
pub(crate) fn new(tx: ProgressTx, mode: BuildMode, build_id: BuildId) -> Self {
Self { tx, mode, build_id }
}
/// Returns true if this is a client build - basically, is this the primary build?
/// We try not to duplicate work between client and server builds, like asset copying.
pub(crate) fn is_primary_build(&self) -> bool {
self.build_id == BuildId::PRIMARY
}
pub(crate) fn profile_phase(&self, label: &'static str) {
_ = self.tx.unbounded_send(BuilderUpdate::ProfilePhase {
profile: BuildPhaseProfile {
label,
start: SystemTime::now(),
},
});
}
pub(crate) fn status_wasm_bindgen_start(&self) {
self.profile_phase("Wasm Bindgen");
_ = self.tx.unbounded_send(BuilderUpdate::Progress {
stage: BuildStage::RunningBindgen,
});
}
pub(crate) fn status_splitting_bundle(&self) {
self.profile_phase("Wasm Split");
_ = self.tx.unbounded_send(BuilderUpdate::Progress {
stage: BuildStage::SplittingBundle,
});
@@ -104,12 +129,14 @@ impl BuildContext {
}
pub(crate) fn status_running_gradle(&self) {
self.profile_phase("Gradle");
_ = self.tx.unbounded_send(BuilderUpdate::Progress {
stage: BuildStage::RunningGradle,
})
}
pub(crate) fn status_compiling_native_plugins(&self, detail: impl Into<String>) {
self.profile_phase("Compiling Native Plugins");
_ = self.tx.unbounded_send(BuilderUpdate::Progress {
stage: BuildStage::CompilingNativePlugins {
detail: detail.into(),
@@ -118,6 +145,7 @@ impl BuildContext {
}
pub(crate) fn status_codesigning(&self) {
self.profile_phase("Code Signing");
_ = self.tx.unbounded_send(BuilderUpdate::Progress {
stage: BuildStage::CodeSigning,
});
@@ -144,6 +172,7 @@ impl BuildContext {
name: String,
fresh: bool,
) {
self.profile_phase("Compiling");
_ = self.tx.unbounded_send(BuilderUpdate::Progress {
stage: BuildStage::Compiling {
current: count,
@@ -155,6 +184,7 @@ impl BuildContext {
}
pub(crate) fn status_starting_build(&self, crate_count: usize) {
self.profile_phase("Compiling");
_ = self.tx.unbounded_send(BuilderUpdate::Progress {
stage: BuildStage::Starting {
patch: matches!(self.mode, BuildMode::Thin { .. }),
@@ -163,19 +193,16 @@ impl BuildContext {
});
}
pub(crate) fn status_starting_link(&self) {
pub(crate) fn status_starting_fat_link(&self) {
self.profile_phase("Fat Linking");
_ = self.tx.unbounded_send(BuilderUpdate::Progress {
stage: BuildStage::Linking,
});
}
pub(crate) fn status_copied_asset(
progress: &UnboundedSender<BuilderUpdate>,
current: usize,
total: usize,
path: PathBuf,
) {
_ = progress.unbounded_send(BuilderUpdate::Progress {
pub(crate) fn status_copied_asset(&self, current: usize, total: usize, path: PathBuf) {
self.profile_phase("Copying Assets");
_ = self.tx.unbounded_send(BuilderUpdate::Progress {
stage: BuildStage::CopyingAssets {
current,
total,
@@ -185,29 +212,34 @@ impl BuildContext {
}
pub(crate) fn status_optimizing_wasm(&self) {
self.profile_phase("Optimizing Wasm");
_ = self.tx.unbounded_send(BuilderUpdate::Progress {
stage: BuildStage::OptimizingWasm,
});
}
pub(crate) fn status_hotpatching(&self) {
pub(crate) fn status_writing_patch(&self) {
_ = self.tx.unbounded_send(BuilderUpdate::Progress {
stage: BuildStage::Hotpatching,
});
}
pub(crate) fn status_installing_tooling(&self) {
self.profile_phase("Installing Tooling");
_ = self.tx.unbounded_send(BuilderUpdate::Progress {
stage: BuildStage::InstallingTooling,
});
}
pub(crate) fn status_compressing_assets(&self) {
self.profile_phase("Compressing Assets");
_ = self.tx.unbounded_send(BuilderUpdate::Progress {
stage: BuildStage::CompressingAssets,
});
}
pub(crate) fn status_extracting_assets(&self) {
self.profile_phase("Extracting assets");
_ = self.tx.unbounded_send(BuilderUpdate::Progress {
stage: BuildStage::ExtractingAssets,
});
-915
View File
@@ -1,915 +0,0 @@
//! iOS/macOS Swift package manifest helpers and compilation.
use crate::Result;
use anyhow::Context;
use manganis_core::SwiftPackageMetadata;
use std::path::{Path, PathBuf};
use target_lexicon::{OperatingSystem, Triple};
use tokio::process::Command;
/// Create a proper framework bundle from a dylib for iOS/macOS.
///
/// iOS uses a flat structure while macOS uses a versioned structure.
/// Both require an Info.plist for proper App Store submission.
pub async fn create_framework_bundle(
dylib_path: &Path,
framework_name: &str,
output_dir: &Path,
target_triple: &Triple,
bundle_identifier: &str,
) -> Result<PathBuf> {
let is_ios = matches!(target_triple.operating_system, OperatingSystem::IOS(_));
let min_os_version = if is_ios { "13.0" } else { "11.0" };
let framework_dir = output_dir.join(format!("{}.framework", framework_name));
// Remove existing framework if present
if framework_dir.exists() {
std::fs::remove_dir_all(&framework_dir)?;
}
if is_ios {
// iOS uses flat structure: Framework.framework/FrameworkName + Info.plist
std::fs::create_dir_all(&framework_dir)?;
// Copy dylib as the framework executable (no extension)
let exec_path = framework_dir.join(framework_name);
std::fs::copy(dylib_path, &exec_path)?;
// Set the install name using install_name_tool
let output = Command::new("xcrun")
.arg("install_name_tool")
.arg("-id")
.arg(format!(
"@rpath/{}.framework/{}",
framework_name, framework_name
))
.arg(&exec_path)
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("install_name_tool failed: {}", stderr);
}
// Create Info.plist
let info_plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>{framework_name}</string>
<key>CFBundleIdentifier</key>
<string>{bundle_identifier}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>{framework_name}</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>MinimumOSVersion</key>
<string>{min_os_version}</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>iPhoneOS</string>
</array>
</dict>
</plist>"#
);
std::fs::write(framework_dir.join("Info.plist"), info_plist)?;
} else {
// macOS uses versioned structure with symlinks
let versions_a = framework_dir.join("Versions").join("A");
let resources_dir = versions_a.join("Resources");
std::fs::create_dir_all(&resources_dir)?;
// Copy dylib as the framework executable
let exec_path = versions_a.join(framework_name);
std::fs::copy(dylib_path, &exec_path)?;
// Set install name
let output = Command::new("xcrun")
.arg("install_name_tool")
.arg("-id")
.arg(format!(
"@rpath/{}.framework/Versions/A/{}",
framework_name, framework_name
))
.arg(&exec_path)
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("install_name_tool failed: {}", stderr);
}
// Create Info.plist in Resources
let info_plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>{framework_name}</string>
<key>CFBundleIdentifier</key>
<string>{bundle_identifier}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>{framework_name}</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>{min_os_version}</string>
</dict>
</plist>"#
);
std::fs::write(resources_dir.join("Info.plist"), info_plist)?;
// Create symbolic links (required for macOS framework structure)
#[cfg(unix)]
{
let versions_dir = framework_dir.join("Versions");
std::os::unix::fs::symlink("A", versions_dir.join("Current"))?;
std::os::unix::fs::symlink(
format!("Versions/Current/{}", framework_name),
framework_dir.join(framework_name),
)?;
std::os::unix::fs::symlink(
"Versions/Current/Resources",
framework_dir.join("Resources"),
)?;
}
}
tracing::debug!(
"Created {} framework bundle: {}",
if is_ios { "iOS" } else { "macOS" },
framework_dir.display()
);
Ok(framework_dir)
}
/// Compile Swift sources and return the path to the dynamic framework bundle.
///
/// This function:
/// 1. Generates an umbrella Package.swift that includes all Swift plugins
/// 2. Runs `swift build` to compile into a dynamic library
/// 3. Wraps the dylib in a proper .framework bundle for iOS/macOS
/// 4. Returns the path to the resulting `.framework` bundle
pub async fn compile_swift_sources(
swift_sources: &[SwiftPackageMetadata],
target_triple: &Triple,
build_dir: &Path,
release: bool,
) -> Result<Option<PathBuf>> {
if swift_sources.is_empty() {
return Ok(None);
}
tracing::debug!(
"Compiling {} Swift plugin(s) for {}",
swift_sources.len(),
target_triple
);
// Create the plugins build directory
let plugins_dir = build_dir.join("swift-plugins");
std::fs::create_dir_all(&plugins_dir)?;
// Copy and prepare all Swift source packages
let mut plugin_paths = Vec::new();
for source in swift_sources {
let source_path = PathBuf::from(source.package_path.as_str());
let plugin_name = source.plugin_name.as_str();
let product_name = source.product.as_str();
if !source_path.exists() {
tracing::warn!(
"Swift package path does not exist: {} (for plugin {})",
source_path.display(),
plugin_name
);
continue;
}
let dest_path = plugins_dir.join(plugin_name);
if dest_path.exists() {
std::fs::remove_dir_all(&dest_path)?;
}
copy_dir_recursive(&source_path, &dest_path)?;
// Modify the Package.swift to produce a dynamic library
if let Err(e) = modify_package_for_dynamic_library(&dest_path, product_name) {
tracing::warn!("Failed to modify Package.swift for dynamic library: {}", e);
}
plugin_paths.push((plugin_name.to_string(), product_name.to_string(), dest_path));
tracing::debug!(
"Copied Swift plugin '{}' from {} to {}",
plugin_name,
source_path.display(),
plugins_dir.join(plugin_name).display()
);
}
if plugin_paths.is_empty() {
tracing::warn!("No valid Swift packages found to compile");
return Ok(None);
}
// Determine Swift target triple and SDK
let (swift_triple, sdk_name) = swift_target_and_sdk(target_triple)?;
let sdk_path = lookup_sdk_path(&sdk_name).await?;
// Build configuration
let configuration = if release { "release" } else { "debug" };
// Build each plugin package individually
for (plugin_name, product_name, package_path) in &plugin_paths {
tracing::debug!(
"Building Swift plugin '{}' (product: {})",
plugin_name,
product_name
);
let build_path = package_path.join(".build");
let mut cmd = Command::new("xcrun");
cmd.args(["swift", "build"])
.arg("--package-path")
.arg(package_path)
.arg("--configuration")
.arg(configuration)
.arg("--triple")
.arg(&swift_triple)
.arg("--sdk")
.arg(&sdk_path)
.arg("--product")
.arg(product_name)
.arg("--build-path")
.arg(&build_path);
tracing::debug!("Running: xcrun swift build for {}", product_name);
let output = cmd.output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
anyhow::bail!(
"Swift build failed for plugin '{}':\n{}\n{}",
plugin_name,
stdout,
stderr
);
}
if !output.stderr.is_empty() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::debug!("Swift build warnings for {}:\n{}", plugin_name, stderr);
}
}
// Find the output dynamic library for each plugin
// Swift puts the output in .build/<triple>/<configuration>/lib<ProductName>.dylib
// or .build/<configuration>/lib<ProductName>.dylib depending on the version
let mut all_dylibs = Vec::new();
for (_, product_name, package_path) in &plugin_paths {
let build_path = package_path.join(".build");
let lib_name = format!("lib{}.dylib", product_name);
let lib_search_paths = [
build_path.join(&swift_triple).join(configuration),
build_path.join(configuration),
build_path.clone(),
];
let mut found = false;
for search_path in &lib_search_paths {
let lib_path = search_path.join(&lib_name);
if lib_path.exists() {
tracing::debug!("Found Swift dynamic library: {}", lib_path.display());
all_dylibs.push((product_name.clone(), lib_path));
found = true;
break;
}
}
if !found {
tracing::warn!(
"Could not find compiled Swift dynamic library for product '{}' (expected {})",
product_name,
lib_name
);
}
}
if all_dylibs.is_empty() {
tracing::warn!("No Swift dynamic libraries were compiled successfully");
return Ok(None);
}
// For dynamic libraries, we need to wrap each in a framework bundle
// If there's only one library, create a single framework
// If there are multiple, we'll create frameworks for each (they're independent)
// The first one is the "primary" framework that gets returned
let (_primary_name, primary_dylib) = all_dylibs.remove(0);
// Create the framework bundle from the dylib
// Use "DioxusSwiftPlugins" as the umbrella framework name
let framework_name = "DioxusSwiftPlugins";
let bundle_identifier = "com.dioxus.swift.plugins";
let framework_path = create_framework_bundle(
&primary_dylib,
framework_name,
build_dir,
target_triple,
bundle_identifier,
)
.await?;
// If there are additional dylibs, create separate framework bundles for them
for (name, dylib_path) in all_dylibs {
let extra_framework = create_framework_bundle(
&dylib_path,
&name,
build_dir,
target_triple,
&format!("com.dioxus.swift.{}", name.to_lowercase()),
)
.await?;
tracing::debug!(
"Created additional framework: {}",
extra_framework.display()
);
}
Ok(Some(framework_path))
}
/// Modify a Package.swift to produce a dynamic library instead of static.
/// This is needed for runtime class lookup via NSClassFromString.
fn modify_package_for_dynamic_library(package_path: &Path, product_name: &str) -> Result<()> {
let package_swift_path = package_path.join("Package.swift");
if !package_swift_path.exists() {
anyhow::bail!(
"Package.swift not found at {}",
package_swift_path.display()
);
}
let content = std::fs::read_to_string(&package_swift_path)?;
// Replace .static with .dynamic for the library type
let modified = content
.replace("type: .static", "type: .dynamic")
.replace("type:.static", "type: .dynamic");
// If no library type was specified, we need to add it
// Look for .library(name: "ProductName", targets: [...]) and change to
// .library(name: "ProductName", type: .dynamic, targets: [...])
let pattern = format!(
r#".library\s*\(\s*name\s*:\s*"{}"\s*,\s*targets"#,
regex::escape(product_name)
);
let replacement = format!(
r#".library(name: "{}", type: .dynamic, targets"#,
product_name
);
let modified = if let Ok(re) = regex::Regex::new(&pattern) {
re.replace_all(&modified, replacement.as_str()).to_string()
} else {
modified
};
std::fs::write(&package_swift_path, modified)?;
Ok(())
}
/// Convert a Rust target triple to Swift target triple and SDK name
fn swift_target_and_sdk(triple: &Triple) -> Result<(String, String)> {
use target_lexicon::{Architecture, Environment, OperatingSystem};
// Check if this is a simulator target using the environment field
let is_simulator = triple.environment == Environment::Sim;
let swift_triple = match (&triple.architecture, &triple.operating_system) {
(Architecture::Aarch64(_), OperatingSystem::IOS(_)) => {
if is_simulator {
"arm64-apple-ios-simulator"
} else {
"arm64-apple-ios"
}
}
(Architecture::Aarch64(_), OperatingSystem::MacOSX { .. } | OperatingSystem::Darwin(_)) => {
"arm64-apple-macosx"
}
(Architecture::X86_64, OperatingSystem::IOS(_)) => "x86_64-apple-ios-simulator",
(Architecture::X86_64, OperatingSystem::MacOSX { .. } | OperatingSystem::Darwin(_)) => {
"x86_64-apple-macosx"
}
_ => anyhow::bail!("Unsupported target for Swift compilation: {}", triple),
};
let sdk_name = match &triple.operating_system {
OperatingSystem::IOS(_) => {
// Check if this is a simulator target using the environment field
if is_simulator {
"iphonesimulator"
} else {
"iphoneos"
}
}
OperatingSystem::MacOSX { .. } | OperatingSystem::Darwin(_) => "macosx",
_ => anyhow::bail!(
"Unsupported operating system for Swift compilation: {:?}",
triple.operating_system
),
};
Ok((swift_triple.to_string(), sdk_name.to_string()))
}
/// Look up the SDK path using xcrun
async fn lookup_sdk_path(sdk_name: &str) -> Result<String> {
let output = Command::new("xcrun")
.args(["--sdk", sdk_name, "--show-sdk-path"])
.output()
.await
.context("Failed to run xcrun to find SDK path")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to find SDK '{}': {}", sdk_name, stderr);
}
let sdk_path = String::from_utf8(output.stdout)
.context("Invalid UTF-8 in SDK path")?
.trim()
.to_string();
if sdk_path.is_empty() {
anyhow::bail!("SDK path for '{}' is empty", sdk_name);
}
Ok(sdk_path)
}
/// Recursively copy a directory
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if ty.is_dir() {
// Skip .build directories
if entry.file_name() == ".build" {
continue;
}
copy_dir_recursive(&src_path, &dst_path)?;
} else {
std::fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
/// Extract Swift metadata from object files in link arguments
pub fn extract_swift_metadata_from_link_args(
link_args: &[String],
workspace_dir: &Path,
) -> Vec<SwiftPackageMetadata> {
let mut swift_packages = Vec::new();
// Look through rlibs and object files for Swift metadata
for arg in link_args {
let path = PathBuf::from(arg);
// Only process files in our workspace
if !path.starts_with(workspace_dir) {
continue;
}
// Check for .rlib files
if arg.ends_with(".rlib") {
if let Ok(swift_meta) = extract_swift_from_rlib(&path) {
swift_packages.extend(swift_meta);
}
}
// Check for .o files
else if arg.ends_with(".o") || arg.ends_with(".obj") {
if let Ok(swift_meta) = extract_swift_from_object(&path) {
swift_packages.extend(swift_meta);
}
}
}
// Deduplicate by plugin name
swift_packages.sort_by(|a, b| a.plugin_name.as_str().cmp(b.plugin_name.as_str()));
swift_packages.dedup_by(|a, b| a.plugin_name.as_str() == b.plugin_name.as_str());
swift_packages
}
/// Extract Swift metadata from an rlib file
fn extract_swift_from_rlib(rlib_path: &Path) -> Result<Vec<SwiftPackageMetadata>> {
let mut results = Vec::new();
let rlib_contents = std::fs::read(rlib_path)?;
let mut reader = ar::Archive::new(std::io::Cursor::new(rlib_contents));
while let Some(Ok(entry)) = reader.next_entry() {
let name = std::str::from_utf8(entry.header().identifier()).unwrap_or_default();
// Only process .o files
if !name.ends_with(".rcgu.o") && !name.ends_with(".obj") {
continue;
}
// Read the object file contents
let mut obj_contents = Vec::new();
std::io::Read::read_to_end(&mut std::io::BufReader::new(entry), &mut obj_contents)?;
if let Ok(swift_meta) = extract_swift_from_bytes(&obj_contents) {
results.extend(swift_meta);
}
}
Ok(results)
}
/// Extract Swift metadata from an object file
fn extract_swift_from_object(obj_path: &Path) -> Result<Vec<SwiftPackageMetadata>> {
let obj_contents = std::fs::read(obj_path)?;
extract_swift_from_bytes(&obj_contents)
}
/// Extract Swift metadata from raw object file bytes
fn extract_swift_from_bytes(bytes: &[u8]) -> Result<Vec<SwiftPackageMetadata>> {
use manganis_core::SymbolData;
use object::{Object, ObjectSection, ObjectSymbol};
let mut results = Vec::new();
let file = match object::File::parse(bytes) {
Ok(f) => f,
Err(_) => return Ok(results),
};
// Look for __ASSETS__ symbols
for symbol in file.symbols() {
let name = match symbol.name() {
Ok(n) => n,
Err(_) => continue,
};
if !name.starts_with("__ASSETS__") {
continue;
}
// Try to get the symbol's data
if let Some(section_idx) = symbol.section().index() {
if let Ok(section) = file.section_by_index(section_idx) {
if let Ok(data) = section.data() {
// Try to find the symbol data in the section
let addr = symbol.address();
let section_addr = section.address();
let offset = (addr - section_addr) as usize;
if offset < data.len() {
let symbol_data = &data[offset..];
// Try to deserialize as SymbolData
if let Some((_, SymbolData::SwiftPackage(meta))) =
const_serialize::deserialize_const!(SymbolData, symbol_data)
{
results.push(meta);
}
}
}
}
}
}
Ok(results)
}
/// Recursively collect all Swift source files from a directory
fn collect_swift_files(dir: &Path) -> Result<Vec<PathBuf>> {
let mut swift_files = Vec::new();
if !dir.exists() {
return Ok(swift_files);
}
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
// Recursively collect from subdirectories
swift_files.extend(collect_swift_files(&path)?);
} else if path.extension().is_some_and(|ext| ext == "swift") {
swift_files.push(path);
}
}
Ok(swift_files)
}
/// Information about an Apple Widget Extension to compile
pub struct AppleWidgetSource {
/// Path to the Swift package source directory
pub source_path: PathBuf,
/// Display name for the widget (shown in system UI)
pub display_name: String,
/// Bundle ID suffix (appended to app bundle ID)
pub bundle_id_suffix: String,
/// Minimum deployment target (e.g., "16.0")
pub deployment_target: String,
/// Swift module name for the widget.
/// This MUST match the module name used by the main app's Swift plugin
/// for ActivityKit type matching to work (e.g., both must define
/// `ModuleName.LocationPermissionAttributes` as the same type).
pub module_name: String,
}
/// Compile an Apple Widget Extension from a Swift package source.
///
/// Widget Extensions are compiled as executables (not libraries) and bundled
/// as .appex bundles which are installed in the app's PlugIns folder.
///
/// **Important**: Widget extensions are XPC services that require special initialization.
/// We use `-e _NSExtensionMain` as the entry point instead of the default `_main` that
/// Swift generates with `@main`. The `_NSExtensionMain` entry point (provided by Foundation):
/// 1. Sets up the XPC listener
/// 2. Initializes ExtensionFoundation's `_EXRunningExtension` singleton
/// 3. Registers with PlugInKit
/// 4. Then calls your Widget code
///
/// # Arguments
/// * `widget` - Widget extension source configuration
/// * `target_triple` - The target platform (e.g., aarch64-apple-ios)
/// * `build_dir` - Directory for intermediate build files
/// * `app_bundle_id` - The main app's bundle identifier (widget ID is derived from this)
/// * `release` - Whether to build in release mode
///
/// # Returns
/// Path to the compiled .appex bundle, ready to be installed to PlugIns/
pub async fn compile_apple_widget(
widget: &AppleWidgetSource,
target_triple: &Triple,
build_dir: &Path,
app_bundle_id: &str,
release: bool,
) -> Result<PathBuf> {
use target_lexicon::OperatingSystem;
// Validate we're on an Apple platform
let is_ios = matches!(target_triple.operating_system, OperatingSystem::IOS(_));
let is_macos = matches!(
target_triple.operating_system,
OperatingSystem::MacOSX { .. } | OperatingSystem::Darwin(_)
);
if !is_ios && !is_macos {
anyhow::bail!(
"Apple Widget Extensions are only supported on iOS and macOS, not {:?}",
target_triple.operating_system
);
}
// Validate source path exists
if !widget.source_path.exists() {
anyhow::bail!(
"Widget Extension source path does not exist: {}",
widget.source_path.display()
);
}
tracing::debug!(
"Compiling Apple Widget Extension '{}' for {}",
widget.display_name,
target_triple
);
// Create the widget build directory
let widget_build_dir = build_dir.join("widget-extensions");
std::fs::create_dir_all(&widget_build_dir)?;
// Copy the Swift package to build directory
// Use the bundle_id_suffix as a unique name since the folder name might just be "widget"
let widget_name = widget.bundle_id_suffix.replace("-", "_");
let source_dir = widget_build_dir.join(format!("{}_src", widget_name));
if source_dir.exists() {
std::fs::remove_dir_all(&source_dir)?;
}
copy_dir_recursive(&widget.source_path, &source_dir)?;
// Get Swift target triple and SDK
let (swift_triple, sdk_name) = swift_target_and_sdk(target_triple)?;
// Collect all Swift source files from the Sources directory
let swift_sources_dir = source_dir.join("Sources");
let swift_files = collect_swift_files(&swift_sources_dir)?;
if swift_files.is_empty() {
anyhow::bail!(
"No Swift source files found in widget extension Sources directory: {}",
swift_sources_dir.display()
);
}
tracing::debug!(
"Found {} Swift files for widget: {:?}",
swift_files.len(),
swift_files
);
// Build output path
let exec_path = widget_build_dir.join(&widget_name);
// Compile the widget extension using swiftc directly
// Widget extensions are XPC services that require _NSExtensionMain as the entry point
let mut cmd = Command::new("xcrun");
cmd.arg("--sdk").arg(&sdk_name).arg("swiftc");
// Add all Swift source files
for swift_file in &swift_files {
cmd.arg(swift_file);
}
// Output executable
cmd.arg("-o").arg(&exec_path);
// Target triple with proper iOS version
// Format: arm64-apple-ios17.0 or arm64-apple-ios17.0-simulator
let is_simulator = swift_triple.contains("simulator");
let base_triple = swift_triple.replace("-simulator", "");
let swift_target = if is_simulator {
format!("{}{}-simulator", base_triple, widget.deployment_target)
} else {
format!("{}{}", base_triple, widget.deployment_target)
};
cmd.arg("-target").arg(&swift_target);
// Module name - use a consistent name that matches the main app's plugin module
// This is critical for ActivityKit type matching between app and widget
cmd.arg("-module-name").arg(&widget.module_name);
// Optimization flags
if release {
cmd.arg("-O").arg("-whole-module-optimization");
}
// Extension-specific flags
cmd.arg("-application-extension");
// Critical: Use _NSExtensionMain as the entry point for widget extensions
// Without this, the widget crashes because ExtensionFoundation's singleton isn't initialized
cmd.arg("-Xlinker")
.arg("-e")
.arg("-Xlinker")
.arg("_NSExtensionMain");
// Link Objective-C runtime (required for Swift/ObjC interop)
cmd.arg("-lobjc");
// Link required frameworks
cmd.arg("-framework").arg("Foundation");
cmd.arg("-framework").arg("SwiftUI");
cmd.arg("-framework").arg("WidgetKit");
cmd.arg("-framework").arg("ActivityKit");
tracing::debug!("Running swiftc for widget: {:?}", cmd);
let output = cmd.output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
anyhow::bail!(
"Swift compilation failed for widget extension '{}':\n{}\n{}",
widget_name,
stdout,
stderr
);
}
tracing::debug!("Compiled widget executable: {}", exec_path.display());
// Create the .appex bundle
let appex_name = format!("{}.appex", widget_name);
let appex_dir = widget_build_dir.join(&appex_name);
// Remove existing appex if present
if appex_dir.exists() {
std::fs::remove_dir_all(&appex_dir)?;
}
std::fs::create_dir_all(&appex_dir)?;
// Copy the executable into the appex bundle
let bundle_exec = appex_dir.join(&widget_name);
std::fs::copy(&exec_path, &bundle_exec)?;
// Create Info.plist for the widget extension
let widget_bundle_id = format!("{}.{}", app_bundle_id, widget.bundle_id_suffix);
let min_os_version = &widget.deployment_target;
let platform_info = if is_ios {
format!(
r#" <key>MinimumOSVersion</key>
<string>{min_os_version}</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>iPhoneOS</string>
</array>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
<integer>2</integer>
</array>"#
)
} else {
format!(
r#" <key>LSMinimumSystemVersion</key>
<string>{min_os_version}</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
</array>"#
)
};
let info_plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>{display_name}</string>
<key>CFBundleExecutable</key>
<string>{widget_name}</string>
<key>CFBundleIdentifier</key>
<string>{widget_bundle_id}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>{widget_name}</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
{platform_info}
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>NSSupportsLiveActivities</key>
<true/>
</dict>
</plist>"#,
display_name = widget.display_name,
widget_name = widget_name,
widget_bundle_id = widget_bundle_id,
platform_info = platform_info,
);
std::fs::write(appex_dir.join("Info.plist"), info_plist)?;
tracing::debug!("Created Widget Extension bundle: {}", appex_dir.display());
Ok(appex_dir)
}
File diff suppressed because it is too large. Load diff
-24
View File
@@ -1,24 +0,0 @@
//! 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 crate::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,
}
+8 -10
View File
@@ -8,24 +8,22 @@
//! hot-patching Rust code through binary analysis and a custom linker. The [`builder`] module contains
//! the management of the ongoing build and methods to open the build as a running app.
// Platform-specific build code
mod android;
mod apple;
mod web;
mod assets;
mod builder;
mod cache;
mod context;
mod ios_swift;
mod manifest;
mod manifest_mapper;
mod link;
mod patch;
mod pre_render;
mod renderer;
mod request;
mod tools;
pub(crate) use android::AndroidTools;
pub(crate) use assets::*;
pub(crate) use builder::*;
pub(crate) use cache::*;
pub(crate) use context::*;
pub(crate) use manifest::*;
pub(crate) use patch::*;
pub(crate) use pre_render::*;
pub(crate) use request::*;
pub(crate) use tools::*;
+66 -10
View File
@@ -9,7 +9,8 @@ use object::{
use rayon::prelude::{IntoParallelRefIterator, ParallelIterator};
use std::{
collections::{BTreeMap, HashMap, HashSet},
ops::{Deref, Range},
io::Read,
ops::Range,
path::Path,
path::PathBuf,
sync::{Arc, RwLock},
@@ -716,6 +717,21 @@ pub fn create_wasm_jump_table(patch: &Path, cache: &HotpatchModuleCache) -> Resu
// Clear the start function from the patch - we don't want any code automatically running!
new.start = None;
// Export __wasm_apply_global_relocs if it exists. wasm-ld generates this synthetic
// function to relocate GOT.func.internal globals by __table_base, but refuses to
// export it via --export or --export-if-defined since it's not a linker symbol.
// Without this export, the runtime can't call it, leaving GOT.func.internal globals
// unrelocated — they contain element-segment-relative offsets instead of absolute
// table indices, causing call_indirect type mismatches in PIC-compiled workspace code.
const APPLY_RELOCS: &str = "__wasm_apply_global_relocs";
if let Some(func) = new
.funcs
.iter()
.find(|f| f.name.as_deref() == Some(APPLY_RELOCS))
{
new.exports.add(APPLY_RELOCS, func.id());
}
// Update the wasm module on the filesystem to use the newly lifted version
let lib = patch.to_path_buf();
std::fs::write(&lib, new.emit_wasm())?;
@@ -846,15 +862,7 @@ pub fn create_undefined_symbol_stub(
let mut defined_symbols = HashSet::new();
for path in sorted {
let bytes = std::fs::read(path).with_context(|| format!("failed to read {path:?}"))?;
let file = File::parse(bytes.deref() as &[u8])?;
for symbol in file.symbols() {
if symbol.is_undefined() {
undefined_symbols.insert(symbol.name()?.to_string());
} else if symbol.is_global() {
defined_symbols.insert(symbol.name()?.to_string());
}
}
collect_stub_symbols_from_path(path, &mut undefined_symbols, &mut defined_symbols)?;
}
let undefined_symbols: Vec<_> = undefined_symbols
.difference(&defined_symbols)
@@ -1251,6 +1259,54 @@ pub fn create_undefined_symbol_stub(
Ok(obj.write()?)
}
fn collect_stub_symbols_from_path(
path: &Path,
undefined_symbols: &mut HashSet<String>,
defined_symbols: &mut HashSet<String>,
) -> Result<()> {
let bytes = std::fs::read(path).with_context(|| format!("failed to read {path:?}"))?;
if path
.extension()
.is_some_and(|ext| matches!(ext.to_str(), Some("rlib" | "a")))
{
let mut archive = ar::Archive::new(std::io::Cursor::new(bytes));
while let Some(entry) = archive.next_entry() {
let mut entry = entry?;
let name = std::str::from_utf8(entry.header().identifier()).unwrap_or_default();
if name.ends_with(".rmeta") || !(name.ends_with(".o") || name.ends_with(".obj")) {
continue;
}
let mut entry_bytes = Vec::with_capacity(entry.header().size() as usize);
entry.read_to_end(&mut entry_bytes)?;
collect_stub_symbols_from_bytes(&entry_bytes, undefined_symbols, defined_symbols)?;
}
return Ok(());
}
collect_stub_symbols_from_bytes(&bytes, undefined_symbols, defined_symbols)
}
fn collect_stub_symbols_from_bytes(
bytes: &[u8],
undefined_symbols: &mut HashSet<String>,
defined_symbols: &mut HashSet<String>,
) -> Result<()> {
let file = File::parse(bytes)?;
for symbol in file.symbols() {
if symbol.is_undefined() {
undefined_symbols.insert(symbol.name()?.to_string());
} else if symbol.is_global() {
defined_symbols.insert(symbol.name()?.to_string());
}
}
Ok(())
}
/// Prepares the base module before running wasm-bindgen.
///
/// This tries to work around how wasm-bindgen works by intelligently promoting non-wasm-bindgen functions
-148
View File
@@ -1,148 +0,0 @@
use anyhow::Context;
use dioxus_cli_config::{server_ip, server_port};
use dioxus_dx_wire_format::BuildStage;
use futures_util::{stream::FuturesUnordered, StreamExt};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::Duration,
};
use tokio::process::Command;
use crate::BuildId;
use super::{AppBuilder, BuilderUpdate};
/// Pre-render the static routes, performing static-site generation
pub(crate) async fn pre_render_static_routes(
devserver_ip: Option<SocketAddr>,
builder: &mut AppBuilder,
updates: Option<&futures_channel::mpsc::UnboundedSender<BuilderUpdate>>,
) -> anyhow::Result<()> {
if let Some(updates) = updates {
updates
.unbounded_send(BuilderUpdate::Progress {
stage: BuildStage::Prerendering,
})
.unwrap();
}
let server_exe = builder.build.main_exe();
// Use the address passed in through environment variables or default to localhost:9999. We need
// to default to a value that is different than the CLI default address to avoid conflicts
let ip = server_ip().unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
let port = server_port().unwrap_or(9999);
let fullstack_address = SocketAddr::new(ip, port);
let address = fullstack_address.ip().to_string();
let port = fullstack_address.port().to_string();
// Borrow port and address so we can easily move them into multiple tasks below
let address = &address;
let port = &port;
tracing::info!("Running SSG at http://{address}:{port} for {server_exe:?}");
let vars = builder.child_environment_variables(
devserver_ip,
Some(fullstack_address),
false,
BuildId::SECONDARY,
);
// Run the server executable
let _child = Command::new(&server_exe)
.envs(vars)
.current_dir(server_exe.parent().unwrap())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.kill_on_drop(true)
.spawn()?;
// Borrow reqwest_client so we only move the reference into the futures
let reqwest_client = reqwest::Client::new();
let reqwest_client = &reqwest_client;
// Get the routes from the `/static_routes` endpoint
let mut routes = None;
// The server may take a few seconds to start up. Try fetching the route up to 5 times with a one second delay
const RETRY_ATTEMPTS: usize = 5;
for i in 0..=RETRY_ATTEMPTS {
tracing::debug!(
"Attempting to get static routes from server. Attempt {i} of {RETRY_ATTEMPTS}"
);
let request = reqwest_client
.post(format!("http://{address}:{port}/api/static_routes"))
.body("{}".to_string())
.send()
.await;
match request {
Ok(request) => {
routes = Some(request
.json::<Vec<String>>()
.await
.inspect(|text| tracing::debug!("Got static routes: {text:?}"))
.context("Failed to parse static routes from the server. Make sure your server function returns Vec<String> with the (default) json encoding")?);
break;
}
Err(err) => {
// If the request fails, try up to 5 times with a one second delay
// If it fails 5 times, return the error
if i == RETRY_ATTEMPTS {
return Err(err).context("Failed to get static routes from server. Make sure you have a server function at the `/api/static_routes` endpoint that returns Vec<String> of static routes.");
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
}
let routes = routes.expect(
"static routes should exist or an error should have been returned on the last attempt",
);
// Create a pool of futures that cache each route
let mut resolved_routes = routes
.into_iter()
.map(|route| async move {
tracing::info!("Rendering {route} for SSG");
// For each route, ping the server to force it to cache the response for ssg
let request = reqwest_client
.get(format!("http://{address}:{port}{route}"))
.header("Accept", "text/html")
.send()
.await?;
// If it takes longer than 30 seconds to resolve the route, log a warning
let warning_task = tokio::spawn({
let route = route.clone();
async move {
tokio::time::sleep(Duration::from_secs(30)).await;
tracing::warn!("Route {route} has been rendering for 30 seconds");
}
});
// Wait for the streaming response to completely finish before continuing. We don't use the html it returns directly
// because it may contain artifacts of intermediate streaming steps while the page is loading. The SSG app should write
// the final clean HTML to the disk automatically after the request completes.
let _html = request.text().await?;
// Cancel the warning task if it hasn't already run
warning_task.abort();
Ok::<_, reqwest::Error>(route)
})
.collect::<FuturesUnordered<_>>();
while let Some(route) = resolved_routes.next().await {
match route {
Ok(route) => tracing::debug!("ssg success: {route:?}"),
Err(err) => tracing::error!("ssg error: {err:?}"),
}
}
tracing::info!("SSG complete");
drop(_child);
Ok(())
}
+201
View File
@@ -0,0 +1,201 @@
use crate::Renderer;
use target_lexicon::Triple;
use crate::BuildRequest;
impl BuildRequest {
pub(crate) fn renderer_enabled_by_dioxus_dependency(
package: &krates::cm::Package,
) -> Option<(Renderer, String)> {
let mut renderers = vec![];
// Attempt to discover the platform directly from the dioxus dependency
//
// [dependencies]
// dioxus = { features = ["web"] }
//
if let Some(dxs) = package.dependencies.iter().find(|dep| dep.name == "dioxus") {
for feature in dxs.features.iter() {
if let Some(renderer) = Renderer::autodetect_from_cargo_feature(feature) {
renderers.push((renderer, format!("dioxus/{}", feature)));
}
}
}
if renderers.len() != 1 {
return None;
}
Some(renderers[0].clone())
}
pub(crate) fn features_that_enable_renderers(
package: &krates::cm::Package,
) -> Vec<(Renderer, String)> {
package
.features
.keys()
.filter_map(|key| {
Renderer::autodetect_from_cargo_feature(key).map(|v| (v, key.to_string()))
})
.collect()
}
/// Return the platforms that are enabled for the package only from the default features
///
/// Ideally only one platform is enabled but we need to be able to
pub(crate) fn enabled_cargo_toml_default_features_renderers(
package: &krates::cm::Package,
) -> Vec<(Renderer, String)> {
let mut renderers = vec![];
// Start searching through the default features
//
// [features]
// default = ["dioxus/web"]
//
// or
//
// [features]
// default = ["web"]
// web = ["dioxus/web"]
let Some(default) = package.features.get("default") else {
return renderers;
};
// we only trace features 1 level deep..
// TODO: trace all enabled features, not just default features
for feature in default.iter() {
// If the user directly specified a platform we can just use that.
if feature.starts_with("dioxus/") {
let dx_feature = feature.trim_start_matches("dioxus/");
let auto = Renderer::autodetect_from_cargo_feature(dx_feature);
if let Some(auto) = auto {
renderers.push((auto, dx_feature.to_string()));
}
}
// If the user is specifying an internal feature that points to a platform, we can use that
let internal_feature = package.features.get(feature);
if let Some(internal_feature) = internal_feature {
for feature in internal_feature {
if feature.starts_with("dioxus/") {
let dx_feature = feature.trim_start_matches("dioxus/");
let auto = Renderer::autodetect_from_cargo_feature(dx_feature);
if let Some(auto) = auto {
renderers.push((auto, dx_feature.to_string()));
}
}
}
}
}
renderers.sort();
renderers.dedup();
renderers
}
/// Gather the features that are enabled for the package
pub fn rendererless_features(package: &krates::cm::Package) -> Vec<String> {
let Some(default) = package.features.get("default") else {
return Vec::new();
};
let mut kept_features = vec![];
// Only keep the top-level features in the default list that don't point to a platform directly
// IE we want to drop `web` if default = ["web"]
'top: for feature in default {
// Don't keep features that point to a platform via dioxus/blah
if feature.starts_with("dioxus/") {
let dx_feature = feature.trim_start_matches("dioxus/");
if Renderer::autodetect_from_cargo_feature(dx_feature).is_some() {
tracing::debug!(
"Dropping feature {feature} since it points to a platform renderer"
);
continue 'top;
}
}
// Don't keep features that point to a platform via an internal feature
if let Some(internal_feature) = package.features.get(feature) {
for feature in internal_feature {
if feature.starts_with("dioxus/") {
let dx_feature = feature.trim_start_matches("dioxus/");
if Renderer::autodetect_from_cargo_feature(dx_feature).is_some() {
tracing::debug!(
"Dropping feature {feature} since it points to a platform renderer transitively"
);
continue 'top;
}
}
}
}
// Otherwise we can keep it
kept_features.push(feature.to_string());
}
kept_features
}
/// Get the features required to build for the given platform
pub fn feature_for_platform_and_renderer(
package: &krates::cm::Package,
triple: &Triple,
renderer: Renderer,
) -> Option<String> {
// Try to find the feature that activates the dioxus feature for the given platform
let dioxus_feature = renderer.feature_name(triple);
let res = package.features.iter().find_map(|(key, features)| {
// if the feature is just the name of the platform, we use that
if key == dioxus_feature {
tracing::debug!("Found feature {key} for renderer {renderer}");
return Some(key.clone());
}
// Otherwise look for the feature that starts with dioxus/ or dioxus?/ and matches just the single platform
// we are looking for.
let mut dioxus_renderers_enabled = Vec::new();
for feature in features {
if let Some((_, after_dioxus)) = feature.split_once("dioxus") {
if let Some(dioxus_feature_enabled) =
after_dioxus.trim_start_matches('?').strip_prefix('/')
{
if Renderer::autodetect_from_cargo_feature(dioxus_feature_enabled).is_some()
{
dioxus_renderers_enabled.push(dioxus_feature_enabled.to_string());
}
}
}
}
// If there is exactly one renderer enabled by this feature, we can use it
if let [feature_name] = dioxus_renderers_enabled.as_slice() {
if feature_name == dioxus_feature {
tracing::debug!(
"Found feature {key} for renderer {renderer} which enables dioxus/{renderer}"
);
return Some(key.clone());
}
}
None
});
res.or_else(|| {
let depends_on_dioxus = package.dependencies.iter().any(|dep| dep.name == "dioxus");
if depends_on_dioxus {
let fallback = format!("dioxus/{dioxus_feature}");
tracing::debug!(
"Could not find explicit feature for renderer {renderer}, passing `fallback` instead"
);
Some(fallback)
} else {
None
}
})
}
}
File diff suppressed because it is too large. Load diff
-359
View File
@@ -1,359 +0,0 @@
use crate::Result;
use anyhow::Context;
use itertools::Itertools;
use std::{path::PathBuf, sync::Arc};
use target_lexicon::{
Aarch64Architecture, Architecture, ArmArchitecture, Triple, X86_32Architecture,
};
use tokio::process::Command;
/// The tools for Android (ndk, sdk, etc)
///
/// <https://gist.github.com/Pulimet/5013acf2cd5b28e55036c82c91bd56d8?permalink_comment_id=3678614>
#[derive(Debug, Clone)]
pub(crate) struct AndroidTools {
pub(crate) sdk: Option<PathBuf>,
pub(crate) ndk: PathBuf,
pub(crate) adb: PathBuf,
pub(crate) java_home: Option<PathBuf>,
}
pub fn get_android_tools() -> Option<Arc<AndroidTools>> {
// We check for SDK first since users might install Android Studio and then install the SDK
// After that they might install the NDK, so the SDK drives the source of truth.
let sdk = var_or_debug("ANDROID_SDK_ROOT")
.or_else(|| var_or_debug("ANDROID_SDK"))
.or_else(|| var_or_debug("ANDROID_HOME"));
// Check the ndk. We look for users's overrides first and then look into the SDK.
// Sometimes users set only the NDK (especially if they're somewhat advanced) so we need to look for it manually
//
// Might look like this, typically under "sdk":
// "/Users/jonkelley/Library/Android/sdk/ndk/25.2.9519653/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android24-clang"
let ndk = var_or_debug("NDK_HOME")
.or_else(|| var_or_debug("ANDROID_NDK_HOME"))
.or_else(|| {
// Look for the most recent NDK in the event the user has installed multiple NDK
// Eventually we might need to drive this from Dioxus.toml
let sdk = sdk.as_ref()?;
let ndk_dir = sdk.join("ndk").read_dir().ok()?;
ndk_dir
.flatten()
.map(|dir| (dir.file_name(), dir.path()))
.sorted()
.next_back()
.map(|(_, path)| path.to_path_buf())
})?;
// Look for ADB in the SDK. If it's not there we'll use `adb` from the PATH
let adb = sdk
.as_ref()
.and_then(|sdk| {
let tools = sdk.join("platform-tools");
if tools.join("adb").exists() {
return Some(tools.join("adb"));
}
if tools.join("adb.exe").exists() {
return Some(tools.join("adb.exe"));
}
None
})
.unwrap_or_else(|| PathBuf::from("adb"));
// https://stackoverflow.com/questions/71381050/java-home-is-set-to-an-invalid-directory-android-studio-flutter
// always respect the user's JAVA_HOME env var above all other options
//
// we only attempt autodetection if java_home is not set
//
// this is a better fallback than falling onto the users' system java home since many users might
// not even know which java that is - they just know they have android studio installed
let java_home = std::env::var_os("JAVA_HOME")
.map(PathBuf::from)
.or_else(|| {
// Attempt to autodetect java home from the android studio path or jdk path on macos
#[cfg(target_os = "macos")]
{
let jbr_home =
PathBuf::from("/Applications/Android Studio.app/Contents/jbr/Contents/Home/");
if jbr_home.exists() {
return Some(jbr_home);
}
let jre_home =
PathBuf::from("/Applications/Android Studio.app/Contents/jre/Contents/Home");
if jre_home.exists() {
return Some(jre_home);
}
let jdk_home =
PathBuf::from("/Library/Java/JavaVirtualMachines/openjdk.jdk/Contents/Home/");
if jdk_home.exists() {
return Some(jdk_home);
}
}
#[cfg(target_os = "windows")]
{
let jbr_home = PathBuf::from("C:\\Program Files\\Android\\Android Studio\\jbr");
if jbr_home.exists() {
return Some(jbr_home);
}
}
// todo(jon): how do we detect java home on linux?
#[cfg(target_os = "linux")]
{
let jbr_home = PathBuf::from("/usr/lib/jvm/java-11-openjdk-amd64");
if jbr_home.exists() {
return Some(jbr_home);
}
}
None
});
Some(Arc::new(AndroidTools {
ndk,
adb,
java_home,
sdk,
}))
}
impl AndroidTools {
pub(crate) fn android_tools_dir(&self) -> PathBuf {
let prebuilt = self.ndk.join("toolchains").join("llvm").join("prebuilt");
if cfg!(target_os = "macos") {
// for whatever reason, even on aarch64 macos, the linker is under darwin-x86_64
return prebuilt.join("darwin-x86_64").join("bin");
}
if cfg!(target_os = "linux") {
return prebuilt.join("linux-x86_64").join("bin");
}
if cfg!(target_os = "windows") {
return prebuilt.join("windows-x86_64").join("bin");
}
// Otherwise return the first entry in the prebuilt directory
prebuilt
.read_dir()
.expect("Failed to read android toolchains directory")
.next()
.expect("Failed to find android toolchains directory")
.expect("Failed to read android toolchain file")
.path()
}
/// Return the location of the clang toolchain for the given target triple.
///
/// Note that we use clang:
/// "~/Library/Android/sdk/ndk/25.2.9519653/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android24-clang"
///
/// But if we needed the linker, we would use:
/// "~/Library/Android/sdk/ndk/25.2.9519653/toolchains/llvm/prebuilt/darwin-x86_64/bin/ld"
///
/// However, for our purposes, we only go through the cc driver and not the linker directly.
pub(crate) fn android_cc(&self, triple: &Triple, sdk_version: u32) -> PathBuf {
let suffix = if cfg!(target_os = "windows") {
".cmd"
} else {
""
};
let target = match triple.architecture {
Architecture::Arm(_) => "armv7a-linux-androideabi",
_ => &triple.to_string(),
};
self.android_tools_dir()
.join(format!("{}{}-clang{}", target, sdk_version, suffix))
}
pub(crate) fn sysroot(&self) -> PathBuf {
// The sysroot is usually located in the NDK under:
// "~/Library/Android/sdk/ndk/25.2.9519653/toolchains/llvm/prebuilt/darwin-x86_64/sysroot"
// or similar, depending on the platform.
self.android_tools_dir().parent().unwrap().join("sysroot")
}
pub(crate) fn sdk(&self) -> PathBuf {
// /Users/jonathankelley/Library/Android/sdk/ndk/25.2/... (25.2 is the ndk here)
// /Users/jonathankelley/Library/Android/sdk/
self.sdk
.clone()
.unwrap_or_else(|| self.ndk.parent().unwrap().parent().unwrap().to_path_buf())
}
pub(crate) fn emulator(&self) -> PathBuf {
self.sdk().join("emulator").join("emulator")
}
pub(crate) fn clang_folder(&self) -> PathBuf {
// The clang folder is usually located in the NDK under:
// "~/Library/Android/sdk/ndk/25.2.9519653/toolchains/llvm/prebuilt/darwin-x86_64/lib/clang/<version>"
// or similar, depending on the platform.
self.android_tools_dir()
.parent()
.unwrap()
.join("lib")
.join("clang")
}
pub(crate) fn ranlib(&self) -> PathBuf {
self.android_tools_dir().join("llvm-ranlib")
}
pub(crate) fn ar_path(&self) -> PathBuf {
self.android_tools_dir().join("llvm-ar")
}
pub(crate) fn target_cc(&self) -> PathBuf {
self.android_tools_dir().join("clang")
}
pub(crate) fn target_cxx(&self) -> PathBuf {
self.android_tools_dir().join("clang++")
}
pub(crate) fn java_home(&self) -> Option<PathBuf> {
self.java_home.clone()
}
pub(crate) fn android_jnilib(triple: &Triple) -> &'static str {
use target_lexicon::Architecture;
match triple.architecture {
Architecture::Arm(_) => "armeabi-v7a",
Architecture::Aarch64(_) => "arm64-v8a",
Architecture::X86_32(_) => "x86",
Architecture::X86_64 => "x86_64",
_ => unimplemented!("Unsupported architecture"),
}
}
pub(crate) async fn autodetect_android_device_triple(&self) -> Triple {
// Use the host's triple and then convert field by field
// ie, the "best" emulator for an m1 mac would be: "aarch64-linux-android"
// - We assume android is always "linux"
// - We try to match the architecture unless otherwise specified. This is because
// emulators that match the host arch are usually faster.
let mut triple = "aarch64-linux-android".parse::<Triple>().unwrap();
// TODO: Wire this up with --device flag. (add `-s serial`` flag before `shell` arg)
let output = Command::new(&self.adb)
.arg("shell")
.arg("uname")
.arg("-m")
.output()
.await
.map(|out| String::from_utf8(out.stdout));
match output {
Ok(Ok(out)) => match out.trim() {
"armv7l" | "armv8l" => {
triple.architecture = Architecture::Arm(ArmArchitecture::Arm)
}
"aarch64" => {
triple.architecture = Architecture::Aarch64(Aarch64Architecture::Aarch64)
}
"i386" => triple.architecture = Architecture::X86_32(X86_32Architecture::I386),
"x86_64" => {
triple.architecture = Architecture::X86_64;
}
"" => {
tracing::debug!("No device running - probably waiting for emulator");
}
other => {
tracing::debug!("Unknown architecture from adb: {other}");
}
},
Ok(Err(err)) => {
tracing::debug!("Failed to parse adb output: {err}");
}
Err(err) => {
tracing::debug!("ADB command failed: {:?}", err);
}
};
triple
}
pub(crate) fn libcpp_shared(&self, triple: &Triple) -> PathBuf {
// The libc++_shared.so is usually located in the sysroot under:
// "~/Library/Android/sdk/ndk/25.2.9519653/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/lib/<arch>/libc++_shared.so"
// or similar, depending on the platform.
self.sysroot()
.join("usr")
.join("lib")
.join(Self::sysroot_target(&triple.to_string()))
.join("libc++_shared.so")
}
pub(crate) fn sysroot_target(rust_target: &str) -> &str {
(match rust_target {
"armv7-linux-androideabi" => "arm-linux-androideabi",
_ => rust_target,
}) as _
}
pub(crate) fn openssl_prebuilt_aar() -> &'static [u8] {
include_bytes!("../../assets/android/prebuilt/openssl-1.1.1q-beta-1.tar.gz")
}
pub(crate) fn openssl_prebuilt_dest() -> PathBuf {
crate::Workspace::dioxus_data_dir()
.join("prebuilt")
.join("openssl-1.1.1q-beta-1")
}
pub(crate) fn openssl_lib_dir(arch: &Triple) -> PathBuf {
let libs_dir = Self::openssl_prebuilt_dest().join("ssl").join("libs");
match arch.architecture {
Architecture::Arm(_) => libs_dir.join("android.armeabi-v7a"),
Architecture::Aarch64(_) => libs_dir.join("android.arm64-v8a"),
Architecture::X86_32(_) => libs_dir.join("android.x86"),
Architecture::X86_64 => libs_dir.join("android.x86_64"),
_ => libs_dir.join("android.arm64-v8a"), // Default to arm64-v8a
}
}
pub(crate) fn openssl_include_dir() -> PathBuf {
Self::openssl_prebuilt_dest().join("ssl").join("include")
}
/// Unzip the prebuilt OpenSSL AAR file into the `.dx/prebuilt/openssl-<version>` directory
pub(crate) fn unpack_prebuilt_openssl() -> Result<()> {
let raw_aar = AndroidTools::openssl_prebuilt_aar();
let aar_dest = AndroidTools::openssl_prebuilt_dest();
if aar_dest.exists() {
tracing::trace!("Prebuilt OpenSSL already exists at {:?}", aar_dest);
return Ok(());
}
std::fs::create_dir_all(aar_dest.parent().context("no parent for aar")?)
.context("failed to create prebuilt OpenSSL directory")?;
// Unpack the entire tar.gz file into the destination directory
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(raw_aar as &[u8]));
archive
.unpack(aar_dest.parent().context("no parent for aar dest")?)
.context("failed to unpack prebuilt OpenSSL archive")?;
tracing::debug!("Unpacked prebuilt OpenSSL to {:?}", aar_dest);
Ok(())
}
}
fn var_or_debug(name: &str) -> Option<PathBuf> {
use std::env::var;
var(name)
.inspect_err(|_| tracing::trace!("{name} not set"))
.ok()
.map(PathBuf::from)
}
+603
View File
@@ -0,0 +1,603 @@
//! ### Web:
//!
//! Create a folder that is somewhat similar to an app-image (exe + asset)
//! The server is dropped into the `web` folder, even if there's no `public` folder.
//! If there's no server (SPA), we still use the `web` folder, but it only contains the
//! public folder.
//!
//! ```
//! web/
//! server
//! assets/
//! public/
//! index.html
//! wasm/
//! app.wasm
//! glue.js
//! snippets/
//! ...
//! assets/
//! logo.png
//! ```
//!
//! ### Linux:
//!
//! <https://docs.appimage.org/reference/appdir.html#ref-appdir>
//! current_exe.join("Assets")
//! ```
//! app.appimage/
//! AppRun
//! app.desktop
//! package.json
//! assets/
//! logo.png
//! ```
use crate::{opt::AppManifest, BuildMode, BuildRequest};
use crate::{BuildContext, BundleFormat, Result, TraceSrc, WasmBindgen, WasmOptConfig};
use anyhow::Context;
use dioxus_cli_config::format_base_path_meta_element;
use manganis::AssetOptions;
use manganis_core::AssetVariant;
use std::{
io::Write,
path::{Path, PathBuf},
};
use uuid::Uuid;
impl BuildRequest {
pub async fn verify_web_tooling(&self) -> Result<()> {
// Wasm bindgen
let krate_bindgen_version =
self.workspace
.wasm_bindgen_version()
.ok_or(anyhow::anyhow!(
"failed to detect wasm-bindgen version, unable to proceed"
))?;
WasmBindgen::verify_install(&krate_bindgen_version).await?;
// esbuild is used for JS asset processing
let _esbuild_path = crate::esbuild::Esbuild::get_or_install().await?;
Ok(())
}
/// Bundle the web app
/// - Run wasm-bindgen
/// - Bundle split
/// - Run wasm-opt
/// - Register the .wasm and .js files with the asset system
pub async fn bundle_web(
&self,
ctx: &BuildContext,
exe: &Path,
assets: &mut AppManifest,
) -> Result<()> {
use crate::{wasm_bindgen::WasmBindgen, wasm_opt};
use std::fmt::Write;
// Locate the output of the build files and the bindgen output
// We'll fill these in a second if they don't already exist
let bindgen_outdir = self.wasm_bindgen_out_dir();
let post_bindgen_wasm = self.wasm_bindgen_wasm_output_file();
let should_bundle_split: bool = self.wasm_split;
let bindgen_version = self
.workspace
.wasm_bindgen_version()
.expect("this should have been checked by tool verification");
// Prepare any work dirs
_ = std::fs::remove_dir_all(&bindgen_outdir);
std::fs::create_dir_all(&bindgen_outdir)?;
// Lift the internal functions to exports
if ctx.mode == BuildMode::Fat {
let unprocessed = std::fs::read(exe)?;
let all_exported_bytes = crate::build::prepare_wasm_base_module(&unprocessed)?;
std::fs::write(exe, all_exported_bytes)?;
}
// Prepare our configuration
//
// we turn on debug symbols in dev mode
//
// We leave demangling to false since it's faster and these tools seem to prefer the raw symbols.
// todo(jon): investigate if the chrome extension needs them demangled or demangles them automatically.
let keep_debug = self.config.web.wasm_opt.debug
|| self.debug_symbols
|| self.wasm_split
|| !self.release
|| ctx.mode == BuildMode::Fat;
let keep_names = self.config.web.wasm_opt.keep_names
|| self.keep_names
|| self.wasm_split
|| ctx.mode == BuildMode::Fat;
let demangle = false;
let wasm_opt_options = WasmOptConfig {
memory_packing: self.wasm_split,
debug: self.debug_symbols,
..self.config.web.wasm_opt.clone()
};
// Run wasm-bindgen. Some of the options are not "optimal" but will be fixed up by wasm-opt
//
// There's performance implications here. Running with --debug is slower than without
// We're keeping around lld sections and names but wasm-opt will fix them
// todo(jon): investigate a good balance of wiping debug symbols during dev (or doing a double build?)
ctx.status_wasm_bindgen_start();
tracing::debug!(dx_src = ?TraceSrc::Bundle, "Running wasm-bindgen");
let start = std::time::Instant::now();
WasmBindgen::new(&bindgen_version)
.input_path(exe)
.target("web")
.debug(keep_debug)
.demangle(demangle)
.keep_debug(keep_debug)
.keep_lld_exports(true)
.out_name(self.executable_name())
.out_dir(&bindgen_outdir)
.remove_name_section(!keep_names)
.remove_producers_section(!keep_names)
.run()
.await
.context("Failed to generate wasm-bindgen bindings")?;
tracing::debug!(dx_src = ?TraceSrc::Bundle, "wasm-bindgen complete in {:?}", start.elapsed());
// Run bundle splitting if the user has requested it
// It's pretty expensive but because of rayon should be running separate threads, hopefully
// not blocking this thread. Dunno if that's true
if should_bundle_split {
ctx.status_splitting_bundle();
// Load the contents of these binaries since we need both of them
// We're going to use the default makeLoad glue from wasm-split
let original = std::fs::read(exe)?;
let bindgened = std::fs::read(&post_bindgen_wasm)?;
let mut glue = wasm_split_cli::MAKE_LOAD_JS.to_string();
// Run the emitter
let splitter = wasm_split_cli::Splitter::new(&original, &bindgened);
let modules = splitter
.context("Failed to parse wasm for splitter")?
.emit()
.context("Failed to emit wasm split modules")?;
// Write the chunks that contain shared imports
// These will be in the format of chunk_0_modulename.wasm - this is hardcoded in wasm-split
tracing::debug!("Writing split chunks to disk");
for (idx, chunk) in modules.chunks.iter().enumerate() {
let path = bindgen_outdir.join(format!("chunk_{}_{}.wasm", idx, chunk.module_name));
wasm_opt::write_wasm(&chunk.bytes, &path, &wasm_opt_options).await?;
writeln!(
glue, "export const __wasm_split_load_chunk_{idx} = makeLoad(\"/{base_path}/assets/{url}\", [], fusedImports);",
base_path = self.base_path_or_default(),
url = assets
.register_asset(&path, AssetOptions::builder().into_asset_options())?.bundled_path(),
)?;
}
// Write the modules that contain the entrypoints
tracing::debug!("Writing split modules to disk");
for (idx, module) in modules.modules.iter().enumerate() {
let comp_name = module
.component_name
.as_ref()
.context("generated bindgen module has no name?")?;
let path = bindgen_outdir.join(format!("module_{idx}_{comp_name}.wasm"));
wasm_opt::write_wasm(&module.bytes, &path, &wasm_opt_options).await?;
let hash_id = module
.hash_id
.as_ref()
.context("generated wasm-split bindgen module has no hash id?")?;
writeln!(
glue,
"export const __wasm_split_load_{module}_{hash_id}_{comp_name} = makeLoad(\"/{base_path}/assets/{url}\", [{deps}], fusedImports);",
module = module.module_name,
base_path = self.base_path_or_default(),
// Again, register this wasm with the asset system
url = assets
.register_asset(&path, AssetOptions::builder().into_asset_options())?
.bundled_path(),
// This time, make sure to write the dependencies of this chunk
// The names here are again, hardcoded in wasm-split - fix this eventually.
deps = module
.relies_on_chunks
.iter()
.map(|idx| format!("__wasm_split_load_chunk_{idx}"))
.collect::<Vec<_>>()
.join(", ")
)?;
}
// Write the js binding
// It's not registered as an asset since it will get included in the main.js file
let js_output_path = bindgen_outdir.join("__wasm_split.js");
std::fs::write(&js_output_path, &glue)?;
// Make sure to write some entropy to the main.js file so it gets a new hash
// If we don't do this, the main.js file will be cached and never pick up the chunk names
let uuid = Uuid::new_v5(&Uuid::NAMESPACE_URL, glue.as_bytes());
std::fs::OpenOptions::new()
.append(true)
.open(self.wasm_bindgen_js_output_file())
.context("Failed to open main.js file")?
.write_all(format!("/*{uuid}*/").as_bytes())?;
// Write the main wasm_bindgen file and register it with the asset system
// This will overwrite the file in place
// We will wasm-opt it in just a second...
std::fs::write(&post_bindgen_wasm, modules.main.bytes).unwrap();
}
if matches!(ctx.mode, BuildMode::Fat) {
// add `export { __wbg_get_imports };` to the end of the wasmbindgen js file
let mut js = std::fs::read(self.wasm_bindgen_js_output_file())?;
writeln!(js, "\nexport {{ __wbg_get_imports }};")?;
std::fs::write(self.wasm_bindgen_js_output_file(), js)?;
}
// Make sure to optimize the main wasm file if requested or if bundle splitting
if should_bundle_split || self.release {
ctx.status_optimizing_wasm();
wasm_opt::optimize(&post_bindgen_wasm, &post_bindgen_wasm, &wasm_opt_options).await?;
}
if self.should_bundle_to_asset() {
// Make sure to register the main wasm file with the asset system
assets.register_asset(
&post_bindgen_wasm,
AssetOptions::builder().into_asset_options(),
)?;
}
// Now that the wasm is registered as an asset, we can write the js glue shim
self.write_js_glue_shim(assets)?;
if self.should_bundle_to_asset() {
// Register the main.js with the asset system so it bundles in the snippets and optimizes
assets.register_asset(
&self.wasm_bindgen_js_output_file(),
AssetOptions::js()
.with_minify(true)
.with_preload(true)
.into_asset_options(),
)?;
}
// Write the index.html file with the pre-configured contents we got from pre-rendering
self.write_index_html(assets)?;
Ok(())
}
fn write_js_glue_shim(&self, assets: &AppManifest) -> Result<()> {
let wasm_path = self.bundled_wasm_path(assets);
// Load and initialize wasm without requiring a separate javascript file.
// This also allows using a strict Content-Security-Policy.
let mut js = std::fs::OpenOptions::new()
.append(true)
.open(self.wasm_bindgen_js_output_file())?;
let mut buf_writer = std::io::BufWriter::new(&mut js);
writeln!(
buf_writer,
r#"
globalThis.__wasm_split_main_initSync = initSync;
// Actually perform the load
__wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{
// assign this module to be accessible globally
globalThis.__dx_mainWasm = wasm;
globalThis.__dx_mainInit = __wbg_init;
globalThis.__dx_mainInitSync = initSync;
globalThis.__dx___wbg_get_imports = __wbg_get_imports;
if (wasm.__wbindgen_start == undefined) {{
wasm.main();
}}
}});
"#,
self.base_path_or_default(),
)?;
Ok(())
}
/// Write the index.html file to the output directory. This must be called after the wasm and js
/// assets are registered with the asset system if this is a release build.
pub(crate) fn write_index_html(&self, assets: &AppManifest) -> Result<()> {
let wasm_path = self.bundled_wasm_path(assets);
let js_path = self.bundled_js_path(assets);
// Write the index.html file with the pre-configured contents we got from pre-rendering
std::fs::write(
self.root_dir().join("index.html"),
self.prepare_html(assets, &wasm_path, &js_path).unwrap(),
)?;
Ok(())
}
fn bundled_js_path(&self, assets: &AppManifest) -> String {
let wasm_bindgen_js_out = self.wasm_bindgen_js_output_file();
if self.should_bundle_to_asset() {
let name = assets
.get_first_asset_for_source(&wasm_bindgen_js_out)
.expect("The js source must exist before creating index.html");
format!("assets/{}", name.bundled_path())
} else {
format!(
"wasm/{}",
wasm_bindgen_js_out.file_name().unwrap().to_str().unwrap()
)
}
}
/// Get the path to the wasm-bindgen output files. Either the direct file or the optimized one depending on the build mode
fn bundled_wasm_path(&self, assets: &AppManifest) -> String {
let wasm_bindgen_wasm_out = self.wasm_bindgen_wasm_output_file();
if self.should_bundle_to_asset() {
let name = assets
.get_first_asset_for_source(&wasm_bindgen_wasm_out)
.expect("The wasm source must exist before creating index.html");
format!("assets/{}", name.bundled_path())
} else {
format!(
"wasm/{}",
wasm_bindgen_wasm_out.file_name().unwrap().to_str().unwrap()
)
}
}
/// Users create an index.html for their SPA if they want it
///
/// We always write our wasm as main.js and main_bg.wasm
///
/// In prod we run the optimizer which bundles everything together properly
///
/// So their index.html needs to include main.js in the scripts otherwise nothing happens?
///
/// Seems like every platform has a weird file that declares a bunch of stuff
/// - web: index.html
/// - ios: info.plist
/// - macos: info.plist
/// - linux: appimage root thing?
/// - android: androidmanifest.xml
///
/// You also might different variants of these files (staging / prod) and different flavors (eu/us)
///
/// web's index.html is weird since it's not just a bundle format but also a *content* format
pub(crate) fn prepare_html(
&self,
assets: &AppManifest,
wasm_path: &str,
js_path: &str,
) -> Result<String> {
let mut html = {
const DEV_DEFAULT_HTML: &str = include_str!("../../assets/web/dev.index.html");
const PROD_DEFAULT_HTML: &str = include_str!("../../assets/web/prod.index.html");
let crate_root: &Path = &self.crate_dir();
let custom_html_file = crate_root.join("index.html");
let default_html = match self.release {
true => PROD_DEFAULT_HTML,
false => DEV_DEFAULT_HTML,
};
std::fs::read_to_string(custom_html_file).unwrap_or_else(|_| String::from(default_html))
};
// Inject any resources from the config into the html
self.inject_resources(assets, &mut html)?;
// Inject loading scripts if they are not already present
self.inject_loading_scripts(assets, &mut html);
// Replace any special placeholders in the HTML with resolved values
self.replace_template_placeholders(&mut html, wasm_path, js_path);
let title = self.config.web.app.title.clone();
Self::replace_or_insert_before("{app_title}", "</title", &title, &mut html);
Ok(html)
}
fn is_dev_build(&self) -> bool {
!self.release
}
// Inject any resources from the config into the html
fn inject_resources(&self, assets: &AppManifest, html: &mut String) -> Result<()> {
use std::fmt::Write;
// Collect all resources into a list of styles and scripts
let resources = &self.config.web.resource;
let mut style_list = resources.style.clone().unwrap_or_default();
let mut script_list = resources.script.clone().unwrap_or_default();
if self.is_dev_build() {
style_list.extend(resources.dev.style.iter().cloned());
script_list.extend(resources.dev.script.iter().cloned());
}
let mut head_resources = String::new();
// Add all styles to the head
for style in &style_list {
writeln!(
&mut head_resources,
"<link rel=\"stylesheet\" href=\"{}\">",
&style.to_str().unwrap(),
)?;
}
// Add all scripts to the head
for script in &script_list {
writeln!(
&mut head_resources,
"<script src=\"{}\"></script>",
&script.to_str().unwrap(),
)?;
}
// Add the base path to the head if this is a debug build
if self.is_dev_build() {
if let Some(base_path) = &self.trimmed_base_path() {
head_resources.push_str(&format_base_path_meta_element(base_path));
}
}
// Inject any resources from manganis into the head
for asset in assets.unique_assets() {
let asset_path = asset.bundled_path();
match asset.options().variant() {
AssetVariant::Css(css_options) => {
if css_options.preloaded() {
_ = write!(
head_resources,
r#"<link rel="preload" as="style" href="/{{base_path}}/assets/{asset_path}" crossorigin>"#
);
}
if css_options.static_head() {
_ = write!(
head_resources,
r#"<link rel="stylesheet" href="/{{base_path}}/assets/{asset_path}" type="text/css">"#
);
}
}
AssetVariant::Image(image_options) => {
if image_options.preloaded() {
_ = write!(
head_resources,
r#"<link rel="preload" as="image" href="/{{base_path}}/assets/{asset_path}" crossorigin>"#
);
}
}
AssetVariant::Js(js_options) => {
if js_options.preloaded() {
_ = write!(
head_resources,
r#"<link rel="preload" as="script" href="/{{base_path}}/assets/{asset_path}" crossorigin>"#
);
}
if js_options.static_head() {
_ = write!(
head_resources,
r#"<script src="/{{base_path}}/assets/{asset_path}"></script>"#
);
}
}
_ => {}
}
}
// Do not preload the wasm file, because in Safari, preload as=fetch requires additional fetch() options to exactly match the network request
// And if they do not match then Safari downloads the wasm file twice.
// See https://github.com/wasm-bindgen/wasm-bindgen/blob/ac51055a4c39fa0affe02f7b63fb1d4c9b3ddfaf/crates/cli-support/src/js/mod.rs#L967
Self::replace_or_insert_before("{style_include}", "</head", &head_resources, html);
Ok(())
}
/// Inject loading scripts if they are not already present
fn inject_loading_scripts(&self, assets: &AppManifest, html: &mut String) {
// If the current build opted out of injecting loading scripts, don't inject anything
if !self.inject_loading_scripts {
return;
}
// If not, insert the script
*html = html.replace(
"</body",
&format!(
r#"<script type="module" async src="/{}/{}"></script>
</body"#,
self.base_path_or_default(),
self.bundled_js_path(assets)
),
);
}
/// Replace any special placeholders in the HTML with resolved values
fn replace_template_placeholders(&self, html: &mut String, wasm_path: &str, js_path: &str) {
let base_path = self.base_path_or_default();
*html = html.replace("{base_path}", base_path);
let app_name = &self.executable_name();
// If the html contains the old `{app_name}` placeholder, replace {app_name}_bg.wasm and {app_name}.js
// with the new paths
*html = html.replace("wasm/{app_name}_bg.wasm", wasm_path);
*html = html.replace("wasm/{app_name}.js", js_path);
// Otherwise replace the new placeholders
*html = html.replace("{wasm_path}", wasm_path);
*html = html.replace("{js_path}", js_path);
// Replace the app_name if we find it anywhere standalone
*html = html.replace("{app_name}", app_name);
}
/// Replace a string or insert the new contents before a marker
fn replace_or_insert_before(
replace: &str,
or_insert_before: &str,
with: &str,
content: &mut String,
) {
if content.contains(replace) {
*content = content.replace(replace, with);
} else if let Some(pos) = content.find(or_insert_before) {
content.insert_str(pos, with);
}
}
/// Check if assets should be pre_compressed. This will only be true in release mode if the user
/// has enabled pre_compress in the web config.
pub fn should_pre_compress_web_assets(&self, release: bool) -> bool {
self.config.web.pre_compress & release
}
/// Check if the wasm output should be bundled to an asset type app.
pub(crate) fn should_bundle_to_asset(&self) -> bool {
self.release && self.bundle == BundleFormat::Web
}
/// Get the path to the wasm bindgen temporary output folder
pub fn wasm_bindgen_out_dir(&self) -> PathBuf {
self.root_dir().join("wasm")
}
/// Get the path to the wasm bindgen javascript output file
pub(crate) fn wasm_bindgen_js_output_file(&self) -> PathBuf {
self.wasm_bindgen_out_dir()
.join(self.executable_name())
.with_extension("js")
}
/// Get the path to the wasm bindgen wasm output file
pub(crate) fn wasm_bindgen_wasm_output_file(&self) -> PathBuf {
self.wasm_bindgen_out_dir()
.join(format!("{}_bg", self.executable_name()))
.with_extension("wasm")
}
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)
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ impl<'a> BundleContext<'a> {
// Build the resources map from assets + config resources
let mut resources_map = HashMap::new();
let asset_dir = build.asset_dir();
let asset_dir = build.bundle_asset_dir();
if asset_dir.exists() {
for entry in walkdir::WalkDir::new(&asset_dir) {
let entry = entry?;
+10 -10
View File
@@ -198,7 +198,7 @@ impl CommandWithPlatformOverrides<BuildArgs> {
// Run SSG and cache static routes
if ssg {
crate::pre_render_static_routes(None, &mut server_build, None).await?;
server_build.pre_render_static_routes(None, None).await?;
}
tracing::info!(path = ?server.root_dir(), "Server build completed successfully! 🚀");
@@ -213,20 +213,20 @@ impl BuildArtifacts {
// The tip crate is identified by replacing hyphens with underscores in the target name,
// but since we don't have the BuildRequest here, we look for the entry with link_args
// (only the tip crate has link_args attached) or fall back to any entry.
let tip_args = self
.workspace_rustc_args
.values()
.find(|a| !a.link_args.is_empty())
.or_else(|| self.workspace_rustc_args.values().next())
.cloned()
let (rustc_args, rustc_envs) = self
.workspace_rustc
.rustc_args
.iter()
.find(|(k, _v)| k.ends_with(".bin"))
.map(|f| (f.1.args.clone(), f.1.envs.clone()))
.unwrap_or_default();
StructuredBuildArtifacts {
path: self.root_dir,
exe: self.exe,
rustc_args: tip_args.args,
rustc_envs: tip_args.envs,
link_args: tip_args.link_args,
rustc_args,
rustc_envs,
link_args: self.workspace_rustc.link_args,
assets: self.assets.unique_assets().cloned().collect(),
}
}
+3 -2
View File
@@ -1,7 +1,7 @@
use std::{fs::create_dir_all, path::PathBuf};
use crate::opt::process_file_to;
use crate::{extract_assets_from_file, Result, StructuredOutput};
use crate::{Result, StructuredOutput};
use clap::Parser;
use tracing::debug;
@@ -16,9 +16,10 @@ pub struct BuildAssets {
impl BuildAssets {
pub async fn run(self) -> Result<StructuredOutput> {
let manifest = extract_assets_from_file(&self.executable).await?;
let manifest = crate::extract_symbols_from_file(&self.executable).await?;
create_dir_all(&self.destination)?;
for asset in manifest.unique_assets() {
let source_path = PathBuf::from(asset.absolute_source_path());
let destination_path = self.destination.join(asset.bundled_path());
+12 -12
View File
@@ -1,5 +1,5 @@
use super::*;
use crate::{Result, Workspace};
use crate::{AndroidTools, Result, Workspace};
use anyhow::{bail, Context};
use itertools::Itertools;
@@ -91,24 +91,24 @@ impl Doctor {
let mut sdk = "not found".to_string();
let mut java_home = "not found".to_string();
let mut emulator = "not found".to_string();
if let Some(rf) = crate::build::get_android_tools() {
if rf.adb.exists() {
adb = rf.adb.display().to_string();
if let Some(tools) = AndroidTools::current() {
if tools.adb.exists() {
adb = tools.adb.display().to_string();
}
if rf.ndk.exists() {
ndk = rf.ndk.display().to_string();
if tools.ndk.exists() {
ndk = tools.ndk.display().to_string();
}
if let Some(jh) = rf.java_home.as_ref() {
if let Some(jh) = tools.java_home.as_ref() {
java_home = jh.display().to_string();
}
if rf.sdk().exists() {
sdk = rf.sdk().display().to_string();
if tools.sdk().exists() {
sdk = tools.sdk().display().to_string();
}
if let Some(jh) = rf.java_home.as_ref() {
if let Some(jh) = tools.java_home.as_ref() {
java_home = jh.display().to_string();
}
if rf.emulator().exists() {
emulator = rf.emulator().display().to_string();
if tools.emulator().exists() {
emulator = tools.emulator().display().to_string();
}
};
+4 -7
View File
@@ -1,12 +1,12 @@
use crate::{
platform_override::CommandWithPlatformOverrides, AppBuilder, BuildArgs, BuildId, BuildMode,
HotpatchModuleCache, Result, StructuredOutput,
HotpatchModuleCache, Result, StructuredOutput, WorkspaceRustcArgs,
};
use anyhow::Context;
use clap::Parser;
use dioxus_dx_wire_format::StructuredBuildArtifacts;
use std::io::Read;
use std::sync::Arc;
use std::{collections::HashMap, io::Read};
const HELP_HEADING: &str = "Hotpatching a binary";
@@ -71,24 +71,21 @@ impl HotpatchTip {
let cache = Arc::new(HotpatchModuleCache::new(&exe, &request.triple)?);
let tip_crate_name = request.main_target.replace('-', "_");
let mut workspace_rustc_args = HashMap::new();
workspace_rustc_args.insert(
let mut workspace_rustc_args = WorkspaceRustcArgs::new(link_args);
workspace_rustc_args.rustc_args.insert(
format!("{tip_crate_name}.bin"),
crate::RustcArgs {
args: rustc_args,
envs: rustc_envs,
link_args,
},
);
let mode = BuildMode::Thin {
workspace_rustc_args,
changed_files: vec![],
changed_crates: vec![],
modified_crates: std::collections::HashSet::new(),
aslr_reference: self.aslr_reference,
cache: cache.clone(),
object_cache: crate::ObjectCache::new(&request.session_cache_dir()),
};
let artifacts = AppBuilder::started(request, mode, build_id)?
+2
View File
@@ -80,6 +80,8 @@ impl LinkAction {
})
}
/// Write the required set of environment variables such that the child process has
/// enough information to perform the linking itself.
pub(crate) fn write_env_vars(
&self,
env_vars: &mut Vec<(Cow<'static, str>, OsString)>,
+1
View File
@@ -161,6 +161,7 @@ impl RunArgs {
BuilderUpdate::ProcessWaitFailed { err } => {
return Err(err.into());
}
BuilderUpdate::ProfilePhase { .. } => {}
}
}
ServeUpdate::Exit { .. } => break,
@@ -15,6 +15,7 @@ use crate::config::{
pub struct AndroidPermissionEntry {
/// Full Android permission string (e.g., "android.permission.CAMERA")
pub permission: String,
/// User-facing description (used for documentation)
pub description: String,
}
@@ -24,6 +25,7 @@ pub struct AndroidPermissionEntry {
pub struct PlistEntry {
/// Plist key (e.g., "NSCameraUsageDescription")
pub key: String,
/// User-facing description shown in permission dialogs
pub value: String,
}
+2
View File
@@ -4,6 +4,7 @@ mod component;
mod dioxus_config;
mod inline_config;
mod manifest;
mod manifest_mapper;
mod serve;
mod web;
@@ -12,5 +13,6 @@ pub(crate) use bundle::*;
pub(crate) use dioxus_config::*;
pub(crate) use inline_config::*;
pub(crate) use manifest::*;
pub(crate) use manifest_mapper::*;
pub(crate) use serve::*;
pub(crate) use web::*;
+24 -6
View File
@@ -1,4 +1,5 @@
use manganis_core::BundledAsset;
use manganis::SwiftPackageMetadata;
use manganis_core::{AndroidArtifactMetadata, BundledAsset};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
@@ -14,16 +15,33 @@ mod json;
pub(crate) use file::process_file_to;
pub(crate) use hash::add_hash_to_asset;
/// A manifest of all assets collected from dependencies
/// A manifest of all assets collected from dependencies. This is persisted to disk for users to be
/// able to pick up the result of asset extraction.
///
/// This will be filled in primarily by incremental compilation artifacts.
#[derive(Debug, PartialEq, Default, Clone, Serialize, Deserialize)]
pub(crate) struct AssetManifest {
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
pub(crate) struct AppManifest {
/// Stable since 0.7.0
pub cli_version: String,
/// Map of bundled asset name to the asset itself
assets: BTreeMap<PathBuf, HashSet<BundledAsset>>,
pub assets: BTreeMap<PathBuf, HashSet<BundledAsset>>,
pub android_artifacts: Vec<AndroidArtifactMetadata>,
pub swift_sources: Vec<SwiftPackageMetadata>,
}
impl AssetManifest {
impl AppManifest {
pub fn new() -> Self {
Self {
cli_version: crate::VERSION.to_string(),
android_artifacts: Default::default(),
swift_sources: Default::default(),
assets: Default::default(),
}
}
/// Manually add an asset to the manifest
pub fn register_asset(
&mut self,
+110 -88
View File
@@ -1,10 +1,34 @@
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
env::{args, vars},
path::PathBuf,
process::ExitCode,
};
/// A "capture" of a workspace's rustc commands, cumulated by reading the various rustc commands
/// from disk.
#[derive(Clone, Debug, PartialEq)]
pub struct WorkspaceRustcArgs {
pub link_args: Vec<String>,
pub rustc_args: HashMap<String, RustcArgs>,
}
impl WorkspaceRustcArgs {
pub fn new(link_args: Vec<String>) -> Self {
Self {
link_args,
rustc_args: Default::default(),
}
}
}
#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RustcArgs {
pub args: Vec<String>,
pub envs: Vec<(String, String)>,
}
/// The environment variable indicating where the args directory is located.
///
/// When `dx-rustc` runs, it writes each workspace crate's arguments to a
@@ -23,12 +47,92 @@ pub fn is_wrapping_rustc() -> bool {
std::env::var(DX_RUSTC_WRAPPER_ENV_VAR).is_ok()
}
#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RustcArgs {
pub args: Vec<String>,
pub envs: Vec<(String, String)>,
/// it doesn't include first program name argument
pub link_args: Vec<String>,
/// Run rustc directly, but output the result to a per-crate file in the args directory.
///
/// <https://doc.rust-lang.org/cargo/reference/config.html#buildrustc>
pub fn run_rustc() -> ExitCode {
let args_dir: PathBuf = std::env::var(DX_RUSTC_WRAPPER_ENV_VAR)
.expect("DX_RUSTC env var must be set")
.into();
// Cargo invokes a workspace wrapper like: `wrapper-name rustc [args...]`
// We skip our own executable name (`wrapper-name`) to get the args passed to us.
let captured_args = args().skip(1).collect::<Vec<_>>();
let rustc_args = RustcArgs {
args: captured_args.clone(),
envs: vars().collect::<_>(),
};
// Always persist the captured rustc invocation, even for link steps.
// The tip crate's bin target is typically only observed during the final link invocation,
// so returning early before writing would lose the exact args/envs we need for fat-link replay.
write_rustc_args(&args_dir, &rustc_args);
// If we are being asked to link, delegate to the linker action after capturing.
if has_linking_args() {
return crate::link::LinkAction::from_env()
.expect("Linker action not found")
.run_link();
}
// Run the actual rustc command.
// We want all stdout/stderr to be inherited, so the user sees the compiler output.
let mut cmd = std::process::Command::new("rustc");
// The first argument in `captured_args` is the rustc path, which we need to skip
// when passing arguments to the `rustc` command we are spawning.
cmd.args(captured_args.iter().skip(1));
cmd.envs(rustc_args.envs);
cmd.stdout(std::process::Stdio::inherit());
cmd.stderr(std::process::Stdio::inherit());
cmd.current_dir(std::env::current_dir().expect("Failed to get current dir"));
// Spawn the process and propagate its exit code.
let status = cmd.status().expect("Failed to execute rustc command");
std::process::exit(status.code().unwrap_or(1)); // Exit with 1 if process was killed by signal
}
fn write_rustc_args(args_dir: &PathBuf, rustc_args: &RustcArgs) {
// Extract the crate name from the args to use as the filename.
// Skip non-sensical args when a build is completely fresh (rustc is invoked with --crate-name ___)
let crate_name = rustc_args
.args
.iter()
.skip_while(|arg| *arg != "--crate-name")
.nth(1);
if let Some(crate_name) = crate_name {
if crate_name != "___" {
std::fs::create_dir_all(args_dir)
.expect("Failed to create args directory for rustc wrapper");
let crate_type = rustc_args
.args
.iter()
.skip_while(|arg| *arg != "--crate-type")
.nth(1)
.map(|s| s.as_str());
let serialized_args =
serde_json::to_string(rustc_args).expect("Failed to serialize rustc args");
// Write args with an explicit target suffix: {crate_name}.lib.json or
// {crate_name}.bin.json. This avoids the ambiguity of a bare {crate_name}.json
// and ensures lib+bin crates don't overwrite each other.
let suffix = match crate_type {
Some("lib" | "rlib") => "lib",
Some("bin") => "bin",
_ => "bin", // proc-macro, cdylib, etc. — treat as bin
};
std::fs::write(
args_dir.join(format!("{crate_name}.{suffix}.json")),
&serialized_args,
)
.expect("Failed to write rustc args to file");
}
}
}
/// Check if the arguments indicate a linking step, including those in command files.
@@ -64,85 +168,3 @@ fn has_linking_args() -> bool {
false
}
/// Run rustc directly, but output the result to a per-crate file in the args directory.
///
/// <https://doc.rust-lang.org/cargo/reference/config.html#buildrustc>
pub fn run_rustc() -> ExitCode {
// If we are being asked to link, delegate to the linker action.
if has_linking_args() {
return crate::link::LinkAction::from_env()
.expect("Linker action not found")
.run_link();
}
let args_dir: PathBuf = std::env::var(DX_RUSTC_WRAPPER_ENV_VAR)
.expect("DX_RUSTC env var must be set")
.into();
// Cargo invokes a workspace wrapper like: `wrapper-name rustc [args...]`
// We skip our own executable name (`wrapper-name`) to get the args passed to us.
let captured_args = args().skip(1).collect::<Vec<_>>();
let rustc_args = RustcArgs {
args: captured_args.clone(),
envs: vars().collect::<_>(),
link_args: Default::default(),
};
// Extract the crate name from the args to use as the filename.
// Skip non-sensical args when a build is completely fresh (rustc is invoked with --crate-name ___)
let crate_name = rustc_args
.args
.iter()
.skip_while(|arg| *arg != "--crate-name")
.nth(1);
if let Some(crate_name) = crate_name {
if crate_name != "___" {
std::fs::create_dir_all(&args_dir)
.expect("Failed to create args directory for rustc wrapper");
let crate_type = rustc_args
.args
.iter()
.skip_while(|arg| *arg != "--crate-type")
.nth(1)
.map(|s| s.as_str());
let serialized_args =
serde_json::to_string(&rustc_args).expect("Failed to serialize rustc args");
// Write args with an explicit target suffix: {crate_name}.lib.json or
// {crate_name}.bin.json. This avoids the ambiguity of a bare {crate_name}.json
// and ensures lib+bin crates don't overwrite each other.
let suffix = match crate_type {
Some("lib" | "rlib") => "lib",
Some("bin") => "bin",
_ => "bin", // proc-macro, cdylib, etc. — treat as bin
};
std::fs::write(
args_dir.join(format!("{crate_name}.{suffix}.json")),
&serialized_args,
)
.expect("Failed to write rustc args to file");
}
}
// Run the actual rustc command.
// We want all stdout/stderr to be inherited, so the user sees the compiler output.
let mut cmd = std::process::Command::new("rustc");
// The first argument in `captured_args` is the rustc path, which we need to skip
// when passing arguments to the `rustc` command we are spawning.
cmd.args(captured_args.iter().skip(1));
cmd.envs(rustc_args.envs);
cmd.stdout(std::process::Stdio::inherit());
cmd.stderr(std::process::Stdio::inherit());
cmd.current_dir(std::env::current_dir().expect("Failed to get current dir"));
// Spawn the process and propagate its exit code.
let status = cmd.status().expect("Failed to execute rustc command");
std::process::exit(status.code().unwrap_or(1)); // Exit with 1 if process was killed by signal
}
+11 -11
View File
@@ -159,6 +159,7 @@ pub(crate) async fn serve_all(args: ServeArgs, tracer: &TraceController) -> Resu
}
}
BuilderUpdate::Progress { .. } => {}
BuilderUpdate::ProfilePhase { .. } => {}
BuilderUpdate::CompilerMessage { message } => {
screen.push_cargo_log(message);
}
@@ -248,24 +249,23 @@ pub(crate) async fn serve_all(args: ServeArgs, tracer: &TraceController) -> Resu
screen.push_log(log);
}
ServeUpdate::OpenApp => match builder.use_hotpatch_engine {
true if !matches!(builder.client.build.bundle, BundleFormat::Web) => {
ServeUpdate::OpenApp => {
if builder.use_hotpatch_engine
&& !matches!(builder.client.build.bundle, BundleFormat::Web)
{
tracing::warn!(
"Opening a native app with hotpatching enabled requires a full rebuild..."
);
builder.full_rebuild().await;
devserver.send_reload_start().await;
devserver.start_build().await;
} else if let Err(err) = builder.open_all(&devserver, true).await {
tracing::error!(
"Failed to open app: {}",
crate::error::log_stacktrace(&err, 15)
)
}
_ => {
if let Err(err) = builder.open_all(&devserver, true).await {
tracing::error!(
"Failed to open app: {}",
crate::error::log_stacktrace(&err, 15)
)
}
}
},
}
ServeUpdate::Redraw => {
// simply returning will cause a redraw
+13 -12
View File
@@ -259,12 +259,12 @@ impl AppServer {
if !self.ssg || server.stage != BuildStage::Success {
return;
}
if let Err(err) = crate::pre_render_static_routes(
Some(devserver.devserver_address()),
server,
Some(&server.tx.clone()),
)
.await
if let Err(err) = server
.pre_render_static_routes(
Some(devserver.devserver_address()),
Some(&server.tx.clone()),
)
.await
{
tracing::error!("Failed to pre-render static routes: {err}");
}
@@ -362,7 +362,7 @@ impl AppServer {
// This prevents losing changes from tools like stylance, tailwind, or sass that generate files
// in response to source changes.
tracing::debug!(
"Queueing file change: client is not ready to receive hotreloads. Files: {:#?}",
"Queueing file change - client is not ready to receive hotreloads. Files: {:?}",
files
);
self.pending_file_changes.extend(files.iter().cloned());
@@ -410,7 +410,6 @@ impl AppServer {
// Get the cached file if it exists - ignoring if it doesn't exist
let Some(cached_file) = self.file_map.get_mut(path) else {
tracing::debug!("No entry for file in filemap: {:?}", path);
tracing::debug!("Filemap: {:#?}", self.file_map.keys());
continue;
};
@@ -601,10 +600,12 @@ impl AppServer {
use crate::cli::styles::GLOW_STYLE;
if should_open {
let time_taken = artifacts
.time_end
.duration_since(artifacts.time_start)
.unwrap();
let time_taken = self.client.total_build_time().unwrap_or_else(|| {
artifacts
.time_end
.duration_since(artifacts.time_start)
.unwrap()
});
if self.client.builds_opened == 0 {
tracing::info!(
+1
View File
@@ -265,6 +265,7 @@ impl WebServer {
BuilderUpdate::StderrReceived { .. } => {}
BuilderUpdate::ProcessExited { .. } => {}
BuilderUpdate::ProcessWaitFailed { .. } => {}
BuilderUpdate::ProfilePhase { .. } => {}
}
}
+1 -2
View File
@@ -117,7 +117,7 @@ async fn test_harnesses() {
assert_eq!(targets.client.triple, "aarch64-apple-ios".parse().unwrap());
})
.asrt(r#"dx build --android --device"#, |targets| async move {
if crate::get_android_tools().is_none() {
if crate::AndroidTools::current().is_none() {
// Skip when Android NDK/SDK is not installed
return;
}
@@ -254,7 +254,6 @@ async fn test_harnesses() {
assert!(t.server.is_none());
assert_eq!(t.client.bundle, BundleFormat::Ios);
assert_eq!(t.client.triple, TestHarnessBuilder::host_ios_triple_sim());
assert!(t.client.no_default_features);
},
),
TestHarnessBuilder::new("harness-fullstack-with-optional-tokio")
+2 -2
View File
@@ -91,9 +91,9 @@ impl WasmBindgen {
}
}
pub(crate) fn keep_lld_sections(self, keep_lld_sections: bool) -> Self {
pub(crate) fn keep_lld_exports(self, keep_lld_exports: bool) -> Self {
Self {
keep_lld_exports: keep_lld_sections,
keep_lld_exports,
..self
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ use tar::Archive;
use tempfile::NamedTempFile;
/// Pinned binaryen version (contains wasm-opt).
const BINARYEN_VERSION: &str = "127";
const BINARYEN_VERSION: &str = "129";
/// Write these wasm bytes with a particular set of optimizations
pub async fn write_wasm(bytes: &[u8], output_path: &Path, cfg: &WasmOptConfig) -> Result<()> {
+1 -1
View File
@@ -101,7 +101,7 @@ impl Workspace {
)
.context("Failed to load Cargo.toml")?;
let android_tools = crate::build::get_android_tools();
let android_tools = AndroidTools::current();
let workspace = Arc::new(Self {
krates,