diff --git a/Cargo.lock b/Cargo.lock index 1ba4945bc..808d16d58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5137,6 +5137,7 @@ dependencies = [ "memmap", "memoize", "notify", + "nucleo", "object 0.37.1", "open", "path-absolutize", @@ -11006,6 +11007,27 @@ dependencies = [ "winapi", ] +[[package]] +name = "nucleo" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5262af4c94921c2646c5ac6ff7900c2af9cbb08dc26a797e18130a7019c039d4" +dependencies = [ + "nucleo-matcher", + "parking_lot", + "rayon", +] + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "num" version = "0.4.3" diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index 3363498be..c7ad62603 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -159,6 +159,7 @@ posthog-rs = "0.3.5" serde_json5 = "0.2.1" fs2 = "0.4.3" sentry-backtrace = "0.42.0" +nucleo = "0.5.0" [build-dependencies] built = { version = "0.8.0", features = ["git2"] } diff --git a/packages/cli/src/build/builder.rs b/packages/cli/src/build/builder.rs index 5635561ec..d27051940 100644 --- a/packages/cli/src/build/builder.rs +++ b/packages/cli/src/build/builder.rs @@ -402,6 +402,7 @@ impl AppBuilder { ); } BuildStage::Bundling => tracing::info!("Bundling app..."), + BuildStage::CodeSigning => tracing::info!("Code signing app..."), _ => {} } @@ -560,16 +561,16 @@ impl AppBuilder { } BundleFormat::Ios => { - if self.build.device { - self.codesign_ios().await?; - self.open_ios_device().await? + if let Some(device) = self.build.device_name.as_deref() { + self.open_ios_device(device).await? } else { self.open_ios_sim(envs).await? } } BundleFormat::Android => { - self.open_android_sim(false, devserver_ip, envs).await?; + self.open_android(false, devserver_ip, envs, self.build.device_name.clone()) + .await?; } // These are all just basically running the main exe, but with slightly different resource dir paths @@ -912,335 +913,252 @@ impl AppBuilder { Ok(()) } - /// We have this whole thing figured out, but we don't actually use it yet. - /// - /// Launching on devices is more complicated and requires us to codesign the app, which we don't - /// currently do. - /// - /// Converting these commands shouldn't be too hard, but device support would imply we need - /// better support for codesigning and entitlements. - async fn open_ios_device(&self) -> Result<()> { - use serde_json::Value; + /// Upload the app to the device and launch it + async fn open_ios_device(&self, device_query: &str) -> Result<()> { + let device_query = device_query.to_string(); + let root_dir = self.build.root_dir().clone(); + tokio::task::spawn(async move { + // 1. Find an active device + let device_uuid = Self::get_ios_device_uuid(&device_query).await?; - // 1. Find an active device - let device_uuid = get_device_uuid().await?; + tracing::info!("Uploading app to iOS device, this might take a while..."); - // 2. Get the installation URL of the app - let installation_url = get_installation_url(&device_uuid, &self.build.root_dir()).await?; + // 2. Get the installation URL of the app + let installation_url = Self::get_ios_installation_url(&device_uuid, &root_dir).await?; - // 3. Launch the app into the background, paused - launch_app_paused(&device_uuid, &installation_url).await?; + // 3. Launch the app into the background, paused + Self::launch_ios_app_paused(&device_uuid, &installation_url).await?; - async fn get_device_uuid() -> Result { - let tmpfile = tempfile::NamedTempFile::new() - .context("Failed to create temporary file for device list")?; - - Command::new("xcrun") - .args([ - "devicectl".to_string(), - "list".to_string(), - "devices".to_string(), - "--json-output".to_string(), - tmpfile.path().to_str().unwrap().to_string(), - ]) - .output() - .await?; - - let json: Value = serde_json::from_str(&std::fs::read_to_string(tmpfile.path())?) - .context("Failed to parse xcrun output")?; - let device_uuid = json["result"]["devices"][0]["identifier"] - .as_str() - .context("Failed to extract device UUID")? - .to_string(); - - Ok(device_uuid) - } - - async fn get_installation_url(device_uuid: &str, app_path: &Path) -> Result { - let tmpfile = tempfile::NamedTempFile::new() - .context("Failed to create temporary file for device list")?; - - // xcrun devicectl device install app --device --path --json-output - let output = Command::new("xcrun") - .args([ - "devicectl", - "device", - "install", - "app", - "--device", - device_uuid, - &app_path.display().to_string(), - "--json-output", - ]) - .arg(tmpfile.path()) - .output() - .await?; - - if !output.status.success() { - bail!( - "Failed to install app: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - let 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) - } - - async fn launch_app_paused(device_uuid: &str, installation_url: &str) -> Result<()> { - let tmpfile = tempfile::NamedTempFile::new() - .context("Failed to create temporary file for device list")?; - - let output = Command::new("xcrun") - .args([ - "devicectl", - "device", - "process", - "launch", - "--no-activate", - "--verbose", - "--device", - device_uuid, - installation_url, - "--json-output", - ]) - .arg(tmpfile.path()) - .output() - .await?; - - if !output.status.success() { - bail!("Failed to launch app: {output:?}"); - } - - let json: Value = serde_json::from_str(&std::fs::read_to_string(tmpfile.path())?) - .context("Failed to parse xcrun output")?; - - let status_pid = json["result"]["process"]["processIdentifier"] - .as_u64() - .context("Failed to extract process identifier")?; - - let output = Command::new("xcrun") - .args([ - "devicectl", - "device", - "process", - "resume", - "--device", - device_uuid, - "--pid", - &status_pid.to_string(), - ]) - .output() - .await?; - - if !output.status.success() { - bail!("Failed to resume app: {output:?}"); - } - - Ok(()) - } + Result::Ok(()) as Result<()> + }); Ok(()) } - async fn codesign_ios(&self) -> Result<()> { - // We don't want to drop the entitlements file, until the end of the block, so we hoist it to this temporary. - let mut _saved_entitlements = None; + /// Parse the xcrun output to get the device based on its name and connected state. + /// + /// ```json, ignore + /// "connectionProperties" : { + /// "authenticationType" : "manualPairing", + /// "isMobileDeviceOnly" : false, + /// "lastConnectionDate" : "2025-08-15T01:46:43.182Z", + /// "pairingState" : "paired", + /// "potentialHostnames" : [ + /// "00008130-0002058401E8001C.coredevice.local", + /// "67054C13-C6C8-5AC2-B967-24C040AD3F17.coredevice.local" + /// ], + /// "transportType" : "localNetwork", + /// "tunnelState" : "disconnected", + /// "tunnelTransportProtocol" : "tcp" + /// }, + /// "deviceProperties" : { + /// "bootedFromSnapshot" : true, + /// "bootedSnapshotName" : "com.apple.os.update-A771E2B3E8C155D1B1188896B3247851B64737ACDE91A5B6F6C1F03A541406AA", + /// "ddiServicesAvailable" : false, + /// "developerModeStatus" : "enabled", + /// "hasInternalOSBuild" : false, + /// "name" : "Jon’s iPhone (2)", + /// "osBuildUpdate" : "22G86", + /// "osVersionNumber" : "18.6", + /// "rootFileSystemIsWritable" : false + /// } + /// ``` + async fn get_ios_device_uuid(device_name_query: &str) -> Result { + use serde_json::Value; - let mut app_dev_name = self.build.apple_team_id.clone(); - if app_dev_name.is_none() { - app_dev_name = - Some(self.auto_provision_signing_name().await.context( - "Failed to automatically provision signing name for iOS codesigning.", - )?); - } + let tmpfile = tempfile::NamedTempFile::new() + .context("Failed to create temporary file for device list")?; - let mut entitlements_file = self.build.apple_entitlements.clone(); - if entitlements_file.is_none() { - let entitlements_xml = self - .auto_provision_entitlements() - .await - .context("Failed to auto-provision entitlements for iOS 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()); - _saved_entitlements = Some(entitlements_temp_file); - } - - let entitlements_file = entitlements_file.as_ref().context( - "No entitlements file provided and could not provision entitlements to sign app.", - )?; - let app_dev_name = app_dev_name.as_ref().context( - "No Apple Development signing name provided and could not auto-provision one.", - )?; - - tracing::debug!( - "Codesigning iOS app with entitlements: {} and dev name: {}", - entitlements_file.display(), - app_dev_name - ); - - // codesign the app - let output = Command::new("codesign") + Command::new("xcrun") .args([ - "--force", - "--entitlements", - entitlements_file.to_str().unwrap(), - "--sign", - app_dev_name, + "devicectl".to_string(), + "list".to_string(), + "devices".to_string(), + "--json-output".to_string(), + tmpfile.path().to_str().unwrap().to_string(), ]) - .arg(self.build.root_dir()) .output() - .await - .context("Failed to codesign the app - is `codesign` in your path?")?; + .await?; + + let json: Value = serde_json::from_str(&std::fs::read_to_string(tmpfile.path())?) + .context("Failed to parse xcrun output")?; + + let devices = json + .get("result") + .context("Failed to parse xcrun output")? + .get("devices") + .context("Failed to parse xcrun output")? + .as_array() + .context("Failed to get devices from xcrun output")?; + + // by default, we just pick the first available device and then look for better fits. + let mut device_idx = 0; + + match device_name_query.is_empty() { + // If the user provided a query, then we look through the device list looking for the right one. + // This searches both UUIDs and names, making it possible to paste an ID or a name. + false => { + use nucleo::{chars, Config, Matcher, Utf32Str}; + let normalize = |c: char| chars::to_lower_case(chars::normalize(c)); + let mut matcher = Matcher::new(Config::DEFAULT); + let mut best_score = 0; + let needle = device_name_query.chars().map(normalize).collect::(); + for (idx, device) in devices.iter().enumerate() { + let device_name = device + .get("deviceProperties") + .and_then(|f| f.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or_default(); + let device_uuid = device + .get("identifier") + .and_then(|n| n.as_str()) + .unwrap_or_default(); + let haystack = format!("{device_name} {device_uuid}") + .chars() + .map(normalize) + .collect::(); + let name_score = matcher.fuzzy_match( + Utf32Str::Ascii(haystack.as_bytes()), + Utf32Str::Ascii(needle.as_bytes()), + ); + if let Some(score) = name_score { + if score > best_score { + best_score = score; + device_idx = idx; + } + } + } + + if best_score == 0 { + tracing::warn!( + "No device found matching query: {device_name_query}. Using first available device." + ); + } + } + + // If the query is empty, then we just find the first connected/available device + // This is somewhat based on the bundle format, since we don't want to accidentally upload + // iOS apps to watches/tvs + true => { + for (idx, device) in devices.iter().enumerate() { + let is_paired = device + .get("connectionProperties") + .and_then(|g| g.get("pairingState")) + .map(|s| s.as_str() == Some("paired")) + .unwrap_or(false); + + let is_ios_device = matches!( + device.get("deviceType").and_then(|s| s.as_str()), + Some("iPhone") | Some("iPad") | Some("iPod") + ); + + if is_paired && is_ios_device { + device_idx = idx; + break; + } + } + } + } + + devices + .get(device_idx) + .context("No devices found")? + .get("identifier") + .and_then(|id| id.as_str()) + .map(|s| s.to_string()) + .context("Failed to extract device UUID") + } + + async fn get_ios_installation_url(device_uuid: &str, app_path: &Path) -> Result { + let tmpfile = tempfile::NamedTempFile::new() + .context("Failed to create temporary file for device list")?; + + // xcrun devicectl device install app --device --path --json-output + let output = Command::new("xcrun") + .args([ + "devicectl", + "device", + "install", + "app", + "--device", + device_uuid, + &app_path.display().to_string(), + "--json-output", + ]) + .arg(tmpfile.path()) + .output() + .await?; if !output.status.success() { bail!( - "Failed to codesign the app: {}", - String::from_utf8(output.stderr).unwrap_or_default() + "Failed to install app: {}", + String::from_utf8_lossy(&output.stderr) ); } - Ok(()) + 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) } - async fn auto_provision_signing_name(&self) -> Result { - let identities = Command::new("security") - .args(["find-identity", "-v", "-p", "codesigning"]) + async fn launch_ios_app_paused(device_uuid: &str, installation_url: &str) -> Result<()> { + let tmpfile = tempfile::NamedTempFile::new() + .context("Failed to create temporary file for device list")?; + + let output = Command::new("xcrun") + .args([ + "devicectl", + "device", + "process", + "launch", + "--no-activate", + "--verbose", + "--device", + device_uuid, + installation_url, + "--json-output", + ]) + .arg(tmpfile.path()) .output() - .await - .context("Failed to run `security find-identity -v -p codesigning` - is `security` in your path?") - .map(|e| { - String::from_utf8(e.stdout) - .context("Failed to parse `security find-identity -v -p codesigning`") - })??; + .await?; - // Parsing this: - // 1231231231231asdasdads123123 "Apple Development: foo@gmail.com (XYZYZY)" - let app_dev_name = regex::Regex::new(r#""Apple Development: (.+)""#) - .unwrap() - .captures(&identities) - .and_then(|caps| caps.get(1)) - .map(|m| m.as_str()) - .context( - "Failed to find Apple Development in `security find-identity -v -p codesigning`", - )?; - - Ok(app_dev_name.to_string()) - } - - async fn auto_provision_entitlements(&self) -> Result { - const CODESIGN_ERROR: &str = r#"This is likely because you haven't -- Created a provisioning profile before -- Accepted the Apple Developer Program License Agreement - -The agreement changes frequently and might need to be accepted again. -To accept the agreement, go to https://developer.apple.com/account - -To create a provisioning profile, follow the instructions here: -https://developer.apple.com/documentation/xcode/sharing-your-teams-signing-certificates"#; - - // Check the xcode 16 location first - let mut profiles_folder = dirs::home_dir() - .context("Your machine has no home-dir")? - .join("Library/Developer/Xcode/UserData/Provisioning Profiles"); - - // If it doesn't exist, check the old location - if !profiles_folder.exists() { - profiles_folder = dirs::home_dir() - .context("Your machine has no home-dir")? - .join("Library/MobileDevice/Provisioning Profiles"); + if !output.status.success() { + bail!("Failed to launch app: {output:?}"); } - if !profiles_folder.exists() || profiles_folder.read_dir()?.next().is_none() { - tracing::error!( - r#"No provisioning profiles found when trying to codesign the app. -We checked the folders: -- XCode16: ~/Library/Developer/Xcode/UserData/Provisioning Profiles -- XCode15: ~/Library/MobileDevice/Provisioning Profiles + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(tmpfile.path())?) + .context("Failed to parse xcrun output")?; -{CODESIGN_ERROR} -"# - ) + let status_pid = json["result"]["process"]["processIdentifier"] + .as_u64() + .context("Failed to extract process identifier")?; + + let output = Command::new("xcrun") + .args([ + "devicectl", + "device", + "process", + "resume", + "--device", + device_uuid, + "--pid", + &status_pid.to_string(), + ]) + .output() + .await?; + + if !output.status.success() { + bail!("Failed to resume app: {output:?}"); } - // 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}")?; - - // The .mobileprovision file has some random binary thrown into into, 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 - .windows(byte_match.len()) - .enumerate() - .rev() - .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, - } - - #[derive(serde::Deserialize, Debug)] - struct Entitlements { - #[serde(rename = "application-identifier")] - application_identifier: String, - #[serde(rename = "keychain-access-groups")] - keychain_access_groups: Vec, - } - - Ok(format!( - r#" - - - - application-identifier - {APPLICATION_IDENTIFIER} - keychain-access-groups - - {APP_ID_ACCESS_GROUP}.* - - get-task-allow - - com.apple.developer.team-identifier - {TEAM_IDENTIFIER} - - "#, - APPLICATION_IDENTIFIER = mbfile.entitlements.application_identifier, - APP_ID_ACCESS_GROUP = mbfile.entitlements.keychain_access_groups[0], - TEAM_IDENTIFIER = mbfile.team_identifier[0], - )) + Ok(()) } /// Launch the Android simulator and deploy the application. @@ -1278,11 +1196,12 @@ We checked the folders: /// /// # Resources: /// - - async fn open_android_sim( + async fn open_android( &self, root: bool, devserver_socket: SocketAddr, envs: Vec<(String, String)>, + device_name_query: Option, ) -> Result<()> { let apk_path = self.build.debug_apk_path(); let session_cache = self.build.session_cache_dir(); @@ -1298,19 +1217,14 @@ We checked the folders: } } - let port = devserver_socket.port(); - if let Err(e) = Command::new(&adb) - .arg("reverse") - .arg(format!("tcp:{port}")) - .arg(format!("tcp:{port}")) - .output() - .await - { - tracing::error!("failed to forward port {port}: {e}"); - } + // Try to get the transport ID for the device in case there are multiple specified devices + // All future commands should use this since its the most recent. + let transport_id_args = + Self::get_android_device_transport_id(&adb, device_name_query).await; // Wait for device to be ready let cmd = Command::new(&adb) + .args(transport_id_args) .arg("wait-for-device") .arg("shell") .arg(r#"while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;"#) @@ -1325,6 +1239,17 @@ We checked the folders: } } + let port = devserver_socket.port(); + if let Err(e) = Command::new(&adb) + .arg("reverse") + .arg(format!("tcp:{port}")) + .arg(format!("tcp:{port}")) + .output() + .await + { + tracing::error!("failed to forward port {port}: {e}"); + } + // Install // adb install -r app-debug.apk let res = Command::new(&adb) @@ -1650,4 +1575,58 @@ We checked the folders: Ok(()) } + + async fn get_android_device_transport_id( + adb: &PathBuf, + device_name_query: Option, + ) -> Vec { + // If there are multiple devices, we pick the one matching the query + let mut device_specifier_args = vec![]; + + if let Some(device_name_query) = device_name_query { + if let Ok(res) = Command::new(adb).arg("devices").arg("-l").output().await { + let devices = String::from_utf8_lossy(&res.stdout); + let mut best_score = 0; + let mut device_identifier = "".to_string(); + use nucleo::{chars, Config, Matcher, Utf32Str}; + let mut matcher = Matcher::new(Config::DEFAULT); + let normalize = |c: char| chars::to_lower_case(chars::normalize(c)); + let needle = device_name_query.chars().map(normalize).collect::>(); + + for line in devices.lines() { + let device_name = line.split_whitespace().next().unwrap_or(""); + let Some(transport_id) = line + .split_whitespace() + .find(|s| s.starts_with("transport_id:")) + .map(|s| s.trim_start_matches("transport_id:")) + else { + continue; + }; + + let device_name = device_name.chars().map(normalize).collect::>(); + let score = matcher + .fuzzy_match(Utf32Str::Unicode(&device_name), Utf32Str::Unicode(&needle)); + if let Some(score) = score { + if score > best_score { + best_score = score; + device_identifier = transport_id.to_string(); + } + } + } + + if best_score != 0 { + device_specifier_args.push("-t".to_string()); + device_specifier_args.push(device_identifier.to_string()); + } + } + + if device_specifier_args.is_empty() { + tracing::warn!( + "No device found matching query: {device_name_query}. Using default transport ID." + ); + } + } + + device_specifier_args + } } diff --git a/packages/cli/src/build/context.rs b/packages/cli/src/build/context.rs index 30a491352..3cf7ac873 100644 --- a/packages/cli/src/build/context.rs +++ b/packages/cli/src/build/context.rs @@ -102,6 +102,12 @@ impl BuildContext { }) } + pub(crate) fn status_codesigning(&self) { + _ = self.tx.unbounded_send(BuilderUpdate::Progress { + stage: BuildStage::CodeSigning, + }); + } + pub(crate) fn status_build_diagnostic(&self, message: Diagnostic) { _ = self .tx diff --git a/packages/cli/src/build/request.rs b/packages/cli/src/build/request.rs index 3d907228c..a6eb49228 100644 --- a/packages/cli/src/build/request.rs +++ b/packages/cli/src/build/request.rs @@ -377,7 +377,8 @@ pub(crate) struct BuildRequest { pub(crate) release: bool, pub(crate) bundle: BundleFormat, pub(crate) triple: Triple, - pub(crate) device: bool, + pub(crate) device_name: Option, + pub(crate) should_codesign: bool, pub(crate) package: String, pub(crate) main_target: String, pub(crate) features: Vec, @@ -625,7 +626,7 @@ impl BuildRequest { // We usually use the simulator unless --device is passed *or* a device is detected by probing. // For now, though, since we don't have probing, it just defaults to false // Tools like xcrun/adb can detect devices - let device = args.device; + let device = args.device.clone(); // Resolve the target alias and renderer into a concrete target alias. // If the user didn't pass a platform, but we have a renderer, get the default platform for that renderer @@ -639,7 +640,7 @@ impl BuildRequest { // If there is an explicit target, use it (Some(target), _) => target, // If there is a platform, use it to determine the target triple - (None, platform) => platform.into_target(device, &workspace).await?, + (None, platform) => platform.into_target(device.is_some(), &workspace).await?, }; // If the user didn't pass a renderer, and we didn't find a good default renderer, but they are using dioxus explicitly, @@ -682,6 +683,10 @@ impl BuildRequest { args.release, ); + // Determine if we should codesign + let should_codesign = + args.codesign || device.is_some() || args.apple_entitlements.is_some(); + // Determining release mode is based on the profile, actually, so we need to check that let release = workspace.is_release_profile(&profile); @@ -807,7 +812,7 @@ impl BuildRequest { crate_target, profile, triple, - device, + device_name: device, workspace, config, target_dir, @@ -824,6 +829,7 @@ impl BuildRequest { main_target, rustflags, using_dioxus_explicitly, + should_codesign, skip_assets: args.skip_assets, base_path: args.base_path.clone(), wasm_split: args.wasm_split, @@ -3973,28 +3979,38 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ /// /// This might include codesigning, zipping, creating an appimage, etc async fn assemble(&self, ctx: &BuildContext) -> Result<()> { - if self.bundle == BundleFormat::Android { - ctx.status_running_gradle(); + match self.bundle { + BundleFormat::Android => { + ctx.status_running_gradle(); - // When the build mode is set to release and there is an Android signature configuration, use assembleRelease - let build_type = if self.release && self.config.bundle.android.is_some() { - "assembleRelease" - } else { - "assembleDebug" - }; + // When the build mode is set to release and there is an Android signature configuration, use assembleRelease + let build_type = if self.release && self.config.bundle.android.is_some() { + "assembleRelease" + } else { + "assembleDebug" + }; - let output = Command::new(self.gradle_exe()?) - .arg(build_type) - .current_dir(self.root_dir()) - .output() - .await?; + let output = Command::new(self.gradle_exe()?) + .arg(build_type) + .current_dir(self.root_dir()) + .output() + .await?; - if !output.status.success() { - bail!( - "Failed to assemble apk: {}", - String::from_utf8_lossy(&output.stderr) - ); + if !output.status.success() { + bail!( + "Failed to assemble apk: {}", + String::from_utf8_lossy(&output.stderr) + ); + } } + BundleFormat::Ios => { + if self.should_codesign { + ctx.status_codesigning(); + self.codesign_ios().await?; + } + } + + _ => {} } Ok(()) @@ -4209,6 +4225,8 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ pub(crate) async fn verify_tooling(&self, ctx: &BuildContext) -> Result<()> { ctx.status_installing_tooling(); + self.verify_toolchain_installed().await?; + match self.bundle { BundleFormat::Web => self.verify_web_tooling().await?, BundleFormat::Ios => self.verify_ios_tooling().await?, @@ -4220,25 +4238,59 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ Ok(()) } - async fn verify_web_tooling(&self) -> Result<()> { + async fn verify_toolchain_installed(&self) -> Result<()> { + let toolchain_dir = self.workspace.sysroot.join("lib/rustlib"); + let triple = self.triple.to_string(); + // Install target using rustup. - #[cfg(not(feature = "no-downloads"))] - if !self.workspace.has_wasm32_unknown_unknown() { + if !toolchain_dir.join(&triple).exists() { tracing::info!( - "Web platform requires wasm32-unknown-unknown to be installed. Installing..." + "{} platform requires {} to be installed. Installing...", + self.bundle, + triple ); - let _ = tokio::process::Command::new("rustup") - .args(["target", "add", "wasm32-unknown-unknown"]) - .output() - .await?; + let mut child = tokio::process::Command::new("rustup") + .args(["target", "add"]) + .arg(&triple) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + + let stdout = tokio::io::BufReader::new(child.stdout.take().unwrap()); + let stderr = tokio::io::BufReader::new(child.stderr.take().unwrap()); + let mut stdout_lines = stdout.lines(); + let mut stderr_lines = stderr.lines(); + loop { + tokio::select! { + line = stdout_lines.next_line() => { + match line { + Ok(Some(line)) => tracing::info!("{}", line), + Err(err) => tracing::error!("{}", err), + Ok(_) => break, + } + } + line = stderr_lines.next_line() => { + match line { + Ok(Some(line)) => tracing::info!("{}", line), + Err(err) => tracing::error!("{}", err), + Ok(_) => break, + } + } + } + } } // Ensure target is installed. - if !self.workspace.has_wasm32_unknown_unknown() { - bail!("Missing target wasm32-unknown-unknown."); + if !toolchain_dir.join(&triple).exists() { + bail!("Missing rust target {}", triple); } + Ok(()) + } + + async fn verify_web_tooling(&self) -> Result<()> { // Wasm bindgen let krate_bindgen_version = self.workspace @@ -4599,7 +4651,7 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ } pub(crate) async fn start_simulators(&self) -> Result<()> { - if self.device { + if self.device_name.is_some() { return Ok(()); } @@ -4793,4 +4845,197 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ Ok(()) } + + pub async fn codesign_ios(&self) -> Result<()> { + // We don't want to drop the entitlements file, until the end of the block, so we hoist it to this temporary. + let mut _saved_entitlements = None; + + let mut app_dev_name = self.apple_team_id.clone(); + if app_dev_name.is_none() { + app_dev_name = + Some(Self::auto_provision_signing_name().await.context( + "Failed to automatically provision signing name for iOS codesigning.", + )?); + } + + let mut entitlements_file = self.apple_entitlements.clone(); + if entitlements_file.is_none() { + let entitlements_xml = Self::auto_provision_entitlements() + .await + .context("Failed to auto-provision entitlements for iOS 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()); + _saved_entitlements = Some(entitlements_temp_file); + } + + let entitlements_file = entitlements_file.as_ref().context( + "No entitlements file provided and could not provision entitlements to sign app.", + )?; + let app_dev_name = app_dev_name.as_ref().context( + "No Apple Development signing name provided and could not auto-provision one.", + )?; + + tracing::debug!( + "Codesigning iOS app with entitlements: {} and dev name: {}", + entitlements_file.display(), + app_dev_name + ); + + // codesign the app + let output = Command::new("codesign") + .args([ + "--force", + "--entitlements", + entitlements_file.to_str().unwrap(), + "--sign", + app_dev_name, + ]) + .arg(self.root_dir()) + .output() + .await + .context("Failed to codesign the app - is `codesign` in your path?")?; + + if !output.status.success() { + bail!( + "Failed to codesign the app: {}", + String::from_utf8(output.stderr).unwrap_or_default() + ); + } + + Ok(()) + } + + async fn auto_provision_signing_name() -> Result { + let identities = Command::new("security") + .args(["find-identity", "-v", "-p", "codesigning"]) + .output() + .await + .context("Failed to run `security find-identity -v -p codesigning` - is `security` in your path?") + .map(|e| { + String::from_utf8(e.stdout) + .context("Failed to parse `security find-identity -v -p codesigning`") + })??; + + // Parsing this: + // 1231231231231asdasdads123123 "Apple Development: foo@gmail.com (XYZYZY)" + let app_dev_name = regex::Regex::new(r#""Apple Development: (.+)""#) + .unwrap() + .captures(&identities) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str()) + .context( + "Failed to find Apple Development in `security find-identity -v -p codesigning`", + )?; + + Ok(app_dev_name.to_string()) + } + + async fn auto_provision_entitlements() -> Result { + const CODESIGN_ERROR: &str = r#"This is likely because you haven't +- Created a provisioning profile before +- Accepted the Apple Developer Program License Agreement + +The agreement changes frequently and might need to be accepted again. +To accept the agreement, go to https://developer.apple.com/account + +To create a provisioning profile, follow the instructions here: +https://developer.apple.com/documentation/xcode/sharing-your-teams-signing-certificates"#; + + // Check the xcode 16 location first + let mut profiles_folder = dirs::home_dir() + .context("Your machine has no home-dir")? + .join("Library/Developer/Xcode/UserData/Provisioning Profiles"); + + // If it doesn't exist, check the old location + if !profiles_folder.exists() { + profiles_folder = dirs::home_dir() + .context("Your machine has no home-dir")? + .join("Library/MobileDevice/Provisioning Profiles"); + } + + if !profiles_folder.exists() || profiles_folder.read_dir()?.next().is_none() { + tracing::error!( + r#"No provisioning profiles found when trying to codesign the app. +We checked the folders: +- XCode16: ~/Library/Developer/Xcode/UserData/Provisioning Profiles +- XCode15: ~/Library/MobileDevice/Provisioning Profiles + +{CODESIGN_ERROR} +"# + ) + } + + // 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}")?; + + // The .mobileprovision file has some random binary thrown into into, 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 + .windows(byte_match.len()) + .enumerate() + .rev() + .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, + } + + #[derive(serde::Deserialize, Debug)] + struct Entitlements { + #[serde(rename = "application-identifier")] + application_identifier: String, + #[serde(rename = "keychain-access-groups")] + keychain_access_groups: Vec, + } + + Ok(format!( + r#" + + + + application-identifier + {APPLICATION_IDENTIFIER} + keychain-access-groups + + {APP_ID_ACCESS_GROUP}.* + + get-task-allow + + com.apple.developer.team-identifier + {TEAM_IDENTIFIER} + + "#, + APPLICATION_IDENTIFIER = mbfile.entitlements.application_identifier, + APP_ID_ACCESS_GROUP = mbfile.entitlements.keychain_access_groups[0], + TEAM_IDENTIFIER = mbfile.team_identifier[0], + )) + } } diff --git a/packages/cli/src/cli/create.rs b/packages/cli/src/cli/create.rs index 3d0f22c71..4f8ae74ed 100644 --- a/packages/cli/src/cli/create.rs +++ b/packages/cli/src/cli/create.rs @@ -141,12 +141,9 @@ pub(crate) fn name_from_path(path: &Path) -> Result { /// Post-creation actions for newly setup crates. pub(crate) fn post_create(path: &Path, vcs: &Vcs) -> Result<()> { - let parent_dir = path.parent(); - let metadata = if parent_dir.is_none() { - None - } else { + let metadata = if let Some(parent_dir) = path.parent() { match cargo_metadata::MetadataCommand::new() - .current_dir(parent_dir.unwrap()) + .current_dir(parent_dir) .exec() { Ok(v) => Some(v), @@ -156,6 +153,8 @@ pub(crate) fn post_create(path: &Path, vcs: &Vcs) -> Result<()> { anyhow::bail!("Couldn't retrieve cargo metadata: {:?}", err) } } + } else { + None }; // 1. Add the new project to the workspace, if it exists. diff --git a/packages/cli/src/cli/run.rs b/packages/cli/src/cli/run.rs index 61bd68ec0..c0cf71963 100644 --- a/packages/cli/src/cli/run.rs +++ b/packages/cli/src/cli/run.rs @@ -110,6 +110,9 @@ impl RunArgs { BuildStage::RunningGradle => { tracing::info!("[{bundle_format}] Running Gradle") } + BuildStage::CodeSigning => { + tracing::info!("[{bundle_format}] Code signing app") + } BuildStage::Success => {} BuildStage::Restarting => {} BuildStage::CompressingAssets => {} diff --git a/packages/cli/src/cli/target.rs b/packages/cli/src/cli/target.rs index b6f537c57..c131a345c 100644 --- a/packages/cli/src/cli/target.rs +++ b/packages/cli/src/cli/target.rs @@ -108,16 +108,22 @@ pub(crate) struct TargetArgs { #[clap(long, default_value_t = true, help_heading = HELP_HEADING, num_args = 0..=1)] pub(crate) debug_symbols: bool, - /// Are we building for a device or just the simulator. - /// If device is false, then we'll build for the simulator - #[clap(long, default_value_t = false, help_heading = HELP_HEADING)] - pub(crate) device: bool, + /// The name of the device we are hoping to upload to. By default, dx tries to upload to the active + /// simulator. If the device name is passed, we will upload to that device instead. + /// + /// This performs a search among devices, and fuzzy matches might be found. + #[arg(long, default_missing_value=None, num_args=0..=1)] + pub(crate) device: Option, /// The base path the build will fetch assets relative to. This will override the /// base path set in the `dioxus` config. #[clap(long, help_heading = HELP_HEADING)] pub(crate) base_path: Option, + /// Should dx attempt to codesign the app bundle? + #[clap(long, default_value_t = false, help_heading = HELP_HEADING, num_args = 0..=1)] + pub(crate) codesign: bool, + /// The path to the Apple entitlements file to used to sign the resulting app bundle. /// /// On iOS, this is required for deploy to a device and some configurations in the simulator. diff --git a/packages/cli/src/serve/output.rs b/packages/cli/src/serve/output.rs index 17ad95c7f..e0404315b 100644 --- a/packages/cli/src/serve/output.rs +++ b/packages/cli/src/serve/output.rs @@ -546,6 +546,7 @@ impl Output { BuildStage::CompressingAssets => lines.push("Compressing assets".yellow()), BuildStage::RunningBindgen => lines.push("Running wasm-bindgen".yellow()), BuildStage::RunningGradle => lines.push("Running gradle assemble".yellow()), + BuildStage::CodeSigning => lines.push("Code signing app".yellow()), BuildStage::Bundling => lines.push("Bundling app".yellow()), BuildStage::CopyingAssets { current, diff --git a/packages/cli/src/workspace.rs b/packages/cli/src/workspace.rs index 154ef59ae..6dba1ec4e 100644 --- a/packages/cli/src/workspace.rs +++ b/packages/cli/src/workspace.rs @@ -273,12 +273,6 @@ impl Workspace { .join("gcc-ld") } - pub fn has_wasm32_unknown_unknown(&self) -> bool { - self.sysroot - .join("lib/rustlib/wasm32-unknown-unknown") - .exists() - } - /// Find the "main" package in the workspace. There might not be one! pub fn find_main_package(&self, package: Option) -> Result { if let Some(package) = package { diff --git a/packages/dx-wire-format/src/lib.rs b/packages/dx-wire-format/src/lib.rs index 018d79486..dcad395b5 100644 --- a/packages/dx-wire-format/src/lib.rs +++ b/packages/dx-wire-format/src/lib.rs @@ -86,6 +86,7 @@ pub enum BuildStage { }, Bundling, RunningGradle, + CodeSigning, Success, Failed, Aborted,