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 }
},
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<String> {
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())
+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 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<String> {
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<String>,
#[serde(rename = "Entitlements")]
entitlements: ProfileEntitlements,
#[allow(dead_code)]
#[serde(rename = "ApplicationIdentifierPrefix")]
application_identifier_prefix: Vec<String>,
#[serde(rename = "ProvisionedDevices", default)]
provisioned_devices: Vec<String>,
}
// 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
fn cut_plist(bytes: &[u8], byte_match: &[u8]) -> Option<usize> {
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"<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)]
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>,
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())
.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<String>,
/// 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#"
<?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">
@@ -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<()> {