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 }
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}}
+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 }
+24 -5
View File
@@ -111,15 +111,34 @@ 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('/', ".");
let activity_class = env.get_object_class(activity)?;
let loader = env
.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;",
&[JValue::Object(&class_name_jstring)],
)?;