Repository navigation
fix(cli/ios): produce App Store-ready IPA from dx bundle (closes #3817) - #5591
mirkobozzetto wants to merge 16 commits into
Conversation
96952df to
119125b
Compare
ealmloff
left a comment
There was a problem hiding this comment.
Seems mostly reasonable from afar, but I'm not familiar enough with the current bundling process for ios to approve this
|
Hey @jkelleyrtp - this is ready for a code-owner review whenever you get a chance. You mentioned in #3817 you wanted these changes, so flagging that it's up. Status, to save you time:
Also - dioxus is genuinely a lot of fun to build with, really enjoy using it. Thanks! |
|
The min dep check will be fixed by #5609 |
119125b to
8fd6cd6
Compare
|
Rebased onto main, so it now includes #5609 - the min-deps check should go green on this run. The only remaining red, |
LucaCappelletti94
left a comment
There was a problem hiding this comment.
Not a maintainer, so I can only comment in the review. This is meant as a "request chages" review.
In my opinion, several things need to change before this can merge, and I tried to add inline suggestion wherever possible but I do not believe I covered all things.
There is a lot of documentation verbosity that is, in my experience, caused by LLMs not be refined. Please read through that documentation and trim it down as needed. I tried to cover a bit of it myself, but it is a lot.
The AppIcon.xcassets step should come out of this PR. It reads an undocumented catalog at the crate root and ignores [ios] icon, which the manifest documents as the iOS override of [bundle] icon. It also runs inside codesign_apple, so simulator and unsigned builds never get an icon. #5901 builds the icon from that config for every iOS build.
The template should not declare ITSAppUsesNonExemptEncryption=false for every app. That is an export compliance statement made on the developer's behalf, and it is false for apps that ship their own cryptography. Without the key App Store Connect asks at upload, and an app that qualifies can set it in [ios.plist]. Together with the other template changes, this also means a default iOS bundle does change, whatever the description says.
DTXcode and DTXcodeBuild are read from a hard-coded /Applications/Xcode.app, while the SDK keys come from the Xcode that xcode-select points at. A renamed or second Xcode install therefore yields empty or mismatched values. The deployment_target fallback is 15.0 here and 16.0 in compile_widget_extensions, so with the key unset an app and its widget declare different minimum versions.
The PR adds no tests. sync_widget_info_plists runs without Xcode and is easy to cover, and a test is attached as a suggestion. The App Store profile filter is the heart of --appstore, but it reads profiles straight from ~/Library/MobileDevice, so it would need to take the parsed profiles as input before it can be tested. I recommend the maintainers consider my issue regarding adding test coverage in CI, which I can add if they green light it.
| let xcode = run( | ||
| "defaults", | ||
| &["read", "/Applications/Xcode.app/Contents/Info", "DTXcode"], | ||
| ); | ||
| let xcode_build = run( | ||
| "defaults", | ||
| &[ | ||
| "read", | ||
| "/Applications/Xcode.app/Contents/Info", | ||
| "DTXcodeBuild", | ||
| ], | ||
| ); |
There was a problem hiding this comment.
You should read DTXcode and DTXcodeBuild from the Xcode that xcode-select points at, which is the one xcrun used for the SDK keys above. With Xcode installed under another name, such as Xcode-beta.app or the versioned names xcodes creates, the probe returns nothing, and with two installs it records a different Xcode than the one that built the app.
| let xcode = run( | |
| "defaults", | |
| &["read", "/Applications/Xcode.app/Contents/Info", "DTXcode"], | |
| ); | |
| let xcode_build = run( | |
| "defaults", | |
| &[ | |
| "read", | |
| "/Applications/Xcode.app/Contents/Info", | |
| "DTXcodeBuild", | |
| ], | |
| ); | |
| // `xcode-select -p` prints `<Xcode.app>/Contents/Developer`, the Xcode xcrun uses above | |
| let xcode_info = Path::new(&run("xcode-select", &["-p"])) | |
| .with_file_name("Info") | |
| .to_string_lossy() | |
| .into_owned(); | |
| let xcode = run("defaults", &["read", &xcode_info, "DTXcode"]); | |
| let xcode_build = run("defaults", &["read", &xcode_info, "DTXcodeBuild"]); |
There was a problem hiding this comment.
Done in 2c83424, with one change. DTXcodeBuild now comes from Contents/version.plist (ProductBuildVersion). The one in Contents/Info is the build that compiled Xcode itself: on my machine it gives 27A266, while Xcode writes 27A266a into the apps it builds (I checked on an extension built with xcodebuild). DTXcode still comes from Contents/Info like you suggested.
Adds the keys Apple Transporter / altool validation has required since iOS 14 / 2024 App Store rules: - ITSAppUsesNonExemptEncryption=false default (skips per-upload export compliance prompt; apps using non-exempt crypto can override via Dioxus.toml `[ios.plist]`). - Modern `UILaunchScreen` dict replaces `UILaunchStoryboardName`. Apple rejects iPad-supporting bundles that reference a missing storyboard; the dict requires no storyboard file (iOS 14+). - `CFBundleSupportedPlatforms` reduced to a single `iPhoneOS` value. Apple rejects `[iPhoneOS, iPadOS]` — iPadOS is not a valid platform identifier here, the binary is iPhoneOS regardless of device family. - Placeholders for `DT*` / `MinimumOSVersion` / `CFBundlePackageType` metadata wired into the template via handlebars guards; the Rust side populates them in the next commit. Part of the fix for DioxusLabs#3817.
Adds a small `IosDtMetadata` struct and `collect_ios_dt_metadata` helper
that probes the local SDK / Xcode install (`xcrun --sdk iphoneos
--show-sdk-version`, `defaults read .../DTXcode`, `sw_vers
-buildVersion`) and feeds the values into the handlebars iOS Info.plist
template.
Apple Transporter / altool reject App Store IPAs that omit
`DTPlatformName`, `DTPlatformVersion`, `DTSDKName`, `DTSDKBuild`,
`DTXcode`, `DTXcodeBuild`, `DTCompiler`, `DTPlatformBuild`,
`BuildMachineOSBuild`, `MinimumOSVersion`, or `CFBundlePackageType` —
the same set Xcode injects when it builds an iOS app.
Each probe is best-effort: a failure leaves the field empty and the
template's `{{#if}}` guards drop the key, so non-developer machines
still produce a valid `.app` (just not one App Store will accept).
`MinimumOSVersion` is sourced from `Dioxus.toml`'s
`[ios].deployment_target` (defaults to `15.0`).
Part of the fix for DioxusLabs#3817.
…DioxusLabs#3817) Adds an opt-in `--appstore` flag to `dx bundle --platform=ios` that completes the chain Apple App Store / Transporter / altool require for upload. Default behavior (dev signing) is unchanged. When `--appstore` is set: - `auto_provision_signing_name` looks for an "Apple Distribution" identity instead of "Apple Development". - `auto_provision_entitlements` filters provisioning profile candidates to those without a `ProvisionedDevices` key (= App Store distribution profiles, not dev / ad-hoc). - Generated entitlements use `get-task-allow=false` (Apple ITMS rejects App Store uploads where the key is `true`). - `should_codesign` is forced on so `dx bundle` produces a signed `.app` even without an explicit `--codesign` flag. Regardless of `--appstore`, iOS bundles are now App Store-shaped: - Nested `.appex` extensions under `PlugIns/` are signed inside-out with their own provisioning profiles (matched by each extension's CFBundleIdentifier). Apple rejects bundles whose nested extensions ship unsigned or with stale dev certs. - `AppIcon.xcassets` at the crate root is compiled via `xcrun actool` and the resulting CFBundleIcons keys are merged into the main Info.plist. Apple rejects iOS bundles missing the 120x120 icon. - `ios/PrivacyInfo.xcprivacy`, if present at the crate root, is copied into the bundle. Apple has required PrivacyInfo on App Store submissions since 2024. - Widget / extension Info.plists are patched with the same DT* metadata, version strings (CFBundleVersion + CFBundleShortVersionString parity), `LSRequiresIPhoneOS`, `CFBundleSupportedPlatforms`, and `UIRequiredDeviceCapabilities` the main bundle carries. End-to-end verified against a real iOS app (FlowFlow) using `xcrun altool --validate-app -f <ipa> -t ios`: Apple responds `VERIFY SUCCEEDED with no errors`. All bundle-content modifications happen before any `codesign` call so the signature covers the final bundle contents. Closes DioxusLabs#3817.
8fd6cd6 to
8be276e
Compare
…don't make it ambiguous
|
Thanks for the careful review @LucaCappelletti94, you were right on every point. I went through them one by one, it's all pushed and I answered in each thread. Two small differences with your suggestions: Re-validating found a real bug. With a "Developer ID Application" certificate in the keychain, To check it end to end, I merged #5901 locally on top of this branch (not pushed) and built FlowFlow with it. I rebased on main and rewrote the description, the old one was wrong about default bundles not changing. About iPad (ealmloff's thread): I only ship on iPhone and I have no iPad use case to test, so I'd rather not touch device targeting in this PR. |
LucaCappelletti94
left a comment
There was a problem hiding this comment.
As per the previous review, I can only leave a comment not being a maintainer. This is still a "request changes" note, part of it my own fault because I was wrong on the widget/app version thing.
I could not place this note in the code due to GitHub UI limits, basically you should sign with the certificate's SHA-1 hash instead of its name, which still fails as ambiguous when two certificates share it, for example one person's development certificates from two Macs, and Apple recommends the hash for this. This range reaches past the changed lines, so it cannot be a suggestion.
I have added a red test for this in one of the suggestions.
@@ -773,20 +773,22 @@ impl BuildRequest {
} else {
"Apple Development"
};
- // Keep the label: "Apple Distribution: Name (TEAM)" and "Developer ID Application: Name (TEAM)"
- // share the same suffix, so codesign rejects the bare name as ambiguous.
- let pattern = format!(r#""({label}: [^"]+)""#);
- let app_dev_name = regex::Regex::new(&pattern)
- .unwrap()
- .captures(&identities)
- .and_then(|caps| caps.get(1))
- .map(|m| m.as_str())
+ Self::find_signing_identity(&identities, label)
+ .map(str::to_string)
.with_context(|| {
format!("Failed to find {label} in `security find-identity -v -p codesigning`")
- })?;
+ })
+ }
- Ok(app_dev_name.to_string())
+ /// SHA-1 of the first `label` identity, which codesign matches to exactly one certificate.
+ fn find_signing_identity<'a>(identities: &'a str, label: &str) -> Option<&'a str> {
+ let pattern = format!(r#"(?m)^\s*\d+\) ([0-9A-F]{{40}}) "{label}: [^"]+""#);
+ regex::Regex::new(&pattern)
+ .unwrap()
+ .captures(identities)
+ .and_then(|caps| caps.get(1))
+ .map(|m| m.as_str())
}|
Thanks for the second round @LucaCappelletti94. I applied everything as suggested: signing uses the certificate's SHA-1 (b683790), the app is back to 15.0 and widgets stay at 16.0 (677d5d6). I rebuilt FlowFlow with #5901 merged locally and FlowFlow sets its own 16.0 minimum, so the new 15.0 default is only covered by the tests. I also updated the description. |
LucaCappelletti94
left a comment
There was a problem hiding this comment.
Hopefully this should be the last review round. What remains is how --appstore picks a provisioning profile when several match the app, hopefully with the suggestions ready to apply it should be fairly straightforward.
I am being this thorough because some of my PRs are stacked on top of this one, so I really look forward to the best possible version of it being merged.
| /// Picks the profile for `bundle_id`: an exact app ID beats a wildcard, then more provisioned | ||
| /// devices win. With `appstore`, profiles tied to devices are skipped. | ||
| fn pick_provisioning_profile( | ||
| profiles: impl IntoIterator<Item = (PathBuf, ProvisioningProfile)>, | ||
| bundle_id: &str, | ||
| appstore: bool, | ||
| ) -> Option<(PathBuf, ProvisioningProfile)> { | ||
| let mut best_match: Option<(PathBuf, ProvisioningProfile, bool, usize)> = None; | ||
|
|
||
| for (path, profile) in profiles { | ||
| let app_id = &profile.entitlements.application_identifier; | ||
| if !profile_matches_bundle_id(app_id, bundle_id) { | ||
| tracing::debug!( | ||
| "Skipping profile {} (app ID {app_id} does not match bundle ID {bundle_id})", | ||
| path.display() | ||
| ); | ||
| continue; | ||
| } | ||
|
|
||
| // App Store profiles carry no `ProvisionedDevices`. | ||
| if appstore && !profile.provisioned_devices.is_empty() { | ||
| tracing::debug!( | ||
| "Skipping profile {} ({} provisioned devices — not an App Store profile)", | ||
| path.display(), | ||
| profile.provisioned_devices.len() | ||
| ); | ||
| continue; | ||
| } | ||
|
|
||
| let is_exact = !app_id.ends_with(".*") && !app_id.ends_with("*"); | ||
| let num_devices = profile.provisioned_devices.len(); | ||
|
|
||
| tracing::debug!( | ||
| "Found matching profile {} (app ID: {app_id}, exact: {is_exact}, devices: {num_devices})", | ||
| path.display() | ||
| ); | ||
|
|
||
| // Prefer: exact match > more provisioned devices (newer profiles have more devices) | ||
| let dominated = match &best_match { | ||
| Some((_, _, prev_exact, prev_devices)) => { | ||
| if *prev_exact && !is_exact { | ||
| true // existing exact match beats wildcard | ||
| } else if is_exact && !*prev_exact { | ||
| false // new exact match beats existing wildcard | ||
| } else { | ||
| // same specificity — prefer more provisioned devices | ||
| num_devices <= *prev_devices | ||
| } | ||
| } | ||
| None => false, | ||
| }; | ||
|
|
||
| if !dominated { | ||
| best_match = Some((path, profile, is_exact, num_devices)); |
There was a problem hiding this comment.
You should skip expired profiles and prefer the newer profile on a tie. With --appstore every candidate has zero ProvisionedDevices, so with two App Store profiles for the bundle ID the first one read_dir returns wins, even when it is expired or older than the other.
The profiles folder can hold several valid profiles for one bundle ID, and signing with the stale one only fails at upload. I only happened against this by pure chance, so I guess we are lucky to have caught it.
| /// Picks the profile for `bundle_id`: an exact app ID beats a wildcard, then more provisioned | |
| /// devices win. With `appstore`, profiles tied to devices are skipped. | |
| fn pick_provisioning_profile( | |
| profiles: impl IntoIterator<Item = (PathBuf, ProvisioningProfile)>, | |
| bundle_id: &str, | |
| appstore: bool, | |
| ) -> Option<(PathBuf, ProvisioningProfile)> { | |
| let mut best_match: Option<(PathBuf, ProvisioningProfile, bool, usize)> = None; | |
| for (path, profile) in profiles { | |
| let app_id = &profile.entitlements.application_identifier; | |
| if !profile_matches_bundle_id(app_id, bundle_id) { | |
| tracing::debug!( | |
| "Skipping profile {} (app ID {app_id} does not match bundle ID {bundle_id})", | |
| path.display() | |
| ); | |
| continue; | |
| } | |
| // App Store profiles carry no `ProvisionedDevices`. | |
| if appstore && !profile.provisioned_devices.is_empty() { | |
| tracing::debug!( | |
| "Skipping profile {} ({} provisioned devices — not an App Store profile)", | |
| path.display(), | |
| profile.provisioned_devices.len() | |
| ); | |
| continue; | |
| } | |
| let is_exact = !app_id.ends_with(".*") && !app_id.ends_with("*"); | |
| let num_devices = profile.provisioned_devices.len(); | |
| tracing::debug!( | |
| "Found matching profile {} (app ID: {app_id}, exact: {is_exact}, devices: {num_devices})", | |
| path.display() | |
| ); | |
| // Prefer: exact match > more provisioned devices (newer profiles have more devices) | |
| let dominated = match &best_match { | |
| Some((_, _, prev_exact, prev_devices)) => { | |
| if *prev_exact && !is_exact { | |
| true // existing exact match beats wildcard | |
| } else if is_exact && !*prev_exact { | |
| false // new exact match beats existing wildcard | |
| } else { | |
| // same specificity — prefer more provisioned devices | |
| num_devices <= *prev_devices | |
| } | |
| } | |
| None => false, | |
| }; | |
| if !dominated { | |
| best_match = Some((path, profile, is_exact, num_devices)); | |
| /// Picks the unexpired profile for `bundle_id`: an exact app ID beats a wildcard, then more | |
| /// provisioned devices win, then the newer profile. With `appstore`, profiles tied to devices | |
| /// are skipped. | |
| fn pick_provisioning_profile( | |
| profiles: impl IntoIterator<Item = (PathBuf, ProvisioningProfile)>, | |
| bundle_id: &str, | |
| appstore: bool, | |
| ) -> Option<(PathBuf, ProvisioningProfile)> { | |
| let now = SystemTime::now(); | |
| let mut best_match: Option<(PathBuf, ProvisioningProfile, bool, (usize, SystemTime))> = None; | |
| for (path, profile) in profiles { | |
| let app_id = &profile.entitlements.application_identifier; | |
| if !profile_matches_bundle_id(app_id, bundle_id) { | |
| tracing::debug!( | |
| "Skipping profile {} (app ID {app_id} does not match bundle ID {bundle_id})", | |
| path.display() | |
| ); | |
| continue; | |
| } | |
| // App Store profiles carry no `ProvisionedDevices`. | |
| if appstore && !profile.provisioned_devices.is_empty() { | |
| tracing::debug!( | |
| "Skipping profile {} ({} provisioned devices — not an App Store profile)", | |
| path.display(), | |
| profile.provisioned_devices.len() | |
| ); | |
| continue; | |
| } | |
| if SystemTime::from(profile.expiration_date) <= now { | |
| tracing::debug!("Skipping profile {} (expired)", path.display()); | |
| continue; | |
| } | |
| let is_exact = !app_id.ends_with(".*") && !app_id.ends_with("*"); | |
| let num_devices = profile.provisioned_devices.len(); | |
| let rank = (num_devices, SystemTime::from(profile.creation_date)); | |
| tracing::debug!( | |
| "Found matching profile {} (app ID: {app_id}, exact: {is_exact}, devices: {num_devices})", | |
| path.display() | |
| ); | |
| // Prefer: exact match > more provisioned devices > newer profile | |
| let dominated = match &best_match { | |
| Some((_, _, prev_exact, prev_rank)) => { | |
| if *prev_exact && !is_exact { | |
| true // existing exact match beats wildcard | |
| } else if is_exact && !*prev_exact { | |
| false // new exact match beats existing wildcard | |
| } else { | |
| // same specificity — prefer more provisioned devices, then the newer profile | |
| rank <= *prev_rank | |
| } | |
| } | |
| None => false, | |
| }; | |
| if !dominated { | |
| best_match = Some((path, profile, is_exact, rank)); |
There was a problem hiding this comment.
Applied in f2c12f3.
It opened one gap. When the only matching profile has expired, the error said no profile matches and told you to change the bundle ID or create a wildcard profile. Profiles last a year, so anyone coming back to an app after that gets sent the wrong way. 18aac35 says the profile expired and needs renewing, with a test.
|
One more data point for this PR, from a second app. A Dioxus 0.7.9 mobile app, bundled with
So the same set of keys was needed on a different app. The set was enough once the app was signed with the App Store profile's entitlements. A side note for 0.7.x users: on 0.7.9, |
|
Same keys on FlowFlow, so two apps needed them. FlowFlow is live on the App Store: https://apps.apple.com/us/app/flowflow/id6773033233 I validated it on my side with this branch, on top of main. The last step you did by hand, the App Store signing, is what To try it on your app without the post-processing: git clone -b fix-ios-appstore-bundle https://github.com/mirkobozzetto/dioxus
cd dioxus
git fetch https://github.com/DioxusLabs/dioxus pull/5901/head
git merge --no-edit FETCH_HEAD
cargo install --path packages/cliThen in your app, with dx bundle --platform ios --release --appstore
xcrun altool --validate-app -f target/dx/<app>/bundle/ios/ipa/<App>_<version>_aarch64.ipa -t ios -u <apple-id> -p <app-specific-password>You need an "Apple Distribution" certificate in your keychain. One catch: this branch's dx is 0.8.0-alpha.1. |
Summary
dx bundle --platform ios --releaseproduces an.ipathat Apple's validator rejects (#3817). This PR fixes the bundle and adds an opt-in--appstoreflag that signs for App Store distribution.Tested on FlowFlow, an app already on TestFlight:
xcrun altool --validate-apppasses (output below).What changes for every iOS bundle
The template changes apply to every iOS build, with or without
--appstore:Info.plistgets the keys Xcode normally adds:CFBundlePackageType,MinimumOSVersionand theDT*set, probed from the Xcode thatxcode-selectpoints at. A probe that fails leaves its key out, so a machine without full Xcode still gets a.app.MinimumOSVersioncomes from[ios] deployment_target, default 15.0. Widget extensions keep their own 16.0 default.CFBundleSupportedPlatformsis[iPhoneOS]only.iPadOSis not a valid value there.UILaunchStoryboardName=LaunchScreenpointed to a storyboard the build never produced. It is replaced by an emptyUILaunchScreendict (iOS 14+).When the app is signed (device builds,
--codesign,--appstore), dx also:ios/PrivacyInfo.xcprivacyinto the app when present,PlugIns/*.appexthe app's version strings, theDT*keys,LSRequiresIPhoneOS,CFBundleSupportedPlatformsandUIRequiredDeviceCapabilities, and signs it with its own provisioning profile before the app,What
--appstorechangesProvisionedDevices).get-task-allow=falsein the generated entitlements.Without the flag, signing works as before.
Not in this PR
[ios]icon or[bundle]icon #5901 builds it from[ios] icon/[bundle] iconfor every iOS build. An App Store upload needs it.ITSAppUsesNonExemptEncryption: an export compliance statement, so it stays with each app ([ios.plist]).CFBundleVersionbump strategy, ad-hoc / TestFlight-only signing.Proof
FlowFlow (Live Activity widget, background audio,
PrivacyInfo.xcprivacy), built with this branch and #5901 merged locally for the icon:In that IPA:
DTXcodeBuild27A266a (what Xcode itself writes),MinimumOSVersion16.0 for the app (FlowFlow sets it), the widget's own 16.2, matching versions, signed by "Apple Distribution".Tests
sync_widget_info_plists: an extension takes the app's version and skips emptyDT*keys.pick_provisioning_profile: with--appstore, profiles tied to devices are skipped.find_signing_identity: returns the SHA-1 of the certificate with the right label.cargo fmtclean, no newcargo clippy -p dioxus-cliwarnings,cargo test -p dioxus-clipasses (165 tests).