use std::{
cmp::Ordering,
collections::HashMap,
ffi::{OsStr, OsString},
fs,
path::{Path, PathBuf},
process::{Command, ExitStatus},
str::FromStr,
};
use anyhow::{Context, Result, bail};
use object::Architecture;
use serde::{Deserialize, Deserializer, de::Error as _};
use tracing::{debug, error, trace};
use crate::{Binary, OSVersion, Platform, util};
pub fn get_temp_dir(udid: &str) -> Result<PathBuf> {
let mut cmd = Command::new("xcrun");
cmd.arg("simctl");
cmd.arg("getenv");
cmd.arg(udid);
cmd.arg("TMPDIR");
debug!("{cmd:?}");
let stdout = util::command_stdout(cmd)?;
let stdout = stdout.strip_suffix(b"\n").unwrap_or(&stdout);
#[cfg(unix)]
let path = <std::ffi::OsStr as std::os::unix::ffi::OsStrExt>::from_bytes(stdout);
#[cfg(not(unix))]
let path = std::ffi::OsStr::new(std::str::from_utf8(stdout).unwrap());
Ok(PathBuf::from(path))
}
pub fn get_device(binary: &Binary) -> Result<(Runtime, Device)> {
let mut cmd = Command::new("xcrun");
cmd.arg("simctl");
cmd.arg("list");
cmd.arg("--json");
debug!("{cmd:?}");
let stdout = util::command_stdout(cmd)?;
let info: SimulatorInfo = serde_json::from_slice(&stdout).context("failed parsing JSON")?;
let mut runtimes = info.runtimes;
if runtimes.is_empty() {
bail!("no simulator runtimes found? Try running `xcrun simctl list runtimes`");
}
runtimes
.retain(|runtime| runtime.availability == Availability::Available && !runtime.is_internal);
if runtimes.is_empty() {
bail!(
"only unavailable simulator runtimes found? Try running `xcrun simctl list runtimes available`"
);
}
let expected_platform_name = match binary.platform() {
Platform::IOSSIMULATOR => "iOS",
Platform::TVOSSIMULATOR => "tvOS",
Platform::WATCHOSSIMULATOR => "watchOS",
Platform::VISIONOSSIMULATOR => "xrOS",
_ => unreachable!("unknown simulator platform"),
};
runtimes.retain(|runtime| runtime.is_platform(expected_platform_name));
if runtimes.is_empty() {
bail!(
"no simulator runtimes for `{expected_platform_name}` found? Try running `xcrun simctl list runtimes available {expected_platform_name}`"
);
}
let expected_arch = String::from(match binary.arch {
Architecture::Aarch64 => "arm64",
Architecture::X86_64 => "x86_64",
Architecture::I386 => "i386", arch => {
error!(?arch, "unknown simulator architecture");
""
}
});
runtimes.retain(|runtime| {
runtime
.supported_architectures
.as_deref()
.map(|archs| archs.contains(&expected_arch))
.unwrap_or(true)
});
if runtimes.is_empty() {
bail!(
"no simulator runtimes for the architecture {expected_arch} found? Ensure that you're running Cargo with the `--target` flag corresponding to your host architecture"
);
}
let minos = binary.minos();
runtimes.retain(|runtime| match OSVersion::from_str(&runtime.version) {
Ok(runtime_version) => minos <= runtime_version,
Err(err) => {
error!(?runtime.version, "failed parsing: {err}");
true
}
});
if runtimes.is_empty() {
bail!(
"the binary was compiled for {expected_platform_name} {minos}, but no simulator runtimes support that high OS version. Check `xcrun simctl list runtimes available`",
);
}
trace!(?runtimes, "found runtimes");
let mut devices = runtimes
.iter()
.flat_map(|runtime| {
info.devices
.get(&runtime.identifier)
.or_else(|| info.devices.get(&runtime.name))
.map(|devices| &**devices)
.unwrap_or_else(|| {
error!("could not find devices for runtime {}", runtime.identifier);
&[]
})
.iter()
.map(move |device| (runtime, device))
})
.collect::<Vec<_>>();
if devices.is_empty() {
bail!(
"no simulator devices found? Run `xcrun simctl list devices {expected_platform_name}` to debug, and consider running `xcrun simctl create` to create a device"
);
}
devices.retain(|(_runtime, device)| device.availability == Availability::Available);
if devices.is_empty() {
bail!(
"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"
);
}
devices.retain(|(_runtime, device)| device.state == DeviceState::Booted);
if devices.is_empty() {
bail!(
"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"
);
}
devices.sort_by(|(runtime_a, device_a), (runtime_b, device_b)| {
match (&device_a.last_booted_at, &device_b.last_booted_at) {
(Some(a), Some(b)) => a.cmp(b),
(Some(_), None) => Ordering::Greater,
(None, Some(_)) => Ordering::Less,
(None, None) => Ordering::Equal,
}
.then_with(|| {
match (
OSVersion::from_str(&runtime_a.version),
OSVersion::from_str(&runtime_b.version),
) {
(Ok(a), Ok(b)) => a.cmp(&b),
_ => Ordering::Equal,
}
})
.then_with(|| {
device_a.name.cmp(&device_b.name)
})
});
trace!(?devices, "found devices");
let (runtime, device) = devices.first().expect("checked before");
Ok(((*runtime).clone(), (*device).clone()))
}
pub fn spawn<A: AsRef<OsStr>>(
udid: &str,
bundle_path: &Path,
exe_path: &Path,
args: impl Iterator<Item = A>,
) -> Result<ExitStatus> {
let temp_dir = get_temp_dir(udid)?;
debug_assert_eq!(bundle_path, exe_path);
let mut cmd = Command::new("xcrun");
cmd.arg("simctl");
cmd.arg("spawn");
cmd.arg(udid);
cmd.arg(exe_path);
cmd.args(args);
cmd.envs(forwarded_env_vars());
cmd.env("SIMCTL_CHILD_CARGO_TARGET_TMPDIR", temp_dir);
debug!("{cmd:?}");
let status = cmd
.status()
.with_context(|| format!("failed spawning executable {exe_path:?}"))?;
Ok(status)
}
pub fn install_and_launch<A: AsRef<OsStr>>(
udid: &str,
bundle_path: &Path,
bundle_identifier: &str,
args: impl Iterator<Item = A>,
) -> Result<ExitStatus> {
let temp_dir = get_temp_dir(udid)?;
let lock_file = fs::File::create(temp_dir.join("cargo-apple-runner.lock"))
.context("failed creating lock file in simulator")?;
lock_file.lock()?;
let mut cmd = Command::new("xcrun");
cmd.arg("simctl");
cmd.arg("install");
cmd.arg(udid);
cmd.arg(bundle_path);
debug!("{cmd:?}");
let _ = util::command_stdout(cmd)?;
let mut cmd = Command::new("xcrun");
cmd.arg("simctl");
cmd.arg("launch");
cmd.arg("--console");
cmd.arg(udid);
cmd.arg(bundle_identifier);
cmd.args(args);
cmd.envs(forwarded_env_vars());
cmd.env("SIMCTL_CHILD_CARGO_TARGET_TMPDIR", temp_dir);
debug!("{cmd:?}");
let status = cmd
.status()
.with_context(|| format!("failed launching application {bundle_identifier:?}"))?;
lock_file.unlock()?;
Ok(status)
}
fn forwarded_env_vars() -> impl IntoIterator<Item = (OsString, OsString)> {
std::env::vars_os()
.filter(|(key, _)| {
let Some(key) = key.to_str() else {
return false;
};
key.starts_with("CARGO_PKG_")
|| matches!(
key,
"CARGO_CRATE_NAME" | "CARGO_BIN_NAME" | "CARGO_PRIMARY_PACKAGE"
)
})
.map(|(key, value)| {
let mut new_key = OsString::from("SIMCTL_CHILD_");
new_key.push(key);
(new_key, value)
})
}
#[derive(Deserialize)]
struct SimulatorInfo {
#[serde(default)]
runtimes: Vec<Runtime>,
#[serde(default)]
devices: HashMap<String, Vec<Device>>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Runtime {
#[allow(dead_code)]
buildversion: String,
name: String,
identifier: String,
version: String,
#[serde(deserialize_with = "deserialize_availability", flatten)]
availability: Availability,
#[serde(rename = "isInternal")]
#[serde(default)] is_internal: bool,
platform: Option<String>,
#[serde(rename = "supportedArchitectures")]
supported_architectures: Option<Vec<String>>,
}
impl Runtime {
fn is_platform(&self, platform_name: &str) -> bool {
if let Some(p) = &self.platform {
p == platform_name
} else {
self.identifier.contains(&platform_name)
}
}
}
#[derive(Deserialize, PartialEq, Eq, Hash, Clone, Debug)]
pub struct Device {
name: String,
pub udid: String,
state: DeviceState,
#[serde(deserialize_with = "deserialize_availability", flatten)]
availability: Availability,
#[serde(rename = "lastBootedAt")]
last_booted_at: Option<String>,
}
#[derive(Deserialize, PartialEq, Eq, Hash, Clone, Debug)]
enum DeviceState {
Creating,
Booting,
Booted,
#[serde(rename = "Shutting Down")]
ShuttingDown,
Shutdown,
#[serde(other)]
Unknown,
}
#[derive(PartialEq, Eq, Hash, Clone, Debug)]
enum Availability {
Available,
Unavailable(String),
}
fn deserialize_availability<'de, D: Deserializer<'de>>(d: D) -> Result<Availability, D::Error> {
#[derive(Deserialize)]
struct Raw {
availability: Option<String>,
#[serde(rename = "isAvailable")]
is_available: Option<bool>,
#[serde(rename = "availabilityError")]
availability_error: Option<String>,
}
let raw = Raw::deserialize(d)?;
match (raw.availability, raw.is_available, raw.availability_error) {
(Some(s), _, _) if s == "(available)" => Ok(Availability::Available),
(Some(message), _, _) => Ok(Availability::Unavailable(message)),
(_, Some(true), _) => Ok(Availability::Available),
(_, Some(false), Some(message)) => Ok(Availability::Unavailable(message)),
(_, Some(false), None) => Ok(Availability::Unavailable(String::new())),
_ => Err(D::Error::custom("missing availability field")),
}
}
#[cfg(test)]
mod tests;