Skip to main content

cargo_apple_runner/
simctl.rs

1//! An interface to `xcrun simctl`.
2//!
3//! # About the simulator
4//!
5//! The iOS/tvOS/watchOS/visionOS simulator uses the host macOS kernel, which
6//! enables easier debugging, higher performance etc. Processes are configured
7//! such that they use various frameworks in `$IPHONE_SIMULATOR_ROOT`, but are
8//! not otherwise isolated, any process running in the simulator still has
9//! access to the host filesystem, peripherals, GPU etc.
10//!
11//! There are two ways of launching binaries on the simulator: spawning a new
12//! process or launching a bundled application.
13//!
14//! Ideally, we'd just always launch applications, but there's a catch: there
15//! can only be a single actively launched application at a time, so launching
16//! must be serialized.
17//!
18//! To make `cargo test` faster, we spawn applications instead of launching
19//! them when heuristics tell us it's (probably) safe to do so.
20
21use std::{
22    cmp::Ordering,
23    collections::HashMap,
24    ffi::{OsStr, OsString},
25    fs,
26    path::{Path, PathBuf},
27    process::{Command, ExitStatus},
28    str::FromStr,
29};
30
31use anyhow::{Context, Result, bail};
32use object::Architecture;
33use serde::{Deserialize, Deserializer, de::Error as _};
34use tracing::{debug, error, trace};
35
36use crate::{Binary, OSVersion, Platform, util};
37
38/// Get the temporary directory of the device.
39///
40/// This works even if the device isn't booted.
41pub fn get_temp_dir(udid: &str) -> Result<PathBuf> {
42    let mut cmd = Command::new("xcrun");
43    cmd.arg("simctl");
44    cmd.arg("getenv");
45    cmd.arg(udid);
46    cmd.arg("TMPDIR");
47    debug!("{cmd:?}");
48    let stdout = util::command_stdout(cmd)?;
49
50    let stdout = stdout.strip_suffix(b"\n").unwrap_or(&stdout);
51    #[cfg(unix)]
52    let path = <std::ffi::OsStr as std::os::unix::ffi::OsStrExt>::from_bytes(stdout);
53    #[cfg(not(unix))]
54    let path = std::ffi::OsStr::new(std::str::from_utf8(stdout).unwrap());
55    Ok(PathBuf::from(path))
56}
57
58/// Find an available device with the correct runtime to run on.
59///
60/// If none exist, return an error that guides the user towards creating a
61/// suitable device (doing that automatically is error-prone).
62pub fn get_device(binary: &Binary) -> Result<(Runtime, Device)> {
63    // Don't use filter options, only the high-level ones (`runtimes`,
64    // `devices`, `devicetypes` or `pairs`) are supported on Xcode 9.2.
65    //
66    // We also don't pass `--no-escape-slashes`, since that isn't supported on
67    // all Xcode versions - we'll have to unescape slashes in paths ourselves.
68    let mut cmd = Command::new("xcrun");
69    cmd.arg("simctl");
70    cmd.arg("list");
71    cmd.arg("--json");
72    debug!("{cmd:?}");
73    let stdout = util::command_stdout(cmd)?;
74
75    let info: SimulatorInfo = serde_json::from_slice(&stdout).context("failed parsing JSON")?;
76    let mut runtimes = info.runtimes;
77    if runtimes.is_empty() {
78        bail!("no simulator runtimes found? Try running `xcrun simctl list runtimes`");
79    }
80
81    // Filter available runtimes.
82    runtimes
83        .retain(|runtime| runtime.availability == Availability::Available && !runtime.is_internal);
84    if runtimes.is_empty() {
85        bail!(
86            "only unavailable simulator runtimes found? Try running `xcrun simctl list runtimes available`"
87        );
88    }
89
90    // Filter runtimes by platform.
91    let expected_platform_name = match binary.platform() {
92        Platform::IOSSIMULATOR => "iOS",
93        Platform::TVOSSIMULATOR => "tvOS",
94        Platform::WATCHOSSIMULATOR => "watchOS",
95        Platform::VISIONOSSIMULATOR => "xrOS",
96        _ => unreachable!("unknown simulator platform"),
97    };
98    runtimes.retain(|runtime| runtime.is_platform(expected_platform_name));
99    if runtimes.is_empty() {
100        bail!(
101            "no simulator runtimes for `{expected_platform_name}` found? Try running `xcrun simctl list runtimes available {expected_platform_name}`"
102        );
103    }
104
105    // Filter runtimes by architecture.
106    let expected_arch = String::from(match binary.arch {
107        Architecture::Aarch64 => "arm64",
108        Architecture::X86_64 => "x86_64",
109        Architecture::I386 => "i386", // probably
110        arch => {
111            error!(?arch, "unknown simulator architecture");
112            ""
113        }
114    });
115    runtimes.retain(|runtime| {
116        runtime
117            .supported_architectures
118            .as_deref()
119            .map(|archs| archs.contains(&expected_arch))
120            .unwrap_or(true)
121    });
122    if runtimes.is_empty() {
123        bail!(
124            "no simulator runtimes for the architecture {expected_arch} found? Ensure that you're running Cargo with the `--target` flag corresponding to your host architecture"
125        );
126    }
127
128    // Filter runtimes by OS version.
129    let minos = binary.minos();
130    runtimes.retain(|runtime| match OSVersion::from_str(&runtime.version) {
131        Ok(runtime_version) => minos <= runtime_version,
132        Err(err) => {
133            error!(?runtime.version, "failed parsing: {err}");
134            true
135        }
136    });
137    if runtimes.is_empty() {
138        bail!(
139            "the binary was compiled for {expected_platform_name} {minos}, but no simulator runtimes support that high OS version. Check `xcrun simctl list runtimes available`",
140        );
141    }
142
143    trace!(?runtimes, "found runtimes");
144
145    // Now that we have a list of suitable runtimes, grab their devices.
146    let mut devices = runtimes
147        .iter()
148        .flat_map(|runtime| {
149            info.devices
150                .get(&runtime.identifier)
151                .or_else(|| info.devices.get(&runtime.name))
152                .map(|devices| &**devices)
153                .unwrap_or_else(|| {
154                    error!("could not find devices for runtime {}", runtime.identifier);
155                    &[]
156                })
157                .iter()
158                .map(move |device| (runtime, device))
159        })
160        .collect::<Vec<_>>();
161    if devices.is_empty() {
162        bail!(
163            "no simulator devices found? Run `xcrun simctl list devices {expected_platform_name}` to debug, and consider running `xcrun simctl create` to create a device"
164        );
165    }
166
167    // Filter available devices.
168    devices.retain(|(_runtime, device)| device.availability == Availability::Available);
169    if devices.is_empty() {
170        bail!(
171            "only unavailable simulator devices found? Run `xcrun simctl list devices available {expected_platform_name}` to debug, and consider running `xcrun simctl create` to create a device"
172        );
173    }
174
175    // Filter booted devices.
176    devices.retain(|(_runtime, device)| device.state == DeviceState::Booted);
177    if devices.is_empty() {
178        bail!(
179            "no booted simulator devices found? Run `xcrun simctl list devices booted {expected_platform_name}` to debug, and consider running `xcrun simctl boot` to boot the device"
180        );
181    }
182
183    // Sort devices.
184    devices.sort_by(|(runtime_a, device_a), (runtime_b, device_b)| {
185        // Prefer recently used devices.
186        match (&device_a.last_booted_at, &device_b.last_booted_at) {
187            // Rely on newer dates sorting higher here (dates are ISO 8601).
188            (Some(a), Some(b)) => a.cmp(b),
189            (Some(_), None) => Ordering::Greater,
190            (None, Some(_)) => Ordering::Less,
191            (None, None) => Ordering::Equal,
192        }
193        .then_with(|| {
194            // Otherwise prefer devices with newer runtimes.
195            match (
196                OSVersion::from_str(&runtime_a.version),
197                OSVersion::from_str(&runtime_b.version),
198            ) {
199                (Ok(a), Ok(b)) => a.cmp(&b),
200                _ => Ordering::Equal,
201            }
202        })
203        .then_with(|| {
204            // Lastly, sort by name for stability.
205            device_a.name.cmp(&device_b.name)
206        })
207    });
208
209    trace!(?devices, "found devices");
210
211    // And grab the most relevant device.
212    let (runtime, device) = devices.first().expect("checked before");
213    Ok(((*runtime).clone(), (*device).clone()))
214}
215
216pub fn spawn<A: AsRef<OsStr>>(
217    udid: &str,
218    bundle_path: &Path,
219    exe_path: &Path,
220    args: impl Iterator<Item = A>,
221) -> Result<ExitStatus> {
222    let temp_dir = get_temp_dir(udid)?;
223
224    // TODO: Place binary in temporary location on device.
225    // DEVICE_EXECUTABLE=$(mktemp $DEVICE_TMPDIR/$(basename $EXECUTABLE).XXXXXX)
226    // cp -c $EXECUTABLE $DEVICE_EXECUTABLE
227    //
228    // This is done to make the executable readable such that accessing
229    // `std::env::current_exe()` still works.
230
231    // TODO: Support bundled apps here, by instead of copying, we write the
232    // bundled app directly to a temporary location on the device.
233    debug_assert_eq!(bundle_path, exe_path);
234
235    // Spawn the executable with the arguments.
236    let mut cmd = Command::new("xcrun");
237    cmd.arg("simctl");
238    cmd.arg("spawn");
239    cmd.arg(udid);
240    cmd.arg(exe_path);
241    cmd.args(args);
242    cmd.envs(forwarded_env_vars());
243    // Set `CARGO_TARGET_TMPDIR` to `TMPDIR`. See also <https://github.com/rust-lang/cargo/issues/16427>.
244    cmd.env("SIMCTL_CHILD_CARGO_TARGET_TMPDIR", temp_dir);
245
246    debug!("{cmd:?}");
247    let status = cmd
248        .status()
249        .with_context(|| format!("failed spawning executable {exe_path:?}"))?;
250
251    Ok(status)
252}
253
254pub fn install_and_launch<A: AsRef<OsStr>>(
255    udid: &str,
256    bundle_path: &Path,
257    bundle_identifier: &str,
258    args: impl Iterator<Item = A>,
259) -> Result<ExitStatus> {
260    let temp_dir = get_temp_dir(udid)?;
261
262    // Only a single application can be launched at a time, so we add a shared
263    // lock on the device (in the temporary directory, so no need to clean up
264    // the file afterwards), and synchronize with other `cargo-apple-runner`
265    // processes to wait launching until the other runners are done.
266    //
267    // This makes test executors like `cargo nextest` that spawn multiple
268    // processes at the same time work.
269    let lock_file = fs::File::create(temp_dir.join("cargo-apple-runner.lock"))
270        .context("failed creating lock file in simulator")?;
271    lock_file.lock()?;
272
273    // Install the application.
274    // TODO: Ensure that what's being installed is unique / won't conflict
275    // with other processes, and move it above the lock somehow?
276    let mut cmd = Command::new("xcrun");
277    cmd.arg("simctl");
278    cmd.arg("install");
279    cmd.arg(udid);
280    cmd.arg(bundle_path);
281    debug!("{cmd:?}");
282    let _ = util::command_stdout(cmd)?;
283
284    // Launch the application.
285    let mut cmd = Command::new("xcrun");
286    cmd.arg("simctl");
287    cmd.arg("launch");
288    cmd.arg("--console");
289    // TODO: Allow controlling this somehow?
290    // cmd.arg("--wait-for-debugger");
291    cmd.arg(udid);
292    cmd.arg(bundle_identifier);
293    cmd.args(args);
294    cmd.envs(forwarded_env_vars());
295    // Set `CARGO_TARGET_TMPDIR` to `TMPDIR`. See also <https://github.com/rust-lang/cargo/issues/16427>.
296    cmd.env("SIMCTL_CHILD_CARGO_TARGET_TMPDIR", temp_dir);
297
298    debug!("{cmd:?}");
299    let status = cmd
300        .status()
301        .with_context(|| format!("failed launching application {bundle_identifier:?}"))?;
302
303    // TODO: Pipe stdout and filter first line which contains the bundle ID
304    // and the process ID.
305
306    lock_file.unlock()?;
307    Ok(status)
308}
309
310/// Environment variables to set for `xcrun` invocations.
311///
312/// This copies:
313/// - All `CARGO_PKG_*` env vars.
314/// - The `CARGO_CRATE_NAME`, `CARGO_BIN_NAME` and `CARGO_PRIMARY_PACKAGE` env
315///   vars.
316///
317/// We deliberately don't copy CWD-relative vars like `CARGO_MANIFEST_DIR`, as
318/// that won't work reliably if the code is located in a protected directory
319/// such as `~/Documents` or `~/Desktop`:
320/// <https://support.apple.com/en-US/guide/security/secddd1d86a6/web>.
321///
322/// TODO: Somehow discourage `Path::new(env!("CARGO_MANIFEST_DIR"))` too?
323fn forwarded_env_vars() -> impl IntoIterator<Item = (OsString, OsString)> {
324    std::env::vars_os()
325        .filter(|(key, _)| {
326            let Some(key) = key.to_str() else {
327                return false;
328            };
329
330            key.starts_with("CARGO_PKG_")
331                || matches!(
332                    key,
333                    "CARGO_CRATE_NAME" | "CARGO_BIN_NAME" | "CARGO_PRIMARY_PACKAGE"
334                )
335        })
336        .map(|(key, value)| {
337            let mut new_key = OsString::from("SIMCTL_CHILD_");
338            new_key.push(key);
339            (new_key, value)
340        })
341}
342
343#[derive(Deserialize)]
344struct SimulatorInfo {
345    #[serde(default)]
346    runtimes: Vec<Runtime>,
347    /// Key is either runtime identifier or runtime name.
348    #[serde(default)]
349    devices: HashMap<String, Vec<Device>>,
350}
351
352#[derive(Deserialize, Clone, Debug)]
353pub struct Runtime {
354    #[allow(dead_code)]
355    buildversion: String,
356    name: String,
357    identifier: String,
358    version: String,
359    #[serde(deserialize_with = "deserialize_availability", flatten)]
360    availability: Availability,
361
362    // The below fields are not available on Xcode 9.2.
363    #[serde(rename = "isInternal")]
364    #[serde(default)] // Default to `false`
365    is_internal: bool,
366    platform: Option<String>,
367    #[serde(rename = "supportedArchitectures")]
368    supported_architectures: Option<Vec<String>>,
369}
370
371impl Runtime {
372    fn is_platform(&self, platform_name: &str) -> bool {
373        if let Some(p) = &self.platform {
374            p == platform_name
375        } else {
376            self.identifier.contains(&platform_name)
377        }
378    }
379}
380
381#[derive(Deserialize, PartialEq, Eq, Hash, Clone, Debug)]
382pub struct Device {
383    name: String,
384    /// Unique device ID (UUID).
385    pub udid: String,
386    state: DeviceState,
387    #[serde(deserialize_with = "deserialize_availability", flatten)]
388    availability: Availability,
389
390    // The below fields are not available on Xcode 9.2.
391    #[serde(rename = "lastBootedAt")]
392    last_booted_at: Option<String>,
393}
394
395// The possible states are not documented, but some can be found in:
396// https://github.com/facebook/idb/blob/cf3dc8643de10efd57dd10617032455888b8b6f9/FBControlCore/Management/FBiOSTargetConstants.m#L12-L20
397#[derive(Deserialize, PartialEq, Eq, Hash, Clone, Debug)]
398enum DeviceState {
399    Creating,
400    Booting,
401    Booted,
402    #[serde(rename = "Shutting Down")]
403    ShuttingDown,
404    Shutdown,
405    #[serde(other)]
406    Unknown,
407}
408
409#[derive(PartialEq, Eq, Hash, Clone, Debug)]
410enum Availability {
411    Available,
412    Unavailable(String),
413}
414
415/// Deserialize availability information.
416///
417/// On Xcode 9.2, `availability` is set to `"(available)"` or an error
418/// value like `" (unavailable, xyz)"`.
419///
420/// On newer Xcode, `isAvailable` is present, and `availabilityError` is
421/// set if the runtime is not available.
422fn deserialize_availability<'de, D: Deserializer<'de>>(d: D) -> Result<Availability, D::Error> {
423    #[derive(Deserialize)]
424    struct Raw {
425        availability: Option<String>,
426        #[serde(rename = "isAvailable")]
427        is_available: Option<bool>,
428        #[serde(rename = "availabilityError")]
429        availability_error: Option<String>,
430    }
431
432    let raw = Raw::deserialize(d)?;
433
434    match (raw.availability, raw.is_available, raw.availability_error) {
435        (Some(s), _, _) if s == "(available)" => Ok(Availability::Available),
436        (Some(message), _, _) => Ok(Availability::Unavailable(message)),
437        (_, Some(true), _) => Ok(Availability::Available),
438        (_, Some(false), Some(message)) => Ok(Availability::Unavailable(message)),
439        (_, Some(false), None) => Ok(Availability::Unavailable(String::new())),
440        _ => Err(D::Error::custom("missing availability field")),
441    }
442}
443
444#[cfg(test)]
445mod tests;