From cb88528c15368ab346a82c2375c35b8c69db2432 Mon Sep 17 00:00:00 2001 From: Serban Bajdechi <1008662+serbanbjd@users.noreply.github.com> Date: Fri, 13 Feb 2026 03:12:29 +0200 Subject: [PATCH] fix(cli) replace installation_url with application_id in launch_ios_app_paused (#5283) * fix(cli) replace installation_url with application_id in launch_ios_app_paused * properly handle provision profile for installed devices * clippy, fmt --------- Co-authored-by: Serban Bajdechi Co-authored-by: Jonathan Kelley --- packages/cli/src/build/builder.rs | 89 ++++++++++---- packages/cli/src/build/request.rs | 197 ++++++++++++++++++++++++------ 2 files changed, 225 insertions(+), 61 deletions(-) diff --git a/packages/cli/src/build/builder.rs b/packages/cli/src/build/builder.rs index 3c93ebf5a..e83bd0b2f 100644 --- a/packages/cli/src/build/builder.rs +++ b/packages/cli/src/build/builder.rs @@ -221,6 +221,8 @@ impl AppBuilder { StderrReceived { msg } }, Some(msg) = OptionFuture::from(self.spawn_handle.as_mut()) => { + // Prevent re-polling the spawn future, similar to above + self.spawn_handle = None; match msg { Ok(Ok(_)) => StdoutReceived { msg: "Finished launching app".to_string() }, Ok(Err(err)) => StderrReceived { msg: err.to_string() }, @@ -1008,17 +1010,18 @@ impl AppBuilder { async fn open_ios_device(&mut self, device_query: &str) -> Result<()> { let device_query = device_query.to_string(); let root_dir = self.build.root_dir().clone(); + let application_id = self.build.bundle_identifier(); self.spawn_handle = Some(tokio::task::spawn(async move { // 1. Find an active device let device_uuid = Self::get_ios_device_uuid(&device_query).await?; tracing::info!("Uploading app to iOS device, this might take a while..."); - // 2. Get the installation URL of the app - let installation_url = Self::get_ios_installation_url(&device_uuid, &root_dir).await?; + // 2. Install the app to the device + Self::install_ios_app(&device_uuid, &root_dir).await?; // 3. Launch the app into the background, paused - Self::launch_ios_app_paused(&device_uuid, &installation_url).await?; + Self::launch_ios_app_paused(&device_uuid, &application_id).await?; Result::Ok(()) as Result<()> })); @@ -1139,11 +1142,20 @@ impl AppBuilder { .unwrap_or(false); let is_ios_device = matches!( - device.get("deviceType").and_then(|s| s.as_str()), + device + .get("hardwareProperties") + .and_then(|h| h.get("deviceType")) + .and_then(|s| s.as_str()), Some("iPhone") | Some("iPad") | Some("iPod") ); - if is_paired && is_ios_device { + let is_available = device + .get("connectionProperties") + .and_then(|c| c.get("tunnelState")) + .and_then(|s| s.as_str()) + != Some("unavailable"); + + if is_paired && is_ios_device && is_available { device_idx = idx; break; } @@ -1160,7 +1172,7 @@ impl AppBuilder { .context("Failed to extract device UUID") } - async fn get_ios_installation_url(device_uuid: &str, app_path: &Path) -> Result { + async fn install_ios_app(device_uuid: &str, app_path: &Path) -> Result<()> { let tmpfile = tempfile::NamedTempFile::new() .context("Failed to create temporary file for device list")?; @@ -1181,27 +1193,60 @@ impl AppBuilder { .await?; if !output.status.success() { - bail!( - "Failed to install app: {}", - String::from_utf8_lossy(&output.stderr) - ); + let stderr = String::from_utf8_lossy(&output.stderr); + + if stderr.contains("DeviceLocked") || stderr.contains("device is locked") { + bail!( + "Failed to install app: your device is locked.\n\ + Unlock your iPhone/iPad and try again." + ); + } + + if stderr.contains("cannot be installed on this device") + || stderr.contains("0xe8008012") + { + bail!( + "Failed to install app: your device is not registered in the provisioning profile.\n\n\ + Your device UDID needs to be added to your Apple Developer account and the \ + provisioning profile regenerated.\n\n\ + To fix this:\n \ + 1. Accept the latest Program License Agreement at:\n \ + https://developer.apple.com/account\n \ + 2. Register your device at:\n \ + https://developer.apple.com/account/resources/devices\n \ + 3. Regenerate your provisioning profile to include the new device\n \ + 4. Or open any project in Xcode, select your device, and build —\n \ + Xcode will update the profile automatically\n\n\ + Raw error: {stderr}" + ); + } + + if stderr.contains("provisioning profile") + || stderr.contains("ApplicationVerificationFailed") + || stderr.contains("code signature") + { + bail!( + "Failed to install app: code signing error.\n\ + A valid provisioning profile was not found for this app.\n\n\ + To fix this:\n \ + 1. Accept the latest Program License Agreement at:\n \ + https://developer.apple.com/account\n \ + 2. Open the project in Xcode, select your device, and build once —\n \ + Xcode will set up signing and provisioning automatically\n \ + 3. Ensure your device is registered in your Apple Developer account\n\n\ + Raw error: {stderr}" + ); + } + + bail!("Failed to install app to device {device_uuid}: {stderr}"); } - let json: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(tmpfile.path())?) - .context("Failed to parse xcrun output")?; - let installation_url = json["result"]["installedApplications"][0]["installationURL"] - .as_str() - .context("Failed to extract installation URL from xcrun output")? - .to_string(); - - Ok(installation_url) + Ok(()) } - async fn launch_ios_app_paused(device_uuid: &str, installation_url: &str) -> Result<()> { + async fn launch_ios_app_paused(device_uuid: &str, application_id: &str) -> Result<()> { let tmpfile = tempfile::NamedTempFile::new() .context("Failed to create temporary file for device list")?; - let output = Command::new("xcrun") .args([ "devicectl", @@ -1212,7 +1257,7 @@ impl AppBuilder { "--verbose", "--device", device_uuid, - installation_url, + application_id, "--json-output", ]) .arg(tmpfile.path()) diff --git a/packages/cli/src/build/request.rs b/packages/cli/src/build/request.rs index 96c1cb272..501d942d1 100644 --- a/packages/cli/src/build/request.rs +++ b/packages/cli/src/build/request.rs @@ -5700,13 +5700,16 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ } let mut entitlements_file = self.apple_entitlements.clone(); + let mut provisioning_profile_path = None; if entitlements_file.is_none() { - let entitlements_xml = Self::auto_provision_entitlements() + let bundle_id = self.bundle_identifier(); + let (entitlements_xml, profile_path) = Self::auto_provision_entitlements(&bundle_id) .await .context("Failed to auto-provision entitlements for Apple codesigning.")?; let entitlements_temp_file = tempfile::NamedTempFile::new()?; std::fs::write(entitlements_temp_file.path(), entitlements_xml)?; entitlements_file = Some(entitlements_temp_file.path().to_path_buf()); + provisioning_profile_path = Some(profile_path); _saved_entitlements = Some(entitlements_temp_file); } @@ -5731,6 +5734,15 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ _ => bail!("Codesigning is only supported for MacOS and iOS bundles"), }; + // iOS devices require the provisioning profile to be embedded in the .app bundle + if self.bundle == BundleFormat::Ios { + if let Some(profile_path) = &provisioning_profile_path { + let dest = target_exe.join("embedded.mobileprovision"); + std::fs::copy(profile_path, &dest) + .context("Failed to embed provisioning profile into .app bundle")?; + } + } + // codesign the app let output = Command::new("codesign") .args([ @@ -5780,7 +5792,7 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ Ok(app_dev_name.to_string()) } - async fn auto_provision_entitlements() -> Result { + async fn auto_provision_entitlements(bundle_id: &str) -> Result<(String, PathBuf)> { const CODESIGN_ERROR: &str = r#"This is likely because you haven't - Created a provisioning profile before - Accepted the Apple Developer Program License Agreement @@ -5815,20 +5827,28 @@ We checked the folders: ) } - // Acquire the provision file - let provision_file = profiles_folder - .read_dir()? - .flatten() - .find(|entry| { - entry - .file_name() - .to_str() - .map(|s| s.contains("mobileprovision")) - .unwrap_or_default() - }) - .context("Failed to find a provisioning profile. \n\n{CODESIGN_ERROR}")?; + #[derive(serde::Deserialize, Debug)] + struct ProvisioningProfile { + #[serde(rename = "TeamIdentifier")] + team_identifier: Vec, + #[serde(rename = "Entitlements")] + entitlements: ProfileEntitlements, + #[allow(dead_code)] + #[serde(rename = "ApplicationIdentifierPrefix")] + application_identifier_prefix: Vec, + #[serde(rename = "ProvisionedDevices", default)] + provisioned_devices: Vec, + } - // The .mobileprovision file has some random binary thrown into into, but it's still basically a plist + #[derive(serde::Deserialize, Debug)] + struct ProfileEntitlements { + #[serde(rename = "application-identifier")] + application_identifier: String, + #[serde(rename = "keychain-access-groups")] + keychain_access_groups: Vec, + } + + // The .mobileprovision file has some random binary thrown into it, but it's still basically a plist // Let's use the plist markers to find the start and end of the plist fn cut_plist(bytes: &[u8], byte_match: &[u8]) -> Option { bytes @@ -5838,34 +5858,131 @@ We checked the folders: .find(|(_, slice)| *slice == byte_match) .map(|(i, _)| i + byte_match.len()) } - let bytes = std::fs::read(provision_file.path())?; - let cut1 = cut_plist(&bytes, b""#.as_bytes()) - .context("Failed to parse .mobileprovision file")?; - let sub_bytes = &bytes[(cut1 - 6)..cut2]; - let mbfile: ProvisioningProfile = - plist::from_bytes(sub_bytes).context("Failed to parse .mobileprovision file")?; - #[derive(serde::Deserialize, Debug)] - struct ProvisioningProfile { - #[serde(rename = "TeamIdentifier")] - team_identifier: Vec, - #[serde(rename = "Entitlements")] - entitlements: Entitlements, - #[allow(dead_code)] - #[serde(rename = "ApplicationIdentifierPrefix")] - application_identifier_prefix: Vec, + fn parse_profile(path: &Path) -> Result { + let bytes = std::fs::read(path)?; + let cut1 = + cut_plist(&bytes, b""#.as_bytes()) + .context("Failed to parse .mobileprovision file")?; + let sub_bytes = &bytes[(cut1 - 6)..cut2]; + plist::from_bytes(sub_bytes).context("Failed to parse .mobileprovision file") } - #[derive(serde::Deserialize, Debug)] - struct Entitlements { - #[serde(rename = "application-identifier")] - application_identifier: String, - #[serde(rename = "keychain-access-groups")] - keychain_access_groups: Vec, + /// Check if a provisioning profile's application-identifier matches the given bundle ID. + /// The app ID is in the format "TEAMID.com.example.app" or "TEAMID.*" for wildcard profiles. + fn profile_matches_bundle_id(app_identifier: &str, bundle_id: &str) -> bool { + // Strip the team ID prefix (everything before and including the first dot) + let app_id_suffix = match app_identifier.split_once('.') { + Some((_, suffix)) => suffix, + None => return false, + }; + + // Wildcard profile matches everything + if app_id_suffix == "*" { + return true; + } + + // Check exact match + if app_id_suffix == bundle_id { + return true; + } + + // Check wildcard prefix match (e.g. "com.example.*" matches "com.example.app") + if let Some(prefix) = app_id_suffix.strip_suffix(".*") { + return bundle_id.starts_with(prefix); + } + + false } - Ok(format!( + // Collect all provisioning profiles and find the best match for the bundle ID. + // Priority: exact app ID match > more provisioned devices > newer file. + let mut best_match: Option<(PathBuf, ProvisioningProfile, bool, usize)> = None; + + for entry in profiles_folder.read_dir()?.flatten() { + let path = entry.path(); + let is_mobileprovision = path + .extension() + .map(|e| e == "mobileprovision") + .unwrap_or(false); + + if !is_mobileprovision { + continue; + } + + let profile = match parse_profile(&path) { + Ok(p) => p, + Err(e) => { + tracing::debug!("Skipping profile {}: {e}", path.display()); + continue; + } + }; + + 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; + } + + 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)); + } + } + + let (profile_path, mbfile) = match best_match { + Some((path, profile, _, _)) => { + tracing::info!( + "Using provisioning profile: {} (app ID: {})", + path.display(), + profile.entitlements.application_identifier + ); + (path, profile) + } + None => { + bail!( + "No provisioning profile found matching bundle identifier \"{bundle_id}\".\n\ + \n\ + Your provisioning profiles are in: {}\n\ + \n\ + To fix this, either:\n \ + 1. Set `bundle.identifier` in Dioxus.toml to match an existing profile\n \ + 2. Create a wildcard provisioning profile in your Apple Developer account\n \ + 3. Open the project in Xcode and let it auto-provision\n\ + \n\ + {CODESIGN_ERROR}", + profiles_folder.display() + ); + } + }; + + let entitlements_xml = format!( r#" @@ -5885,7 +6002,9 @@ We checked the folders: APPLICATION_IDENTIFIER = mbfile.entitlements.application_identifier, APP_ID_ACCESS_GROUP = mbfile.entitlements.keychain_access_groups[0], TEAM_IDENTIFIER = mbfile.team_identifier[0], - )) + ); + + Ok((entitlements_xml, profile_path)) } async fn write_app_manifest(&self, assets: &AssetManifest) -> Result<()> {