Compare commits

...
3 Commits
Author SHA1 Message Date
swaits aeb5568727 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.
2026-09-05 20:34:33 -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
13 changed files with 172 additions and 20 deletions

No files matched your search

+1 -1
View File
@@ -27,7 +27,7 @@ dioxus-cli-telemetry = { workspace = true }
dioxus-component-manifest = { workspace = true } dioxus-component-manifest = { workspace = true }
bytes = { workspace = true } bytes = { workspace = true }
clap = { workspace = true, features = ["derive", "cargo"] } clap = { workspace = true, features = ["derive", "cargo", "env"] }
convert_case = { workspace = true } convert_case = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
uuid = { workspace = true, features = ["v4", "v5", "v7"] } uuid = { workspace = true, features = ["v4", "v5", "v7"] }
@@ -13,7 +13,7 @@ android {
applicationId = "{{ application_id }}" applicationId = "{{ application_id }}"
minSdk = {{ min_sdk }} minSdk = {{ min_sdk }}
targetSdk = {{ target_sdk }} targetSdk = {{ target_sdk }}
versionCode = 1 versionCode = {{ version_code }}
versionName = "{{ version }}" versionName = "{{ version }}"
} }
{{#if android_bundle}} {{#if android_bundle}}
+1 -1
View File
@@ -15,7 +15,7 @@
<string>{{ bundle_name }}</string> <string>{{ bundle_name }}</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>{{ version }}</string> <string>{{ bundle_version }}</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>{{ version }}</string> <string>{{ version }}</string>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
+1 -1
View File
@@ -27,7 +27,7 @@
<string>{{ version }}</string> <string>{{ version }}</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>{{ version }}</string> <string>{{ bundle_version }}</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>{{ minimum_system_version }}</string> <string>{{ minimum_system_version }}</string>
+16
View File
@@ -267,6 +267,15 @@
"items": { "items": {
"type": "string" "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" "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": { "category": {
"description": "App category. Overrides `bundle.category` for iOS builds.", "description": "App category. Overrides `bundle.category` for iOS builds.",
"type": [ "type": [
+2
View File
@@ -120,6 +120,7 @@ impl BuildRequest {
application_id: String, application_id: String,
app_name: String, app_name: String,
version: String, version: String,
version_code: u32,
android_bundle: Option<crate::AndroidSettings>, android_bundle: Option<crate::AndroidSettings>,
/// Android SDK version settings /// Android SDK version settings
min_sdk: u32, min_sdk: u32,
@@ -180,6 +181,7 @@ impl BuildRequest {
application_id: self.bundle_identifier(), application_id: self.bundle_identifier(),
app_name: self.bundled_app_name(), app_name: self.bundled_app_name(),
version: self.crate_version(), version: self.crate_version(),
version_code: self.android_version_code()?,
android_bundle: self.config.bundle.android.clone(), android_bundle: self.config.bundle.android.clone(),
min_sdk: self.config.android.min_sdk.unwrap_or(24), min_sdk: self.config.android.min_sdk.unwrap_or(24),
target_sdk: self.config.android.target_sdk.unwrap_or(34), target_sdk: self.config.android.target_sdk.unwrap_or(34),
+4
View File
@@ -160,6 +160,8 @@ impl BuildRequest {
pub executable_name: String, pub executable_name: String,
/// App version string (from Cargo.toml) /// App version string (from Cargo.toml)
pub version: String, pub version: String,
/// Bundle version string (CFBundleVersion)
pub bundle_version: String,
/// Permission usage descriptions /// Permission usage descriptions
pub permissions: Vec<PlistPermission>, pub permissions: Vec<PlistPermission>,
/// Additional plist entries as raw XML /// Additional plist entries as raw XML
@@ -231,6 +233,7 @@ impl BuildRequest {
executable_name: self.platform_exe_name(), executable_name: self.platform_exe_name(),
bundle_identifier: self.bundle_identifier(), bundle_identifier: self.bundle_identifier(),
version: self.crate_version(), version: self.crate_version(),
bundle_version: self.apple_bundle_version(),
permissions, permissions,
plist_entries, plist_entries,
raw_plist, raw_plist,
@@ -265,6 +268,7 @@ impl BuildRequest {
executable_name: self.platform_exe_name(), executable_name: self.platform_exe_name(),
bundle_identifier: self.bundle_identifier(), bundle_identifier: self.bundle_identifier(),
version: self.crate_version(), version: self.crate_version(),
bundle_version: self.apple_bundle_version(),
permissions, permissions,
plist_entries, plist_entries,
raw_plist, raw_plist,
+62
View File
@@ -269,6 +269,8 @@ pub(crate) struct BuildRequest {
pub(crate) using_dioxus_explicitly: bool, pub(crate) using_dioxus_explicitly: bool,
pub(crate) apple_entitlements: Option<PathBuf>, pub(crate) apple_entitlements: Option<PathBuf>,
pub(crate) apple_team_id: Option<String>, 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) session_cache_dir: PathBuf,
pub(crate) raw_json_diagnostics: bool, pub(crate) raw_json_diagnostics: bool,
pub(crate) windows_subsystem: Option<String>, pub(crate) windows_subsystem: Option<String>,
@@ -905,6 +907,8 @@ impl BuildRequest {
inject_loading_scripts: args.inject_loading_scripts, inject_loading_scripts: args.inject_loading_scripts,
apple_entitlements: args.apple_entitlements.clone(), apple_entitlements: args.apple_entitlements.clone(),
apple_team_id: args.apple_team_id.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, raw_json_diagnostics: args.raw_json_diagnostics,
windows_subsystem: args.windows_subsystem.clone(), windows_subsystem: args.windows_subsystem.clone(),
}) })
@@ -2685,6 +2689,64 @@ impl BuildRequest {
.to_string() .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 { pub(crate) fn bundle_identifier(&self) -> String {
use crate::config::BundlePlatform; use crate::config::BundlePlatform;
+5 -1
View File
@@ -482,7 +482,11 @@ impl BundleContext<'_> {
); );
let version = self.version_string(); 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( dict.insert(
"CFBundleShortVersionString".into(), "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)] #[clap(long, default_value_t = true, help_heading = HELP_HEADING, num_args = 0..=1)]
pub(crate) wasm_js_cfg: bool, 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`. /// 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. /// 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, "debug_symbols": self.debug_symbols,
"keep_names": self.keep_names, "keep_names": self.keep_names,
"device": self.device, "device": self.device,
"version_code": self.version_code,
"bundle_version": self.bundle_version.is_some(),
"base_path": self.base_path.is_some(), "base_path": self.base_path.is_some(),
"cargo_args": self.cargo_args.is_some(), "cargo_args": self.cargo_args.is_some(),
"rustc_args": self.rustc_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>, pub long_description: Option<String>,
// === iOS-specific settings === // === 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"). /// Minimum iOS deployment target (e.g., "15.0").
#[serde(default)] #[serde(default)]
pub deployment_target: Option<String>, pub deployment_target: Option<String>,
@@ -590,6 +598,16 @@ pub struct AndroidConfig {
pub signing: Option<AndroidSigningConfig>, pub signing: Option<AndroidSigningConfig>,
// === Android-specific settings === // === 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. /// Minimum SDK version.
#[serde(default)] #[serde(default)]
pub min_sdk: Option<u32>, 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 // 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| { let inner_result: Option<Result<Self, String>> = manganis::android::with_activity(|mut env, activity| {
// Find the class // 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 { let class = match class_result {
Ok(c) => c, Ok(c) => c,
Err(e) => return Some(Err(format!("Failed to find class {}: {:?}", #class_name_lit, e))), 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 class_name_bytes = format!("{}\0", name);
let type_def = quote! { 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 /// The actual Swift class is looked up dynamically at runtime after dx links everything
pub struct #name { pub struct #name {
inner: *mut manganis::objc2::runtime::AnyObject, inner: manganis::objc2::rc::Retained<manganis::objc2::runtime::AnyObject>,
} }
unsafe impl Send for #name {} 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![class, alloc];
let instance: *mut manganis::objc2::runtime::AnyObject = manganis::objc2::msg_send![instance, init]; let instance: *mut manganis::objc2::runtime::AnyObject = manganis::objc2::msg_send![instance, init];
if instance.is_null() { // `alloc` + `init` returns a +1 retained object; `Retained::from_raw`
return Err("Failed to initialize instance"); // 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 { 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 // Build the msg_send call
let this_expr = if func.receiver.is_some() { 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 { } else {
// For static methods, we'd need the class // For static methods, we'd need the class
quote! { class } quote! { class }
+24 -5
View File
@@ -111,15 +111,34 @@ pub fn request_permissions_via_helper(
Ok(()) 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>( pub fn load_class_from_classloader<'env>(
env: &mut JNIEnv<'env>, env: &mut JNIEnv<'env>,
activity: &JObject<'_>,
class_name: &str, class_name: &str,
) -> Result<JClass<'env>> { ) -> Result<JClass<'env>> {
let class_name_jstring = new_string(env, class_name)?; let binary_name = class_name.replace('/', ".");
let class = env.call_static_method( let activity_class = env.get_object_class(activity)?;
"java/lang/Class", let loader = env
"forName", .call_method(
&activity_class,
"getClassLoader",
"()Ljava/lang/ClassLoader;",
&[],
)?
.l()?;
let class_name_jstring = new_string(env, &binary_name)?;
let class = env.call_method(
&loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;", "(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&class_name_jstring)], &[JValue::Object(&class_name_jstring)],
)?; )?;