Compare commits

...
5 Commits
Author SHA1 Message Date
swaits 275fc84a4a CLI: ship uncompressed native libs (extractNativeLibs=false)
The Android template asked the installer to extract and copy every .so
out of the APK at install time. Since Android 6.0 the loader can map
them straight from an uncompressed, page-aligned APK, which is smaller
on disk, faster to install, and — the reason it matters here — the only
form in which a 16 KB-page-aligned library stays aligned. Play requires
16 KB page support for apps targeting API 35+, so the extracted path is
now a correctness problem, not only a size one.
2026-09-06 12:08:52 -06:00
swaits 657e6f2125 CLI: pin the Android template to AGP 8.13.0 on Gradle 8.13
The generated project shipped AGP 8.7.0 against the Gradle 8.13 wrapper,
a pair Gradle 9.1.0 does not accept: AGP 8.7 predates Gradle 9 and its
configuration fails outright. Move both to the newest matched pair that
AGP documents (8.13.0 requires Gradle 8.13+), which is also the first
AGP that builds cleanly against compileSdk 36.

Kotlin stays at 2.0.20 deliberately: the template's app build script
still uses the removed `kotlinOptions` block, so a KGP 2.2.x bump needs
that rewritten first.
2026-09-06 12:08:45 -06:00
swaits 48baafc3ff manganis: load FFI classes from the activity classloader, not FindClass
JNIEnv::find_class resolves against the system classloader on a thread
attached from native code, which cannot see APK classes (#5629). Route
the generated #[manganis::ffi] type lookup through
load_class_from_classloader, and reimplement that helper to ask the
activity's own ClassLoader.loadClass instead of Class.forName.

Ask the activity INSTANCE, not its Class: Context.getClassLoader() is
documented to return a loader for the application package
unconditionally, whereas Class.getClassLoader() returns null for classes
loaded by the bootstrap loader.

Describe and clear the pending Java exception on every error path. jni
0.21 leaves the exception pending when a call returns Err, and the next
JNI call then aborts the process with 'JNI DETECTED ERROR IN
APPLICATION: ... called with pending exception' -- which is what turns a
recoverable ClassNotFoundException into an unreadable ART abort.
2026-09-05 21:31:12 -06:00
nico.burns 07dddd569e CLI: configurable Android versionCode and iOS/macOS CFBundleVersion 2026-09-05 20:29:43 -06:00
nicoburns 6adf7ba465 Fix leak in generated iOS FFI wrappers by holding Retained<AnyObject> 2026-09-05 20:29:37 -06:00
16 changed files with 196 additions and 24 deletions

No files matched your search

+1 -1
View File
@@ -27,7 +27,7 @@ dioxus-cli-telemetry = { workspace = true }
dioxus-component-manifest = { workspace = true }
bytes = { workspace = true }
clap = { workspace = true, features = ["derive", "cargo"] }
clap = { workspace = true, features = ["derive", "cargo", "env"] }
convert_case = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true, features = ["v4", "v5", "v7"] }
@@ -13,7 +13,7 @@ android {
applicationId = "{{ application_id }}"
minSdk = {{ min_sdk }}
targetSdk = {{ target_sdk }}
versionCode = 1
versionCode = {{ version_code }}
versionName = "{{ version }}"
}
{{#if android_bundle}}
@@ -17,7 +17,7 @@
{{{ raw_manifest }}}
<application android:hasCode="true" android:icon="@mipmap/ic_launcher"
android:extractNativeLibs="true"
android:extractNativeLibs="false"
android:allowNativeHeapPointerTagging="false"
android:label="@string/app_name"
android:supportsRtl="{{#if supports_rtl}}{{ supports_rtl }}{{else}}true{{/if}}"
@@ -4,7 +4,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.7.0")
classpath("com.android.tools.build:gradle:8.13.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.20")
}
}
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+1 -1
View File
@@ -15,7 +15,7 @@
<string>{{ bundle_name }}</string>
<key>CFBundleVersion</key>
<string>{{ version }}</string>
<string>{{ bundle_version }}</string>
<key>CFBundleShortVersionString</key>
<string>{{ version }}</string>
<key>CFBundleDevelopmentRegion</key>
+1 -1
View File
@@ -27,7 +27,7 @@
<string>{{ version }}</string>
<key>CFBundleVersion</key>
<string>{{ version }}</string>
<string>{{ bundle_version }}</string>
<key>LSMinimumSystemVersion</key>
<string>{{ minimum_system_version }}</string>
+16
View File
@@ -267,6 +267,15 @@
"items": {
"type": "string"
}
},
"version_code": {
"description": "The version code used for `versionCode` in the generated Gradle project.\n\nMust be between 1 and 2100000000 (the Google Play Store maximum).\n\nCan be overridden per-build with `--version-code` or the `DX_ANDROID_VERSION_CODE`\nenvironment variable. If unset, it is derived from the crate version as\n`major * 1000000 + minor * 1000 + patch`.",
"type": [
"integer",
"null"
],
"format": "uint32",
"minimum": 0
}
}
},
@@ -964,6 +973,13 @@
"type": "string"
}
},
"bundle_version": {
"description": "The bundle version string (CFBundleVersion).\n\nThis is the machine-readable build number that must increase with every upload\nto App Store Connect. Can be overridden per-build with `--bundle-version` or the\n`DX_BUNDLE_VERSION` environment variable. Defaults to the crate version.",
"type": [
"string",
"null"
]
},
"category": {
"description": "App category. Overrides `bundle.category` for iOS builds.",
"type": [
+2
View File
@@ -120,6 +120,7 @@ impl BuildRequest {
application_id: String,
app_name: String,
version: String,
version_code: u32,
android_bundle: Option<crate::AndroidSettings>,
/// Android SDK version settings
min_sdk: u32,
@@ -180,6 +181,7 @@ impl BuildRequest {
application_id: self.bundle_identifier(),
app_name: self.bundled_app_name(),
version: self.crate_version(),
version_code: self.android_version_code()?,
android_bundle: self.config.bundle.android.clone(),
min_sdk: self.config.android.min_sdk.unwrap_or(24),
target_sdk: self.config.android.target_sdk.unwrap_or(34),
+4
View File
@@ -160,6 +160,8 @@ impl BuildRequest {
pub executable_name: String,
/// App version string (from Cargo.toml)
pub version: String,
/// Bundle version string (CFBundleVersion)
pub bundle_version: String,
/// Permission usage descriptions
pub permissions: Vec<PlistPermission>,
/// Additional plist entries as raw XML
@@ -231,6 +233,7 @@ impl BuildRequest {
executable_name: self.platform_exe_name(),
bundle_identifier: self.bundle_identifier(),
version: self.crate_version(),
bundle_version: self.apple_bundle_version(),
permissions,
plist_entries,
raw_plist,
@@ -265,6 +268,7 @@ impl BuildRequest {
executable_name: self.platform_exe_name(),
bundle_identifier: self.bundle_identifier(),
version: self.crate_version(),
bundle_version: self.apple_bundle_version(),
permissions,
plist_entries,
raw_plist,
+62
View File
@@ -269,6 +269,8 @@ pub(crate) struct BuildRequest {
pub(crate) using_dioxus_explicitly: bool,
pub(crate) apple_entitlements: Option<PathBuf>,
pub(crate) apple_team_id: Option<String>,
pub(crate) version_code: Option<u32>,
pub(crate) bundle_version: Option<String>,
pub(crate) session_cache_dir: PathBuf,
pub(crate) raw_json_diagnostics: bool,
pub(crate) windows_subsystem: Option<String>,
@@ -905,6 +907,8 @@ impl BuildRequest {
inject_loading_scripts: args.inject_loading_scripts,
apple_entitlements: args.apple_entitlements.clone(),
apple_team_id: args.apple_team_id.clone(),
version_code: args.version_code,
bundle_version: args.bundle_version.clone(),
raw_json_diagnostics: args.raw_json_diagnostics,
windows_subsystem: args.windows_subsystem.clone(),
})
@@ -2685,6 +2689,64 @@ impl BuildRequest {
.to_string()
}
/// Get the Android `versionCode` for the generated Gradle project.
///
/// Resolution order: `--version-code` / `DX_ANDROID_VERSION_CODE`, then
/// `[android] version_code` in Dioxus.toml, then a value derived from the crate
/// version as `major * 1000000 + minor * 1000 + patch`.
pub(crate) fn android_version_code(&self) -> Result<u32> {
/// The maximum value accepted by the Google Play Store.
const MAX_VERSION_CODE: u32 = 2_100_000_000;
let code = match self.version_code.or(self.config.android.version_code) {
Some(code) => code,
None => {
let version = &self.workspace.krates[self.crate_package].version;
if version.major > 2099 || version.minor > 999 || version.patch > 999 {
bail!(
"Cannot derive an Android versionCode from crate version `{version}`: each component must fit `major <= 2099`, `minor <= 999`, `patch <= 999`. Set an explicit version code via `[android] version_code` in Dioxus.toml or the `--version-code` flag."
);
}
if !version.pre.is_empty() {
tracing::warn!(
"Pre-release identifier of crate version `{version}` is ignored when deriving the Android versionCode"
);
}
let code =
(version.major * 1_000_000 + version.minor * 1_000 + version.patch) as u32;
// Google Play requires versionCode >= 1, so bump 0.0.0 up to 1.
code.max(1)
}
};
if !(1..=MAX_VERSION_CODE).contains(&code) {
bail!(
"Android versionCode must be between 1 and {MAX_VERSION_CODE}, got {code}. See https://developer.android.com/studio/publish/versioning"
);
}
Ok(code)
}
/// The explicitly-configured `CFBundleVersion` for iOS/macOS bundles, if any.
///
/// Resolution order: `--bundle-version` / `DX_BUNDLE_VERSION`, then the platform's
/// `bundle_version` in Dioxus.toml (`[ios]` or `[macos]`).
pub(crate) fn apple_bundle_version_override(&self) -> Option<String> {
self.bundle_version.clone().or_else(|| match self.bundle {
BundleFormat::Ios => self.config.ios.bundle_version.clone(),
BundleFormat::MacOS => self.config.macos.bundle_version.clone(),
_ => None,
})
}
/// The `CFBundleVersion` for iOS/macOS bundles, falling back to the crate version.
pub(crate) fn apple_bundle_version(&self) -> String {
self.apple_bundle_version_override()
.unwrap_or_else(|| self.crate_version())
}
pub(crate) fn bundle_identifier(&self) -> String {
use crate::config::BundlePlatform;
+5 -1
View File
@@ -482,7 +482,11 @@ impl BundleContext<'_> {
);
let version = self.version_string();
let bundle_version = macos_settings.bundle_version.as_deref().unwrap_or(&version);
let bundle_version = self
.build
.apple_bundle_version_override()
.or_else(|| macos_settings.bundle_version.clone())
.unwrap_or_else(|| version.clone());
dict.insert(
"CFBundleShortVersionString".into(),
+18
View File
@@ -162,6 +162,22 @@ pub(crate) struct TargetArgs {
#[clap(long, default_value_t = true, help_heading = HELP_HEADING, num_args = 0..=1)]
pub(crate) wasm_js_cfg: bool,
/// The Android `versionCode` to stamp into the generated Gradle project.
///
/// Must be between 1 and 2100000000 (the Google Play Store maximum). Overrides
/// `[android] version_code` in Dioxus.toml. If neither is set, the version code is
/// derived from the crate version as `major * 1000000 + minor * 1000 + patch`.
#[clap(long, env = "DX_ANDROID_VERSION_CODE", help_heading = HELP_HEADING)]
pub(crate) version_code: Option<u32>,
/// The bundle version (CFBundleVersion) to stamp into the Info.plist of iOS and macOS bundles.
///
/// This is the machine-readable build number that must increase with every upload to
/// App Store Connect. Overrides `[ios] bundle_version` / `[macos] bundle_version` in
/// Dioxus.toml. Defaults to the crate version.
#[clap(long, env = "DX_BUNDLE_VERSION", help_heading = HELP_HEADING)]
pub(crate) bundle_version: Option<String>,
/// The Windows subsystem to use when building for Windows targets. This can be either `CONSOLE` or `WINDOWS`.
///
/// By default, DX uses `WINDOWS` since it assumes a GUI application, but you can override this behavior with this flag.
@@ -200,6 +216,8 @@ impl Anonymized for TargetArgs {
"debug_symbols": self.debug_symbols,
"keep_names": self.keep_names,
"device": self.device,
"version_code": self.version_code,
"bundle_version": self.bundle_version.is_some(),
"base_path": self.base_path.is_some(),
"cargo_args": self.cargo_args.is_some(),
"rustc_args": self.rustc_args.is_some(),
+18
View File
@@ -326,6 +326,14 @@ pub struct IosConfig {
pub long_description: Option<String>,
// === iOS-specific settings ===
/// The bundle version string (CFBundleVersion).
///
/// This is the machine-readable build number that must increase with every upload
/// to App Store Connect. Can be overridden per-build with `--bundle-version` or the
/// `DX_BUNDLE_VERSION` environment variable. Defaults to the crate version.
#[serde(default)]
pub bundle_version: Option<String>,
/// Minimum iOS deployment target (e.g., "15.0").
#[serde(default)]
pub deployment_target: Option<String>,
@@ -590,6 +598,16 @@ pub struct AndroidConfig {
pub signing: Option<AndroidSigningConfig>,
// === Android-specific settings ===
/// The version code used for `versionCode` in the generated Gradle project.
///
/// Must be between 1 and 2100000000 (the Google Play Store maximum).
///
/// Can be overridden per-build with `--version-code` or the `DX_ANDROID_VERSION_CODE`
/// environment variable. If unset, it is derived from the crate version as
/// `major * 1000000 + minor * 1000 + patch`.
#[serde(default)]
pub version_code: Option<u32>,
/// Minimum SDK version.
#[serde(default)]
pub min_sdk: Option<u32>,
+19 -10
View File
@@ -398,7 +398,7 @@ impl FfiBridgeParser {
// Use with_activity which returns Option<R>, wrapping our Result in the Option
let inner_result: Option<Result<Self, String>> = manganis::android::with_activity(|mut env, activity| {
// Find the class
let class_result = env.find_class(#class_name_lit);
let class_result = manganis::android::load_class_from_classloader(env, activity, #class_name_lit);
let class = match class_result {
Ok(c) => c,
Err(e) => return Some(Err(format!("Failed to find class {}: {:?}", #class_name_lit, e))),
@@ -724,10 +724,10 @@ impl FfiBridgeParser {
let class_name_bytes = format!("{}\0", name);
let type_def = quote! {
/// Opaque wrapper around an Objective-C object pointer
/// Opaque wrapper around a retained Objective-C object
/// The actual Swift class is looked up dynamically at runtime after dx links everything
pub struct #name {
inner: *mut manganis::objc2::runtime::AnyObject,
inner: manganis::objc2::rc::Retained<manganis::objc2::runtime::AnyObject>,
}
unsafe impl Send for #name {}
@@ -827,17 +827,26 @@ impl FfiBridgeParser {
let instance: *mut manganis::objc2::runtime::AnyObject = manganis::objc2::msg_send![class, alloc];
let instance: *mut manganis::objc2::runtime::AnyObject = manganis::objc2::msg_send![instance, init];
if instance.is_null() {
return Err("Failed to initialize instance");
}
// `alloc` + `init` returns a +1 retained object; `Retained::from_raw`
// takes over that reference and sends `release` on drop.
let inner = manganis::objc2::rc::Retained::from_raw(instance)
.ok_or("Failed to initialize instance")?;
Ok(Self { inner: instance })
Ok(Self { inner })
}
}
/// Create from an existing object pointer
/// Create from an existing object pointer.
///
/// # Safety
///
/// `ptr` must be a valid, non-null pointer to an Objective-C object,
/// and the caller must transfer ownership of one retain count (+1)
/// to the returned wrapper, which sends `release` on drop.
pub unsafe fn from_raw(ptr: *mut manganis::objc2::runtime::AnyObject) -> Self {
Self { inner: ptr }
let inner = manganis::objc2::rc::Retained::from_raw(ptr)
.expect("from_raw called with a null pointer");
Self { inner }
}
}
};
@@ -960,7 +969,7 @@ impl FfiBridgeParser {
// Build the msg_send call
let this_expr = if func.receiver.is_some() {
quote! { this.inner }
quote! { manganis::objc2::rc::Retained::as_ptr(&this.inner) as *mut manganis::objc2::runtime::AnyObject }
} else {
// For static methods, we'd need the class
quote! { class }
+45 -6
View File
@@ -111,17 +111,56 @@ pub fn request_permissions_via_helper(
Ok(())
}
/// Load a Java class from the APK's classloader
/// Load a Java class from the APK's classloader.
///
/// `JNIEnv::find_class` resolves against the *system* classloader on a thread that
/// was attached from native code (`JavaVM::attach_current_thread`), which cannot see
/// classes packaged in the APK, so it raises `ClassNotFoundException`. The activity's
/// own classloader can, so we ask the activity for it and call `loadClass` on it.
///
/// `class_name` may be given in either JNI form (`com/example/Foo`) or binary form
/// (`com.example.Foo`); `loadClass` requires the binary form, so slashes are converted.
pub fn load_class_from_classloader<'env>(
env: &mut JNIEnv<'env>,
activity: &JObject<'_>,
class_name: &str,
) -> Result<JClass<'env>> {
let class_name_jstring = new_string(env, class_name)?;
let class = env.call_static_method(
"java/lang/Class",
"forName",
let binary_name = class_name.replace('/', ".");
// Ask the activity INSTANCE for its classloader. `Activity` is a `Context`, and
// `Context.getClassLoader()` is documented to return a loader for the application
// package unconditionally. Do not route this through `get_object_class(activity)`:
// that calls `Class.getClassLoader()`, which asks which loader loaded the Activity's
// own class and is specified to return null for anything the bootstrap loader
// loaded -- a null receiver that then fails obscurely at the `loadClass` below.
let loader = env.call_method(activity, "getClassLoader", "()Ljava/lang/ClassLoader;", &[]);
let loader = describe_and_clear(env, loader)?.l()?;
let class_name_jstring = new_string(env, &binary_name);
let class_name_jstring = describe_and_clear(env, class_name_jstring)?;
let class = env.call_method(
&loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&class_name_jstring)],
)?;
);
let class = describe_and_clear(env, class)?;
Ok(class.l()?.into())
}
/// Log and clear a pending Java exception before returning an `Err`.
///
/// jni 0.21 does NOT clear the pending exception when a call returns `Err`
/// (see its `JNIEnv` docs), and any later JNI call made while one is pending
/// aborts the process with "JNI DETECTED ERROR IN APPLICATION: ... called with
/// pending exception". That is what turns a recoverable `ClassNotFoundException`
/// into an unreadable ART abort. Describe it to logcat so the cause is visible,
/// then clear it so the `Err` can travel normally.
fn describe_and_clear<T>(env: &mut JNIEnv<'_>, result: Result<T>) -> Result<T> {
if result.is_err() && env.exception_check().unwrap_or(false) {
let _ = env.exception_describe();
let _ = env.exception_clear();
}
result
}