rip out tauri bundle (v2), swc -> esbuild, blacksmith -> warpbuild, FAST CI 🚀 (#5328)

- inline mac, windows, linux bundling in the cli
- remove tauri bundle from our dep tree
- trim down the rpm crate for fewer dependencies in tree
- migrate from blacksmith to warpbuild
- move off swc and instead use esbuild via tool
- use dev drives in windows CI
- use fork of nix action for faster caching
- test bundling in CI
- fix asset handling in linux 
- add default APPDATA location for webview2 installs for dioxus apps on windows
This commit is contained in:
Jonathan Kelley authored and GitHub committed 2026-03-20 12:27:13 -07:00
1 parent be7296f4f0
commit b2d01e6779
83 files changed
+7756 -5403

No files matched your search

+23 -26
View File
@@ -7,11 +7,10 @@ description = "CLI for building fullstack web, desktop, and mobile apps with a s
repository = "https://github.com/DioxusLabs/dioxus/"
license = "MIT OR Apache-2.0"
keywords = ["mobile", "gui", "cli", "dioxus", "wasm"]
rust-version = "1.81.0"
rust-version = "1.82.0"
[dependencies]
dioxus-autofmt = { workspace = true }
dioxus-check = { workspace = true }
dioxus-rsx-rosetta = { workspace = true }
dioxus-rsx = { workspace = true }
dioxus-rsx-hotreload = { workspace = true }
@@ -20,8 +19,6 @@ dioxus-core = { workspace = true, features = ["serialize"] }
dioxus-core-types = { workspace = true }
dioxus-devtools-types = { workspace = true }
dioxus-cli-config = { workspace = true }
dioxus-cli-opt = { workspace = true }
dioxus-fullstack = { workspace = true }
dioxus-dx-wire-format = { workspace = true }
wasm-split-cli = { workspace = true }
depinfo = { workspace = true }
@@ -33,10 +30,8 @@ bytes = { workspace = true }
clap = { workspace = true, features = ["derive", "cargo"] }
convert_case = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
# swc uses serde internals that break with newer versions of serde. We should move off of swc eventually,
# but pinning serde fixes the issue in the meantime
serde = { version = "=1.0.221", features = ["derive"] }
uuid = { workspace = true, features = ["v4", "v5", "v7"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
toml = { workspace = true }
cargo_toml = { workspace = true, features = ["features"] }
@@ -61,9 +56,10 @@ console = "0.16.0"
ctrlc = "3.4.7"
axum = { workspace = true, default-features = true, features = ["ws"] }
axum-server = { workspace = true, features = ["tls-rustls"] }
axum-server = { workspace = true, features = ["tls-rustls-no-provider"] }
axum-extra = { workspace = true, features = ["typed-header"] }
tower-http = { workspace = true, features = ["full"] }
owo-colors = { workspace = true, features = ["supports-colors"] }
proc-macro2 = { workspace = true, features = ["span-locations"] }
syn = { workspace = true, features = [
"full",
@@ -72,13 +68,12 @@ syn = { workspace = true, features = [
"visit-mut",
] }
headers = "0.4.1"
walkdir = "2"
dunce = { workspace = true }
# tools download
dirs = { workspace = true }
reqwest = { workspace = true, features = ["rustls-tls", "trust-dns", "json"] }
reqwest = { workspace = true, default-features = false, features = ["rustls-tls", "json"] }
tower = { workspace = true }
git2 = "0.20.2"
@@ -95,9 +90,14 @@ toml_edit = "0.22.27"
prettyplease = { workspace = true }
# Assets
lightningcss = { version = "1.0.0-alpha.67", features = ["browserslist", "into_owned"] }
grass = "0.13.4"
codemap = "0.1.3"
mozjpeg = { version = "0.10.13", default-features = false, features = ["parallel"] }
imagequant = "4.4.0"
png = "0.17.16"
brotli = "8.0.1"
ignore = "0.4.23"
env_logger = { workspace = true }
const-serialize = { workspace = true, features = ["serde"] }
const-serialize-07 = { package = "const-serialize", version = "=0.7.2", features = ["serde"] }
@@ -127,7 +127,6 @@ manganis = { workspace = true }
manganis-core = { workspace = true }
manganis-core-07 = { workspace = true }
target-lexicon = { version = "0.13.2", features = ["serde", "serde_support"] }
wasm-encoder = "0.235.0"
# Extracting data from an executable
object = { workspace = true, features = ["all"] }
@@ -136,26 +135,26 @@ itertools = { workspace = true }
throbber-widgets-tui = "0.8.0"
unicode-segmentation = "1.12.0"
handlebars = "6.3.2"
strum = { version = "0.27.1", features = ["derive"] }
memmap = "0.7.0"
walrus = { workspace = true, features = ["parallel"] }
wasmparser = { workspace = true }
tauri-utils = { workspace = true }
tauri-macos-sign = { workspace = true }
tauri-bundler = { workspace = true }
# bundler dependencies
icns = { workspace = true }
image = { workspace = true }
zip = { workspace = true }
sha1 = { workspace = true }
sha2 = { workspace = true }
md5 = { workspace = true }
rpm = { workspace = true }
include_dir = "0.7.4"
flate2 = "1.1.2"
tar = "0.4.44"
local-ip-address = "0.6.5"
dircpy = "0.3.19"
plist = "1.7.4"
memoize = "0.5.1"
backtrace = "0.3.74"
ar = "0.9.0"
wasm-bindgen-externref-xform = "0.2.100"
pdb = "0.8.0"
self_update = { version = "0.42.0", features = [
self_update = { version = "0.42.0", default-features = false, features = [
"archive-tar",
"archive-zip",
"compression-flate2",
@@ -163,7 +162,6 @@ self_update = { version = "0.42.0", features = [
] }
self-replace = "1.5.0"
cargo-config2 = { workspace = true }
posthog-rs = "0.3.5"
serde_json5 = "0.2.1"
fs2 = "0.4.3"
sentry-backtrace = "0.42.0"
@@ -175,9 +173,6 @@ home = "=0.5.11"
[target.'cfg(target_env = "musl")'.dependencies]
git2 = { version = "0.20.2", features = ["vendored-libgit2"] }
[build-dependencies]
built = { version = "0.8.0", features = ["git2"] }
[features]
default = []
tokio-console = ["dep:console-subscriber", "tokio/tracing"]
@@ -190,6 +185,8 @@ name = "dx"
[dev-dependencies]
escargot = "0.5"
indoc = "2.0.6"
pretty_assertions = { workspace = true }
[package.metadata.binstall]
pkg-url = "{ repo }/releases/download/v{ version }/dx-{ target }{ archive-suffix }"
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+40 -1
View File
@@ -1,3 +1,42 @@
use std::process::Command;
fn main() {
built::write_built_file().expect("Failed to acquire build-time information");
println!("cargo:rerun-if-env-changed=DIOXUS_CLI_GIT_SHA");
println!("cargo:rerun-if-env-changed=DIOXUS_CLI_GIT_SHA_SHORT");
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);
if let Some(full_hash) = full_hash {
println!("cargo:rustc-env=DIOXUS_CLI_GIT_SHA={full_hash}");
let short_hash = std::env::var("DIOXUS_CLI_GIT_SHA_SHORT")
.ok()
.unwrap_or_else(|| full_hash.chars().take(7).collect());
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)
}
}
+2 -2
View File
@@ -3266,14 +3266,14 @@
]
},
"upgrade_code": {
"description": "A GUID upgrade code for MSI installer. This code **_must stay the same across all of your updates_**, otherwise, Windows will treat your update as a different app and your users will have duplicate versions of your app.\n\nBy default, tauri generates this code by generating a Uuid v5 using the string `<productName>.exe.app.x64` in the DNS namespace. You can use Tauri's CLI to generate and print this code for you by running `tauri inspect wix-upgrade-code`.\n\nIt is recommended that you set this value in your tauri config file to avoid accidental changes in your upgrade code whenever you want to change your product name.",
"description": "A GUID upgrade code for MSI installer. This code **_must stay the same across all of your updates_**, otherwise, Windows will treat your update as a different app and your users will have duplicate versions of your app.\n\nBy default, this code is generated as a Uuid v5 using the string `<productName>.exe.app.x64` in the DNS namespace.\n\nIt is recommended that you set this value in your Dioxus.toml to avoid accidental changes in your upgrade code whenever you want to change your product name.",
"type": [
"string",
"null"
]
},
"version": {
"description": "MSI installer version in the format `major.minor.patch.build` (build is optional).\n\nBecause a valid version is required for MSI installer, it will be derived from [`tauri_bundler::PackageSettings::version`] if this field is not set.\n\nThe first field is the major version and has a maximum value of 255. The second field is the minor version and has a maximum value of 255. The third and fourth fields have a maximum value of 65,535.\n\nSee <https://learn.microsoft.com/en-us/windows/win32/msi/productversion> for more info.",
"description": "MSI installer version in the format `major.minor.patch.build` (build is optional).\n\nBecause a valid version is required for MSI installer, it will be derived from the package version if this field is not set.\n\nThe first field is the major version and has a maximum value of 255. The second field is the minor version and has a maximum value of 255. The third and fourth fields have a maximum value of 65,535.\n\nSee <https://learn.microsoft.com/en-us/windows/win32/msi/productversion> for more info.",
"type": [
"string",
"null"
+2 -2
View File
@@ -33,10 +33,10 @@ use std::{
path::{Path, PathBuf},
};
use crate::opt::AssetManifest;
use crate::Result;
use anyhow::{bail, Context};
use const_serialize::{deserialize_const, serialize_const, ConstVec};
use dioxus_cli_opt::AssetManifest;
use manganis::{AssetOptions, AssetVariant, BundledAsset, ImageFormat, ImageSize};
use manganis_core::{AndroidArtifactMetadata, SwiftPackageMetadata, SymbolData};
use object::{File, Object, ObjectSection, ObjectSymbol, ReadCache, ReadRef, Section, Symbol};
@@ -811,7 +811,7 @@ pub(crate) async fn extract_symbols_from_file(
// Add the hash to each asset in parallel
assets
.par_iter_mut()
.for_each(dioxus_cli_opt::add_hash_to_asset);
.for_each(crate::opt::add_hash_to_asset);
// Write back only assets to the binary file (permissions are not modified)
for entry in write_entries {
+10 -5
View File
@@ -1,10 +1,10 @@
use crate::opt::process_file_to;
use crate::{
build::cache::ObjectCache, serve::WebServer, verbosity_or_default, BuildArtifacts,
BuildRequest, BuildStage, BuilderUpdate, BundleFormat, ProgressRx, ProgressTx, Result,
RustcArgs, StructuredOutput,
};
use anyhow::{bail, Context, Error};
use dioxus_cli_opt::process_file_to;
use futures_util::{future::OptionFuture, pin_mut, FutureExt};
use itertools::Itertools;
use std::{
@@ -475,9 +475,12 @@ impl AppBuilder {
current,
total,
krate,
fresh,
..
} => {
tracing::info!("Compiled [{current:>3}/{total}]: {krate}");
if !fresh {
tracing::info!("Compiled [{current:>3}/{total}]: {krate}");
}
}
BuildStage::RunningBindgen => tracing::info!("Running wasm-bindgen..."),
BuildStage::CopyingAssets {
@@ -509,7 +512,7 @@ impl AppBuilder {
}
BuilderUpdate::BuildFailed { err } => {
// Flush remaining compiler messages
while let Ok(Some(msg)) = self.rx.try_next() {
while let Ok(msg) = self.rx.try_recv() {
if let BuilderUpdate::CompilerMessage { message } = msg {
tracing::info!(json = %StructuredOutput::RustcOutput { message: message.clone() }, %message);
}
@@ -760,7 +763,8 @@ impl AppBuilder {
let to = asset_dir.join(bundled.bundled_path());
tracing::debug!("Copying asset from patch: {}", from.display());
if let Err(e) = dioxus_cli_opt::process_file_to(bundled.options(), &from, &to) {
let esbuild = crate::esbuild::Esbuild::path_if_installed();
if let Err(e) = process_file_to(bundled.options(), &from, &to, esbuild.as_deref()) {
tracing::error!("Failed to copy asset: {e}");
continue;
}
@@ -869,7 +873,8 @@ impl AppBuilder {
// the asset would be in a new location because the contents and hash have changed. Since we are
// hotreloading, we need to use the old asset location it was originally written to.
let options = *resource.options();
let res = process_file_to(&options, &changed_file, &output_path);
let esbuild = crate::esbuild::Esbuild::path_if_installed();
let res = process_file_to(&options, &changed_file, &output_path, esbuild.as_deref());
let bundled_name = PathBuf::from(resource.bundled_path());
if let Err(e) = res {
tracing::debug!("Failed to hotreload asset {e}");
+8 -1
View File
@@ -137,12 +137,19 @@ impl BuildContext {
tracing::trace!(dx_src = ?TraceSrc::Cargo, "{line}");
}
pub(crate) fn status_build_progress(&self, count: usize, total: usize, name: String) {
pub(crate) fn status_build_progress(
&self,
count: usize,
total: usize,
name: String,
fresh: bool,
) {
_ = self.tx.unbounded_send(BuilderUpdate::Progress {
stage: BuildStage::Compiling {
current: count,
total,
krate: name,
fresh,
},
});
}
+1 -1
View File
@@ -144,9 +144,9 @@ pub async fn create_framework_bundle(
std::fs::write(resources_dir.join("Info.plist"), info_plist)?;
// Create symbolic links (required for macOS framework structure)
let versions_dir = framework_dir.join("Versions");
#[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),
+1 -1
View File
@@ -8,7 +8,7 @@
//! Eventually, we might want to expand this to include more metadata about the build, including
//! build time, target platform, etc.
use dioxus_cli_opt::AssetManifest;
use crate::opt::AssetManifest;
use serde::{Deserialize, Serialize};
#[derive(Default, Serialize, Deserialize)]
+19 -2
View File
@@ -1420,8 +1420,25 @@ fn name_is_bindgen_symbol(name: &str) -> bool {
|| name.contains("__wbindgen_externref")
|| name.contains("wasm_bindgen8describe6inform")
|| name.contains("wasm_bindgen..describe..WasmDescribe")
|| name.contains("wasm_bindgen..closure..WasmClosure$GT$8describe")
|| name.contains("wasm_bindgen7closure16Closure$LT$T$GT$4wrap8describe")
|| (name.contains("wasm_bindgen..closure..WasmClosure") && name.contains("describe"))
|| (name.contains("wasm_bindgen7closure16Closure") && name.contains("describe"))
|| (name.contains("wasm_bindgen7convert8closures") && name.contains("describe_invoke"))
}
// Test for bindgen symbols. As we find more bad symbols, add them here
#[test]
fn bindgen_symbol_catch() {
let symbol = "_ZN12wasm_bindgen7convert8closures1_142_$LT$impl$u20$wasm_bindgen..closure..WasmClosure$u20$for$u20$dyn$u20$core..ops..function..FnMut$LT$$LP$$RP$$GT$$u2b$Output$u20$$u3d$$u20$R$GT$15describe_invoke17h4373f8b6570333dcE";
assert!(name_is_bindgen_symbol(symbol));
// matches_legacy_wasm_bindgen_closure_describe_symbols
let symbol = "_ZN12wasm_bindgen7closure16Closure$LT$T$GT$4wrap8describe17h1234567890abcdefE";
assert!(name_is_bindgen_symbol(symbol));
// does_not_match_saved_runtime_exports
assert!(!name_is_bindgen_symbol("__wbindgen_malloc"));
assert!(!name_is_bindgen_symbol("__wbindgen_realloc"));
assert!(!name_is_bindgen_symbol("__wbindgen_free"));
}
/// Manually parse the data section from a wasm module
+32 -4
View File
@@ -320,6 +320,7 @@
//! - xbuild: <https://github.com/rust-mobile/xbuild/blob/master/xbuild/src/command/build.rs>
use super::HotpatchModuleCache;
use crate::opt::{process_file_to, AssetManifest};
use crate::{
AndroidTools, AppManifest, BuildContext, BuildId, BundleFormat, DioxusConfig, Error,
LinkAction, LinkerFlavor, ObjectCache, Platform, Renderer, Result, RustcArgs, TargetArgs,
@@ -331,7 +332,6 @@ use cargo_toml::{Profile, Profiles, StripSetting};
use depinfo::RustcDepInfo;
use dioxus_cli_config::{format_base_path_meta_element, PRODUCT_NAME_ENV};
use dioxus_cli_config::{APP_TITLE_ENV, ASSET_ROOT_ENV};
use dioxus_cli_opt::{process_file_to, AssetManifest};
use itertools::Itertools;
use krates::{cm::TargetKind, NodeId};
use manganis::{AssetOptions, BundledAsset, SwiftPackageMetadata};
@@ -1306,7 +1306,12 @@ impl BuildRequest {
}
Message::CompilerArtifact(artifact) => {
units_compiled += 1;
ctx.status_build_progress(units_compiled, crate_count, artifact.target.name);
ctx.status_build_progress(
units_compiled,
crate_count,
artifact.target.name,
artifact.fresh,
);
output_location = artifact.executable.map(Into::into);
}
// todo: this can occasionally swallow errors, so we should figure out what exactly is going wrong
@@ -2280,6 +2285,7 @@ impl BuildRequest {
// Parallel Copy over the assets and keep track of progress with an atomic counter
let progress = ctx.tx.clone();
let ws_dir = self.workspace_dir();
let esbuild_path = crate::esbuild::Esbuild::path_if_installed();
// Optimizing assets is expensive and blocking, so we do it in a tokio spawn blocking task
tokio::task::spawn_blocking(move || {
@@ -2292,7 +2298,7 @@ impl BuildRequest {
"Starting asset copy {processing}/{asset_count} from {from_:?}"
);
let res = process_file_to(options, from, to);
let res = process_file_to(options, from, to, esbuild_path.as_deref());
if let Err(err) = res.as_ref() {
tracing::error!("Failed to copy asset {from:?}: {err}");
}
@@ -5524,6 +5530,25 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{
.join("app-debug.apk")
}
pub(crate) fn release_apk_path(&self) -> PathBuf {
self.root_dir()
.join("app")
.join("build")
.join("outputs")
.join("apk")
.join("release")
.join("app-release.apk")
}
pub(crate) fn android_apk_path(&self) -> PathBuf {
let assembled_release = self.release && self.config.bundle.android.is_some();
if assembled_release {
self.release_apk_path()
} else {
self.debug_apk_path()
}
}
/// We only really currently care about:
///
/// - app dir (.app, .exe, .apk, etc)
@@ -5683,6 +5708,9 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{
self.verify_toolchain_installed().await?;
// esbuild is used for JS asset processing on all platforms
let _esbuild_path = crate::esbuild::Esbuild::get_or_install().await?;
match self.bundle {
BundleFormat::Web => self.verify_web_tooling().await?,
BundleFormat::Ios => self.verify_ios_tooling().await?,
@@ -5970,7 +5998,7 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{
if let Some(name) = entry.file_name().to_str() {
if name.starts_with(&prefix) && name.ends_with(".rlib") {
let mtime = entry.metadata().ok()?.modified().ok()?;
if best.as_ref().map_or(true, |(_, t)| mtime > *t) {
if best.as_ref().is_none_or(|(_, t)| mtime > *t) {
best = Some((entry.path(), mtime));
}
}
-194
View File
@@ -1,194 +0,0 @@
use crate::{
config::BundleConfig, CustomSignCommandSettings, DebianSettings, MacOsSettings,
NSISInstallerMode, NsisSettings, PackageType, WebviewInstallMode, WindowsSettings, WixSettings,
};
impl From<NsisSettings> for tauri_bundler::NsisSettings {
fn from(val: NsisSettings) -> Self {
tauri_bundler::NsisSettings {
header_image: val.header_image,
sidebar_image: val.sidebar_image,
installer_icon: val.installer_icon,
install_mode: val.install_mode.into(),
languages: val.languages,
display_language_selector: val.display_language_selector,
custom_language_files: None,
template: None,
compression: tauri_utils::config::NsisCompression::None,
start_menu_folder: val.start_menu_folder,
installer_hooks: val.installer_hooks,
minimum_webview2_version: val.minimum_webview2_version,
}
}
}
impl From<BundleConfig> for tauri_bundler::BundleSettings {
fn from(val: BundleConfig) -> Self {
tauri_bundler::BundleSettings {
identifier: val.identifier,
publisher: val.publisher,
icon: val.icon,
resources: val.resources,
copyright: val.copyright,
category: val.category.and_then(|c| c.parse().ok()),
short_description: val.short_description,
long_description: val.long_description,
external_bin: val.external_bin,
deb: val.deb.map(Into::into).unwrap_or_default(),
macos: val.macos.map(Into::into).unwrap_or_default(),
windows: val.windows.map(Into::into).unwrap_or_default(),
..Default::default()
}
}
}
impl From<DebianSettings> for tauri_bundler::DebianSettings {
fn from(val: DebianSettings) -> Self {
tauri_bundler::DebianSettings {
depends: val.depends,
files: val.files,
desktop_template: val.desktop_template,
provides: val.provides,
conflicts: val.conflicts,
replaces: val.replaces,
section: val.section,
priority: val.priority,
changelog: val.changelog,
pre_install_script: val.pre_install_script,
post_install_script: val.post_install_script,
pre_remove_script: val.pre_remove_script,
post_remove_script: val.post_remove_script,
recommends: val.recommends,
}
}
}
impl From<WixSettings> for tauri_bundler::WixSettings {
fn from(val: WixSettings) -> Self {
tauri_bundler::WixSettings {
language: tauri_bundler::bundle::WixLanguage({
let mut languages: Vec<_> = val
.language
.iter()
.map(|l| {
(
l.0.clone(),
tauri_bundler::bundle::WixLanguageConfig {
locale_path: l.1.clone(),
},
)
})
.collect();
if languages.is_empty() {
languages.push(("en-US".into(), Default::default()));
}
languages
}),
template: val.template,
fragment_paths: val.fragment_paths,
component_group_refs: val.component_group_refs,
component_refs: val.component_refs,
feature_group_refs: val.feature_group_refs,
feature_refs: val.feature_refs,
merge_refs: val.merge_refs,
enable_elevated_update_task: val.enable_elevated_update_task,
banner_path: val.banner_path,
dialog_image_path: val.dialog_image_path,
fips_compliant: val.fips_compliant,
version: val.version,
upgrade_code: val.upgrade_code,
}
}
}
impl From<MacOsSettings> for tauri_bundler::MacOsSettings {
fn from(val: MacOsSettings) -> Self {
tauri_bundler::MacOsSettings {
frameworks: val.frameworks,
minimum_system_version: val.minimum_system_version,
exception_domain: val.exception_domain,
signing_identity: val.signing_identity,
provider_short_name: val.provider_short_name,
entitlements: val.entitlements,
info_plist_path: val.info_plist_path,
files: val.files,
hardened_runtime: val.hardened_runtime,
bundle_version: val.bundle_version,
bundle_name: val.bundle_name,
}
}
}
#[allow(deprecated)]
impl From<WindowsSettings> for tauri_bundler::WindowsSettings {
fn from(val: WindowsSettings) -> Self {
tauri_bundler::WindowsSettings {
digest_algorithm: val.digest_algorithm,
certificate_thumbprint: val.certificate_thumbprint,
timestamp_url: val.timestamp_url,
tsp: val.tsp,
wix: val.wix.map(Into::into),
webview_install_mode: val.webview_install_mode.into(),
allow_downgrades: val.allow_downgrades,
nsis: val.nsis.map(Into::into),
sign_command: val.sign_command.map(Into::into),
icon_path: val.icon_path.unwrap_or("./icons/icon.ico".into()),
}
}
}
impl From<NSISInstallerMode> for tauri_utils::config::NSISInstallerMode {
fn from(val: NSISInstallerMode) -> Self {
match val {
NSISInstallerMode::CurrentUser => tauri_utils::config::NSISInstallerMode::CurrentUser,
NSISInstallerMode::PerMachine => tauri_utils::config::NSISInstallerMode::PerMachine,
NSISInstallerMode::Both => tauri_utils::config::NSISInstallerMode::Both,
}
}
}
impl From<PackageType> for tauri_bundler::PackageType {
fn from(value: PackageType) -> Self {
match value {
PackageType::MacOsBundle => Self::MacOsBundle,
PackageType::IosBundle => Self::IosBundle,
PackageType::WindowsMsi => Self::WindowsMsi,
PackageType::Deb => Self::Deb,
PackageType::Rpm => Self::Rpm,
PackageType::AppImage => Self::AppImage,
PackageType::Dmg => Self::Dmg,
PackageType::Updater => Self::Updater,
PackageType::Nsis => Self::Nsis,
}
}
}
impl WebviewInstallMode {
fn into(self) -> tauri_utils::config::WebviewInstallMode {
match self {
Self::Skip => tauri_utils::config::WebviewInstallMode::Skip,
Self::DownloadBootstrapper { silent } => {
tauri_utils::config::WebviewInstallMode::DownloadBootstrapper { silent }
}
Self::EmbedBootstrapper { silent } => {
tauri_utils::config::WebviewInstallMode::EmbedBootstrapper { silent }
}
Self::OfflineInstaller { silent } => {
tauri_utils::config::WebviewInstallMode::OfflineInstaller { silent }
}
Self::FixedRuntime { path } => {
tauri_utils::config::WebviewInstallMode::FixedRuntime { path }
}
}
}
}
impl From<CustomSignCommandSettings> for tauri_bundler::CustomSignCommandSettings {
fn from(val: CustomSignCommandSettings) -> Self {
tauri_bundler::CustomSignCommandSettings {
cmd: val.cmd,
args: val.args,
}
}
}
+125
View File
@@ -0,0 +1,125 @@
use super::{copy_dir_recursive, zip_dir_recursive, Arch, Bundle, BundleContext};
use crate::PackageType;
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
use tokio::process::Command;
pub(crate) struct IosBundled {
pub ipa: Vec<PathBuf>,
#[allow(dead_code)]
pub app: Vec<PathBuf>,
}
impl BundleContext<'_> {
/// Return the already-assembled iOS `.app` bundle produced by the build pipeline.
pub(crate) async fn bundle_ios_app(&self) -> Result<Vec<PathBuf>> {
let app_path = self.build.root_dir();
if !app_path.exists() {
bail!(
"iOS app bundle not found at {}. Ensure the iOS build completed successfully.",
app_path.display()
);
}
Ok(vec![app_path])
}
/// Package a signed iOS `.app` into an App Store distributable `.ipa`.
pub(crate) async fn bundle_ios_ipa(&self, bundles: &[Bundle]) -> Result<IosBundled> {
validate_ios_ipa_target(&self.target(), self.binary_arch())?;
let (app_paths, synthesized_app_paths) = if let Some(app_bundle) = bundles
.iter()
.find(|bundle| bundle.package_type == PackageType::IosApp)
{
(app_bundle.bundle_paths.clone(), Vec::new())
} else {
let paths = self.bundle_ios_app().await?;
(paths.clone(), paths)
};
if app_paths.is_empty() {
bail!("No iOS .app bundle found to package into an .ipa");
}
let app_path = &app_paths[0];
if !app_path.exists() {
bail!(
"iOS .app bundle does not exist at expected path: {}",
app_path.display()
);
}
verify_codesigned_app(app_path).await?;
let output_dir = self.project_out_directory().join("bundle").join("ipa");
std::fs::create_dir_all(&output_dir)?;
let ipa_name = format!(
"{}_{}_{}.ipa",
self.product_name(),
self.version_string(),
self.binary_arch()
);
let output_path = output_dir.join(ipa_name);
tracing::info!("Creating iOS IPA at {}", output_path.display());
let staging_dir =
tempfile::tempdir().context("Failed to create temp dir for IPA staging")?;
let payload_dir = staging_dir.path().join("Payload");
std::fs::create_dir_all(&payload_dir)?;
let staged_app = payload_dir.join(
app_path
.file_name()
.context("iOS .app bundle is missing a file name")?,
);
copy_dir_recursive(app_path, &staged_app)?;
if output_path.exists() {
std::fs::remove_file(&output_path)?;
}
zip_dir_recursive(staging_dir.path(), &output_path)?;
tracing::info!("IPA created at {}", output_path.display());
Ok(IosBundled {
ipa: vec![output_path],
app: synthesized_app_paths,
})
}
}
fn validate_ios_ipa_target(target: &str, arch: Arch) -> Result<()> {
let is_device = arch == Arch::AArch64 && !target.contains("sim");
if !is_device {
bail!(
"IPA packaging requires a physical-device iOS target. Resolved target was `{target}`."
);
}
Ok(())
}
async fn verify_codesigned_app(app_path: &Path) -> Result<()> {
verify_codesigned_app_with(Path::new("codesign"), app_path).await
}
async fn verify_codesigned_app_with(codesign: &Path, app_path: &Path) -> Result<()> {
let output = Command::new(codesign)
.args(["--verify", "--deep", "--strict"])
.arg(app_path)
.output()
.await
.with_context(|| format!("Failed to run `{}`", codesign.display()))?;
if !output.status.success() {
bail!(
"iOS .app bundle must be codesigned before creating an .ipa: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
File diff suppressed because it is too large. Load diff
+981
View File
@@ -0,0 +1,981 @@
use crate::bundler::{copy_dir_recursive, AppCategory, Bundle, BundleContext};
use crate::{MacOsSettings, PackageType};
use anyhow::{bail, Context, Result};
use image::{DynamicImage, ImageReader};
use std::fs::{self, File};
use std::io::BufWriter;
use std::path::{Path, PathBuf};
use std::process::{Command as StdCommand, Stdio};
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
impl BundleContext<'_> {
/// Create the final macOS `.app` bundle and apply Apple-specific metadata.
///
/// This is the primary macOS bundling routine. It takes the executable produced by
/// the Dioxus build pipeline and assembles the canonical app bundle structure in
/// `project_out_directory()/macos/<BundleName>.app`.
///
/// The method performs the following steps:
/// 1. Resolve macOS settings and choose the bundle/display name.
/// 2. Remove any existing output bundle to keep the result deterministic.
/// 3. Create the `Contents/`, `Contents/MacOS`, `Contents/Resources`, and
/// optionally `Contents/Frameworks` directories.
/// 4. Copy the main executable into `Contents/MacOS/` and mark it executable.
/// 5. Build or copy an `.icns` file into `Contents/Resources/`.
/// 6. Copy configured resources and sidecar binaries into the bundle.
/// 7. Copy configured frameworks and arbitrary extra files from
/// `MacOsSettings::files`.
/// 8. Generate an `Info.plist` from bundle metadata, unless a custom plist path
/// was configured and exists.
/// 9. Write the legacy `PkgInfo` file.
/// 10. If signing is configured, sign frameworks first, then the main binary, and
/// finally the enclosing `.app`.
/// 11. If notarization credentials are available, zip the `.app`, submit it to
/// Apple's notary service, staple the result, and delete the temporary zip.
///
/// Contributor notes:
/// - The build system guarantees the executable exists, but this method is
/// responsible for the final `.app` on-disk shape.
/// - Framework paths may be absolute or relative to the crate directory. Missing
/// paths are tolerated because some entries may refer to system frameworks.
/// - Lack of signing or notarization credentials is not fatal; an unsigned `.app`
/// is still returned as a valid bundle artifact.
pub(crate) async fn bundle_macos_app(&self) -> Result<Vec<PathBuf>> {
let product_name = self.product_name();
let macos_settings = self.macos();
let bundle_name = macos_settings
.bundle_name
.as_deref()
.unwrap_or(&product_name);
let output_dir = self.project_out_directory().join("macos");
let app_dir = output_dir.join(format!("{bundle_name}.app"));
tracing::info!("Creating macOS .app bundle at {}", app_dir.display());
if app_dir.exists() {
fs::remove_dir_all(&app_dir)
.with_context(|| format!("Failed to clean existing .app: {}", app_dir.display()))?;
}
let contents_dir = app_dir.join("Contents");
let macos_dir = contents_dir.join("MacOS");
let resources_dir = contents_dir.join("Resources");
let frameworks_dir = contents_dir.join("Frameworks");
fs::create_dir_all(&macos_dir)?;
fs::create_dir_all(&resources_dir)?;
let binary_src = self.main_binary_path();
let binary_dest = macos_dir.join(self.main_binary_name());
tracing::debug!(
"Copying binary: {} -> {}",
binary_src.display(),
binary_dest.display()
);
fs::copy(&binary_src, &binary_dest)
.with_context(|| format!("Failed to copy main binary to {}", binary_dest.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&binary_dest, fs::Permissions::from_mode(0o755))?;
}
let icon_path = self.create_macos_icns_file(&resources_dir)?;
let icon_filename = icon_path
.as_ref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string());
self.copy_resources(&resources_dir)?;
let _external_bins = self.copy_external_binaries(&macos_dir)?;
if let Some(frameworks) = &macos_settings.frameworks {
if !frameworks.is_empty() {
fs::create_dir_all(&frameworks_dir)?;
for framework in frameworks {
let framework_path = PathBuf::from(framework);
if !framework_path.exists() {
let resolved = self.crate_dir().join(&framework_path);
if resolved.exists() {
copy_framework(&resolved, &frameworks_dir)?;
} else {
tracing::debug!(
"Framework not found as file, assuming system framework: {}",
framework
);
}
} else {
copy_framework(&framework_path, &frameworks_dir)?;
}
}
}
}
for (bundle_path, source_path) in &macos_settings.files {
let dest = contents_dir.join(bundle_path);
let src = if source_path.is_relative() {
self.crate_dir().join(source_path)
} else {
source_path.clone()
};
if !src.exists() {
tracing::warn!(
"Custom file not found: {} (for bundle path {})",
src.display(),
bundle_path.display()
);
continue;
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
if src.is_dir() {
copy_dir_recursive(&src, &dest)?;
} else {
fs::copy(&src, &dest).with_context(|| {
format!(
"Failed to copy custom file {} -> {}",
src.display(),
dest.display()
)
})?;
}
}
let info_plist = self.create_macos_info_plist(&macos_settings, icon_filename.as_deref())?;
let plist_path = contents_dir.join("Info.plist");
if let Some(custom_plist_path) = &macos_settings.info_plist_path {
let custom_path = if custom_plist_path.is_relative() {
self.crate_dir().join(custom_plist_path)
} else {
custom_plist_path.clone()
};
if custom_path.exists() {
tracing::info!("Using custom Info.plist from {}", custom_path.display());
fs::copy(&custom_path, &plist_path)?;
} else {
tracing::warn!(
"Custom Info.plist not found at {}, generating default",
custom_path.display()
);
write_plist(&info_plist, &plist_path)?;
}
} else {
write_plist(&info_plist, &plist_path)?;
}
fs::write(contents_dir.join("PkgInfo"), "APPL????")?;
let signing_identity = setup_keychain(macos_settings.signing_identity.as_deref()).await?;
if let Some(identity) = &signing_identity {
tracing::info!("Signing .app bundle with identity: {}", identity.identity);
let mut sign_targets = Vec::new();
if frameworks_dir.exists() {
for entry in fs::read_dir(&frameworks_dir)? {
let entry = entry?;
sign_targets.push(SignTarget { path: entry.path() });
}
}
sign_targets.push(SignTarget {
path: binary_dest.clone(),
});
sign_targets.push(SignTarget {
path: app_dir.clone(),
});
sign_paths(identity, sign_targets, &macos_settings).await?;
let should_notarize =
std::env::var("APPLE_ID").is_ok() || std::env::var("APPLE_API_KEY").is_ok();
if should_notarize {
let zip_path = output_dir.join(format!("{bundle_name}.zip"));
tracing::info!("Creating zip for notarization: {}", zip_path.display());
let status = Command::new("ditto")
.args([
"-c",
"-k",
"--sequesterRsrc",
"--keepParent",
&app_dir.display().to_string(),
&zip_path.display().to_string(),
])
.status()
.await
.context("Failed to run ditto for zip creation")?;
if !status.success() {
bail!("ditto failed to create zip for notarization");
}
match notarize(&zip_path).await {
Ok(()) => {
let _ = fs::remove_file(&zip_path);
}
Err(e) => {
let _ = fs::remove_file(&zip_path);
return Err(e.context("Notarization failed"));
}
}
}
} else {
tracing::debug!("No signing identity found; skipping code signing");
}
Ok(vec![app_dir])
}
/// Package the macOS application bundle into a distributable `.dmg` disk image.
///
/// DMG generation depends on having a complete `.app` bundle first. If the
/// current bundling pass already produced one, this method reuses it. Otherwise it
/// calls [`BundleContext::bundle_macos_app`] internally and returns both the
/// resulting `.dmg` and the intermediate `.app` paths so the higher-level
/// orchestrator can decide whether the `.app` should be preserved or cleaned up.
///
/// The process is:
/// 1. Resolve the `.app` input, either from `bundles` or by building it now.
/// 2. Create a temporary staging directory containing the `.app` and an
/// `Applications` symlink for drag-and-drop installation.
/// 3. Invoke `hdiutil create -format UDZO` to build a compressed, read-only disk
/// image in `project_out_directory()/macos`.
/// 4. Optionally sign the generated `.dmg`.
/// 5. Optionally notarize and staple the `.dmg` if Apple credentials are present.
///
/// Only the final `.dmg` and, when synthesized as a prerequisite, the `.app` are
/// considered outputs. The temporary DMG staging directory is always discarded.
pub(crate) async fn bundle_macos_dmg(&self, bundles: &[Bundle]) -> Result<DmgBundled> {
let product_name = self.product_name();
let macos_settings = self.macos();
let bundle_name = macos_settings
.bundle_name
.as_deref()
.unwrap_or(&product_name);
let (app_paths, app_bundle_paths) = if let Some(app_bundle) = bundles
.iter()
.find(|b| b.package_type == PackageType::MacOsBundle)
{
(app_bundle.bundle_paths.clone(), Vec::new())
} else {
let paths = self.bundle_macos_app().await?;
(paths.clone(), paths)
};
if app_paths.is_empty() {
bail!("No .app bundle found to package into a DMG");
}
let app_path = &app_paths[0];
if !app_path.exists() {
bail!(
".app bundle does not exist at expected path: {}",
app_path.display()
);
}
let output_dir = self.project_out_directory().join("macos");
fs::create_dir_all(&output_dir)?;
let dmg_filename = format!(
"{}_{}_{}",
bundle_name,
self.version_string(),
self.binary_arch()
);
let dmg_path = output_dir.join(format!("{dmg_filename}.dmg"));
tracing::info!("Creating DMG at {}", dmg_path.display());
let staging_dir =
tempfile::tempdir().context("Failed to create temp dir for DMG staging")?;
let staging_path = staging_dir.path();
let staged_app = staging_path.join(app_path.file_name().unwrap());
copy_dir_recursive(app_path, &staged_app)?;
#[cfg(unix)]
{
std::os::unix::fs::symlink("/Applications", staging_path.join("Applications"))
.context("Failed to create /Applications symlink in DMG staging")?;
}
if dmg_path.exists() {
fs::remove_file(&dmg_path)?;
}
let status = Command::new("hdiutil")
.args([
"create",
"-volname",
bundle_name,
"-srcfolder",
&staging_path.display().to_string(),
"-ov",
"-format",
"UDZO",
&dmg_path.display().to_string(),
])
.status()
.await
.context("Failed to execute `hdiutil create`")?;
if !status.success() {
bail!("`hdiutil create` failed with exit code: {status}");
}
tracing::info!("DMG created at {}", dmg_path.display());
let signing_identity = setup_keychain(macos_settings.signing_identity.as_deref()).await?;
if let Some(identity) = &signing_identity {
tracing::info!("Signing DMG with identity: {}", identity.identity);
sign_paths(
identity,
vec![SignTarget {
path: dmg_path.clone(),
}],
&macos_settings,
)
.await?;
let should_notarize =
std::env::var("APPLE_ID").is_ok() || std::env::var("APPLE_API_KEY").is_ok();
if should_notarize {
notarize(&dmg_path).await?;
}
}
Ok(DmgBundled {
dmg: vec![dmg_path],
app: app_bundle_paths,
})
}
/// Create an ICNS file from the icon files configured in the bundle context.
fn create_macos_icns_file(&self, out_dir: &Path) -> Result<Option<PathBuf>> {
let icon_paths = self.icon_files()?;
if icon_paths.is_empty() {
return Ok(None);
}
let dest_path = out_dir.join(format!("{}.icns", self.product_name()));
for icon_path in &icon_paths {
if icon_path
.extension()
.map(|ext| ext.eq_ignore_ascii_case("icns"))
.unwrap_or(false)
{
tracing::info!("Copying existing .icns file: {}", icon_path.display());
std::fs::copy(icon_path, &dest_path).with_context(|| {
format!(
"Failed to copy .icns file from {} to {}",
icon_path.display(),
dest_path.display()
)
})?;
return Ok(Some(dest_path));
}
}
let mut family = icns::IconFamily::new();
for icon_path in &icon_paths {
let ext = icon_path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
if ext != "png" {
tracing::debug!("Skipping non-PNG icon file: {}", icon_path.display());
continue;
}
let img = ImageReader::open(icon_path)
.with_context(|| format!("Failed to open icon image: {}", icon_path.display()))?
.decode()
.with_context(|| format!("Failed to decode icon image: {}", icon_path.display()))?;
add_image_to_family(&mut family, &img)?;
}
if family.is_empty() {
tracing::warn!("No valid icon images found; skipping .icns generation");
return Ok(None);
}
let file = File::create(&dest_path)
.with_context(|| format!("Failed to create {}", dest_path.display()))?;
let writer = BufWriter::new(file);
family
.write(writer)
.with_context(|| format!("Failed to write .icns to {}", dest_path.display()))?;
tracing::info!("Generated .icns at {}", dest_path.display());
Ok(Some(dest_path))
}
/// Build a `plist::Dictionary` for Info.plist.
fn create_macos_info_plist(
&self,
macos_settings: &MacOsSettings,
icon_filename: Option<&str>,
) -> Result<plist::Dictionary> {
let mut dict = plist::Dictionary::new();
let product_name = self.product_name();
let bundle_name = macos_settings
.bundle_name
.as_deref()
.unwrap_or(&product_name);
dict.insert(
"CFBundleDevelopmentRegion".into(),
plist::Value::String("English".into()),
);
dict.insert(
"CFBundleDisplayName".into(),
plist::Value::String(bundle_name.to_string()),
);
dict.insert(
"CFBundleExecutable".into(),
plist::Value::String(self.main_binary_name().to_string()),
);
if let Some(icon) = icon_filename {
dict.insert(
"CFBundleIconFile".into(),
plist::Value::String(icon.to_string()),
);
}
dict.insert(
"CFBundleIdentifier".into(),
plist::Value::String(self.bundle_identifier()),
);
dict.insert(
"CFBundleInfoDictionaryVersion".into(),
plist::Value::String("6.0".into()),
);
dict.insert(
"CFBundleName".into(),
plist::Value::String(bundle_name.to_string()),
);
dict.insert(
"CFBundlePackageType".into(),
plist::Value::String("APPL".into()),
);
let version = self.version_string();
let bundle_version = macos_settings.bundle_version.as_deref().unwrap_or(&version);
dict.insert(
"CFBundleShortVersionString".into(),
plist::Value::String(version.clone()),
);
dict.insert(
"CFBundleVersion".into(),
plist::Value::String(bundle_version.to_string()),
);
let min_version = macos_settings
.minimum_system_version
.as_deref()
.unwrap_or("10.13");
dict.insert(
"LSMinimumSystemVersion".into(),
plist::Value::String(min_version.to_string()),
);
if let Some(category_str) = self.app_category() {
if let Ok(category) = category_str.parse::<AppCategory>() {
dict.insert(
"LSApplicationCategoryType".into(),
plist::Value::String(category.macos_application_category_type().to_string()),
);
}
}
if let Some(copyright) = self.copyright_string() {
dict.insert(
"NSHumanReadableCopyright".into(),
plist::Value::String(copyright.to_string()),
);
}
dict.insert(
"NSHighResolutionCapable".into(),
plist::Value::Boolean(true),
);
if let Some(domain) = &macos_settings.exception_domain {
let mut ats_dict = plist::Dictionary::new();
let mut exception_dict = plist::Dictionary::new();
let mut domain_dict = plist::Dictionary::new();
domain_dict.insert(
"NSExceptionAllowsInsecureHTTPLoads".into(),
plist::Value::Boolean(true),
);
domain_dict.insert("NSIncludesSubdomains".into(), plist::Value::Boolean(true));
exception_dict.insert(domain.clone(), plist::Value::Dictionary(domain_dict));
ats_dict.insert(
"NSExceptionDomains".into(),
plist::Value::Dictionary(exception_dict),
);
dict.insert(
"NSAppTransportSecurity".into(),
plist::Value::Dictionary(ats_dict),
);
}
if let Some(provider) = &macos_settings.provider_short_name {
dict.insert(
"ITSAppUsesNonExemptEncryption".into(),
plist::Value::Boolean(false),
);
let _ = provider;
}
Ok(dict)
}
}
/// Add all appropriate size variants of an image to the ICNS family.
fn add_image_to_family(family: &mut icns::IconFamily, img: &DynamicImage) -> Result<()> {
// The icon sizes (in points) we generate for the .icns file, along with their
// densities. macOS expects both 1x and 2x variants.
const ICON_SIZES: &[(u32, u32, u32)] = &[
(16, 16, 1),
(16, 16, 2),
(32, 32, 1),
(32, 32, 2),
(64, 64, 1),
(64, 64, 2),
(128, 128, 1),
(128, 128, 2),
(256, 256, 1),
(256, 256, 2),
(512, 512, 1),
(512, 512, 2),
];
for &(width, height, density) in ICON_SIZES {
let pixel_width = width * density;
let pixel_height = height * density;
let icon_type =
match icns::IconType::from_pixel_size_and_density(pixel_width, pixel_height, density) {
Some(t) => t,
None => continue,
};
if family.has_icon_with_type(icon_type) {
continue;
}
let resized = img.resize_exact(
pixel_width,
pixel_height,
image::imageops::FilterType::Lanczos3,
);
let rgba = resized.to_rgba8();
let icns_image = icns::Image::from_data(
icns::PixelFormat::RGBA,
pixel_width,
pixel_height,
rgba.into_raw(),
)
.with_context(|| {
format!("Failed to create icns::Image for {pixel_width}x{pixel_height}@{density}x")
})?;
family
.add_icon_with_type(&icns_image, icon_type)
.with_context(|| format!("Failed to add icon type {icon_type:?} to ICNS family"))?;
}
Ok(())
}
/// Write a plist dictionary to a file.
fn write_plist(dict: &plist::Dictionary, path: &Path) -> Result<()> {
plist::Value::Dictionary(dict.clone())
.to_file_xml(path)
.with_context(|| format!("Failed to write Info.plist to {}", path.display()))
}
/// Copy a framework (directory or .dylib) to the Frameworks directory.
fn copy_framework(src: &Path, frameworks_dir: &Path) -> Result<()> {
let dest = frameworks_dir.join(src.file_name().context("Framework path has no filename")?);
tracing::debug!("Copying framework: {} -> {}", src.display(), dest.display());
if src.is_dir() {
copy_dir_recursive(src, &dest)?;
} else {
fs::copy(src, &dest)?;
}
Ok(())
}
/// Set up the signing identity.
async fn setup_keychain(identity: Option<&str>) -> Result<Option<SigningIdentity>> {
let certificate_encoded = std::env::var("APPLE_CERTIFICATE").ok();
let certificate_password = std::env::var("APPLE_CERTIFICATE_PASSWORD")
.ok()
.unwrap_or_default();
if let Some(cert_base64) = certificate_encoded {
tracing::info!("Setting up temporary keychain for code signing (CI mode)");
let keychain = setup_temp_keychain(&cert_base64, &certificate_password).await?;
let identity_name = find_identity_in_keychain(&keychain.path).await?;
return Ok(Some(SigningIdentity {
identity: identity_name,
temp_keychain: Some(keychain),
}));
}
if let Some(id) = identity {
if !id.is_empty() {
return Ok(Some(SigningIdentity {
identity: id.to_string(),
temp_keychain: None,
}));
}
}
Ok(None)
}
/// Set up a temporary keychain and import the certificate into it.
async fn setup_temp_keychain(cert_base64: &str, password: &str) -> Result<TempKeychain> {
use std::io::Write;
let keychain_password = "dioxus-bundle-keychain";
let keychain_path = std::env::temp_dir().join("dioxus-signing.keychain-db");
let cert_data = base64_decode(cert_base64)
.await
.context("Failed to decode APPLE_CERTIFICATE from base64")?;
let cert_file = std::env::temp_dir().join("dioxus-signing-cert.p12");
let mut f = std::fs::File::create(&cert_file)?;
f.write_all(&cert_data)?;
drop(f);
let _ = Command::new("security")
.args(["delete-keychain", &keychain_path.display().to_string()])
.output()
.await;
let mut create_keychain_cmd = Command::new("security");
create_keychain_cmd.args([
"create-keychain",
"-p",
keychain_password,
&keychain_path.display().to_string(),
]);
run_command(&mut create_keychain_cmd, "create-keychain").await?;
let mut import_cmd = Command::new("security");
import_cmd.args([
"import",
&cert_file.display().to_string(),
"-k",
&keychain_path.display().to_string(),
"-P",
password,
"-T",
"/usr/bin/codesign",
"-T",
"/usr/bin/security",
]);
run_command(&mut import_cmd, "import certificate").await?;
let output = Command::new("security")
.args(["list-keychains", "-d", "user"])
.output()
.await
.context("Failed to list keychains")?;
let current_keychains = String::from_utf8_lossy(&output.stdout);
let mut keychains: Vec<String> = current_keychains
.lines()
.map(|l| l.trim().trim_matches('"').to_string())
.filter(|l| !l.is_empty())
.collect();
keychains.insert(0, keychain_path.display().to_string());
let mut cmd = Command::new("security");
cmd.args(["list-keychains", "-d", "user", "-s"]);
for kc in &keychains {
cmd.arg(kc);
}
run_command(&mut cmd, "list-keychains -s").await?;
let mut unlock_cmd = Command::new("security");
unlock_cmd.args([
"unlock-keychain",
"-p",
keychain_password,
&keychain_path.display().to_string(),
]);
run_command(&mut unlock_cmd, "unlock-keychain").await?;
let mut set_partition_cmd = Command::new("security");
set_partition_cmd.args([
"set-key-partition-list",
"-S",
"apple-tool:,apple:",
"-s",
"-k",
keychain_password,
&keychain_path.display().to_string(),
]);
run_command(&mut set_partition_cmd, "set-key-partition-list").await?;
let _ = std::fs::remove_file(&cert_file);
Ok(TempKeychain {
path: keychain_path,
password: keychain_password.to_string(),
})
}
/// Find the signing identity in a keychain using `security find-identity`.
async fn find_identity_in_keychain(keychain_path: &Path) -> Result<String> {
let output = Command::new("security")
.args([
"find-identity",
"-v",
"-p",
"codesigning",
&keychain_path.display().to_string(),
])
.output()
.await
.context("Failed to run `security find-identity`")?;
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let line = line.trim();
if line.starts_with("1)")
|| line.contains("Developer ID")
|| line.contains("Apple Development")
{
if let Some(start) = line.find('"') {
if let Some(end) = line.rfind('"') {
if end > start {
return Ok(line[start + 1..end].to_string());
}
}
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 && parts[1].len() == 40 {
return Ok(parts[1].to_string());
}
}
}
bail!(
"No valid signing identity found in keychain {}.\nOutput: {}",
keychain_path.display(),
stdout
)
}
/// Sign a list of paths with the given identity.
async fn sign_paths(
identity: &SigningIdentity,
targets: Vec<SignTarget>,
settings: &MacOsSettings,
) -> Result<()> {
for target in &targets {
sign_path(identity, target, settings).await?;
}
Ok(())
}
/// Sign a single path with `codesign`.
async fn sign_path(
identity: &SigningIdentity,
target: &SignTarget,
settings: &MacOsSettings,
) -> Result<()> {
tracing::info!("Signing: {}", target.path.display());
let mut cmd = Command::new("codesign");
cmd.args(["--force", "--sign", &identity.identity]);
if settings.hardened_runtime {
cmd.arg("--options");
cmd.arg("runtime");
}
if target
.path
.extension()
.map(|e| e == "app" || e == "framework")
.unwrap_or(false)
{
cmd.arg("--deep");
}
if let Some(entitlements) = &settings.entitlements {
cmd.args(["--entitlements", entitlements]);
}
if let Some(keychain) = &identity.temp_keychain {
cmd.args(["--keychain", &keychain.path.display().to_string()]);
}
cmd.arg(&target.path);
run_command(&mut cmd, &format!("codesign {}", target.path.display())).await?;
Ok(())
}
/// Notarize a .app or .dmg with Apple's notary service.
async fn notarize(app_path: &Path) -> Result<()> {
let apple_id = std::env::var("APPLE_ID").ok();
let apple_password = std::env::var("APPLE_PASSWORD").ok();
let apple_team_id = std::env::var("APPLE_TEAM_ID").ok();
let api_key = std::env::var("APPLE_API_KEY").ok();
let api_issuer = std::env::var("APPLE_API_ISSUER").ok();
let api_key_path = std::env::var("APPLE_API_KEY_PATH").ok();
let mut cmd = Command::new("xcrun");
cmd.args(["notarytool", "submit"]);
cmd.arg(app_path);
if let (Some(key), Some(issuer), Some(key_path)) = (&api_key, &api_issuer, &api_key_path) {
cmd.args(["--key", key_path]);
cmd.args(["--key-id", key]);
cmd.args(["--issuer", issuer]);
} else if let (Some(id), Some(pwd), Some(team)) = (&apple_id, &apple_password, &apple_team_id) {
cmd.args(["--apple-id", id]);
cmd.args(["--password", pwd]);
cmd.args(["--team-id", team]);
} else {
bail!(
"Notarization requires either:\n\
- APPLE_ID, APPLE_PASSWORD, and APPLE_TEAM_ID env vars, or\n\
- APPLE_API_KEY, APPLE_API_ISSUER, and APPLE_API_KEY_PATH env vars"
);
}
cmd.arg("--wait");
tracing::info!("Submitting {} for notarization...", app_path.display());
run_command(&mut cmd, "xcrun notarytool submit").await?;
tracing::info!("Stapling notarization ticket...");
let mut staple_cmd = Command::new("xcrun");
staple_cmd.args(["stapler", "staple"]).arg(app_path);
run_command(&mut staple_cmd, "xcrun stapler staple").await?;
tracing::info!("Notarization complete for {}", app_path.display());
Ok(())
}
/// Helper to run a command and return a nice error on failure.
async fn run_command(cmd: &mut Command, description: &str) -> Result<()> {
tracing::debug!("Running: {:?}", cmd);
let status = cmd
.status()
.await
.with_context(|| format!("Failed to execute `{description}`"))?;
if !status.success() {
bail!("`{description}` failed with exit code: {}", status);
}
Ok(())
}
/// Decode base64 (standard or URL-safe).
async fn base64_decode(input: &str) -> Result<Vec<u8>> {
let mut child = Command::new("base64")
.args(["--decode"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("Failed to decode base64 certificate")?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(input.as_bytes())
.await
.context("Failed writing base64 certificate to decoder stdin")?;
}
let output = child
.wait_with_output()
.await
.context("Failed to decode base64 certificate")?;
if !output.status.success() {
bail!(
"base64 --decode failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(output.stdout)
}
/// The result of DMG bundling, which may include both the `.dmg` and `.app` outputs.
pub(crate) struct DmgBundled {
/// Paths to the generated `.dmg` file(s).
pub dmg: Vec<PathBuf>,
/// Paths to the generated `.app` bundle(s) (if the `.app` was built as a dependency).
pub app: Vec<PathBuf>,
}
/// A target to be code-signed.
struct SignTarget {
path: PathBuf,
}
/// A code signing identity, optionally backed by a temporary keychain.
struct SigningIdentity {
/// The identity string passed to `codesign --sign`.
/// This is either a team/certificate name or a SHA-1 hash.
identity: String,
/// If we created a temporary keychain to import a certificate,
/// this holds its path so we can clean it up later.
temp_keychain: Option<TempKeychain>,
}
/// A temporary keychain created for CI certificate import.
#[allow(dead_code)]
struct TempKeychain {
path: PathBuf,
password: String,
}
impl Drop for TempKeychain {
fn drop(&mut self) {
tracing::debug!("Cleaning up temporary keychain: {}", self.path.display());
let _ = StdCommand::new("security")
.args(["delete-keychain", &self.path.display().to_string()])
.status();
}
}
+892
View File
@@ -0,0 +1,892 @@
mod ios;
mod linux;
mod macos;
mod tools;
mod updater;
mod windows;
use crate::PackageType;
use crate::{BuildRequest, DebianSettings, MacOsSettings, WindowsSettings};
use anyhow::Context;
use anyhow::Result;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tools::ResolvedTools;
/// A completed bundle with its output paths.
#[derive(Debug)]
pub(crate) struct Bundle {
pub package_type: PackageType,
pub bundle_paths: Vec<PathBuf>,
}
/// BundleContext wraps a BuildRequest and provides the settings API
/// that bundle format modules need. This is the adapter between
/// Dioxus build infrastructure and the individual bundler implementations.
pub(crate) struct BundleContext<'a> {
pub(crate) build: &'a BuildRequest,
pub(crate) package_types: Vec<PackageType>,
/// Pre-computed resource map: source path -> target path in bundle
pub(crate) resources_map: HashMap<String, String>,
/// Pre-resolved tool paths (NSIS, WiX, linuxdeploy, WebView2).
pub(crate) tools: ResolvedTools,
}
impl<'a> BundleContext<'a> {
/// Create a new BundleContext from a BuildRequest and optional package types.
pub(crate) async fn new(
build: &'a BuildRequest,
package_types: &Option<Vec<PackageType>>,
) -> Result<Self> {
let package_types = package_types.clone().unwrap_or_default();
// Build the resources map from assets + config resources
let mut resources_map = HashMap::new();
let asset_dir = build.asset_dir();
if asset_dir.exists() {
for entry in walkdir::WalkDir::new(&asset_dir) {
let entry = entry?;
let path = entry.path();
if path.is_file() {
let old = path
.canonicalize()
.with_context(|| format!("Failed to canonicalize {entry:?}"))?;
let new =
PathBuf::from("assets").join(path.strip_prefix(&asset_dir).unwrap_or(path));
resources_map.insert(old.display().to_string(), new.display().to_string());
}
}
}
// Merge in any custom resources from config
if let Some(resources) = &build.config.bundle.resources {
for resource_path in resources {
resources_map.insert(resource_path.clone(), String::new());
}
}
let tools_dir = dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from(".cache"))
.join("dioxus");
let _ = std::fs::create_dir_all(&tools_dir);
let arch = {
let target = build.triple.to_string();
if target.starts_with("x86_64") {
Arch::X86_64
} else if target.starts_with('i') {
Arch::X86
} else if target.starts_with("arm") && target.ends_with("hf") {
Arch::Armhf
} else if target.starts_with("arm") {
Arch::Armel
} else if target.starts_with("aarch64") {
Arch::AArch64
} else if target.starts_with("riscv64") {
Arch::Riscv64
} else if target.starts_with("universal") {
Arch::Universal
} else if cfg!(target_arch = "x86_64") {
Arch::X86_64
} else if cfg!(target_arch = "aarch64") {
Arch::AArch64
} else {
Arch::X86_64
}
};
let windows_settings = build.config.bundle.windows.clone().unwrap_or_default();
let tools =
tools::resolve_tools(&tools_dir, &package_types, &windows_settings, arch).await?;
Ok(Self {
build,
package_types,
resources_map,
tools,
})
}
/// Bundle the project for every configured package type, in dependency order.
///
/// This is the orchestration entrypoint for native packaging. It owns the
/// sequencing rules between bundle formats, but delegates the actual format
/// implementation to the per-platform `bundle_*` methods on `BundleContext`.
///
/// The method performs the following high-level workflow:
/// 1. Read the resolved package types from the immutable context.
/// 2. Sort them so prerequisite artifacts are built before dependents.
/// In practice, this means raw distributable formats such as `.app`, `.deb`,
/// `.rpm`, `.AppImage`, `.msi`, `.exe`, `.apk`, and `.aab` run before
/// dependent archive formats like `.ipa`/`.dmg`, and `Updater` always runs last.
/// 3. Dispatch to the top-level format-specific bundling method for each package
/// type and collect the artifact paths it returns.
/// 4. Reuse outputs where formats depend on each other. For example, DMG creation
/// may synthesize a `.app` bundle and return it alongside the `.dmg`.
/// 5. Remove intermediate macOS `.app` bundles when they were only needed as an
/// implementation detail of `.dmg` or updater generation.
///
/// The returned [`Bundle`] values are the canonical record of what was emitted by
/// the bundling pass. Later steps such as updater packaging rely on these paths
/// rather than rediscovering artifacts on disk.
pub(crate) async fn bundle_project(&self) -> Result<Vec<Bundle>> {
let mut package_types = self.package_types();
// Sort so dependencies come first (e.g. .app before .dmg)
package_types.sort_by_key(|ptype| match ptype {
PackageType::MacOsBundle
| PackageType::IosApp
| PackageType::WindowsMsi
| PackageType::Nsis
| PackageType::Deb
| PackageType::Rpm
| PackageType::AppImage
| PackageType::Apk
| PackageType::Aab => 0,
PackageType::Ipa | PackageType::Dmg => 1,
PackageType::Updater => 2,
});
let mut bundles = Vec::<Bundle>::new();
for package_type in &package_types {
// Skip if already built (e.g. DMG already built .app)
if bundles.iter().any(|b| b.package_type == *package_type) {
continue;
}
let bundle_paths = match package_type {
PackageType::MacOsBundle => self.bundle_macos_app().await?,
PackageType::IosApp => self.bundle_ios_app().await?,
PackageType::Ipa => self.bundle_ios_ipa(&bundles).await?.ipa,
PackageType::Dmg => {
let bundled = self.bundle_macos_dmg(&bundles).await?;
if !bundled.app.is_empty() {
bundles.push(Bundle {
package_type: PackageType::MacOsBundle,
bundle_paths: bundled.app,
});
}
bundled.dmg
}
PackageType::Deb => self.bundle_linux_deb().await?,
PackageType::Rpm => self.bundle_linux_rpm().await?,
PackageType::AppImage => self.bundle_linux_appimage().await?,
PackageType::WindowsMsi => self.bundle_windows_msi().await?,
PackageType::Nsis => self.bundle_windows_nsis().await?,
PackageType::Updater => self.bundle_updater(&bundles).await?,
PackageType::Apk | PackageType::Aab => self.bundle_android(*package_type).await?,
};
bundles.push(Bundle {
package_type: *package_type,
bundle_paths,
});
}
// On macOS, clean up .app if only building dmg or updater
if !package_types.contains(&PackageType::MacOsBundle) {
if let Some(idx) = bundles
.iter()
.position(|b| b.package_type == PackageType::MacOsBundle)
{
let app_bundle = bundles.remove(idx);
for path in &app_bundle.bundle_paths {
tracing::info!("Cleaning up intermediate .app: {}", path.display());
if path.is_dir() {
let _ = std::fs::remove_dir_all(path);
} else {
let _ = std::fs::remove_file(path);
}
}
}
}
for bundle in &bundles {
for path in &bundle.bundle_paths {
tracing::info!("Bundled: {}", path.display());
}
}
Ok(bundles)
}
/// The package types to bundle.
pub(crate) fn package_types(&self) -> Vec<PackageType> {
self.package_types.clone()
}
/// The product name (PascalCase).
pub(crate) fn product_name(&self) -> String {
self.build.bundled_app_name()
}
/// The bundle identifier (e.g. "com.example.app").
pub(crate) fn bundle_identifier(&self) -> String {
self.build.bundle_identifier()
}
/// The publisher name.
pub(crate) fn publisher(&self) -> Option<&str> {
self.build.config.bundle.publisher.as_deref()
}
/// The version string from Cargo.toml.
pub(crate) fn version_string(&self) -> String {
self.build.package().version.to_string()
}
/// The short description.
pub(crate) fn short_description(&self) -> String {
self.build
.config
.bundle
.short_description
.clone()
.or_else(|| self.build.package().description.clone())
.unwrap_or_default()
}
/// The long description.
pub(crate) fn long_description(&self) -> Option<&str> {
self.build.config.bundle.long_description.as_deref()
}
/// The copyright string.
pub(crate) fn copyright_string(&self) -> Option<&str> {
self.build.config.bundle.copyright.as_deref()
}
/// The authors list.
pub(crate) fn authors(&self) -> &[String] {
&self.build.package().authors
}
/// Authors as a comma-separated string.
pub(crate) fn authors_comma_separated(&self) -> Option<String> {
let names = self.authors();
if names.is_empty() {
None
} else {
Some(names.join(", "))
}
}
/// The homepage URL.
pub(crate) fn homepage_url(&self) -> Option<String> {
self.build
.package()
.homepage
.as_ref()
.filter(|hp| !hp.is_empty())
.cloned()
}
/// The license string.
pub(crate) fn license(&self) -> Option<&str> {
self.build.package().license.as_deref()
}
/// The app category string.
pub(crate) fn app_category(&self) -> Option<&str> {
self.build.config.bundle.category.as_deref()
}
/// The main binary name (without extension).
pub(crate) fn main_binary_name(&self) -> &str {
self.build.executable_name()
}
/// The path to the main binary.
pub(crate) fn main_binary_path(&self) -> PathBuf {
self.build.main_exe()
}
/// The output directory for bundles.
pub(crate) fn project_out_directory(&self) -> PathBuf {
self.build.bundle_dir(self.build.bundle)
}
/// The target triple string.
pub(crate) fn target(&self) -> String {
self.build.triple.to_string()
}
/// The binary architecture.
pub(crate) fn binary_arch(&self) -> Arch {
let target = self.target();
if target.starts_with("x86_64") {
Arch::X86_64
} else if target.starts_with('i') {
Arch::X86
} else if target.starts_with("arm") && target.ends_with("hf") {
Arch::Armhf
} else if target.starts_with("arm") {
Arch::Armel
} else if target.starts_with("aarch64") {
Arch::AArch64
} else if target.starts_with("riscv64") {
Arch::Riscv64
} else if target.starts_with("universal") {
Arch::Universal
} else {
// Default to the host arch
if cfg!(target_arch = "x86_64") {
Arch::X86_64
} else if cfg!(target_arch = "aarch64") {
Arch::AArch64
} else {
Arch::X86_64
}
}
}
/// Icon file paths from config, canonicalized relative to the crate dir.
pub(crate) fn icon_files(&self) -> Result<Vec<PathBuf>> {
let mut icons = Vec::new();
if let Some(icon_paths) = &self.build.config.bundle.icon {
for icon in icon_paths {
let icon_path = self
.build
.crate_dir()
.join(icon)
.canonicalize()
.with_context(|| format!("Failed to canonicalize icon path: {icon}"))?;
icons.push(icon_path);
}
}
Ok(icons)
}
/// Copy resources to the given path.
pub(crate) fn copy_resources(&self, dest: &Path) -> Result<()> {
for (src, target) in &self.resources_map {
let src_path = PathBuf::from(src);
if !src_path.exists() {
tracing::warn!("Resource not found: {src}");
continue;
}
let dest_path = if target.is_empty() {
dest.join(src_path.file_name().unwrap_or_default())
} else {
dest.join(target)
};
if let Some(parent) = dest_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&src_path, &dest_path).with_context(|| {
format!("Failed to copy resource {src} -> {}", dest_path.display())
})?;
}
Ok(())
}
/// Copy external binaries to the given path.
pub(crate) fn copy_external_binaries(&self, dest: &Path) -> Result<Vec<PathBuf>> {
let mut paths = Vec::new();
if let Some(bins) = &self.build.config.bundle.external_bin {
let target = self.target();
for bin in bins {
let src = PathBuf::from(format!("{bin}-{target}"));
if src.exists() {
let dest_name = src
.file_name()
.unwrap()
.to_string_lossy()
.replace(&format!("-{target}"), "");
let dest_path = dest.join(dest_name);
std::fs::copy(&src, &dest_path)?;
paths.push(dest_path);
}
}
}
Ok(paths)
}
/// The crate directory (where Cargo.toml is).
pub(crate) fn crate_dir(&self) -> PathBuf {
self.build.crate_dir()
}
/// Debian settings from config.
pub(crate) fn deb(&self) -> DebianSettings {
self.build.config.bundle.deb.clone().unwrap_or_default()
}
/// macOS settings from config.
pub(crate) fn macos(&self) -> MacOsSettings {
self.build.config.bundle.macos.clone().unwrap_or_default()
}
/// Windows settings from config.
pub(crate) fn windows(&self) -> WindowsSettings {
self.build.config.bundle.windows.clone().unwrap_or_default()
}
}
/// The architecture of the target binary.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum Arch {
X86_64,
X86,
AArch64,
Armhf,
Armel,
Riscv64,
Universal,
}
impl Arch {
pub(crate) fn as_str(&self) -> &'static str {
match self {
Arch::X86_64 => "x86_64",
Arch::X86 => "x86",
Arch::AArch64 => "aarch64",
Arch::Armhf => "armhf",
Arch::Armel => "armel",
Arch::Riscv64 => "riscv64",
Arch::Universal => "universal",
}
}
pub(crate) fn deb_arch(&self) -> &'static str {
match self {
Arch::X86_64 => "amd64",
Arch::X86 => "i386",
Arch::AArch64 => "arm64",
Arch::Armhf => "armhf",
Arch::Armel => "armel",
Arch::Riscv64 => "riscv64",
Arch::Universal => "all",
}
}
pub(crate) fn rpm_arch(&self) -> &'static str {
match self {
Arch::X86_64 => "x86_64",
Arch::X86 => "i686",
Arch::AArch64 => "aarch64",
Arch::Armhf => "armv7hl",
Arch::Armel => "armv6l",
Arch::Riscv64 => "riscv64",
Arch::Universal => "noarch",
}
}
pub(crate) fn appimage_arch(&self) -> &'static str {
match self {
Arch::X86_64 => "x86_64",
Arch::X86 => "i386",
Arch::AArch64 => "aarch64",
Arch::Armhf => "armhf",
Arch::Armel => "armel",
Arch::Riscv64 => "riscv64",
Arch::Universal => "x86_64",
}
}
pub(crate) fn windows_arch(&self) -> &'static str {
match self {
Arch::X86_64 => "x64",
Arch::X86 => "x86",
Arch::AArch64 => "arm64",
_ => "x64",
}
}
pub(crate) fn wix_arch(&self) -> &'static str {
match self {
Arch::X86_64 => "x64",
Arch::X86 => "x86",
Arch::AArch64 => "arm64",
_ => "x64",
}
}
pub(crate) fn wix_program_files_folder(&self) -> &'static str {
match self {
Arch::X86 => "ProgramFilesFolder",
Arch::X86_64 | Arch::AArch64 => "ProgramFiles64Folder",
_ => "ProgramFiles64Folder",
}
}
pub(crate) fn linuxdeploy_arch(&self) -> &'static str {
match self {
Arch::X86_64 => "x86_64",
Arch::X86 => "i386",
Arch::AArch64 => "aarch64",
Arch::Armhf | Arch::Armel => "armhf",
Arch::Riscv64 | Arch::Universal => "x86_64",
}
}
}
impl std::fmt::Display for Arch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
use std::str::FromStr;
/// The possible app categories.
/// Corresponds to `LSApplicationCategoryType` on macOS and the GNOME desktop categories on Debian.
#[allow(missing_docs)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum AppCategory {
Business,
DeveloperTool,
Education,
Entertainment,
Finance,
Game,
ActionGame,
AdventureGame,
ArcadeGame,
BoardGame,
CardGame,
CasinoGame,
DiceGame,
EducationalGame,
FamilyGame,
KidsGame,
MusicGame,
PuzzleGame,
RacingGame,
RolePlayingGame,
SimulationGame,
SportsGame,
StrategyGame,
TriviaGame,
WordGame,
GraphicsAndDesign,
HealthcareAndFitness,
Lifestyle,
Medical,
Music,
News,
Photography,
Productivity,
Reference,
SocialNetworking,
Sports,
Travel,
Utility,
Video,
Weather,
}
impl FromStr for AppCategory {
type Err = String;
fn from_str(input: &str) -> Result<AppCategory, Self::Err> {
const MACOS_APP_CATEGORY_PREFIX: &str = "public.app-category.";
let mut input = input.to_ascii_lowercase();
if input.starts_with(MACOS_APP_CATEGORY_PREFIX) {
input = input
.split_at(MACOS_APP_CATEGORY_PREFIX.len())
.1
.to_string();
}
input = input.replace(' ', "");
input = input.replace('-', "");
for &(string, category) in CATEGORY_STRINGS.iter() {
if input == string {
return Ok(category);
}
}
Err(format!("Unknown app category: {input}"))
}
}
impl AppCategory {
/// Map to closest set of Freedesktop categories.
pub(crate) fn freedesktop_categories(self) -> &'static str {
match &self {
AppCategory::Business => "Office;",
AppCategory::DeveloperTool => "Development;",
AppCategory::Education => "Education;",
AppCategory::Entertainment => "Network;",
AppCategory::Finance => "Office;Finance;",
AppCategory::Game => "Game;",
AppCategory::ActionGame => "Game;ActionGame;",
AppCategory::AdventureGame => "Game;AdventureGame;",
AppCategory::ArcadeGame => "Game;ArcadeGame;",
AppCategory::BoardGame => "Game;BoardGame;",
AppCategory::CardGame => "Game;CardGame;",
AppCategory::CasinoGame => "Game;",
AppCategory::DiceGame => "Game;",
AppCategory::EducationalGame => "Game;Education;",
AppCategory::FamilyGame => "Game;",
AppCategory::KidsGame => "Game;KidsGame;",
AppCategory::MusicGame => "Game;",
AppCategory::PuzzleGame => "Game;LogicGame;",
AppCategory::RacingGame => "Game;",
AppCategory::RolePlayingGame => "Game;RolePlaying;",
AppCategory::SimulationGame => "Game;Simulation;",
AppCategory::SportsGame => "Game;SportsGame;",
AppCategory::StrategyGame => "Game;StrategyGame;",
AppCategory::TriviaGame => "Game;",
AppCategory::WordGame => "Game;",
AppCategory::GraphicsAndDesign => "Graphics;",
AppCategory::HealthcareAndFitness => "Science;",
AppCategory::Lifestyle => "Education;",
AppCategory::Medical => "Science;MedicalSoftware;",
AppCategory::Music => "AudioVideo;Audio;Music;",
AppCategory::News => "Network;News;",
AppCategory::Photography => "Graphics;Photography;",
AppCategory::Productivity => "Office;",
AppCategory::Reference => "Education;",
AppCategory::SocialNetworking => "Network;",
AppCategory::Sports => "Education;Sports;",
AppCategory::Travel => "Education;",
AppCategory::Utility => "Utility;",
AppCategory::Video => "AudioVideo;Video;",
AppCategory::Weather => "Science;",
}
}
/// Map to macOS LSApplicationCategoryType.
pub(crate) fn macos_application_category_type(self) -> &'static str {
match &self {
AppCategory::Business => "public.app-category.business",
AppCategory::DeveloperTool => "public.app-category.developer-tools",
AppCategory::Education => "public.app-category.education",
AppCategory::Entertainment => "public.app-category.entertainment",
AppCategory::Finance => "public.app-category.finance",
AppCategory::Game => "public.app-category.games",
AppCategory::ActionGame => "public.app-category.action-games",
AppCategory::AdventureGame => "public.app-category.adventure-games",
AppCategory::ArcadeGame => "public.app-category.arcade-games",
AppCategory::BoardGame => "public.app-category.board-games",
AppCategory::CardGame => "public.app-category.card-games",
AppCategory::CasinoGame => "public.app-category.casino-games",
AppCategory::DiceGame => "public.app-category.dice-games",
AppCategory::EducationalGame => "public.app-category.educational-games",
AppCategory::FamilyGame => "public.app-category.family-games",
AppCategory::KidsGame => "public.app-category.kids-games",
AppCategory::MusicGame => "public.app-category.music-games",
AppCategory::PuzzleGame => "public.app-category.puzzle-games",
AppCategory::RacingGame => "public.app-category.racing-games",
AppCategory::RolePlayingGame => "public.app-category.role-playing-games",
AppCategory::SimulationGame => "public.app-category.simulation-games",
AppCategory::SportsGame => "public.app-category.sports-games",
AppCategory::StrategyGame => "public.app-category.strategy-games",
AppCategory::TriviaGame => "public.app-category.trivia-games",
AppCategory::WordGame => "public.app-category.word-games",
AppCategory::GraphicsAndDesign => "public.app-category.graphics-design",
AppCategory::HealthcareAndFitness => "public.app-category.healthcare-fitness",
AppCategory::Lifestyle => "public.app-category.lifestyle",
AppCategory::Medical => "public.app-category.medical",
AppCategory::Music => "public.app-category.music",
AppCategory::News => "public.app-category.news",
AppCategory::Photography => "public.app-category.photography",
AppCategory::Productivity => "public.app-category.productivity",
AppCategory::Reference => "public.app-category.reference",
AppCategory::SocialNetworking => "public.app-category.social-networking",
AppCategory::Sports => "public.app-category.sports",
AppCategory::Travel => "public.app-category.travel",
AppCategory::Utility => "public.app-category.utilities",
AppCategory::Video => "public.app-category.video",
AppCategory::Weather => "public.app-category.weather",
}
}
}
const CATEGORY_STRINGS: &[(&str, AppCategory)] = &[
("actiongame", AppCategory::ActionGame),
("actiongames", AppCategory::ActionGame),
("adventuregame", AppCategory::AdventureGame),
("adventuregames", AppCategory::AdventureGame),
("arcadegame", AppCategory::ArcadeGame),
("arcadegames", AppCategory::ArcadeGame),
("boardgame", AppCategory::BoardGame),
("boardgames", AppCategory::BoardGame),
("business", AppCategory::Business),
("cardgame", AppCategory::CardGame),
("cardgames", AppCategory::CardGame),
("casinogame", AppCategory::CasinoGame),
("casinogames", AppCategory::CasinoGame),
("developer", AppCategory::DeveloperTool),
("developertool", AppCategory::DeveloperTool),
("developertools", AppCategory::DeveloperTool),
("development", AppCategory::DeveloperTool),
("dicegame", AppCategory::DiceGame),
("dicegames", AppCategory::DiceGame),
("education", AppCategory::Education),
("educationalgame", AppCategory::EducationalGame),
("educationalgames", AppCategory::EducationalGame),
("entertainment", AppCategory::Entertainment),
("familygame", AppCategory::FamilyGame),
("familygames", AppCategory::FamilyGame),
("finance", AppCategory::Finance),
("fitness", AppCategory::HealthcareAndFitness),
("game", AppCategory::Game),
("games", AppCategory::Game),
("graphicdesign", AppCategory::GraphicsAndDesign),
("graphicsanddesign", AppCategory::GraphicsAndDesign),
("graphicsdesign", AppCategory::GraphicsAndDesign),
("healthcareandfitness", AppCategory::HealthcareAndFitness),
("healthcarefitness", AppCategory::HealthcareAndFitness),
("kidsgame", AppCategory::KidsGame),
("kidsgames", AppCategory::KidsGame),
("lifestyle", AppCategory::Lifestyle),
("logicgame", AppCategory::PuzzleGame),
("medical", AppCategory::Medical),
("music", AppCategory::Music),
("musicgame", AppCategory::MusicGame),
("musicgames", AppCategory::MusicGame),
("news", AppCategory::News),
("photography", AppCategory::Photography),
("productivity", AppCategory::Productivity),
("puzzlegame", AppCategory::PuzzleGame),
("puzzlegames", AppCategory::PuzzleGame),
("racinggame", AppCategory::RacingGame),
("racinggames", AppCategory::RacingGame),
("reference", AppCategory::Reference),
("roleplaying", AppCategory::RolePlayingGame),
("roleplayinggame", AppCategory::RolePlayingGame),
("roleplayinggames", AppCategory::RolePlayingGame),
("rpg", AppCategory::RolePlayingGame),
("simulationgame", AppCategory::SimulationGame),
("simulationgames", AppCategory::SimulationGame),
("socialnetwork", AppCategory::SocialNetworking),
("socialnetworking", AppCategory::SocialNetworking),
("sports", AppCategory::Sports),
("sportsgame", AppCategory::SportsGame),
("sportsgames", AppCategory::SportsGame),
("strategygame", AppCategory::StrategyGame),
("strategygames", AppCategory::StrategyGame),
("travel", AppCategory::Travel),
("triviagame", AppCategory::TriviaGame),
("triviagames", AppCategory::TriviaGame),
("utilities", AppCategory::Utility),
("utility", AppCategory::Utility),
("video", AppCategory::Video),
("weather", AppCategory::Weather),
("wordgame", AppCategory::WordGame),
("wordgames", AppCategory::WordGame),
];
/// Recursively copy a directory tree.
///
/// Preserves symlinks on unix targets and falls back to copying link targets on non-unix.
pub(crate) fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
std::fs::create_dir_all(dest)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let source_path = entry.path();
let dest_path = dest.join(entry.file_name());
let file_type = entry.file_type()?;
if file_type.is_dir() {
copy_dir_recursive(&source_path, &dest_path)?;
} else if file_type.is_symlink() {
#[cfg(unix)]
{
let target = std::fs::read_link(&source_path)?;
std::os::unix::fs::symlink(&target, &dest_path)?;
}
#[cfg(not(unix))]
{
std::fs::copy(&source_path, &dest_path)?;
}
} else {
std::fs::copy(&source_path, &dest_path)?;
}
}
Ok(())
}
/// Recursively zip a directory tree while preserving relative paths and Unix modes.
pub(crate) fn zip_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
use std::fs::File;
use std::io::{Read, Write};
let file =
File::create(dest).with_context(|| format!("Failed to create {}", dest.display()))?;
let mut zip = zip::ZipWriter::new(file);
for entry in walkdir::WalkDir::new(src) {
let entry = entry?;
let path = entry.path();
let relative = path
.strip_prefix(src)
.with_context(|| format!("Failed to strip prefix for {}", path.display()))?;
if relative.as_os_str().is_empty() {
continue;
}
let archive_path = relative.to_string_lossy().replace('\\', "/");
let metadata = entry.metadata()?;
#[cfg(unix)]
let mode = {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode()
};
#[cfg(not(unix))]
let mode = if metadata.is_dir() { 0o755 } else { 0o644 };
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(mode);
if metadata.is_dir() {
zip.add_directory(format!("{archive_path}/"), options)?;
continue;
}
zip.start_file(&archive_path, options)?;
let mut src_file = File::open(path)?;
let mut buffer = Vec::new();
src_file.read_to_end(&mut buffer)?;
zip.write_all(&buffer)?;
}
zip.finish()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::zip_dir_recursive;
#[test]
fn zip_dir_preserves_layout_and_permissions() {
let temp = tempfile::tempdir().unwrap();
let src = temp.path().join("src");
let payload = src.join("Payload/Test.app");
std::fs::create_dir_all(&payload).unwrap();
let exec = payload.join("runner");
std::fs::write(&exec, "hello").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&exec, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let zip_path = temp.path().join("bundle.zip");
zip_dir_recursive(&src, &zip_path).unwrap();
let file = std::fs::File::open(&zip_path).unwrap();
let mut archive = zip::ZipArchive::new(file).unwrap();
let entry = archive.by_name("Payload/Test.app/runner").unwrap();
let expected_mode = if cfg!(unix) { 0o755 } else { 0o644 };
assert_eq!(
entry.unix_mode().map(|mode| mode & 0o777),
Some(expected_mode)
);
}
}
+305
View File
@@ -0,0 +1,305 @@
//! Tool downloading and caching for external bundling tools.
//!
//! All downloads happen upfront via `resolve_tools()` before any bundling starts.
//! This keeps tool downloads out of the bundle format modules.
use super::Arch;
use crate::{PackageType, WebviewInstallMode, WindowsSettings};
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
/// NSIS download URL and expected hash
const NSIS_URL: &str =
"https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip";
const NSIS_SHA1: &str = "EF7FF767E5CBD9EDD22ADD3A32C9B8F4500BB10D";
/// WiX download URL and expected hash
const WIX_URL: &str =
"https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314-binaries.zip";
const WIX_SHA256: &str = "6ac824e1642d6f7277d0ed7ea09411a508f6116ba6fae0aa5f2c7daa2ff43d31";
/// linuxdeploy download base URL
const LINUXDEPLOY_URL_BASE: &str =
"https://github.com/tauri-apps/binary-releases/releases/download/linuxdeploy";
/// WebView2 download URLs.
const WEBVIEW2_BOOTSTRAPPER_URL: &str = "https://go.microsoft.com/fwlink/p/?LinkId=2124703";
const WEBVIEW2_X64_INSTALLER_URL: &str = "https://go.microsoft.com/fwlink/?linkid=2099617";
const WEBVIEW2_X86_INSTALLER_URL: &str = "https://go.microsoft.com/fwlink/?linkid=2099617";
const WEBVIEW2_ARM64_INSTALLER_URL: &str = "https://go.microsoft.com/fwlink/?linkid=2099617";
/// Pre-resolved tool paths. All downloads happen before bundling starts.
pub(crate) struct ResolvedTools {
/// Path to the NSIS directory (contains makensis). Set if NSIS bundling is requested.
pub nsis_dir: Option<PathBuf>,
/// Path to the WiX directory (contains candle.exe, light.exe). Set if MSI bundling is requested.
pub wix_dir: Option<PathBuf>,
/// Path to the linuxdeploy binary. Set if AppImage bundling is requested.
pub linuxdeploy: Option<PathBuf>,
/// Path to a downloaded WebView2 bootstrapper or offline installer, if needed by NSIS.
pub webview2_installer: Option<PathBuf>,
}
/// Resolve and download all tools needed for the given package types.
///
/// This must be called before `bundle_project()` so that no HTTP calls happen
/// during the actual bundling phase.
pub(crate) async fn resolve_tools(
tools_dir: &Path,
package_types: &[PackageType],
windows_settings: &WindowsSettings,
arch: Arch,
) -> Result<ResolvedTools> {
let mut resolved = ResolvedTools {
nsis_dir: None,
wix_dir: None,
linuxdeploy: None,
webview2_installer: None,
};
for pt in package_types {
match pt {
PackageType::Nsis => {
resolved.nsis_dir = Some(ensure_nsis(tools_dir).await?);
resolved.webview2_installer =
resolve_webview2(tools_dir, windows_settings, arch).await?;
}
PackageType::WindowsMsi => {
resolved.wix_dir = Some(ensure_wix(tools_dir).await?);
}
PackageType::AppImage => {
let linuxdeploy_arch = arch.linuxdeploy_arch();
resolved.linuxdeploy = Some(ensure_linuxdeploy(tools_dir, linuxdeploy_arch).await?);
}
_ => {}
}
}
Ok(resolved)
}
/// Determine if a WebView2 installer needs to be downloaded based on NSIS settings,
/// and download it if so.
async fn resolve_webview2(
tools_dir: &Path,
settings: &WindowsSettings,
arch: Arch,
) -> Result<Option<PathBuf>> {
let mode = &settings.webview_install_mode;
match mode {
WebviewInstallMode::Skip | WebviewInstallMode::FixedRuntime { .. } => Ok(None),
WebviewInstallMode::DownloadBootstrapper { .. }
| WebviewInstallMode::EmbedBootstrapper { .. } => {
Ok(Some(download_webview2_bootstrapper(tools_dir).await?))
}
WebviewInstallMode::OfflineInstaller { .. } => {
let arch_str = arch.windows_arch();
Ok(Some(
download_webview2_offline_installer(tools_dir, arch_str).await?,
))
}
}
}
async fn ensure_nsis(tools_dir: &Path) -> Result<PathBuf> {
let nsis_dir = tools_dir.join("nsis-3.11");
let makensis = if cfg!(target_os = "windows") {
nsis_dir.join("makensis.exe")
} else {
nsis_dir.join("makensis")
};
if makensis.exists() {
return Ok(nsis_dir);
}
if cfg!(feature = "no-downloads") {
bail!("NSIS not found and automatic downloads are disabled. Install NSIS manually.");
}
tracing::info!("Downloading NSIS...");
let data = download_and_verify(NSIS_URL, NSIS_SHA1, HashAlgo::Sha1).await?;
extract_zip(&data, tools_dir)?;
if !makensis.exists() {
bail!(
"NSIS extraction succeeded but makensis not found at {}",
makensis.display()
);
}
#[cfg(unix)]
let _ = std::fs::set_permissions(&makensis, std::fs::Permissions::from_mode(0o755));
Ok(nsis_dir)
}
async fn ensure_wix(tools_dir: &Path) -> Result<PathBuf> {
let wix_dir = tools_dir.join("wix314");
let candle = wix_dir.join("candle.exe");
if candle.exists() {
return Ok(wix_dir);
}
if cfg!(feature = "no-downloads") {
bail!("WiX not found and automatic downloads are disabled. Install WiX manually.");
}
tracing::info!("Downloading WiX toolset...");
let data = download_and_verify(WIX_URL, WIX_SHA256, HashAlgo::Sha256).await?;
std::fs::create_dir_all(&wix_dir)?;
extract_zip(&data, &wix_dir)?;
if !candle.exists() {
bail!(
"WiX extraction succeeded but candle.exe not found at {}",
candle.display()
);
}
Ok(wix_dir)
}
async fn ensure_linuxdeploy(tools_dir: &Path, arch: &str) -> Result<PathBuf> {
let linuxdeploy_name = format!("linuxdeploy-{arch}.AppImage");
let linuxdeploy_path = tools_dir.join(&linuxdeploy_name);
if linuxdeploy_path.exists() {
return Ok(linuxdeploy_path);
}
if cfg!(feature = "no-downloads") {
bail!(
"linuxdeploy not found and automatic downloads are disabled. Install linuxdeploy manually."
);
}
let url = format!("{LINUXDEPLOY_URL_BASE}/{linuxdeploy_name}");
tracing::info!("Downloading linuxdeploy from {url}...");
let data = download_bytes(&url).await?;
std::fs::create_dir_all(tools_dir)?;
std::fs::write(&linuxdeploy_path, &data)?;
#[cfg(unix)]
std::fs::set_permissions(&linuxdeploy_path, std::fs::Permissions::from_mode(0o755))?;
Ok(linuxdeploy_path)
}
async fn download_webview2_bootstrapper(tools_dir: &Path) -> Result<PathBuf> {
let path = tools_dir.join("MicrosoftEdgeWebview2Setup.exe");
if path.exists() {
return Ok(path);
}
tracing::info!("Downloading WebView2 bootstrapper...");
let data = download_bytes(WEBVIEW2_BOOTSTRAPPER_URL).await?;
std::fs::create_dir_all(tools_dir)?;
std::fs::write(&path, &data).context("Failed to write WebView2 bootstrapper")?;
Ok(path)
}
async fn download_webview2_offline_installer(tools_dir: &Path, arch: &str) -> Result<PathBuf> {
let name = format!("MicrosoftEdgeWebView2RuntimeInstaller_{arch}.exe");
let path = tools_dir.join(&name);
if path.exists() {
return Ok(path);
}
let url = match arch {
"x64" => WEBVIEW2_X64_INSTALLER_URL,
"x86" => WEBVIEW2_X86_INSTALLER_URL,
"arm64" => WEBVIEW2_ARM64_INSTALLER_URL,
_ => bail!("Unsupported architecture for WebView2 offline installer: {arch}"),
};
tracing::info!("Downloading WebView2 offline installer for {arch}...");
let data = download_bytes(url).await?;
std::fs::create_dir_all(tools_dir)?;
std::fs::write(&path, &data).context("Failed to write WebView2 offline installer")?;
Ok(path)
}
enum HashAlgo {
Sha1,
Sha256,
}
async fn download_and_verify(url: &str, expected_hash: &str, algo: HashAlgo) -> Result<Vec<u8>> {
let data = download_bytes(url).await?;
let computed = match algo {
HashAlgo::Sha1 => {
use sha1::Digest;
let mut hasher = sha1::Sha1::new();
hasher.update(&data);
format!("{:X}", hasher.finalize())
}
HashAlgo::Sha256 => {
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
hasher.update(&data);
format!("{:x}", hasher.finalize())
}
};
if computed.to_uppercase() != expected_hash.to_uppercase() {
bail!("Hash mismatch for {url}: expected {expected_hash}, got {computed}");
}
Ok(data)
}
/// Download bytes from a URL using the async reqwest client.
pub(crate) async fn download_bytes(url: &str) -> Result<Vec<u8>> {
let response = reqwest::get(url)
.await
.with_context(|| format!("Failed to download {url}"))?;
if !response.status().is_success() {
bail!("Download failed with status {}: {url}", response.status());
}
response
.bytes()
.await
.map(|b| b.to_vec())
.with_context(|| format!("Failed to read response body from {url}"))
}
/// Extract a zip archive to a directory.
fn extract_zip(data: &[u8], dest: &Path) -> Result<()> {
use std::io::Cursor;
let cursor = Cursor::new(data);
let mut archive = zip::ZipArchive::new(cursor).context("Failed to read zip archive")?;
std::fs::create_dir_all(dest)?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let outpath = dest.join(file.mangled_name());
if file.name().ends_with('/') {
std::fs::create_dir_all(&outpath)?;
} else {
if let Some(parent) = outpath.parent() {
std::fs::create_dir_all(parent)?;
}
let mut outfile = std::fs::File::create(&outpath)?;
std::io::copy(&mut file, &mut outfile)?;
#[cfg(unix)]
if let Some(mode) = file.unix_mode() {
std::fs::set_permissions(&outpath, std::fs::Permissions::from_mode(mode))?;
}
}
}
Ok(())
}
+152
View File
@@ -0,0 +1,152 @@
//! Updater bundle creation.
//!
//! Creates zip/tar.gz archives of bundle artifacts for auto-update distribution.
use super::{Bundle, BundleContext};
use crate::PackageType;
use anyhow::{Context, Result};
use std::{
fs::File,
io,
path::{Path, PathBuf},
};
impl BundleContext<'_> {
/// Repackage previously-built bundle artifacts into updater-friendly archives.
///
/// This step never builds the application itself. Instead, it consumes the
/// [`Bundle`] records produced earlier in the main bundling pass and wraps those
/// artifacts into archive formats that are convenient for update distribution.
///
/// Archive mapping:
/// - macOS `.app` bundles become `.app.tar.gz` archives so the bundle directory
/// layout is preserved exactly.
/// - Windows installers (`.msi` and NSIS `.exe`) become single-file `.zip`
/// archives.
/// - Linux artifacts (`.AppImage` and `.deb`) become single-file `.tar.gz`
/// archives.
///
/// All outputs are written to `project_out_directory()/bundle/updater`. This
/// method assumes the input bundles are already finalized and signed as needed; it
/// performs no platform-specific mutation beyond wrapping them in the selected
/// archive container.
pub(crate) async fn bundle_updater(&self, bundles: &[Bundle]) -> Result<Vec<PathBuf>> {
let mut updater_paths = Vec::new();
let output_dir = self.project_out_directory().join("bundle").join("updater");
std::fs::create_dir_all(&output_dir)?;
for bundle in bundles {
match bundle.package_type {
PackageType::MacOsBundle => {
// Create .tar.gz of the .app bundle
for app_path in &bundle.bundle_paths {
let tar_path = output_dir.join(format!(
"{}_{}.app.tar.gz",
self.product_name(),
self.version_string()
));
create_tar_gz(app_path, &tar_path)?;
tracing::info!("Created updater archive: {}", tar_path.display());
updater_paths.push(tar_path);
}
}
PackageType::Nsis | PackageType::WindowsMsi => {
// Create .zip of the installer
for installer_path in &bundle.bundle_paths {
let zip_path = output_dir.join(format!(
"{}.zip",
installer_path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
));
create_zip(installer_path, &zip_path)?;
tracing::info!("Created updater archive: {}", zip_path.display());
updater_paths.push(zip_path);
}
}
PackageType::AppImage | PackageType::Deb => {
// Create .tar.gz of the artifact
for artifact_path in &bundle.bundle_paths {
let tar_path = output_dir.join(format!(
"{}.tar.gz",
artifact_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
));
create_tar_gz_single_file(artifact_path, &tar_path)?;
tracing::info!("Created updater archive: {}", tar_path.display());
updater_paths.push(tar_path);
}
}
_ => {}
}
}
Ok(updater_paths)
}
}
/// Create a .tar.gz of a directory (e.g., a .app bundle).
fn create_tar_gz(src_dir: &Path, dest: &Path) -> Result<()> {
let file =
File::create(dest).with_context(|| format!("Failed to create {}", dest.display()))?;
let enc = flate2::write::GzEncoder::new(file, flate2::Compression::default());
let mut tar = tar::Builder::new(enc);
let dir_name = src_dir
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
tar.append_dir_all(&dir_name, src_dir)
.with_context(|| format!("Failed to add {} to tar", src_dir.display()))?;
tar.into_inner()?.finish()?;
Ok(())
}
/// Create a .tar.gz containing a single file.
fn create_tar_gz_single_file(src_file: &Path, dest: &Path) -> Result<()> {
let file =
File::create(dest).with_context(|| format!("Failed to create {}", dest.display()))?;
let enc = flate2::write::GzEncoder::new(file, flate2::Compression::default());
let mut tar = tar::Builder::new(enc);
let file_name = src_file
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
tar.append_path_with_name(src_file, &file_name)
.with_context(|| format!("Failed to add {} to tar", src_file.display()))?;
tar.into_inner()?.finish()?;
Ok(())
}
/// Create a .zip containing a single file.
fn create_zip(src_file: &Path, dest: &Path) -> Result<()> {
let file =
File::create(dest).with_context(|| format!("Failed to create {}", dest.display()))?;
let mut zip = zip::ZipWriter::new(file);
let file_name = src_file
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
zip.start_file(&file_name, options)?;
let mut src = File::open(src_file)?;
io::copy(&mut src, &mut zip)?;
zip.finish()?;
Ok(())
}
File diff suppressed because it is too large. Load diff
+15 -15
View File
@@ -67,43 +67,43 @@ fn extend_manifest_config_toml(manifest: &mut Manifest, path: &Path) {
/// Merge the new profiles into the target profiles. Keep the existing values if they exist.
fn merge_profiles(target: &mut Profiles, new: Profiles) {
if let Some(new_release) = new.release {
if target.release.is_none() {
target.release = Some(new_release);
if let Some(profile) = target.release.as_mut() {
merge_profile(profile, new_release);
} else {
merge_profile(target.release.as_mut().unwrap(), new_release);
target.release = Some(new_release);
}
}
if let Some(new_dev) = new.dev {
if target.dev.is_none() {
target.dev = Some(new_dev);
if let Some(profile) = target.dev.as_mut() {
merge_profile(profile, new_dev);
} else {
merge_profile(target.dev.as_mut().unwrap(), new_dev);
target.dev = Some(new_dev);
}
}
if let Some(new_test) = new.test {
if target.test.is_none() {
target.test = Some(new_test);
if let Some(profile) = target.test.as_mut() {
merge_profile(profile, new_test);
} else {
merge_profile(target.test.as_mut().unwrap(), new_test);
target.test = Some(new_test);
}
}
if let Some(new_bench) = new.bench {
if target.bench.is_none() {
target.bench = Some(new_bench);
if let Some(profile) = target.bench.as_mut() {
merge_profile(profile, new_bench);
} else {
merge_profile(target.bench.as_mut().unwrap(), new_bench);
target.bench = Some(new_bench);
}
}
#[allow(deprecated)]
if let Some(new_doc) = new.doc {
if target.doc.is_none() {
target.doc = Some(new_doc);
if let Some(profile) = target.doc.as_mut() {
merge_profile(profile, new_doc);
} else {
merge_profile(target.doc.as_mut().unwrap(), new_doc);
target.doc = Some(new_doc);
}
}
+436
View File
@@ -0,0 +1,436 @@
use owo_colors::{
colors::{css::LightBlue, BrightRed},
OwoColorize, Stream,
};
use std::{
fmt::Display,
path::{Path, PathBuf},
};
use super::metadata::{
AnyLoopInfo, AsyncInfo, ClosureInfo, ConditionalInfo, ForInfo, HookInfo, IfInfo, MatchInfo,
WhileInfo,
};
/// The result of checking a Dioxus file for issues.
pub struct IssueReport {
pub path: PathBuf,
pub crate_root: PathBuf,
pub file_content: String,
pub issues: Vec<Issue>,
}
impl IssueReport {
pub fn new<S: ToString>(
path: PathBuf,
crate_root: PathBuf,
file_content: S,
issues: Vec<Issue>,
) -> Self {
Self {
path,
crate_root,
file_content: file_content.to_string(),
issues,
}
}
}
fn lightblue(text: &str) -> String {
text.if_supports_color(Stream::Stderr, |text| text.fg::<LightBlue>())
.to_string()
}
fn brightred(text: &str) -> String {
text.if_supports_color(Stream::Stderr, |text| text.fg::<BrightRed>())
.to_string()
}
fn bold(text: &str) -> String {
text.if_supports_color(Stream::Stderr, |text| text.bold())
.to_string()
}
impl Display for IssueReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let relative_file = Path::new(&self.path)
.strip_prefix(&self.crate_root)
.unwrap_or(Path::new(&self.path))
.display();
let pipe_char = lightblue("|");
for (i, issue) in self.issues.iter().enumerate() {
let hook_info = issue.hook_info();
let hook_span = hook_info.span;
let hook_name_span = hook_info.name_span;
let error_line = format!("{}: {}", brightred("error"), issue);
writeln!(f, "{}", bold(&error_line))?;
writeln!(
f,
" {} {}:{}:{}",
lightblue("-->"),
relative_file,
hook_span.start.line,
hook_span.start.column + 1
)?;
let max_line_num_len = hook_span.end.line.to_string().len();
writeln!(f, "{:>max_line_num_len$} {}", "", pipe_char)?;
for (i, line) in self.file_content.lines().enumerate() {
let line_num = i + 1;
if line_num >= hook_span.start.line && line_num <= hook_span.end.line {
writeln!(
f,
"{:>max_line_num_len$} {} {}",
lightblue(&line_num.to_string()),
pipe_char,
line,
)?;
if line_num == hook_span.start.line {
let mut caret = String::new();
for _ in 0..hook_name_span.start.column {
caret.push(' ');
}
for _ in hook_name_span.start.column..hook_name_span.end.column {
caret.push('^');
}
writeln!(
f,
"{:>max_line_num_len$} {} {}",
"",
pipe_char,
brightred(&caret),
)?;
}
}
}
let note_text_prefix = format!(
"{:>max_line_num_len$} {}\n{:>max_line_num_len$} {} note:",
"",
pipe_char,
"",
lightblue("=")
);
match issue {
Issue::HookInsideConditional(
_,
ConditionalInfo::If(IfInfo { span: _, head_span }),
)
| Issue::HookInsideConditional(
_,
ConditionalInfo::Match(MatchInfo { span: _, head_span }),
) => {
if let Some(source_text) = &head_span.source_text {
writeln!(
f,
"{} `{} {{ … }}` is the conditional",
note_text_prefix, source_text,
)?;
}
}
Issue::HookInsideLoop(_, AnyLoopInfo::For(ForInfo { span: _, head_span }))
| Issue::HookInsideLoop(_, AnyLoopInfo::While(WhileInfo { span: _, head_span })) => {
if let Some(source_text) = &head_span.source_text {
writeln!(
f,
"{} `{} {{ … }}` is the loop",
note_text_prefix, source_text,
)?;
}
}
Issue::HookInsideLoop(_, AnyLoopInfo::Loop(_)) => {
writeln!(f, "{} `loop {{ … }}` is the loop", note_text_prefix,)?;
}
Issue::HookOutsideComponent(_)
| Issue::HookInsideClosure(_, _)
| Issue::HookInsideAsync(_, _) => {}
}
if i < self.issues.len() - 1 {
writeln!(f)?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
#[allow(clippy::enum_variant_names)] // we'll add non-hook ones in the future
/// Issues that might be found via static analysis of a Dioxus file.
pub enum Issue {
/// <https://dioxuslabs.com/learn/0.7/reference/hooks#no-hooks-in-conditionals>
HookInsideConditional(HookInfo, ConditionalInfo),
/// <https://dioxuslabs.com/learn/0.7/reference/hooks#no-hooks-in-loops>
HookInsideLoop(HookInfo, AnyLoopInfo),
/// <https://dioxuslabs.com/learn/0.7/reference/hooks#no-hooks-in-closures>
HookInsideClosure(HookInfo, ClosureInfo),
HookInsideAsync(HookInfo, AsyncInfo),
HookOutsideComponent(HookInfo),
}
impl Issue {
pub fn hook_info(&self) -> HookInfo {
match self {
Issue::HookInsideConditional(hook_info, _)
| Issue::HookInsideLoop(hook_info, _)
| Issue::HookInsideClosure(hook_info, _)
| Issue::HookInsideAsync(hook_info, _)
| Issue::HookOutsideComponent(hook_info) => hook_info.clone(),
}
}
}
impl std::fmt::Display for Issue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Issue::HookInsideConditional(hook_info, conditional_info) => {
write!(
f,
"hook called conditionally: `{}` (inside `{}`)",
hook_info.name,
match conditional_info {
ConditionalInfo::If(_) => "if",
ConditionalInfo::Match(_) => "match",
}
)
}
Issue::HookInsideLoop(hook_info, loop_info) => {
write!(
f,
"hook called in a loop: `{}` (inside {})",
hook_info.name,
match loop_info {
AnyLoopInfo::For(_) => "`for` loop",
AnyLoopInfo::While(_) => "`while` loop",
AnyLoopInfo::Loop(_) => "`loop`",
}
)
}
Issue::HookInsideClosure(hook_info, _) => {
write!(f, "hook called in a closure: `{}`", hook_info.name)
}
Issue::HookInsideAsync(hook_info, _) => {
write!(f, "hook called in an async block: `{}`", hook_info.name)
}
Issue::HookOutsideComponent(hook_info) => {
write!(
f,
"hook called outside component or hook: `{}`",
hook_info.name
)
}
}
}
}
#[cfg(test)]
mod tests {
use crate::check::check_file;
use indoc::indoc;
use pretty_assertions::assert_eq;
#[test]
fn test_issue_report_display_conditional_if() {
owo_colors::set_override(false);
let issue_report = check_file(
"src/main.rs".into(),
indoc! {r#"
fn App() -> Element {
if you_are_happy && you_know_it {
let something = use_signal(|| "hands");
println!("clap your {something}")
}
}
"#},
);
let expected = indoc! {r#"
error: hook called conditionally: `use_signal` (inside `if`)
--> src/main.rs:3:25
|
3 | let something = use_signal(|| "hands");
| ^^^^^^^^^^
|
= note: `if you_are_happy && you_know_it { … }` is the conditional
"#};
assert_eq!(expected, issue_report.to_string());
}
#[test]
fn test_issue_report_display_conditional_match() {
owo_colors::set_override(false);
let issue_report = check_file(
"src/main.rs".into(),
indoc! {r#"
fn App() -> Element {
match you_are_happy && you_know_it {
true => {
let something = use_signal(|| "hands");
println!("clap your {something}")
}
_ => {}
}
}
"#},
);
let expected = indoc! {r#"
error: hook called conditionally: `use_signal` (inside `match`)
--> src/main.rs:4:29
|
4 | let something = use_signal(|| "hands");
| ^^^^^^^^^^
|
= note: `match you_are_happy && you_know_it { … }` is the conditional
"#};
assert_eq!(expected, issue_report.to_string());
}
#[test]
fn test_issue_report_display_for_loop() {
owo_colors::set_override(false);
let issue_report = check_file(
"src/main.rs".into(),
indoc! {r#"
fn App() -> Element {
for i in 0..10 {
let something = use_signal(|| "hands");
println!("clap your {something}")
}
}
"#},
);
let expected = indoc! {r#"
error: hook called in a loop: `use_signal` (inside `for` loop)
--> src/main.rs:3:25
|
3 | let something = use_signal(|| "hands");
| ^^^^^^^^^^
|
= note: `for i in 0..10 { … }` is the loop
"#};
assert_eq!(expected, issue_report.to_string());
}
#[test]
fn test_issue_report_display_while_loop() {
owo_colors::set_override(false);
let issue_report = check_file(
"src/main.rs".into(),
indoc! {r#"
fn App() -> Element {
while check_thing() {
let something = use_signal(|| "hands");
println!("clap your {something}")
}
}
"#},
);
let expected = indoc! {r#"
error: hook called in a loop: `use_signal` (inside `while` loop)
--> src/main.rs:3:25
|
3 | let something = use_signal(|| "hands");
| ^^^^^^^^^^
|
= note: `while check_thing() { … }` is the loop
"#};
assert_eq!(expected, issue_report.to_string());
}
#[test]
fn test_issue_report_display_loop() {
owo_colors::set_override(false);
let issue_report = check_file(
"src/main.rs".into(),
indoc! {r#"
fn App() -> Element {
loop {
let something = use_signal(|| "hands");
println!("clap your {something}")
}
}
"#},
);
let expected = indoc! {r#"
error: hook called in a loop: `use_signal` (inside `loop`)
--> src/main.rs:3:25
|
3 | let something = use_signal(|| "hands");
| ^^^^^^^^^^
|
= note: `loop { … }` is the loop
"#};
assert_eq!(expected, issue_report.to_string());
}
#[test]
fn test_issue_report_display_closure() {
owo_colors::set_override(false);
let issue_report = check_file(
"src/main.rs".into(),
indoc! {r#"
fn App() -> Element {
let something = || {
let something = use_signal(|| "hands");
println!("clap your {something}")
};
}
"#},
);
let expected = indoc! {r#"
error: hook called in a closure: `use_signal`
--> src/main.rs:3:25
|
3 | let something = use_signal(|| "hands");
| ^^^^^^^^^^
"#};
assert_eq!(expected, issue_report.to_string());
}
#[test]
fn test_issue_report_display_multiline_hook() {
owo_colors::set_override(false);
let issue_report = check_file(
"src/main.rs".into(),
indoc! {r#"
fn App() -> Element {
if you_are_happy && you_know_it {
let something = use_signal(|| {
"hands"
});
println!("clap your {something}")
}
}
"#},
);
let expected = indoc! {r#"
error: hook called conditionally: `use_signal` (inside `if`)
--> src/main.rs:3:25
|
3 | let something = use_signal(|| {
| ^^^^^^^^^^
4 | "hands"
5 | });
|
= note: `if you_are_happy && you_know_it { … }` is the conditional
"#};
assert_eq!(expected, issue_report.to_string());
}
}
+215
View File
@@ -0,0 +1,215 @@
#[derive(Debug, Clone, PartialEq, Eq)]
/// Information about a hook call or function.
pub struct HookInfo {
/// The name of the hook, e.g. `use_signal`.
pub name: String,
/// The span of the hook, e.g. `use_signal`.
pub span: Span,
/// The span of the name, e.g. `use_signal`.
pub name_span: Span,
}
impl HookInfo {
pub const fn new(span: Span, name_span: Span, name: String) -> Self {
Self {
span,
name_span,
name,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConditionalInfo {
If(IfInfo),
Match(MatchInfo),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IfInfo {
/// The span of the `if` statement, e.g. `if true { ... }`.
pub span: Span,
/// The span of the `if true` part only.
pub head_span: Span,
}
impl IfInfo {
pub const fn new(span: Span, head_span: Span) -> Self {
Self { span, head_span }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MatchInfo {
/// The span of the `match` statement, e.g. `match true { ... }`.
pub span: Span,
/// The span of the `match true` part only.
pub head_span: Span,
}
impl MatchInfo {
pub const fn new(span: Span, head_span: Span) -> Self {
Self { span, head_span }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// Information about one of the possible loop types.
pub enum AnyLoopInfo {
For(ForInfo),
While(WhileInfo),
Loop(LoopInfo),
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// Information about a `for` loop.
pub struct ForInfo {
pub span: Span,
pub head_span: Span,
}
impl ForInfo {
pub const fn new(span: Span, head_span: Span) -> Self {
Self { span, head_span }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// Information about a `while` loop.
pub struct WhileInfo {
pub span: Span,
pub head_span: Span,
}
impl WhileInfo {
pub const fn new(span: Span, head_span: Span) -> Self {
Self { span, head_span }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// Information about a `loop` loop.
pub struct LoopInfo {
pub span: Span,
}
impl LoopInfo {
pub const fn new(span: Span) -> Self {
Self { span }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// Information about a closure.
pub struct ClosureInfo {
pub span: Span,
}
impl ClosureInfo {
pub const fn new(span: Span) -> Self {
Self { span }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// Information about an async block.
pub struct AsyncInfo {
pub span: Span,
}
impl AsyncInfo {
pub const fn new(span: Span) -> Self {
Self { span }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// Information about a component function.
pub struct ComponentInfo {
pub span: Span,
pub name: String,
pub name_span: Span,
}
impl ComponentInfo {
pub const fn new(span: Span, name: String, name_span: Span) -> Self {
Self {
span,
name,
name_span,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// Information about a non-component, non-hook function.
pub struct FnInfo {
pub span: Span,
pub name: String,
pub name_span: Span,
}
impl FnInfo {
pub const fn new(span: Span, name: String, name_span: Span) -> Self {
Self {
span,
name,
name_span,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// A span of text in a source code file.
pub struct Span {
pub source_text: Option<String>,
pub start: LineColumn,
pub end: LineColumn,
}
impl Span {
#[cfg(test)]
pub fn new_from_str(source_text: &str, start: LineColumn) -> Self {
let mut lines = source_text.lines();
let first_line = lines.next().unwrap_or_default();
let mut end = LineColumn {
line: start.line,
column: start.column + first_line.len(),
};
for line in lines {
end.line += 1;
end.column = line.len();
}
Self {
source_text: Some(source_text.to_string()),
start,
end,
}
}
}
impl From<proc_macro2::Span> for Span {
fn from(span: proc_macro2::Span) -> Self {
Self {
source_text: span.source_text(),
start: span.start().into(),
end: span.end().into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// A location in a source code file.
pub struct LineColumn {
pub line: usize,
pub column: usize,
}
impl From<proc_macro2::LineColumn> for LineColumn {
fn from(lc: proc_macro2::LineColumn) -> Self {
Self {
line: lc.line,
column: lc.column,
}
}
}
+845
View File
@@ -0,0 +1,845 @@
mod issues;
mod metadata;
use std::path::PathBuf;
use syn::{spanned::Spanned, visit::Visit, Pat};
use {
issues::{Issue, IssueReport},
metadata::{
AnyLoopInfo, AsyncInfo, ClosureInfo, ComponentInfo, ConditionalInfo, FnInfo, ForInfo,
HookInfo, IfInfo, LoopInfo, MatchInfo, Span, WhileInfo,
},
};
struct VisitHooks {
issues: Vec<Issue>,
context: Vec<Node>,
}
impl VisitHooks {
const fn new() -> Self {
Self {
issues: vec![],
context: vec![],
}
}
}
/// Checks a Dioxus file for issues.
pub fn check_file(path: PathBuf, file_content: &str) -> IssueReport {
let file = syn::parse_file(file_content).unwrap();
let mut visit_hooks = VisitHooks::new();
visit_hooks.visit_file(&file);
IssueReport::new(
path,
std::env::current_dir().unwrap_or_default(),
file_content.to_string(),
visit_hooks.issues,
)
}
#[allow(unused)]
#[derive(Debug, Clone)]
enum Node {
If(IfInfo),
Match(MatchInfo),
For(ForInfo),
While(WhileInfo),
Loop(LoopInfo),
Closure(ClosureInfo),
Async(AsyncInfo),
ComponentFn(ComponentInfo),
HookFn(HookInfo),
OtherFn(FnInfo),
}
fn returns_element(ty: &syn::ReturnType) -> bool {
match ty {
syn::ReturnType::Default => false,
syn::ReturnType::Type(_, ref ty) => {
if let syn::Type::Path(ref path) = **ty {
if let Some(segment) = path.path.segments.last() {
if segment.ident == "Element" {
return true;
}
}
}
false
}
}
}
fn is_hook_ident(ident: &syn::Ident) -> bool {
ident.to_string().starts_with("use_")
}
fn is_component_fn(item_fn: &syn::ItemFn) -> bool {
returns_element(&item_fn.sig.output)
}
fn get_closure_hook_body(local: &syn::Local) -> Option<&syn::Expr> {
if let Pat::Ident(ident) = &local.pat {
if is_hook_ident(&ident.ident) {
if let Some(init) = &local.init {
if let syn::Expr::Closure(closure) = init.expr.as_ref() {
return Some(&closure.body);
}
}
}
}
None
}
fn fn_name_and_name_span(item_fn: &syn::ItemFn) -> (String, Span) {
let name = item_fn.sig.ident.to_string();
let name_span = item_fn.sig.ident.span().into();
(name, name_span)
}
impl<'ast> syn::visit::Visit<'ast> for VisitHooks {
fn visit_expr_call(&mut self, i: &'ast syn::ExprCall) {
if let syn::Expr::Path(ref path) = *i.func {
if let Some(segment) = path.path.segments.last() {
if is_hook_ident(&segment.ident) {
let hook_info = HookInfo::new(
i.span().into(),
segment.ident.span().into(),
segment.ident.to_string(),
);
let mut container_fn: Option<Node> = None;
for node in self.context.iter().rev() {
match &node {
Node::If(if_info) => {
let issue = Issue::HookInsideConditional(
hook_info.clone(),
ConditionalInfo::If(if_info.clone()),
);
self.issues.push(issue);
}
Node::Match(match_info) => {
let issue = Issue::HookInsideConditional(
hook_info.clone(),
ConditionalInfo::Match(match_info.clone()),
);
self.issues.push(issue);
}
Node::For(for_info) => {
let issue = Issue::HookInsideLoop(
hook_info.clone(),
AnyLoopInfo::For(for_info.clone()),
);
self.issues.push(issue);
}
Node::While(while_info) => {
let issue = Issue::HookInsideLoop(
hook_info.clone(),
AnyLoopInfo::While(while_info.clone()),
);
self.issues.push(issue);
}
Node::Loop(loop_info) => {
let issue = Issue::HookInsideLoop(
hook_info.clone(),
AnyLoopInfo::Loop(loop_info.clone()),
);
self.issues.push(issue);
}
Node::Closure(closure_info) => {
let issue = Issue::HookInsideClosure(
hook_info.clone(),
closure_info.clone(),
);
self.issues.push(issue);
}
Node::Async(async_info) => {
let issue =
Issue::HookInsideAsync(hook_info.clone(), async_info.clone());
self.issues.push(issue);
}
Node::ComponentFn(_) | Node::HookFn(_) | Node::OtherFn(_) => {
container_fn = Some(node.clone());
break;
}
}
}
if let Some(Node::OtherFn(_)) = container_fn {
let issue = Issue::HookOutsideComponent(hook_info);
self.issues.push(issue);
}
}
}
}
syn::visit::visit_expr_call(self, i);
}
fn visit_item_fn(&mut self, i: &'ast syn::ItemFn) {
let (name, name_span) = fn_name_and_name_span(i);
if is_component_fn(i) {
self.context.push(Node::ComponentFn(ComponentInfo::new(
i.span().into(),
name,
name_span,
)));
} else if is_hook_ident(&i.sig.ident) {
self.context.push(Node::HookFn(HookInfo::new(
i.span().into(),
i.sig.ident.span().into(),
name,
)));
} else {
self.context
.push(Node::OtherFn(FnInfo::new(i.span().into(), name, name_span)));
}
syn::visit::visit_item_fn(self, i);
self.context.pop();
}
fn visit_local(&mut self, i: &'ast syn::Local) {
if let Some(body) = get_closure_hook_body(i) {
// if the closure is a hook, we only visit the body of the closure.
// this prevents adding a ClosureInfo node to the context
syn::visit::visit_expr(self, body);
} else {
// otherwise visit the whole local
syn::visit::visit_local(self, i);
}
}
fn visit_expr_if(&mut self, i: &'ast syn::ExprIf) {
self.context.push(Node::If(IfInfo::new(
i.span().into(),
i.if_token
.span()
.join(i.cond.span())
.unwrap_or_else(|| i.span())
.into(),
)));
// only visit the body and else branch, calling hooks inside the expression is not conditional
self.visit_block(&i.then_branch);
if let Some(it) = &i.else_branch {
self.visit_expr(&(it).1);
}
self.context.pop();
}
fn visit_expr_match(&mut self, i: &'ast syn::ExprMatch) {
self.context.push(Node::Match(MatchInfo::new(
i.span().into(),
i.match_token
.span()
.join(i.expr.span())
.unwrap_or_else(|| i.span())
.into(),
)));
// only visit the arms, calling hooks inside the expression is not conditional
for it in &i.arms {
self.visit_arm(it);
}
self.context.pop();
}
fn visit_expr_for_loop(&mut self, i: &'ast syn::ExprForLoop) {
self.context.push(Node::For(ForInfo::new(
i.span().into(),
i.for_token
.span()
.join(i.expr.span())
.unwrap_or_else(|| i.span())
.into(),
)));
syn::visit::visit_expr_for_loop(self, i);
self.context.pop();
}
fn visit_expr_while(&mut self, i: &'ast syn::ExprWhile) {
self.context.push(Node::While(WhileInfo::new(
i.span().into(),
i.while_token
.span()
.join(i.cond.span())
.unwrap_or_else(|| i.span())
.into(),
)));
syn::visit::visit_expr_while(self, i);
self.context.pop();
}
fn visit_expr_loop(&mut self, i: &'ast syn::ExprLoop) {
self.context
.push(Node::Loop(LoopInfo::new(i.span().into())));
syn::visit::visit_expr_loop(self, i);
self.context.pop();
}
fn visit_expr_closure(&mut self, i: &'ast syn::ExprClosure) {
self.context
.push(Node::Closure(ClosureInfo::new(i.span().into())));
syn::visit::visit_expr_closure(self, i);
self.context.pop();
}
fn visit_expr_async(&mut self, i: &'ast syn::ExprAsync) {
self.context
.push(Node::Async(AsyncInfo::new(i.span().into())));
syn::visit::visit_expr_async(self, i);
self.context.pop();
}
}
#[cfg(test)]
mod tests {
use crate::check::metadata::{
AnyLoopInfo, ClosureInfo, ConditionalInfo, ForInfo, HookInfo, IfInfo, LineColumn, LoopInfo,
MatchInfo, Span, WhileInfo,
};
use indoc::indoc;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_no_hooks() {
let contents = indoc! {r#"
fn App() -> Element {
rsx! {
p { "Hello World" }
}
}
"#};
let report = check_file("app.rs".into(), contents);
assert_eq!(report.issues, vec![]);
}
#[test]
fn test_hook_correctly_used_inside_component() {
let contents = indoc! {r#"
fn App() -> Element {
let count = use_signal(|| 0);
rsx! {
p { "Hello World: {count}" }
}
}
"#};
let report = check_file("app.rs".into(), contents);
assert_eq!(report.issues, vec![]);
}
#[test]
fn test_hook_correctly_used_inside_hook_fn() {
let contents = indoc! {r#"
fn use_thing() -> UseState<i32> {
use_signal(|| 0)
}
"#};
let report = check_file("use_thing.rs".into(), contents);
assert_eq!(report.issues, vec![]);
}
#[test]
fn test_hook_correctly_used_inside_hook_closure() {
let contents = indoc! {r#"
fn App() -> Element {
let use_thing = || {
use_signal(|| 0)
};
let count = use_thing();
rsx! {
p { "Hello World: {count}" }
}
}
"#};
let report = check_file("app.rs".into(), contents);
assert_eq!(report.issues, vec![]);
}
#[test]
fn test_conditional_hook_if() {
let contents = indoc! {r#"
fn App() -> Element {
if you_are_happy && you_know_it {
let something = use_signal(|| "hands");
println!("clap your {something}")
}
}
"#};
let report = check_file("app.rs".into(), contents);
assert_eq!(
report.issues,
vec![Issue::HookInsideConditional(
HookInfo::new(
Span::new_from_str(
r#"use_signal(|| "hands")"#,
LineColumn { line: 3, column: 24 },
),
Span::new_from_str(
r#"use_signal"#,
LineColumn { line: 3, column: 24 },
),
"use_signal".to_string()
),
ConditionalInfo::If(IfInfo::new(
Span::new_from_str(
"if you_are_happy && you_know_it {\n let something = use_signal(|| \"hands\");\n println!(\"clap your {something}\")\n }",
LineColumn { line: 2, column: 4 },
),
Span::new_from_str(
"if you_are_happy && you_know_it",
LineColumn { line: 2, column: 4 }
)
))
)],
);
}
#[test]
fn test_conditional_hook_match() {
let contents = indoc! {r#"
fn App() -> Element {
match you_are_happy && you_know_it {
true => {
let something = use_signal(|| "hands");
println!("clap your {something}")
}
false => {}
}
}
"#};
let report = check_file("app.rs".into(), contents);
assert_eq!(
report.issues,
vec![Issue::HookInsideConditional(
HookInfo::new(
Span::new_from_str(r#"use_signal(|| "hands")"#, LineColumn { line: 4, column: 28 }),
Span::new_from_str(r#"use_signal"#, LineColumn { line: 4, column: 28 }),
"use_signal".to_string()
),
ConditionalInfo::Match(MatchInfo::new(
Span::new_from_str(
"match you_are_happy && you_know_it {\n true => {\n let something = use_signal(|| \"hands\");\n println!(\"clap your {something}\")\n }\n false => {}\n }",
LineColumn { line: 2, column: 4 },
),
Span::new_from_str("match you_are_happy && you_know_it", LineColumn { line: 2, column: 4 })
))
)]
);
}
#[test]
fn test_use_in_match_expr() {
let contents = indoc! {r#"
fn use_thing() {
match use_resource(|| async {}) {
Ok(_) => {}
Err(_) => {}
}
}
"#};
let report = check_file("app.rs".into(), contents);
assert_eq!(report.issues, vec![]);
}
#[test]
fn test_for_loop_hook() {
let contents = indoc! {r#"
fn App() -> Element {
for _name in &names {
let is_selected = use_signal(|| false);
println!("selected: {is_selected}");
}
}
"#};
let report = check_file("app.rs".into(), contents);
assert_eq!(
report.issues,
vec![Issue::HookInsideLoop(
HookInfo::new(
Span::new_from_str(
"use_signal(|| false)",
LineColumn { line: 3, column: 26 },
),
Span::new_from_str(
"use_signal",
LineColumn { line: 3, column: 26 },
),
"use_signal".to_string()
),
AnyLoopInfo::For(ForInfo::new(
Span::new_from_str(
"for _name in &names {\n let is_selected = use_signal(|| false);\n println!(\"selected: {is_selected}\");\n }",
LineColumn { line: 2, column: 4 },
),
Span::new_from_str(
"for _name in &names",
LineColumn { line: 2, column: 4 },
)
))
)]
);
}
#[test]
fn test_while_loop_hook() {
let contents = indoc! {r#"
fn App() -> Element {
while true {
let something = use_signal(|| "hands");
println!("clap your {something}")
}
}
"#};
let report = check_file("app.rs".into(), contents);
assert_eq!(
report.issues,
vec![Issue::HookInsideLoop(
HookInfo::new(
Span::new_from_str(
r#"use_signal(|| "hands")"#,
LineColumn { line: 3, column: 24 },
),
Span::new_from_str(
"use_signal",
LineColumn { line: 3, column: 24 },
),
"use_signal".to_string()
),
AnyLoopInfo::While(WhileInfo::new(
Span::new_from_str(
"while true {\n let something = use_signal(|| \"hands\");\n println!(\"clap your {something}\")\n }",
LineColumn { line: 2, column: 4 },
),
Span::new_from_str(
"while true",
LineColumn { line: 2, column: 4 },
)
))
)],
);
}
#[test]
fn test_loop_hook() {
let contents = indoc! {r#"
fn App() -> Element {
loop {
let something = use_signal(|| "hands");
println!("clap your {something}")
}
}
"#};
let report = check_file("app.rs".into(), contents);
assert_eq!(
report.issues,
vec![Issue::HookInsideLoop(
HookInfo::new(
Span::new_from_str(
r#"use_signal(|| "hands")"#,
LineColumn { line: 3, column: 24 },
),
Span::new_from_str(
"use_signal",
LineColumn { line: 3, column: 24 },
),
"use_signal".to_string()
),
AnyLoopInfo::Loop(LoopInfo::new(Span::new_from_str(
"loop {\n let something = use_signal(|| \"hands\");\n println!(\"clap your {something}\")\n }",
LineColumn { line: 2, column: 4 },
)))
)],
);
}
#[test]
fn test_conditional_okay() {
let contents = indoc! {r#"
fn App() -> Element {
let something = use_signal(|| "hands");
if you_are_happy && you_know_it {
println!("clap your {something}")
}
}
"#};
let report = check_file("app.rs".into(), contents);
assert_eq!(report.issues, vec![]);
}
#[test]
fn test_conditional_expr_okay() {
let contents = indoc! {r#"
fn App() -> Element {
if use_signal(|| true) {
println!("clap your {something}")
}
}
"#};
let report = check_file("app.rs".into(), contents);
assert_eq!(report.issues, vec![]);
}
#[test]
fn test_closure_hook() {
let contents = indoc! {r#"
fn App() -> Element {
let _a = || {
let b = use_signal(|| 0);
b.get()
};
}
"#};
let report = check_file("app.rs".into(), contents);
assert_eq!(
report.issues,
vec![Issue::HookInsideClosure(
HookInfo::new(
Span::new_from_str(
"use_signal(|| 0)",
LineColumn {
line: 3,
column: 16
},
),
Span::new_from_str(
"use_signal",
LineColumn {
line: 3,
column: 16
},
),
"use_signal".to_string()
),
ClosureInfo::new(Span::new_from_str(
"|| {\n let b = use_signal(|| 0);\n b.get()\n }",
LineColumn {
line: 2,
column: 13
},
))
)]
);
}
#[test]
fn test_hook_outside_component() {
let contents = indoc! {r#"
fn not_component_or_hook() {
let _a = use_signal(|| 0);
}
"#};
let report = check_file("app.rs".into(), contents);
assert_eq!(
report.issues,
vec![Issue::HookOutsideComponent(HookInfo::new(
Span::new_from_str(
"use_signal(|| 0)",
LineColumn {
line: 2,
column: 13
}
),
Span::new_from_str(
"use_signal",
LineColumn {
line: 2,
column: 13
},
),
"use_signal".to_string()
))]
);
}
#[test]
fn test_hook_inside_hook() {
let contents = indoc! {r#"
fn use_thing() {
let _a = use_signal(|| 0);
}
"#};
let report = check_file("app.rs".into(), contents);
assert_eq!(report.issues, vec![]);
}
#[test]
fn test_hook_inside_hook_initialization() {
let contents = indoc! {r#"
fn use_thing() {
let _a = use_signal(|| use_signal(|| 0));
}
"#};
let report = check_file("app.rs".into(), contents);
assert_eq!(
report.issues,
vec![Issue::HookInsideClosure(
HookInfo::new(
Span::new_from_str(
"use_signal(|| 0)",
LineColumn {
line: 2,
column: 27,
},
),
Span::new_from_str(
"use_signal",
LineColumn {
line: 2,
column: 27,
},
),
"use_signal".to_string()
),
ClosureInfo::new(Span::new_from_str(
"|| use_signal(|| 0)",
LineColumn {
line: 2,
column: 24,
},
))
),]
);
}
#[test]
fn test_hook_inside_hook_async_initialization() {
let contents = indoc! {r#"
fn use_thing() {
let _a = use_future(|| async move { use_signal(|| 0) });
}
"#};
let report = check_file("app.rs".into(), contents);
assert_eq!(
report.issues,
vec![
Issue::HookInsideAsync(
HookInfo::new(
Span::new_from_str(
"use_signal(|| 0)",
LineColumn {
line: 2,
column: 40,
},
),
Span::new_from_str(
"use_signal",
LineColumn {
line: 2,
column: 40,
},
),
"use_signal".to_string()
),
AsyncInfo::new(Span::new_from_str(
"async move { use_signal(|| 0) }",
LineColumn {
line: 2,
column: 27,
},
))
),
Issue::HookInsideClosure(
HookInfo::new(
Span::new_from_str(
"use_signal(|| 0)",
LineColumn {
line: 2,
column: 40,
},
),
Span::new_from_str(
"use_signal",
LineColumn {
line: 2,
column: 40,
},
),
"use_signal".to_string()
),
ClosureInfo::new(Span::new_from_str(
"|| async move { use_signal(|| 0) }",
LineColumn {
line: 2,
column: 24,
},
))
),
]
);
}
#[test]
fn test_hook_inside_spawn() {
let contents = indoc! {r#"
fn use_thing() {
let _a = spawn(async move { use_signal(|| 0) });
}
"#};
let report = check_file("app.rs".into(), contents);
assert_eq!(
report.issues,
vec![Issue::HookInsideAsync(
HookInfo::new(
Span::new_from_str(
"use_signal(|| 0)",
LineColumn {
line: 2,
column: 32,
},
),
Span::new_from_str(
"use_signal",
LineColumn {
line: 2,
column: 32,
},
),
"use_signal".to_string()
),
AsyncInfo::new(Span::new_from_str(
"async move { use_signal(|| 0) }",
LineColumn {
line: 2,
column: 19,
},
))
),]
);
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
use std::{fs::create_dir_all, path::PathBuf};
use crate::opt::process_file_to;
use crate::{extract_assets_from_file, Result, StructuredOutput};
use clap::Parser;
use dioxus_cli_opt::process_file_to;
use tracing::debug;
#[derive(Clone, Debug, Parser)]
@@ -28,7 +28,7 @@ impl BuildAssets {
destination_path.display(),
asset
);
process_file_to(asset.options(), &source_path, &destination_path)?;
process_file_to(asset.options(), &source_path, &destination_path, None)?;
}
Ok(StructuredOutput::Success)
+177 -162
View File
@@ -1,10 +1,10 @@
use crate::{AppBuilder, BuildArgs, BuildId, BuildMode, BuildRequest, BundleFormat};
use anyhow::{bail, Context};
use crate::{
AppBuilder, BuildArgs, BuildId, BuildMode, BuildRequest, BundleFormat, PackageType, Platform,
};
use anyhow::Context;
use path_absolutize::Absolutize;
use std::collections::HashMap;
use tauri_bundler::{BundleBinary, BundleSettings, PackageSettings, SettingsBuilder};
use walkdir::WalkDir;
use std::path::PathBuf;
use target_lexicon::Triple;
use super::*;
@@ -15,7 +15,7 @@ use super::*;
pub struct Bundle {
/// The package types to bundle
#[clap(long)]
pub package_types: Option<Vec<crate::PackageType>>,
pub package_types: Option<Vec<PackageType>>,
/// The directory in which the final bundle will be placed.
///
@@ -37,15 +37,14 @@ impl Bundle {
pub(crate) async fn bundle(mut self) -> Result<StructuredOutput> {
tracing::info!("Bundling project...");
self.force_ios_bundle_device_target_if_needed()?;
let BuildTargets { client, server } = self.args.into_targets().await?;
let mut server_artifacts = None;
let client_artifacts =
AppBuilder::started(&client, BuildMode::Base { run: false }, BuildId::PRIMARY)?
.finish_build()
.await?;
tracing::info!(path = ?client.root_dir(), "Client build completed successfully! 🚀");
let mut server_artifacts = None;
if let Some(server) = server.as_ref() {
// If the server is present, we need to build it as well
@@ -58,11 +57,13 @@ impl Bundle {
tracing::info!(path = ?client.root_dir(), "Server build completed successfully! 🚀");
}
// If we're building for iOS, we need to bundle the iOS bundle
if client.bundle == BundleFormat::Ios && self.package_types.is_none() {
self.package_types = Some(vec![crate::PackageType::IosBundle]);
// Fill platform-specific defaults for package types when omitted.
if self.package_types.is_none() {
self.package_types = Self::default_package_types(client.bundle);
}
Self::validate_package_types_for_bundle(client.bundle, self.package_types.as_deref())?;
let mut bundles = vec![];
// Copy the server over if it exists
@@ -70,31 +71,24 @@ impl Bundle {
bundles.push(server.main_exe());
}
// Create a list of bundles that we might need to copy
// Create a list of bundles that we might need to copy.
// Package-type based bundling is handled by the bundler module.
match client.bundle {
// By default, mac/win/linux work with tauri bundle
BundleFormat::MacOS | BundleFormat::Linux | BundleFormat::Windows => {
tracing::info!("Running desktop bundler...");
for bundle in Self::bundle_desktop(&client, &self.package_types)? {
// Desktop and Android platforms use package-type dispatch in the bundler module.
BundleFormat::MacOS
| BundleFormat::Ios
| BundleFormat::Linux
| BundleFormat::Windows
| BundleFormat::Android => {
tracing::info!("Running package bundler...");
for bundle in Self::bundle_with_package_types(&client, &self.package_types).await? {
bundles.extend(bundle.bundle_paths);
}
}
// Web/ios can just use their root_dir
// Web can use its root_dir directly.
BundleFormat::Web => bundles.push(client.root_dir()),
BundleFormat::Ios => {
tracing::warn!("iOS bundles are not currently codesigned! You will need to codesign the app before distributing.");
bundles.push(client.root_dir())
}
BundleFormat::Server => bundles.push(client.root_dir()),
BundleFormat::Android => {
let aab = client
.android_gradle_bundle()
.await
.context("Failed to run gradle bundleRelease")?;
bundles.push(aab);
}
};
// Copy the bundles to the output directory if one was specified
@@ -148,147 +142,45 @@ impl Bundle {
})
}
fn bundle_desktop(
async fn bundle_with_package_types(
build: &BuildRequest,
package_types: &Option<Vec<crate::PackageType>>,
) -> Result<Vec<tauri_bundler::Bundle>, Error> {
let krate = &build;
let exe = build.main_exe();
package_types: &Option<Vec<PackageType>>,
) -> Result<Vec<crate::bundler::Bundle>, Error> {
use anyhow::bail;
_ = std::fs::remove_dir_all(krate.bundle_dir(build.bundle));
if matches!(
build.bundle,
BundleFormat::MacOS | BundleFormat::Linux | BundleFormat::Windows
) {
let krate = &build;
let exe = build.main_exe();
let package = krate.package();
let mut name: PathBuf = krate.executable_name().into();
if cfg!(windows) {
name.set_extension("exe");
}
std::fs::create_dir_all(krate.bundle_dir(build.bundle))
.context("Failed to create bundle directory")?;
std::fs::copy(&exe, krate.bundle_dir(build.bundle).join(&name))
.with_context(|| "Failed to copy the output executable into the bundle directory")?;
_ = std::fs::remove_dir_all(krate.bundle_dir(build.bundle));
let binaries = vec![
// We use the name of the exe but it has to be in the same directory
BundleBinary::new(krate.executable_name().to_string(), true)
.set_src_path(Some(exe.display().to_string())),
];
let mut name: PathBuf = krate.executable_name().into();
if cfg!(windows) {
name.set_extension("exe");
}
std::fs::create_dir_all(krate.bundle_dir(build.bundle))
.context("Failed to create bundle directory")?;
std::fs::copy(&exe, krate.bundle_dir(build.bundle).join(&name)).with_context(|| {
"Failed to copy the output executable into the bundle directory"
})?;
let mut bundle_settings: BundleSettings = krate.config.bundle.clone().into();
// Check if required fields are provided instead of failing silently.
if bundle_settings.identifier.is_none() {
bail!("\n\nBundle identifier was not provided in `Dioxus.toml`. Add it as:\n\n[bundle]\nidentifier = \"com.mycompany\"\n\n");
}
if bundle_settings.publisher.is_none() {
bail!("\n\nBundle publisher was not provided in `Dioxus.toml`. Add it as:\n\n[bundle]\npublisher = \"MyCompany\"\n\n");
}
/// Resolve an icon path relative to the crate dir
fn canonicalize_icon_path(build: &BuildRequest, icon: &mut String) -> Result<(), Error> {
let icon_path = build
.crate_dir()
.join(&icon)
.canonicalize()
.with_context(|| format!("Failed to canonicalize path to icon {icon:?}"))?;
*icon = icon_path.to_string_lossy().to_string();
Ok(())
}
// Resolve bundle.icon relative to the crate dir
if let Some(icons) = bundle_settings.icon.as_mut() {
for icon in icons.iter_mut() {
canonicalize_icon_path(build, icon)?;
// Check if required fields are provided instead of failing silently.
if build.config.bundle.identifier.is_none() {
bail!("\n\nBundle identifier was not provided in `Dioxus.toml`. Add it as:\n\n[bundle]\nidentifier = \"com.mycompany\"\n\n");
}
if build.config.bundle.publisher.is_none() {
bail!("\n\nBundle publisher was not provided in `Dioxus.toml`. Add it as:\n\n[bundle]\npublisher = \"MyCompany\"\n\n");
}
}
#[allow(deprecated)]
if cfg!(windows) {
// Resolve bundle.windows.icon_path relative to the crate dir
let mut windows_icon_path = bundle_settings
.windows
.icon_path
.to_string_lossy()
.to_string();
canonicalize_icon_path(build, &mut windows_icon_path)?;
bundle_settings.windows.icon_path = PathBuf::from(&windows_icon_path);
let ctx = crate::bundler::BundleContext::new(build, package_types).await?;
let windows_icon_override = krate.config.bundle.windows.as_ref().map(|w| &w.icon_path);
if windows_icon_override.is_none() {
let icon_path = bundle_settings
.icon
.as_ref()
.and_then(|icons| icons.first());
tracing::debug!("Bundling project for {:?}", ctx.package_types());
if let Some(icon_path) = icon_path {
bundle_settings.icon = Some(vec![icon_path.into()]);
};
}
}
if bundle_settings.resources_map.is_none() {
bundle_settings.resources_map = Some(HashMap::new());
}
let asset_dir = build.asset_dir();
if asset_dir.exists() {
for entry in WalkDir::new(&asset_dir) {
let entry = entry.unwrap();
let path = entry.path();
if path.is_file() {
let old = path
.canonicalize()
.with_context(|| format!("Failed to canonicalize {entry:?}"))?;
let new =
PathBuf::from("assets").join(path.strip_prefix(&asset_dir).unwrap_or(path));
tracing::debug!("Bundled asset: {old:?} -> {new:?}");
bundle_settings
.resources_map
.as_mut()
.expect("to be set")
.insert(old.display().to_string(), new.display().to_string());
}
}
}
for resource_path in bundle_settings.resources.take().into_iter().flatten() {
bundle_settings
.resources_map
.as_mut()
.expect("to be set")
.insert(resource_path, "".to_string());
}
let mut settings = SettingsBuilder::new()
.project_out_directory(krate.bundle_dir(build.bundle))
.package_settings(PackageSettings {
product_name: krate.bundled_app_name(),
version: package.version.to_string(),
description: package.description.clone().unwrap_or_default(),
homepage: Some(package.homepage.clone().unwrap_or_default()),
authors: Some(package.authors.clone()),
default_run: Some(name.display().to_string()),
})
.log_level(log::Level::Debug)
.binaries(binaries)
.bundle_settings(bundle_settings);
if let Some(packages) = &package_types {
settings = settings.package_types(packages.iter().map(|p| (*p).into()).collect());
}
settings = settings.target(build.triple.to_string());
let settings = settings
.build()
.context("failed to bundle tauri bundle settings")?;
tracing::debug!("Bundling project with settings: {:#?}", settings);
if cfg!(target_os = "macos") {
std::env::set_var("CI", "true");
}
let bundles = tauri_bundler::bundle::bundle_project(&settings).inspect_err(|err| {
let bundles = ctx.bundle_project().await.inspect_err(|err| {
tracing::error!("Failed to bundle project: {:#?}", err);
if cfg!(target_os = "macos") {
tracing::error!("Make sure you have automation enabled in your terminal (https://github.com/tauri-apps/tauri/issues/3055#issuecomment-1624389208) and full disk access enabled for your terminal (https://github.com/tauri-apps/tauri/issues/3055#issuecomment-1624389208)");
@@ -297,4 +189,127 @@ impl Bundle {
Ok(bundles)
}
fn default_package_types(bundle: BundleFormat) -> Option<Vec<PackageType>> {
match bundle {
BundleFormat::Ios => Some(vec![crate::PackageType::Ipa]),
BundleFormat::Android => Some(vec![crate::PackageType::Aab]),
_ => None,
}
}
fn force_ios_bundle_device_target_if_needed(&mut self) -> Result<()> {
let client_args = self
.args
.client
.as_mut()
.map(|args| &mut args.build_arguments)
.unwrap_or(&mut self.args.shared.build_arguments);
if client_args.platform == Platform::Ios && client_args.target.is_none() {
client_args.target = Some("aarch64-apple-ios".parse::<Triple>()?);
}
Ok(())
}
fn validate_package_types_for_bundle(
bundle: BundleFormat,
package_types: Option<&[PackageType]>,
) -> Result<(), Error> {
let Some(package_types) = package_types else {
return Ok(());
};
let is_package_supported = |package_type: &PackageType| -> bool {
match bundle {
BundleFormat::MacOS => matches!(
package_type,
PackageType::MacOsBundle | PackageType::Dmg | PackageType::Updater
),
BundleFormat::Linux => matches!(
package_type,
PackageType::Deb
| PackageType::Rpm
| PackageType::AppImage
| PackageType::Updater
),
BundleFormat::Windows => {
matches!(
package_type,
PackageType::WindowsMsi | PackageType::Nsis | PackageType::Updater
)
}
BundleFormat::Android => {
matches!(package_type, PackageType::Apk | PackageType::Aab)
}
BundleFormat::Ios => matches!(package_type, PackageType::IosApp | PackageType::Ipa),
BundleFormat::Web | BundleFormat::Server => false,
}
};
if let Some(invalid) = package_types.iter().find(|pt| !is_package_supported(pt)) {
anyhow::bail!(
"Package type '{invalid:?}' is not supported for bundle format '{bundle}'."
);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::Bundle;
use crate::{
cli::CommandWithPlatformOverrides, BuildArgs, BundleFormat, PackageType, Platform,
};
#[test]
fn ios_bundle_defaults_to_ipa() {
assert_eq!(
Bundle::default_package_types(BundleFormat::Ios),
Some(vec![PackageType::Ipa])
);
}
#[test]
fn validates_ios_package_types() {
assert!(Bundle::validate_package_types_for_bundle(
BundleFormat::Ios,
Some(&[PackageType::IosApp, PackageType::Ipa]),
)
.is_ok());
assert!(Bundle::validate_package_types_for_bundle(
BundleFormat::Ios,
Some(&[PackageType::Dmg]),
)
.is_err());
}
#[test]
fn bundle_ios_defaults_to_device_target() {
let mut bundle = Bundle {
package_types: None,
out_dir: None,
args: CommandWithPlatformOverrides {
shared: BuildArgs {
build_arguments: crate::TargetArgs {
platform: Platform::Ios,
..Default::default()
},
..Default::default()
},
..Default::default()
},
};
bundle.force_ios_bundle_device_target_if_needed().unwrap();
assert_eq!(
bundle.args.shared.build_arguments.target,
Some("aarch64-apple-ios".parse().unwrap())
);
}
}
+1 -1
View File
@@ -84,7 +84,7 @@ async fn check_files_and_report(files_to_check: Vec<PathBuf>) -> Result<()> {
let res = tokio::spawn(async move {
tokio::fs::read_to_string(&_path)
.await
.map(|contents| dioxus_check::check_file(_path, &contents))
.map(|contents| crate::check::check_file(_path, &contents))
})
.await;
+60 -1
View File
@@ -63,7 +63,7 @@ where
{
fn augment_args(cmd: clap::Command) -> clap::Command {
T::augment_args(cmd).defer(|cmd| {
PlatformOverrides::<Self>::augment_subcommands(cmd.disable_help_subcommand(true))
PlatformOverrides::<T>::augment_subcommands(cmd.disable_help_subcommand(true))
})
}
@@ -182,3 +182,62 @@ where
unimplemented!()
}
}
#[cfg(test)]
mod tests {
use clap::Parser;
#[test]
fn cli_parses_bundle_with_package_types_without_recursing() {
let cli = crate::Cli::try_parse_from([
"dx",
"bundle",
"--platform",
"desktop",
"--package-types",
"msi",
])
.expect("bundle command should parse");
assert!(matches!(cli.action, crate::Commands::Bundle(_)));
}
#[test]
fn cli_parses_platform_override_subcommands_without_recursing() {
let cli = crate::Cli::try_parse_from([
"dx",
"bundle",
"--platform",
"desktop",
"@client",
"--release",
"@server",
"--platform",
"server",
])
.expect("platform override subcommands should parse");
let crate::Commands::Bundle(bundle) = cli.action else {
panic!("expected bundle command");
};
let client = bundle
.args
.client
.as_ref()
.expect("@client override should be captured");
let server = bundle
.args
.server
.as_ref()
.expect("@server override should be captured");
assert!(!bundle.args.shared.build_arguments.release);
assert!(client.build_arguments.release);
assert_eq!(
server.build_arguments.platform,
crate::Platform::Server,
"@server should override the platform"
);
}
}
+3 -5
View File
@@ -178,8 +178,6 @@ impl Print {
///
/// This function comes from shell-escape
fn escape_windows(s: Cow<str>) -> Cow<str> {
use std::iter::repeat;
let mut needs_escape = s.is_empty();
for ch in s.chars() {
match ch {
@@ -202,15 +200,15 @@ impl Print {
match chars.next() {
Some('"') => {
es.extend(repeat('\\').take(nslashes * 2 + 1));
es.extend(std::iter::repeat_n('\\', nslashes * 2 + 1));
es.push('"');
}
Some(c) => {
es.extend(repeat('\\').take(nslashes));
es.extend(std::iter::repeat_n('\\', nslashes));
es.push(c);
}
None => {
es.extend(repeat('\\').take(nslashes * 2));
es.extend(std::iter::repeat_n('\\', nslashes * 2));
break;
}
}
+6 -3
View File
@@ -82,10 +82,13 @@ impl RunArgs {
current,
total,
krate,
fresh,
} => {
tracing::debug!(
"[{bundle_format}] ({current}/{total}) Compiling {krate} ",
)
if !fresh {
tracing::debug!(
"[{bundle_format}] ({current}/{total}) Compiling {krate} ",
)
}
}
BuildStage::RunningBindgen => {
tracing::info!("[{bundle_format}] Running WASM bindgen")
+33 -8
View File
@@ -121,7 +121,7 @@ pub(crate) struct WixSettings {
pub(crate) fips_compliant: bool,
/// MSI installer version in the format `major.minor.patch.build` (build is optional).
///
/// Because a valid version is required for MSI installer, it will be derived from [`tauri_bundler::PackageSettings::version`] if this field is not set.
/// Because a valid version is required for MSI installer, it will be derived from the package version if this field is not set.
///
/// The first field is the major version and has a maximum value of 255. The second field is the minor version and has a maximum value of 255.
/// The third and fourth fields have a maximum value of 65,535.
@@ -132,10 +132,9 @@ pub(crate) struct WixSettings {
/// A GUID upgrade code for MSI installer. This code **_must stay the same across all of your updates_**,
/// otherwise, Windows will treat your update as a different app and your users will have duplicate versions of your app.
///
/// By default, tauri generates this code by generating a Uuid v5 using the string `<productName>.exe.app.x64` in the DNS namespace.
/// You can use Tauri's CLI to generate and print this code for you by running `tauri inspect wix-upgrade-code`.
/// By default, this code is generated as a Uuid v5 using the string `<productName>.exe.app.x64` in the DNS namespace.
///
/// It is recommended that you set this value in your tauri config file to avoid accidental changes in your upgrade code
/// It is recommended that you set this value in your Dioxus.toml to avoid accidental changes in your upgrade code
/// whenever you want to change your product name.
#[serde(default)]
#[schemars(with = "Option<String>")]
@@ -146,28 +145,39 @@ pub(crate) struct WixSettings {
pub(crate) struct MacOsSettings {
#[serde(default)]
pub(crate) bundle_version: Option<String>,
#[serde(default)]
pub(crate) frameworks: Option<Vec<String>>,
#[serde(default)]
pub(crate) minimum_system_version: Option<String>,
#[serde(default)]
pub(crate) license: Option<String>,
#[serde(default)]
pub(crate) exception_domain: Option<String>,
#[serde(default)]
pub(crate) signing_identity: Option<String>,
#[serde(default)]
pub(crate) provider_short_name: Option<String>,
#[serde(default)]
pub(crate) entitlements: Option<String>,
#[serde(default)]
pub(crate) info_plist_path: Option<PathBuf>,
#[serde(default)]
pub(crate) bundle_name: Option<String>,
/// List of custom files to add to the application bundle.
/// Maps the path in the Contents directory in the app to the path of the file to include (relative to the current working directory).
#[serde(default)]
pub files: HashMap<PathBuf, PathBuf>,
/// Preserve the hardened runtime version flag, see <https://developer.apple.com/documentation/security/hardened_runtime>
///
/// Settings this to `false` is useful when using an ad-hoc signature, making it less strict.
@@ -290,15 +300,19 @@ pub struct CustomSignCommandSettings {
pub args: Vec<String>,
}
#[derive(Clone, Copy, Debug, clap::ValueEnum, Serialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum, Serialize)]
pub(crate) enum PackageType {
/// The macOS application bundle (.app).
#[clap(name = "macos")]
MacOsBundle,
/// The iOS app bundle.
/// The raw iOS application bundle (.app).
#[clap(name = "ios")]
IosBundle,
IosApp,
/// The distributable iOS application archive (.ipa).
#[clap(name = "ipa")]
Ipa,
/// The Windows bundle (.msi).
#[clap(name = "msi")]
@@ -327,6 +341,14 @@ pub(crate) enum PackageType {
/// The Updater bundle (a patch of an existing app)
#[clap(name = "updater")]
Updater,
/// Android application package (.apk).
#[clap(name = "apk")]
Apk,
/// Android App Bundle (.aab).
#[clap(name = "aab")]
Aab,
}
impl FromStr for PackageType {
@@ -335,7 +357,8 @@ impl FromStr for PackageType {
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"macos" => Ok(PackageType::MacOsBundle),
"ios" => Ok(PackageType::IosBundle),
"ios" => Ok(PackageType::IosApp),
"ipa" => Ok(PackageType::Ipa),
"msi" => Ok(PackageType::WindowsMsi),
"nsis" => Ok(PackageType::Nsis),
"deb" => Ok(PackageType::Deb),
@@ -343,6 +366,8 @@ impl FromStr for PackageType {
"appimage" => Ok(PackageType::AppImage),
"dmg" => Ok(PackageType::Dmg),
"updater" => Ok(PackageType::Updater),
"apk" => Ok(PackageType::Apk),
"aab" => Ok(PackageType::Aab),
_ => Err(format!("{s} is not a valid package type")),
}
}
+8 -2
View File
@@ -1,2 +1,8 @@
// The file has been placed there by the build script.
include!(concat!(env!("OUT_DIR"), "/built.rs"));
pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const PKG_VERSION_MAJOR: &str = env!("CARGO_PKG_VERSION_MAJOR");
pub const PKG_VERSION_MINOR: &str = env!("CARGO_PKG_VERSION_MINOR");
pub const PKG_VERSION_PATCH: &str = env!("CARGO_PKG_VERSION_PATCH");
pub const PKG_VERSION_PRE: &str = env!("CARGO_PKG_VERSION_PRE");
pub const GIT_COMMIT_HASH: Option<&str> = option_env!("DIOXUS_CLI_GIT_SHA");
pub const GIT_COMMIT_HASH_SHORT: Option<&str> = option_env!("DIOXUS_CLI_GIT_SHA_SHORT");
+207
View File
@@ -0,0 +1,207 @@
//! esbuild binary download, caching, and invocation.
//!
//! esbuild is used for JavaScript bundling and minification, replacing the
//! previous SWC Rust library dependencies. The standalone Go binary is
//! downloaded from the npm registry and cached locally.
use crate::{CliSettings, Workspace};
use anyhow::{anyhow, Context};
use std::path::PathBuf;
/// Pinned esbuild version.
const ESBUILD_VERSION: &str = "0.27.3";
pub(crate) struct Esbuild;
impl Esbuild {
/// Ensure the esbuild binary is available, downloading if needed.
/// Returns the path to the binary.
pub(crate) async fn get_or_install() -> anyhow::Result<PathBuf> {
if CliSettings::prefer_no_downloads() {
which::which("esbuild")
.map_err(|_| anyhow!("esbuild not found on PATH and downloads are disabled"))
} else {
let path = Self::installed_bin_path();
if !path.exists() {
Self::install_from_npm().await?;
}
Ok(path)
}
}
/// Return the esbuild binary path if it's already been installed or is on PATH.
/// This is a sync check intended for use after `verify_tooling` has been called.
pub(crate) fn path_if_installed() -> Option<PathBuf> {
let path = Self::installed_bin_path();
if path.exists() {
Some(path)
} else {
which::which("esbuild").ok()
}
}
/// The path where we cache the esbuild binary.
fn installed_bin_path() -> PathBuf {
let dir = Workspace::dioxus_data_dir().join("esbuild");
let name = if cfg!(windows) {
format!("esbuild-{ESBUILD_VERSION}.exe")
} else {
format!("esbuild-{ESBUILD_VERSION}")
};
dir.join(name)
}
/// Download esbuild from the npm registry and extract the binary.
///
/// esbuild publishes platform-specific packages to npm as tar.gz archives.
/// Each archive contains `package/bin/esbuild[.exe]`.
async fn install_from_npm() -> anyhow::Result<()> {
let platform = Self::npm_platform_package()
.ok_or_else(|| anyhow!("No esbuild binary available for this platform"))?;
let url = format!(
"https://registry.npmjs.org/@esbuild/{platform}/-/{platform}-{ESBUILD_VERSION}.tgz"
);
tracing::info!("Installing esbuild v{ESBUILD_VERSION} from {url}...");
let bytes = reqwest::get(&url)
.await
.with_context(|| format!("Failed to download esbuild from {url}"))?
.bytes()
.await
.context("Failed to read esbuild download response")?;
// Extract `package/bin/esbuild[.exe]` from the tar.gz
let binary_data = Self::extract_binary_from_tgz(&bytes)?;
let binary_path = Self::installed_bin_path();
std::fs::create_dir_all(binary_path.parent().unwrap())
.context("Failed to create esbuild cache directory")?;
std::fs::write(&binary_path, &binary_data).context("Failed to write esbuild binary")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = binary_path.metadata()?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&binary_path, perms)?;
}
tracing::info!("esbuild v{ESBUILD_VERSION} installed successfully");
Ok(())
}
/// Extract the esbuild binary from an npm tar.gz package.
///
/// The binary is at `package/bin/esbuild` (or `package/bin/esbuild.exe` on Windows).
fn extract_binary_from_tgz(tgz_bytes: &[u8]) -> anyhow::Result<Vec<u8>> {
let bin_name = if cfg!(windows) {
"esbuild.exe"
} else {
"esbuild"
};
Self::extract_binary_named_from_tgz(tgz_bytes, bin_name)
}
fn extract_binary_named_from_tgz(tgz_bytes: &[u8], bin_name: &str) -> anyhow::Result<Vec<u8>> {
use flate2::read::GzDecoder;
use std::io::Read;
use tar::Archive;
let decoder = GzDecoder::new(tgz_bytes);
let mut archive = Archive::new(decoder);
let expected_paths = [
format!("package/bin/{bin_name}"),
format!("package/{bin_name}"),
];
let mut archive_entries = Vec::new();
for entry in archive.entries().context("Failed to read tar entries")? {
let mut entry = entry.context("Failed to read tar entry")?;
let path = entry.path().context("Failed to read entry path")?;
let path_string = path.to_string_lossy().replace('\\', "/");
if expected_paths.contains(&path_string) {
let mut data = Vec::new();
entry
.read_to_end(&mut data)
.context("Failed to read esbuild binary from archive")?;
return Ok(data);
}
archive_entries.push(path_string);
}
archive_entries.sort();
archive_entries.truncate(10);
anyhow::bail!(
"esbuild binary not found in archive (expected one of {}). Found entries: {}",
expected_paths.join(", "),
archive_entries.join(", ")
);
}
/// Map the host platform to the npm package name for esbuild.
///
/// esbuild publishes per-platform packages under `@esbuild/{name}`:
/// - darwin-arm64, darwin-x64
/// - linux-x64, linux-arm64
/// - win32-x64, win32-arm64
fn npm_platform_package() -> Option<&'static str> {
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
Some("darwin-arm64")
} else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
Some("darwin-x64")
} else if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
Some("linux-x64")
} else if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
Some("linux-arm64")
} else if cfg!(all(target_os = "windows", target_arch = "x86_64")) {
Some("win32-x64")
} else if cfg!(all(target_os = "windows", target_arch = "aarch64")) {
Some("win32-arm64")
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::Esbuild;
use flate2::{write::GzEncoder, Compression};
fn tgz_with_entries(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
{
let mut tar = tar::Builder::new(&mut encoder);
for (path, contents) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(contents.len() as u64);
header.set_mode(0o644);
header.set_cksum();
tar.append_data(&mut header, *path, *contents).unwrap();
}
tar.finish().unwrap();
}
encoder.finish().unwrap()
}
#[test]
fn extracts_binary_from_bin_layout() {
let tgz = tgz_with_entries(&[("package/bin/esbuild.exe", b"windows-binary")]);
let extracted = Esbuild::extract_binary_named_from_tgz(&tgz, "esbuild.exe").unwrap();
assert_eq!(extracted, b"windows-binary");
}
#[test]
fn extracts_binary_from_package_root_layout() {
let tgz = tgz_with_entries(&[("package/esbuild.exe", b"windows-binary")]);
let extracted = Esbuild::extract_binary_named_from_tgz(&tgz, "esbuild.exe").unwrap();
assert_eq!(extracted, b"windows-binary");
}
}
+78 -17
View File
@@ -42,14 +42,14 @@ use futures_util::FutureExt;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{any::Any, io::Read, str::FromStr, sync::Arc, time::SystemTime};
use std::{any::Any, io::Read, pin::Pin, str::FromStr, sync::Arc, time::SystemTime};
use std::{borrow::Cow, sync::OnceLock};
use std::{
collections::HashMap,
env,
fmt::{Debug, Display, Write as _},
sync::atomic::{AtomicBool, Ordering},
time::Instant,
time::{Duration, Instant},
};
use std::{future::Future, panic::AssertUnwindSafe};
use tracing::{field::Visit, Level, Subscriber};
@@ -139,10 +139,9 @@ impl TraceController {
///
/// We pass the TraceController around the CLI in a few places, namely the serve command so the TUI
/// can access things like the logs.
pub async fn main<F>(run_app: impl FnOnce(Commands, Self) -> F) -> StructuredOutput
where
F: Future<Output = Result<StructuredOutput>>,
{
pub async fn main(
run_app: impl FnOnce(Commands, Self) -> Pin<Box<dyn Future<Output = Result<StructuredOutput>>>>,
) -> StructuredOutput {
let args = Cli::parse();
let tui_active = Arc::new(AtomicBool::new(false));
let is_serve_cmd = matches!(args.action, Commands::Serve(_));
@@ -178,7 +177,7 @@ impl TraceController {
// We complete filter out a few fields that are not relevant to the user, like `dx_src` and `json`
let fmt_layer = tracing_subscriber::fmt::layer()
.with_target(args.verbosity.verbose)
.with_target(false)
.fmt_fields(
format::debug_fn(move |writer, field, value| {
if field.name() == "json" && !args.verbosity.json_output {
@@ -521,7 +520,7 @@ impl TraceController {
error_handled,
} = event;
let mut ph_event = posthog_rs::Event::new(action, distinct_id.to_string());
let mut ph_event = PosthogEvent::new(action, distinct_id.to_string());
// The reporter's fields
_ = ph_event.insert_prop("is_ci", CliSettings::is_ci());
@@ -649,7 +648,7 @@ impl TraceController {
reset_cursor();
// re-emit any remaining messages in case they're useful.
while let Ok(Some(msg)) = self.tui_rx.lock().await.try_next() {
while let Ok(msg) = self.tui_rx.lock().await.try_recv() {
let content = match msg.content {
TraceContent::Text(text) => text,
TraceContent::Cargo(msg) => msg.message.to_string(),
@@ -729,7 +728,7 @@ impl TraceController {
true => {
let mut msgs = self.telemetry_rx.lock().await;
let request_body = std::iter::from_fn(|| msgs.try_next().ok().flatten())
let request_body = std::iter::from_fn(|| msgs.try_recv().ok())
.filter_map(|msg| serde_json::to_value(msg).ok())
.collect::<Vec<_>>();
@@ -740,8 +739,7 @@ impl TraceController {
false => {
let mut msgs = self.telemetry_rx.lock().await;
let msg_list =
std::iter::from_fn(|| msgs.try_next().ok().flatten()).collect::<Vec<_>>();
let msg_list = std::iter::from_fn(|| msgs.try_recv().ok()).collect::<Vec<_>>();
if !msg_list.is_empty() {
let dest = Workspace::dioxus_data_dir()
@@ -1336,10 +1334,43 @@ impl Default for PrettyUptime {
impl FormatTime for PrettyUptime {
fn format_time(&self, w: &mut Writer<'_>) -> std::fmt::Result {
let e = self.epoch.elapsed();
write!(w, "{:4}.{:2}s", e.as_secs(), e.subsec_millis())
Self::write_elapsed(self.epoch.elapsed(), w)
}
}
impl PrettyUptime {
fn write_elapsed(elapsed: Duration, mut w: impl std::fmt::Write) -> std::fmt::Result {
write!(
w,
"{:3}.{:02}s",
elapsed.as_secs(),
elapsed.subsec_millis() / 10
)
}
}
#[cfg(test)]
mod pretty_uptime_tests {
use super::PrettyUptime;
use std::time::Duration;
#[test]
fn pretty_uptime_pads_centiseconds_to_keep_a_stable_width() {
let cases = [
(Duration::from_millis(92_993), " 92.99s"),
(Duration::from_millis(93_085), " 93.08s"),
(Duration::from_millis(93_130), " 93.13s"),
(Duration::from_millis(999_999), "999.99s"),
];
for (elapsed, expected) in cases {
let mut rendered = String::new();
PrettyUptime::write_elapsed(elapsed, &mut rendered).unwrap();
assert_eq!(rendered, expected);
}
}
}
/// Run the provided future and wait for it to complete, handling Ctrl-C gracefully.
///
/// If ctrl-c is pressed twice, it exits immediately, skipping our telemetry flush.
@@ -1372,7 +1403,37 @@ async fn run_with_ctrl_c(
/// We only store non-pii information in telemetry to track issues and performance
/// across the CLI.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Reporter {
pub session_id: Uuid,
pub distinct_id: Uuid,
struct Reporter {
session_id: Uuid,
distinct_id: Uuid,
}
/// A minimal PostHog event, replacing the `posthog-rs` crate dependency.
/// See <https://posthog.com/docs/api/capture>
#[derive(Serialize, Debug)]
struct PosthogEvent {
event: String,
#[serde(rename = "$distinct_id")]
distinct_id: String,
properties: std::collections::HashMap<String, serde_json::Value>,
}
impl PosthogEvent {
fn new(event: impl Into<String>, distinct_id: impl Into<String>) -> Self {
Self {
event: event.into(),
distinct_id: distinct_id.into(),
properties: std::collections::HashMap::new(),
}
}
fn insert_prop<V: Serialize>(
&mut self,
key: impl Into<String>,
value: V,
) -> Result<(), serde_json::Error> {
self.properties
.insert(key.into(), serde_json::to_value(value)?);
Ok(())
}
}
+25 -20
View File
@@ -5,15 +5,18 @@
#![allow(clippy::doc_overindented_list_items)]
mod build;
mod bundle_utils;
mod bundler;
mod cargo_toml;
mod check;
mod cli;
mod config;
mod devcfg;
mod dx_build_info;
mod error;
mod esbuild;
mod fastfs;
mod logging;
mod opt;
mod platform;
mod rustcwrapper;
mod serve;
@@ -53,25 +56,27 @@ async fn main() -> ExitCode {
}
// Run under the tracing collector so we can capture errors/panics.
let result = TraceController::main(|args, tracer| async move {
match args {
Commands::Serve(opts) => opts.serve(&tracer).await,
Commands::Translate(opts) => opts.translate(),
Commands::New(opts) => opts.create().await,
Commands::Init(opts) => opts.init().await,
Commands::Config(opts) => opts.config().await,
Commands::Autoformat(opts) => opts.autoformat().await,
Commands::Check(opts) => opts.check().await,
Commands::Build(opts) => opts.build().await,
Commands::Bundle(opts) => opts.bundle().await,
Commands::Run(opts) => opts.run().await,
Commands::SelfUpdate(opts) => opts.self_update().await,
Commands::Tools(BuildTools::BuildAssets(opts)) => opts.run().await,
Commands::Tools(BuildTools::HotpatchTip(opts)) => opts.run().await,
Commands::Doctor(opts) => opts.doctor().await,
Commands::Print(opts) => opts.print().await,
Commands::Components(opts) => opts.run().await,
}
let result = TraceController::main(|args, tracer| {
Box::pin(async move {
match args {
Commands::Serve(opts) => opts.serve(&tracer).await,
Commands::Translate(opts) => opts.translate(),
Commands::New(opts) => opts.create().await,
Commands::Init(opts) => opts.init().await,
Commands::Config(opts) => opts.config().await,
Commands::Autoformat(opts) => opts.autoformat().await,
Commands::Check(opts) => opts.check().await,
Commands::Build(opts) => opts.build().await,
Commands::Bundle(opts) => opts.bundle().await,
Commands::Run(opts) => opts.run().await,
Commands::SelfUpdate(opts) => opts.self_update().await,
Commands::Tools(BuildTools::BuildAssets(opts)) => opts.run().await,
Commands::Tools(BuildTools::HotpatchTip(opts)) => opts.run().await,
Commands::Doctor(opts) => opts.doctor().await,
Commands::Print(opts) => opts.print().await,
Commands::Components(opts) => opts.run().await,
}
})
});
// Print the structured output in JSON format for third-party tools to consume.
+223
View File
@@ -0,0 +1,223 @@
use std::{hash::Hasher, path::Path};
use anyhow::{anyhow, Context};
use codemap::SpanLoc;
use grass::OutputStyle;
use lightningcss::{
printer::PrinterOptions,
stylesheet::{MinifyOptions, ParserOptions, StyleSheet},
targets::{Browsers, Targets},
};
use manganis_core::{create_module_hash, transform_css, CssAssetOptions, CssModuleAssetOptions};
pub(crate) fn process_css(
css_options: &CssAssetOptions,
source: &Path,
output_path: &Path,
) -> anyhow::Result<()> {
let css = std::fs::read_to_string(source)?;
let css = if css_options.minified() {
// Try to minify the css. If we fail, log the error and use the unminified css
match minify_css(&css) {
Ok(minified) => minified,
Err(err) => {
tracing::error!(
"Failed to minify css; Falling back to unminified css. Error: {}",
err
);
css
}
}
} else {
css
};
std::fs::write(output_path, css).with_context(|| {
format!(
"Failed to write css to output location: {}",
output_path.display()
)
})?;
Ok(())
}
pub(crate) fn process_css_module(
css_options: &CssModuleAssetOptions,
source: &Path,
output_path: &Path,
) -> anyhow::Result<()> {
let css = std::fs::read_to_string(source)?;
// Collect the file hash name.
let mut src_name = source
.file_name()
.and_then(|x| x.to_str())
.ok_or_else(|| {
anyhow!(
"Failed to read name of css module file `{}`.",
source.display()
)
})?
.strip_suffix(".css")
.ok_or_else(|| {
anyhow!(
"Css module file `{}` should end with a `.css` suffix.",
source.display(),
)
})?
.to_string();
src_name.push('-');
let hash = create_module_hash(source);
let css = transform_css(css.as_str(), hash.as_str()).map_err(|error| {
anyhow!(
"Invalid css for file `{}`\nError:\n{}",
source.display(),
error
)
})?;
// Minify CSS
let css = if css_options.minified() {
// Try to minify the css. If we fail, log the error and use the unminified css
match minify_css(&css) {
Ok(minified) => minified,
Err(err) => {
tracing::error!(
"Failed to minify css module; Falling back to unminified css. Error: {}",
err
);
css
}
}
} else {
css
};
std::fs::write(output_path, css).with_context(|| {
format!(
"Failed to write css module to output location: {}",
output_path.display()
)
})?;
Ok(())
}
pub(crate) fn minify_css(css: &str) -> anyhow::Result<String> {
let options = ParserOptions {
error_recovery: true,
..Default::default()
};
let mut stylesheet = StyleSheet::parse(css, options).map_err(|err| err.into_owned())?;
// We load the browser list from the standard browser list file or use the browserslist default if we don't find any
// settings. Without the browser lists default, lightningcss will default to supporting only the newest versions of
// browsers.
let browsers_list = match Browsers::load_browserslist()? {
Some(browsers) => Some(browsers),
None => {
Browsers::from_browserslist(["defaults"]).expect("borwserslists should have defaults")
}
};
let targets = Targets {
browsers: browsers_list,
..Default::default()
};
stylesheet.minify(MinifyOptions {
targets,
..Default::default()
})?;
let printer = PrinterOptions {
targets,
minify: true,
..Default::default()
};
let res = stylesheet.to_css(printer)?;
Ok(res.code)
}
/// Compile scss with grass
pub(crate) fn compile_scss(
scss_options: &CssAssetOptions,
source: &Path,
) -> anyhow::Result<String> {
let style = match scss_options.minified() {
true => OutputStyle::Compressed,
false => OutputStyle::Expanded,
};
let options = grass::Options::default()
.style(style)
.quiet(false)
.logger(&ScssLogger {});
let css = grass::from_path(source, &options)
.with_context(|| format!("Failed to compile scss file: {}", source.display()))?;
Ok(css)
}
/// Process an scss/sass file into css.
pub(crate) fn process_scss(
scss_options: &CssAssetOptions,
source: &Path,
output_path: &Path,
) -> anyhow::Result<()> {
let css = compile_scss(scss_options, source)?;
let minified = minify_css(&css)?;
std::fs::write(output_path, minified).with_context(|| {
format!(
"Failed to write css to output location: {}",
output_path.display()
)
})?;
Ok(())
}
/// Logger for Grass that re-uses their StdLogger formatting but with tracing.
#[derive(Debug)]
struct ScssLogger {}
impl grass::Logger for ScssLogger {
fn debug(&self, location: SpanLoc, message: &str) {
tracing::debug!(
"{}:{} DEBUG: {}",
location.file.name(),
location.begin.line + 1,
message
);
}
fn warn(&self, location: SpanLoc, message: &str) {
tracing::warn!(
"Warning: {}\n ./{}:{}:{}",
message,
location.file.name(),
location.begin.line + 1,
location.begin.column + 1
);
}
}
/// Hash the inputs to the scss file
pub(crate) fn hash_scss(
scss_options: &CssAssetOptions,
source: &Path,
hasher: &mut impl Hasher,
) -> anyhow::Result<()> {
// Grass doesn't expose the ast for us to traverse the imports in the file. Instead of parsing scss ourselves
// we just hash the expanded version of the file for now
let css = compile_scss(scss_options, source)?;
// Hash the compiled css
hasher.write(css.as_bytes());
Ok(())
}
+154
View File
@@ -0,0 +1,154 @@
use anyhow::Context;
use manganis::{AssetOptions, CssModuleAssetOptions, FolderAssetOptions};
use manganis_core::{AssetVariant, CssAssetOptions, ImageAssetOptions, JsAssetOptions};
use std::path::Path;
use crate::opt::css::{process_css_module, process_scss};
use super::{
css::process_css, folder::process_folder, image::process_image, js::process_js,
json::process_json,
};
/// Process a specific file asset with the given options reading from the source and writing to the output path
pub(crate) fn process_file_to(
options: &AssetOptions,
source: &Path,
output_path: &Path,
esbuild_path: Option<&Path>,
) -> anyhow::Result<()> {
process_file_to_with_options(options, source, output_path, false, esbuild_path)
}
/// Process a specific file asset with additional options
pub(crate) fn process_file_to_with_options(
options: &AssetOptions,
source: &Path,
output_path: &Path,
in_folder: bool,
esbuild_path: Option<&Path>,
) -> anyhow::Result<()> {
// If the file already exists and this is a hashed asset, then we must have a file
// with the same hash already. The hash has the file contents and options, so if we
// find a file with the same hash, we probably already created this file in the past
if output_path.exists() && options.hash_suffix() {
return Ok(());
}
if let Some(parent) = output_path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent).context("Failed to create directory")?;
}
}
// Processing can be slow. Write to a temporary file first and then rename it to the final output path. If everything
// goes well. Without this, the user could quit in the middle of processing and the file will look complete to the
// caching system even though it is empty.
let temp_path = output_path.with_file_name(format!(
"partial.{}",
output_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
));
let resolved_options = resolve_asset_options(source, options.variant());
match &resolved_options {
ResolvedAssetType::Css(options) => {
process_css(options, source, &temp_path)?;
}
ResolvedAssetType::CssModule(options) => {
process_css_module(options, source, &temp_path)?;
}
ResolvedAssetType::Scss(options) => {
process_scss(options, source, &temp_path)?;
}
ResolvedAssetType::Js(options) => {
process_js(options, source, &temp_path, !in_folder, esbuild_path)?;
}
ResolvedAssetType::Image(options) => {
process_image(options, source, &temp_path)?;
}
ResolvedAssetType::Json => {
process_json(source, &temp_path)?;
}
ResolvedAssetType::Folder(_) => {
process_folder(source, &temp_path, esbuild_path)?;
}
ResolvedAssetType::File => {
let source_file = std::fs::File::open(source)?;
let mut reader = std::io::BufReader::new(source_file);
let output_file = std::fs::File::create(&temp_path)?;
let mut writer = std::io::BufWriter::new(output_file);
std::io::copy(&mut reader, &mut writer).with_context(|| {
format!(
"Failed to write file to output location: {}",
temp_path.display()
)
})?;
}
}
// Remove the existing output file if it exists
if output_path.exists() {
if output_path.is_file() {
std::fs::remove_file(output_path).context("Failed to remove previous output file")?;
} else if output_path.is_dir() {
std::fs::remove_dir_all(output_path)
.context("Failed to remove previous output file")?;
}
}
// If everything was successful, rename the temp file to the final output path
std::fs::rename(temp_path, output_path)
.with_context(|| format!("Failed to rename output file to: {}", output_path.display()))?;
Ok(())
}
pub(crate) enum ResolvedAssetType {
/// An image asset
Image(ImageAssetOptions),
/// A css asset
Css(CssAssetOptions),
/// A css module asset
CssModule(CssModuleAssetOptions),
/// A SCSS asset
Scss(CssAssetOptions),
/// A javascript asset
Js(JsAssetOptions),
/// A json asset
Json,
/// A folder asset
Folder(FolderAssetOptions),
/// A generic file
File,
}
pub(crate) fn resolve_asset_options(source: &Path, options: &AssetVariant) -> ResolvedAssetType {
match options {
AssetVariant::Image(image) => ResolvedAssetType::Image(*image),
AssetVariant::Css(css) => ResolvedAssetType::Css(*css),
AssetVariant::CssModule(css) => ResolvedAssetType::CssModule(*css),
AssetVariant::Js(js) => ResolvedAssetType::Js(*js),
AssetVariant::Folder(folder) => ResolvedAssetType::Folder(*folder),
AssetVariant::Unknown => resolve_unknown_asset_options(source),
_ => {
tracing::warn!("Unknown asset options... you may need to update the Dioxus CLI. Defaulting to a generic file: {:?}", options);
resolve_unknown_asset_options(source)
}
}
}
fn resolve_unknown_asset_options(source: &Path) -> ResolvedAssetType {
match source.extension().map(|e| e.to_string_lossy()).as_deref() {
Some("scss" | "sass") => ResolvedAssetType::Scss(CssAssetOptions::default()),
Some("css") => ResolvedAssetType::Css(CssAssetOptions::default()),
Some("js") => ResolvedAssetType::Js(JsAssetOptions::default()),
Some("json") => ResolvedAssetType::Json,
Some("jpg" | "jpeg" | "png" | "webp" | "avif") => {
ResolvedAssetType::Image(ImageAssetOptions::default())
}
_ if source.is_dir() => ResolvedAssetType::Folder(FolderAssetOptions::default()),
_ => ResolvedAssetType::File,
}
}
+51
View File
@@ -0,0 +1,51 @@
use std::path::Path;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use crate::opt::file::process_file_to_with_options;
/// Process a folder, optimizing and copying all assets into the output folder
pub fn process_folder(
source: &Path,
output_folder: &Path,
esbuild_path: Option<&Path>,
) -> anyhow::Result<()> {
// Create the folder
std::fs::create_dir_all(output_folder)?;
// Then optimize children
let files: Vec<_> = std::fs::read_dir(source)
.into_iter()
.flatten()
.flatten()
.collect();
files.par_iter().try_for_each(|file| {
let file = file.path();
let metadata = file.metadata()?;
let output_path = output_folder.join(file.strip_prefix(source)?);
if metadata.is_dir() {
process_folder(&file, &output_path, esbuild_path)
} else {
process_file_minimal(&file, &output_path, esbuild_path)
}
})?;
Ok(())
}
/// Optimize a file without changing any of its contents significantly (e.g. by changing the extension)
fn process_file_minimal(
input_path: &Path,
output_path: &Path,
esbuild_path: Option<&Path>,
) -> anyhow::Result<()> {
process_file_to_with_options(
&manganis_core::AssetOptions::builder().into_asset_options(),
input_path,
output_path,
true,
esbuild_path,
)?;
Ok(())
}
+184
View File
@@ -0,0 +1,184 @@
//! Utilities for creating hashed paths to assets in Manganis. This module defines [`AssetHash`] which is used to create a hashed path to an asset in both the CLI and the macro.
use std::{
hash::{Hash, Hasher},
io::Read,
path::{Path, PathBuf},
};
use crate::opt::{
css::hash_scss,
file::{resolve_asset_options, ResolvedAssetType},
js::hash_js,
};
use manganis::{AssetOptions, BundledAsset};
/// The opaque hash type manganis uses to identify assets. Each time an asset or asset options change, this hash will
/// change. This hash is included in the URL of the bundled asset for cache busting.
struct AssetHash {
/// We use a wrapper type here to hide the exact size of the hash so we can switch to a sha hash in a minor version bump
hash: [u8; 8],
}
impl AssetHash {
/// Create a new asset hash
const fn new(hash: u64) -> Self {
Self {
hash: hash.to_le_bytes(),
}
}
/// Get the hash bytes
pub const fn bytes(&self) -> &[u8] {
&self.hash
}
/// Create a new asset hash for a file. The input file to this function should be fully resolved
pub fn hash_file_contents(
options: &AssetOptions,
file_path: impl AsRef<Path>,
) -> anyhow::Result<AssetHash> {
hash_file(options, file_path.as_ref())
}
}
/// Process a specific file asset with the given options reading from the source and writing to the output path
fn hash_file(options: &AssetOptions, source: &Path) -> anyhow::Result<AssetHash> {
// Create a hasher
let mut hash = std::collections::hash_map::DefaultHasher::new();
options.hash(&mut hash);
// Hash the version of the CLI
hash.write(crate::dx_build_info::PKG_VERSION.as_bytes());
hash_file_with_options(options, source, &mut hash, false)?;
let hash = hash.finish();
Ok(AssetHash::new(hash))
}
/// Process a specific file asset with additional options
pub(crate) fn hash_file_with_options(
options: &AssetOptions,
source: &Path,
hasher: &mut impl Hasher,
in_folder: bool,
) -> anyhow::Result<()> {
let resolved_options = resolve_asset_options(source, options.variant());
match &resolved_options {
// Scss and JS can import files during the bundling process. We need to hash
// both the files themselves and any imports they have
ResolvedAssetType::Scss(options) => {
hash_scss(options, source, hasher)?;
}
ResolvedAssetType::Js(options) => {
hash_js(options, source, hasher, !in_folder)?;
}
// Otherwise, we can just hash the file contents
ResolvedAssetType::CssModule(_)
| ResolvedAssetType::Css(_)
| ResolvedAssetType::Image(_)
| ResolvedAssetType::Json
| ResolvedAssetType::File => {
hash_file_contents(source, hasher)?;
}
// Or the folder contents recursively
ResolvedAssetType::Folder(_) => {
for file in std::fs::read_dir(source)?.flatten() {
let path = file.path();
hash_file_with_options(
// We can't reuse the options here since they contain the source variant which no
// longer applies to the nested files
//
// We don't hash nested files either since we assume the parent here is already being hashed
// (or being opted out of hashing)
&AssetOptions::builder()
.with_hash_suffix(false)
.into_asset_options(),
&path,
hasher,
true,
)?;
}
}
}
Ok(())
}
pub(crate) fn hash_file_contents(source: &Path, hasher: &mut impl Hasher) -> anyhow::Result<()> {
// Otherwise, open the file to get its contents
let mut file = std::fs::File::open(source)?;
// We add a hash to the end of the file so it is invalidated when the bundled version of the file changes
// The hash includes the file contents, the options, and the version of manganis. From the macro, we just
// know the file contents, so we only include that hash
let mut buffer = [0; 8192];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.write(&buffer[..read]);
}
Ok(())
}
/// Add a hash to the asset, or log an error if it fails
pub(crate) fn add_hash_to_asset(asset: &mut BundledAsset) {
let source = asset.absolute_source_path();
match AssetHash::hash_file_contents(asset.options(), source) {
Ok(hash) => {
let options = *asset.options();
// Set the bundled path to the source path with the hash appended before the extension
let source_path = PathBuf::from(source);
let Some(file_name) = source_path.file_name() else {
tracing::error!("Failed to get file name from path: {source}");
return;
};
// The output extension path is the extension set by the options
// or the extension of the source file if we don't recognize the file
let mut ext = asset.options().extension().map(Into::into).or_else(|| {
source_path
.extension()
.map(|ext| ext.to_string_lossy().to_string())
});
// Rewrite scss as css
if let Some("scss" | "sass") = ext.as_deref() {
ext = Some("css".to_string());
}
let hash = hash.bytes();
let hash = hash
.iter()
.map(|byte| format!("{byte:x}"))
.collect::<String>();
let file_stem = source_path.file_stem().unwrap_or(file_name);
let mut bundled_path = if asset.options().hash_suffix() {
PathBuf::from(format!("{}-dxh{hash}", file_stem.to_string_lossy()))
} else {
PathBuf::from(file_stem)
};
if let Some(ext) = ext {
// Push the extension to the bundled path. There may be multiple extensions (e.g. .js.map)
// with one left after the file_stem is extracted above so we need to push the extension
// instead of setting it
bundled_path.as_mut_os_string().push(format!(".{ext}"));
}
let bundled_path = bundled_path.to_string_lossy().to_string();
*asset = BundledAsset::new(source, &bundled_path, options);
}
Err(err) => {
tracing::error!("Failed to hash asset {source}: {err}");
}
}
}
+136
View File
@@ -0,0 +1,136 @@
use anyhow::Context;
use image::{DynamicImage, EncodableLayout};
use manganis_core::{ImageAssetOptions, ImageFormat, ImageSize};
use std::io::{BufWriter, Write};
use std::path::Path;
pub(crate) fn process_image(
image_options: &ImageAssetOptions,
source: &Path,
output_path: &Path,
) -> anyhow::Result<()> {
let mut image = image::ImageReader::new(std::io::Cursor::new(&*std::fs::read(source)?))
.with_guessed_format()
.context("Failed to guess image format")?
.decode();
if let Ok(image) = &mut image {
if let ImageSize::Manual { width, height } = image_options.size() {
*image = image.resize_exact(width, height, image::imageops::FilterType::Lanczos3);
}
}
match (image, image_options.format()) {
(image, ImageFormat::Png) => {
compress_png(image.context("Failed to decode image")?, output_path);
}
(image, ImageFormat::Jpg) => {
compress_jpg(image.context("Failed to decode image")?, output_path)?;
}
(Ok(image), ImageFormat::Avif) => {
if let Err(error) = image.save(output_path) {
tracing::error!("Failed to save avif image: {} with path {}. You must have the avif feature enabled to use avif assets", error, output_path.display());
}
}
(Ok(image), ImageFormat::Webp) => {
if let Err(err) = image.save(output_path) {
tracing::error!("Failed to save webp image: {}. You must have the avif feature enabled to use webp assets", err);
}
}
(Ok(image), _) => {
image.save(output_path).with_context(|| {
format!(
"Failed to save image (from {}) with path {}",
source.display(),
output_path.display()
)
})?;
}
// If we can't decode the image or it is of an unknown type, we just copy the file
_ => {
let source_file = std::fs::File::open(source).context("Failed to open source file")?;
let mut reader = std::io::BufReader::new(source_file);
let output_file = std::fs::File::create(output_path).with_context(|| {
format!("Failed to create output file: {}", output_path.display())
})?;
let mut writer = std::io::BufWriter::new(output_file);
std::io::copy(&mut reader, &mut writer)
.with_context(|| {
format!(
"Failed to write image to output location: {}",
output_path.display()
)
})
.context("Failed to copy image data")?;
}
}
Ok(())
}
pub(crate) fn compress_png(image: DynamicImage, output_location: &Path) {
// Image loading/saving is outside scope of this library
let width = image.width() as usize;
let height = image.height() as usize;
let bitmap: Vec<_> = image
.into_rgba8()
.pixels()
.map(|px| imagequant::RGBA::new(px[0], px[1], px[2], px[3]))
.collect();
// Configure the library
let mut liq = imagequant::new();
liq.set_speed(5).unwrap();
liq.set_quality(0, 99).unwrap();
// Describe the bitmap
let mut img = liq.new_image(&bitmap[..], width, height, 0.0).unwrap();
// The magic happens in quantize()
let mut res = match liq.quantize(&mut img) {
Ok(res) => res,
Err(err) => panic!("Quantization failed, because: {err:?}"),
};
let (palette, pixels) = res.remapped(&mut img).unwrap();
let file = std::fs::File::create(output_location).unwrap();
let w = &mut BufWriter::new(file);
let mut encoder = png::Encoder::new(w, width as u32, height as u32);
encoder.set_color(png::ColorType::Rgba);
let mut flattened_palette = Vec::new();
let mut alpha_palette = Vec::new();
for px in palette {
flattened_palette.push(px.r);
flattened_palette.push(px.g);
flattened_palette.push(px.b);
alpha_palette.push(px.a);
}
encoder.set_palette(flattened_palette);
encoder.set_trns(alpha_palette);
encoder.set_depth(png::BitDepth::Eight);
encoder.set_color(png::ColorType::Indexed);
encoder.set_compression(png::Compression::Best);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&pixels).unwrap();
writer.finish().unwrap();
}
pub(crate) fn compress_jpg(image: DynamicImage, output_location: &Path) -> anyhow::Result<()> {
let mut comp = mozjpeg::Compress::new(mozjpeg::ColorSpace::JCS_EXT_RGBX);
let width = image.width() as usize;
let height = image.height() as usize;
comp.set_size(width, height);
let mut comp = comp.start_compress(Vec::new())?; // any io::Write will work
comp.write_scanlines(image.to_rgba8().as_bytes())?;
let jpeg_bytes = comp.finish()?;
let file = std::fs::File::create(output_location)?;
let w = &mut BufWriter::new(file);
w.write_all(&jpeg_bytes)?;
Ok(())
}
+87
View File
@@ -0,0 +1,87 @@
use std::path::Path;
use anyhow::Context;
use manganis_core::JsAssetOptions;
use crate::opt::hash::hash_file_contents;
pub(crate) fn process_js(
js_options: &JsAssetOptions,
source: &Path,
output_path: &Path,
bundle: bool,
esbuild_path: Option<&Path>,
) -> anyhow::Result<()> {
let minify = js_options.minified();
let needs_esbuild = minify || bundle;
if needs_esbuild {
if let Some(esbuild) = esbuild_path {
match run_esbuild(esbuild, source, output_path, bundle, minify) {
Ok(()) => return Ok(()),
Err(err) => {
tracing::error!(
"Failed to process JS with esbuild. Falling back to copy: {err}"
);
}
}
} else {
tracing::warn!("esbuild binary path not set. Copying JS without processing.");
}
}
// Fallback: copy unprocessed
let mut source_file = std::fs::File::open(source)?;
let mut writer = std::io::BufWriter::new(std::fs::File::create(output_path)?);
std::io::copy(&mut source_file, &mut writer).with_context(|| {
format!(
"Failed to write JS to output location: {}",
output_path.display()
)
})?;
Ok(())
}
/// Run esbuild to bundle and/or minify a JavaScript file.
fn run_esbuild(
esbuild: &Path,
source: &Path,
output_path: &Path,
bundle: bool,
minify: bool,
) -> anyhow::Result<()> {
let mut cmd = std::process::Command::new(esbuild);
cmd.arg(source);
cmd.arg(format!("--outfile={}", output_path.display()));
cmd.arg("--log-level=warning");
if bundle {
cmd.arg("--bundle");
cmd.arg("--format=esm");
}
if minify {
cmd.arg("--minify");
}
tracing::debug!("Running esbuild: {:?}", cmd);
let output = cmd.output().context("Failed to run esbuild")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("esbuild failed: {stderr}");
}
Ok(())
}
pub(crate) fn hash_js(
_js_options: &JsAssetOptions,
source: &Path,
hasher: &mut impl std::hash::Hasher,
_bundle: bool,
) -> anyhow::Result<()> {
hash_file_contents(source, hasher)
}
+33
View File
@@ -0,0 +1,33 @@
use std::{io::Read, path::Path};
use anyhow::Context;
pub(crate) fn minify_json(source: &str) -> anyhow::Result<String> {
// First try to parse the json
let json: serde_json::Value = serde_json::from_str(source)?;
// Then print it in a minified format
let json = serde_json::to_string(&json)?;
Ok(json)
}
pub(crate) fn process_json(source: &Path, output_path: &Path) -> anyhow::Result<()> {
let mut source_file = std::fs::File::open(source)?;
let mut source = String::new();
source_file.read_to_string(&mut source)?;
let json = match minify_json(&source) {
Ok(json) => json,
Err(err) => {
tracing::error!("Failed to minify json: {}", err);
source
}
};
std::fs::write(output_path, json).with_context(|| {
format!(
"Failed to write json to output location: {}",
output_path.display()
)
})?;
Ok(())
}
+86
View File
@@ -0,0 +1,86 @@
use manganis_core::BundledAsset;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
mod css;
mod file;
mod folder;
mod hash;
mod image;
mod js;
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
///
/// This will be filled in primarily by incremental compilation artifacts.
#[derive(Debug, PartialEq, Default, Clone, Serialize, Deserialize)]
pub(crate) struct AssetManifest {
/// Map of bundled asset name to the asset itself
assets: BTreeMap<PathBuf, HashSet<BundledAsset>>,
}
impl AssetManifest {
/// Manually add an asset to the manifest
pub fn register_asset(
&mut self,
asset_path: &Path,
options: manganis::AssetOptions,
) -> anyhow::Result<BundledAsset> {
let output_path_str = asset_path.to_str().ok_or(anyhow::anyhow!(
"Failed to convert wasm bindgen output path to string"
))?;
let mut bundled_asset =
manganis::macro_helpers::create_bundled_asset(output_path_str, options);
add_hash_to_asset(&mut bundled_asset);
self.assets
.entry(asset_path.to_path_buf())
.or_default()
.insert(bundled_asset);
Ok(bundled_asset)
}
/// Insert an existing bundled asset to the manifest
pub fn insert_asset(&mut self, asset: BundledAsset) {
let asset_path = asset.absolute_source_path();
self.assets
.entry(asset_path.into())
.or_default()
.insert(asset);
}
/// Get any assets that are tied to a specific source file
pub fn get_assets_for_source(&self, path: &Path) -> Option<&HashSet<BundledAsset>> {
self.assets.get(path)
}
/// Get the first asset that matches the given source path
pub fn get_first_asset_for_source(&self, path: &Path) -> Option<&BundledAsset> {
self.assets
.get(path)
.and_then(|assets| assets.iter().next())
}
/// Check if the manifest contains a specific asset
pub fn contains(&self, asset: &BundledAsset) -> bool {
self.assets
.get(&PathBuf::from(asset.absolute_source_path()))
.is_some_and(|assets| assets.contains(asset))
}
/// Iterate over all the assets with unique output paths in the manifest. This will not include
/// assets that have different source paths, but the same file contents.
pub fn unique_assets(&self) -> impl Iterator<Item = &BundledAsset> {
let mut seen = HashSet::new();
self.assets
.values()
.flat_map(|assets| assets.iter())
.filter(move |asset| seen.insert(asset.bundled_path()))
}
}
+21
View File
@@ -439,6 +439,7 @@ impl Output {
/// Render the current state of everything to the console screen
pub fn render(&mut self, runner: &AppServer, server: &WebServer) {
if !self.interactive {
self.emit_logs_to_tracing();
return;
}
@@ -1094,11 +1095,31 @@ impl Output {
lines
}
/// Drains the current log queue to tracing so they still get printed even if the output is non-interactive
fn emit_logs_to_tracing(&mut self) {
// In non-interactive mode (CI, piped output), drain pending logs to tracing
// so they still appear in stdout/stderr instead of being silently dropped.
while let Some(log) = self.pending_logs.pop_back() {
let msg = match &log.content {
TraceContent::Text(s) => s.as_str(),
TraceContent::Cargo(d) => d.message.as_str(),
};
match log.level {
Level::ERROR => tracing::error!("{msg}"),
Level::WARN => tracing::warn!("{msg}"),
Level::INFO => tracing::info!("{msg}"),
Level::DEBUG => tracing::debug!("{msg}"),
Level::TRACE => tracing::trace!("{msg}"),
}
}
}
}
impl std::ops::Drop for Output {
fn drop(&mut self) {
if !self.interactive {
self.emit_logs_to_tracing();
return;
}
+2 -2
View File
@@ -300,7 +300,7 @@ impl AppServer {
let mut changes: Vec<_> = event.into_iter().collect();
// Dequeue in bulk if we can, we might've received a lot of events in one go
while let Some(event) = self.watcher_rx.try_next().ok().flatten() {
while let Ok(event) = self.watcher_rx.try_recv() {
changes.push(event);
}
@@ -1313,7 +1313,7 @@ impl AppServer {
let depth = relative.components().count();
let is_better = best_match
.as_ref()
.map_or(true, |(_, best_depth)| depth < *best_depth);
.is_none_or(|(_, best_depth)| depth < *best_depth);
if is_better {
best_match = Some((krate.name.replace('-', "_"), depth));
}
+1 -1
View File
@@ -26,7 +26,7 @@ use futures_util::{
StreamExt,
};
use hyper::HeaderMap;
use rustls::crypto::{aws_lc_rs::default_provider, CryptoProvider};
use rustls::crypto::{ring::default_provider, CryptoProvider};
use serde::{Deserialize, Serialize};
use std::{
convert::Infallible,
+5 -5
View File
@@ -135,15 +135,15 @@ async fn find_latest_wasm_opt_download_url() -> anyhow::Result<String> {
// Find the platform identifier based on the current OS and architecture
// hardcoded for now to get around github api rate limits
if cfg!(all(target_os = "windows", target_arch = "x86_64")) {
return Ok("https://github.com/WebAssembly/binaryen/releases/download/version_123/binaryen-version_123-x86_64-windows.tar.gz".to_string());
return Ok("https://github.com/WebAssembly/binaryen/releases/download/version_127/binaryen-version_127-x86_64-windows.tar.gz".to_string());
} else if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
return Ok("https://github.com/WebAssembly/binaryen/releases/download/version_123/binaryen-version_123-x86_64-linux.tar.gz".to_string());
return Ok("https://github.com/WebAssembly/binaryen/releases/download/version_127/binaryen-version_127-x86_64-linux.tar.gz".to_string());
} else if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
return Ok("https://github.com/WebAssembly/binaryen/releases/download/version_123/binaryen-version_123-aarch64-linux.tar.gz".to_string());
return Ok("https://github.com/WebAssembly/binaryen/releases/download/version_127/binaryen-version_127-aarch64-linux.tar.gz".to_string());
} else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
return Ok("https://github.com/WebAssembly/binaryen/releases/download/version_123/binaryen-version_123-x86_64-macos.tar.gz".to_string());
return Ok("https://github.com/WebAssembly/binaryen/releases/download/version_127/binaryen-version_127-x86_64-macos.tar.gz".to_string());
} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
return Ok("https://github.com/WebAssembly/binaryen/releases/download/version_123/binaryen-version_123-arm64-macos.tar.gz".to_string());
return Ok("https://github.com/WebAssembly/binaryen/releases/download/version_127/binaryen-version_127-arm64-macos.tar.gz".to_string());
};
let url = "https://api.github.com/repos/WebAssembly/binaryen/releases/latest";