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 <serban.bajdechi@phreesia.com>
Co-authored-by: Jonathan Kelley <jkelleyrtp@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-02-12 17:12:29 -08:00
1 parent df4fe37180
commit cb88528c15
2 files changed
+221 -57

No files matched your search

+65 -20
View File
@@ -221,6 +221,8 @@ impl AppBuilder {
StderrReceived { msg } StderrReceived { msg }
}, },
Some(msg) = OptionFuture::from(self.spawn_handle.as_mut()) => { Some(msg) = OptionFuture::from(self.spawn_handle.as_mut()) => {
// Prevent re-polling the spawn future, similar to above
self.spawn_handle = None;
match msg { match msg {
Ok(Ok(_)) => StdoutReceived { msg: "Finished launching app".to_string() }, Ok(Ok(_)) => StdoutReceived { msg: "Finished launching app".to_string() },
Ok(Err(err)) => StderrReceived { msg: err.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<()> { async fn open_ios_device(&mut self, device_query: &str) -> Result<()> {
let device_query = device_query.to_string(); let device_query = device_query.to_string();
let root_dir = self.build.root_dir().clone(); let root_dir = self.build.root_dir().clone();
let application_id = self.build.bundle_identifier();
self.spawn_handle = Some(tokio::task::spawn(async move { self.spawn_handle = Some(tokio::task::spawn(async move {
// 1. Find an active device // 1. Find an active device
let device_uuid = Self::get_ios_device_uuid(&device_query).await?; let device_uuid = Self::get_ios_device_uuid(&device_query).await?;
tracing::info!("Uploading app to iOS device, this might take a while..."); tracing::info!("Uploading app to iOS device, this might take a while...");
// 2. Get the installation URL of the app // 2. Install the app to the device
let installation_url = Self::get_ios_installation_url(&device_uuid, &root_dir).await?; Self::install_ios_app(&device_uuid, &root_dir).await?;
// 3. Launch the app into the background, paused // 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<()> Result::Ok(()) as Result<()>
})); }));
@@ -1139,11 +1142,20 @@ impl AppBuilder {
.unwrap_or(false); .unwrap_or(false);
let is_ios_device = matches!( 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") 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; device_idx = idx;
break; break;
} }
@@ -1160,7 +1172,7 @@ impl AppBuilder {
.context("Failed to extract device UUID") .context("Failed to extract device UUID")
} }
async fn get_ios_installation_url(device_uuid: &str, app_path: &Path) -> Result<String> { async fn install_ios_app(device_uuid: &str, app_path: &Path) -> Result<()> {
let tmpfile = tempfile::NamedTempFile::new() let tmpfile = tempfile::NamedTempFile::new()
.context("Failed to create temporary file for device list")?; .context("Failed to create temporary file for device list")?;
@@ -1181,27 +1193,60 @@ impl AppBuilder {
.await?; .await?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("DeviceLocked") || stderr.contains("device is locked") {
bail!( bail!(
"Failed to install app: {}", "Failed to install app: your device is locked.\n\
String::from_utf8_lossy(&output.stderr) Unlock your iPhone/iPad and try again."
); );
} }
let json: serde_json::Value = if stderr.contains("cannot be installed on this device")
serde_json::from_str(&std::fs::read_to_string(tmpfile.path())?) || stderr.contains("0xe8008012")
.context("Failed to parse xcrun output")?; {
let installation_url = json["result"]["installedApplications"][0]["installationURL"] bail!(
.as_str() "Failed to install app: your device is not registered in the provisioning profile.\n\n\
.context("Failed to extract installation URL from xcrun output")? Your device UDID needs to be added to your Apple Developer account and the \
.to_string(); provisioning profile regenerated.\n\n\
To fix this:\n \
Ok(installation_url) 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}"
);
} }
async fn launch_ios_app_paused(device_uuid: &str, installation_url: &str) -> Result<()> { 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}");
}
Ok(())
}
async fn launch_ios_app_paused(device_uuid: &str, application_id: &str) -> Result<()> {
let tmpfile = tempfile::NamedTempFile::new() let tmpfile = tempfile::NamedTempFile::new()
.context("Failed to create temporary file for device list")?; .context("Failed to create temporary file for device list")?;
let output = Command::new("xcrun") let output = Command::new("xcrun")
.args([ .args([
"devicectl", "devicectl",
@@ -1212,7 +1257,7 @@ impl AppBuilder {
"--verbose", "--verbose",
"--device", "--device",
device_uuid, device_uuid,
installation_url, application_id,
"--json-output", "--json-output",
]) ])
.arg(tmpfile.path()) .arg(tmpfile.path())
+156 -37
View File
@@ -5700,13 +5700,16 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{
} }
let mut entitlements_file = self.apple_entitlements.clone(); let mut entitlements_file = self.apple_entitlements.clone();
let mut provisioning_profile_path = None;
if entitlements_file.is_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 .await
.context("Failed to auto-provision entitlements for Apple codesigning.")?; .context("Failed to auto-provision entitlements for Apple codesigning.")?;
let entitlements_temp_file = tempfile::NamedTempFile::new()?; let entitlements_temp_file = tempfile::NamedTempFile::new()?;
std::fs::write(entitlements_temp_file.path(), entitlements_xml)?; std::fs::write(entitlements_temp_file.path(), entitlements_xml)?;
entitlements_file = Some(entitlements_temp_file.path().to_path_buf()); entitlements_file = Some(entitlements_temp_file.path().to_path_buf());
provisioning_profile_path = Some(profile_path);
_saved_entitlements = Some(entitlements_temp_file); _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"), _ => 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 // codesign the app
let output = Command::new("codesign") let output = Command::new("codesign")
.args([ .args([
@@ -5780,7 +5792,7 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{
Ok(app_dev_name.to_string()) Ok(app_dev_name.to_string())
} }
async fn auto_provision_entitlements() -> Result<String> { async fn auto_provision_entitlements(bundle_id: &str) -> Result<(String, PathBuf)> {
const CODESIGN_ERROR: &str = r#"This is likely because you haven't const CODESIGN_ERROR: &str = r#"This is likely because you haven't
- Created a provisioning profile before - Created a provisioning profile before
- Accepted the Apple Developer Program License Agreement - Accepted the Apple Developer Program License Agreement
@@ -5815,20 +5827,28 @@ We checked the folders:
) )
} }
// Acquire the provision file #[derive(serde::Deserialize, Debug)]
let provision_file = profiles_folder struct ProvisioningProfile {
.read_dir()? #[serde(rename = "TeamIdentifier")]
.flatten() team_identifier: Vec<String>,
.find(|entry| { #[serde(rename = "Entitlements")]
entry entitlements: ProfileEntitlements,
.file_name() #[allow(dead_code)]
.to_str() #[serde(rename = "ApplicationIdentifierPrefix")]
.map(|s| s.contains("mobileprovision")) application_identifier_prefix: Vec<String>,
.unwrap_or_default() #[serde(rename = "ProvisionedDevices", default)]
}) provisioned_devices: Vec<String>,
.context("Failed to find a provisioning profile. \n\n{CODESIGN_ERROR}")?; }
// 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<String>,
}
// 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 // Let's use the plist markers to find the start and end of the plist
fn cut_plist(bytes: &[u8], byte_match: &[u8]) -> Option<usize> { fn cut_plist(bytes: &[u8], byte_match: &[u8]) -> Option<usize> {
bytes bytes
@@ -5838,34 +5858,131 @@ We checked the folders:
.find(|(_, slice)| *slice == byte_match) .find(|(_, slice)| *slice == byte_match)
.map(|(i, _)| i + byte_match.len()) .map(|(i, _)| i + byte_match.len())
} }
let bytes = std::fs::read(provision_file.path())?;
let cut1 = cut_plist(&bytes, b"<plist").context("Failed to parse .mobileprovision file")?; fn parse_profile(path: &Path) -> Result<ProvisioningProfile> {
let bytes = std::fs::read(path)?;
let cut1 =
cut_plist(&bytes, b"<plist").context("Failed to parse .mobileprovision file")?;
let cut2 = cut_plist(&bytes, r#"</dict>"#.as_bytes()) let cut2 = cut_plist(&bytes, r#"</dict>"#.as_bytes())
.context("Failed to parse .mobileprovision file")?; .context("Failed to parse .mobileprovision file")?;
let sub_bytes = &bytes[(cut1 - 6)..cut2]; let sub_bytes = &bytes[(cut1 - 6)..cut2];
let mbfile: ProvisioningProfile = plist::from_bytes(sub_bytes).context("Failed to parse .mobileprovision file")
plist::from_bytes(sub_bytes).context("Failed to parse .mobileprovision file")?;
#[derive(serde::Deserialize, Debug)]
struct ProvisioningProfile {
#[serde(rename = "TeamIdentifier")]
team_identifier: Vec<String>,
#[serde(rename = "Entitlements")]
entitlements: Entitlements,
#[allow(dead_code)]
#[serde(rename = "ApplicationIdentifierPrefix")]
application_identifier_prefix: Vec<String>,
} }
#[derive(serde::Deserialize, Debug)] /// Check if a provisioning profile's application-identifier matches the given bundle ID.
struct Entitlements { /// The app ID is in the format "TEAMID.com.example.app" or "TEAMID.*" for wildcard profiles.
#[serde(rename = "application-identifier")] fn profile_matches_bundle_id(app_identifier: &str, bundle_id: &str) -> bool {
application_identifier: String, // Strip the team ID prefix (everything before and including the first dot)
#[serde(rename = "keychain-access-groups")] let app_id_suffix = match app_identifier.split_once('.') {
keychain_access_groups: Vec<String>, Some((_, suffix)) => suffix,
None => return false,
};
// Wildcard profile matches everything
if app_id_suffix == "*" {
return true;
} }
Ok(format!( // 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
}
// 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#" r#"
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -5885,7 +6002,9 @@ We checked the folders:
APPLICATION_IDENTIFIER = mbfile.entitlements.application_identifier, APPLICATION_IDENTIFIER = mbfile.entitlements.application_identifier,
APP_ID_ACCESS_GROUP = mbfile.entitlements.keychain_access_groups[0], APP_ID_ACCESS_GROUP = mbfile.entitlements.keychain_access_groups[0],
TEAM_IDENTIFIER = mbfile.team_identifier[0], TEAM_IDENTIFIER = mbfile.team_identifier[0],
)) );
Ok((entitlements_xml, profile_path))
} }
async fn write_app_manifest(&self, assets: &AssetManifest) -> Result<()> { async fn write_app_manifest(&self, assets: &AssetManifest) -> Result<()> {