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
+225 -61

No files matched your search

+67 -22
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() {
bail!( let stderr = String::from_utf8_lossy(&output.stderr);
"Failed to install app: {}",
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 = Ok(())
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)
} }
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() 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())
+158 -39
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")?;
let cut2 = cut_plist(&bytes, r#"</dict>"#.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)] fn parse_profile(path: &Path) -> Result<ProvisioningProfile> {
struct ProvisioningProfile { let bytes = std::fs::read(path)?;
#[serde(rename = "TeamIdentifier")] let cut1 =
team_identifier: Vec<String>, cut_plist(&bytes, b"<plist").context("Failed to parse .mobileprovision file")?;
#[serde(rename = "Entitlements")] let cut2 = cut_plist(&bytes, r#"</dict>"#.as_bytes())
entitlements: Entitlements, .context("Failed to parse .mobileprovision file")?;
#[allow(dead_code)] let sub_bytes = &bytes[(cut1 - 6)..cut2];
#[serde(rename = "ApplicationIdentifierPrefix")] plist::from_bytes(sub_bytes).context("Failed to parse .mobileprovision file")
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;
}
// 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#" 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<()> {