use std::{
collections::HashMap,
path::PathBuf,
time::{Duration, Instant},
};
use eyre::{Context as _, bail, eyre};
use jiff::Timestamp;
use semver::Version;
use serde::Deserialize;
use smol::{
Timer,
channel::Sender,
io::{AsyncBufReadExt, BufReader},
process::Stdio,
spawn,
stream::StreamExt,
};
use tracing::{debug as trace_debug, info, warn};
use std::path::Path;
use crate::{
apple::{physical::ApplePhysicalDevice, platform::apple_deployment_target},
debug,
device::{
ApplicationExit, Artifact, Device, DeviceEvent, FailToRun, Local, LogLevel, Running,
format_panic_message,
},
platform::TargetPlatform,
project::Project,
toolchain::Host,
utils::parse_semver_version,
};
use smol::channel::Receiver;
#[derive(Debug, Clone)]
struct PanicInfo {
payload: String,
location: Option<String>,
}
async fn install_simulator_artifact(
host: &Host,
udid: &str,
artifact_path: &Path,
) -> Result<(), FailToRun> {
let install_output = host
.command("xcrun")
.args(["simctl", "install", udid])
.arg(artifact_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|error| FailToRun::Install(eyre!("Failed to install app: {error}")))?;
if install_output.status.success() {
return Ok(());
}
Err(FailToRun::Install(eyre!(
"Failed to install app:\n{}\n{}",
String::from_utf8_lossy(&install_output.stdout).trim(),
String::from_utf8_lossy(&install_output.stderr).trim(),
)))
}
fn simulator_process_name(artifact: &Artifact) -> Result<String, FailToRun> {
artifact
.path()
.file_stem()
.ok_or_else(|| {
FailToRun::Run(eyre!(
"Artifact path has no filename: {}",
artifact.path().display()
))
})?
.to_str()
.ok_or_else(|| {
FailToRun::Run(eyre!(
"Artifact filename is not valid UTF-8: {}",
artifact.path().display()
))
})
.map(std::string::ToString::to_string)
}
fn simulator_env_vars(options: &crate::device::RunOptions) -> Vec<(String, String)> {
options
.env_vars()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect()
}
async fn launch_simulator_app(
host: &Host,
udid: &str,
bundle_id: &str,
env_vars: &[(String, String)],
) -> Result<u32, FailToRun> {
let mut launch = host.command("xcrun");
launch
.arg("simctl")
.arg("launch")
.arg("--terminate-running-process")
.arg(udid)
.arg(bundle_id);
for (key, value) in env_vars {
launch.env(format!("SIMCTL_CHILD_{key}"), value);
}
let launch_output = launch
.output()
.await
.map_err(|error| FailToRun::Launch(eyre!("Failed to launch app: {error}")))?;
if !launch_output.status.success() {
return Err(FailToRun::Launch(eyre!(
"Failed to launch app:\n{}\n{}",
String::from_utf8_lossy(&launch_output.stdout).trim(),
String::from_utf8_lossy(&launch_output.stderr).trim(),
)));
}
parse_simctl_launch_pid(&String::from_utf8_lossy(&launch_output.stdout)).ok_or_else(|| {
FailToRun::Launch(eyre!(
"Failed to parse PID from simctl launch output: {}",
String::from_utf8_lossy(&launch_output.stdout).trim()
))
})
}
fn spawn_simulator_termination(host: &Host, udid: String, bundle_id: String) {
let host = host.clone();
let spawn_result = std::thread::Builder::new()
.name("waterui-simctl-terminate".to_string())
.spawn(move || {
match host
.std_command("xcrun")
.args(["simctl", "terminate", &udid, &bundle_id])
.output()
{
Ok(output) if output.status.success() => {}
Ok(output) => {
tracing::error!(
"Failed to terminate app on simulator: status={}, stdout={}, stderr={}",
output.status,
String::from_utf8_lossy(&output.stdout).trim(),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Err(error) => {
tracing::error!("Failed to terminate app on simulator: {error}");
}
}
});
if let Err(error) = spawn_result {
tracing::error!("Failed to spawn simulator termination thread: {error}");
}
}
struct SimulatorExitContext {
device_name: String,
device_identifier: String,
bundle_id: String,
process_name: String,
pid: u32,
start_time: Timestamp,
start_instant: Instant,
}
fn spawn_simulator_exit_monitor(
host: &Host,
sender: Sender<DeviceEvent>,
panic_rx: Receiver<PanicInfo>,
context: SimulatorExitContext,
) {
let host = host.clone();
spawn(async move {
wait_for_pid_exit(&host, context.pid).await;
if let Ok(info) = panic_rx.try_recv() {
let _ = sender.try_send(DeviceEvent::Crashed(format_panic_message(
&info.payload,
info.location.as_deref(),
)));
return;
}
if let Some(report) = poll_for_crash_report(&host, &context, Duration::from_secs(10)).await
{
let _ = sender.try_send(DeviceEvent::Crashed(report.to_string()));
return;
}
if let Some(panic_msg) = fetch_recent_panic_logs(
&host,
&context.device_identifier,
context.start_instant,
Some(context.pid),
)
.await
{
let _ = sender.try_send(DeviceEvent::Crashed(panic_msg));
return;
}
let _ = sender.try_send(DeviceEvent::Exited(ApplicationExit::user_closed()));
})
.detach();
}
fn start_log_stream(
host: &Host,
sender: Sender<DeviceEvent>,
log_level: Option<LogLevel>,
pid: u32,
native_logs: bool,
udid: &str,
) -> eyre::Result<(Receiver<PanicInfo>, smol::process::Child)> {
let (panic_tx, panic_rx) = smol::channel::bounded::<PanicInfo>(1);
let stream_level = log_level.map_or("default", |l| l.to_apple_level());
let predicate = if native_logs {
format!("processID == {pid}")
} else {
format!("processID == {pid} AND subsystem == \"dev.waterui\"")
};
let mut log_cmd = host.command("xcrun");
log_cmd
.args(["simctl", "spawn", udid, "log", "stream"])
.arg("--predicate")
.arg(&predicate)
.arg("--level")
.arg(stream_level)
.arg("--style")
.arg("compact")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true);
let mut log_child = log_cmd
.spawn()
.map_err(|error| eyre!("Failed to start simulator log stream: {error}"))?;
let stdout = log_child
.stdout
.take()
.expect("stdout is piped for the simulator log stream");
replay_log_history(
host.clone(),
udid.to_string(),
predicate,
sender.clone(),
panic_tx.clone(),
log_level,
);
spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Some(Ok(line)) = lines.next().await {
if line.starts_with("Filtering") || line.starts_with("Timestamp") {
continue;
}
if line.contains("panic.payload=")
&& let Some(info) = extract_panic_info_from_log(&line)
{
let _ = panic_tx.try_send(info);
}
if log_level.is_some()
&& sender
.try_send(DeviceEvent::Log {
level: compact_log_level(&line),
message: line,
})
.is_err()
{
break;
}
}
})
.detach();
Ok((panic_rx, log_child))
}
fn compact_log_level(line: &str) -> tracing::Level {
if line.contains(" F ") || line.contains(" E ") {
tracing::Level::ERROR
} else if line.contains(" W ") {
tracing::Level::WARN
} else if line.contains(" D ") {
tracing::Level::DEBUG
} else {
tracing::Level::INFO
}
}
fn replay_log_history(
host: Host,
udid: String,
predicate: String,
sender: Sender<DeviceEvent>,
panic_tx: Sender<PanicInfo>,
log_level: Option<LogLevel>,
) {
spawn(async move {
Timer::after(Duration::from_secs(4)).await;
let Ok(output) = host
.command("xcrun")
.args(["simctl", "spawn", &udid, "log", "show"])
.args(["--last", "2m", "--predicate", &predicate])
.args(["--style", "compact"])
.output()
.await
else {
return;
};
for line in String::from_utf8_lossy(&output.stdout).lines() {
if line.starts_with("Filtering") || line.starts_with("Timestamp") {
continue;
}
if line.contains("panic.payload=")
&& let Some(info) = extract_panic_info_from_log(line)
{
let _ = panic_tx.try_send(info);
}
if log_level.is_some() {
let _ = sender.try_send(DeviceEvent::Log {
level: compact_log_level(line),
message: line.to_string(),
});
}
}
})
.detach();
}
fn extract_panic_info_from_log(line: &str) -> Option<PanicInfo> {
let mut payload = None;
let mut location = None;
if let Some(start) = line.find("panic.payload=\"") {
let start = start + 15;
if let Some(end) = line[start..].find('"') {
payload = Some(line[start..start + end].to_string());
}
}
if let Some(start) = line.find("panic.location=\"") {
let start = start + 16;
if let Some(end) = line[start..].find('"') {
location = Some(line[start..start + end].to_string());
}
}
payload.map(|p| PanicInfo {
payload: p,
location,
})
}
async fn fetch_recent_panic_logs(
host: &Host,
udid: &str,
started_at: Instant,
pid: Option<u32>,
) -> Option<String> {
let last = started_at.elapsed() + Duration::from_secs(2);
let last_arg = format!("{}s", last.as_secs().max(5));
let predicate = pid.map_or_else(|| "subsystem == \"dev.waterui\" AND eventMessage CONTAINS \"panic\"".to_string(), |pid| format!(
"processID == {pid} AND subsystem == \"dev.waterui\" AND eventMessage CONTAINS \"panic\""
));
let output = host
.output(
"xcrun",
[
"simctl",
"spawn",
udid,
"log",
"show",
"--predicate",
predicate.as_str(),
"--style",
"compact",
"--last",
last_arg.as_str(),
],
)
.await
.ok()?;
let stdout = String::from_utf8(output.stdout).ok()?;
for line in stdout.lines() {
if line.starts_with("Filtering") || line.starts_with("Timestamp") || line.is_empty() {
continue;
}
let mut location = None;
let mut payload = None;
if let Some(loc_start) = line.find("panic.location=\"") {
let start = loc_start + 16;
if let Some(end) = line[start..].find('"') {
location = Some(&line[start..start + end]);
}
}
if let Some(pay_start) = line.find("panic.payload=\"") {
let start = pay_start + 15;
if let Some(end) = line[start..].find('"') {
payload = Some(&line[start..start + end]);
}
}
if payload.is_some() || location.is_some() {
let mut msg = String::from("Panic:");
if let Some(p) = payload {
msg = format!("{msg} {p}");
}
if let Some(l) = location {
msg = format!("{msg}\n at {l}");
}
return Some(msg);
}
}
None
}
async fn poll_for_crash_report(
host: &Host,
context: &SimulatorExitContext,
timeout: Duration,
) -> Option<debug::CrashReport> {
trace_debug!(
"Polling for crash report: bundle_id={}, process_name={}, pid={:?}, timeout={:?}",
context.bundle_id,
context.process_name,
context.pid,
timeout
);
let deadline = Instant::now() + timeout;
let mut poll_count = 0;
loop {
poll_count += 1;
if let Some(report) = debug::find_macos_ips_crash_report_since(
host,
&context.device_name,
&context.device_identifier,
&context.bundle_id,
&context.process_name,
Some(context.pid),
context.start_time,
)
.await
{
trace_debug!(
"Found crash report after {} polls: {}",
poll_count,
report.summary()
);
return Some(report);
}
if Instant::now() >= deadline {
trace_debug!(
"No crash report found after {} polls within {:?}",
poll_count,
timeout
);
return None;
}
Timer::after(Duration::from_millis(250)).await;
}
}
fn parse_simctl_launch_pid(stdout: &str) -> Option<u32> {
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some((_, pid_part)) = line.rsplit_once(':')
&& let Ok(pid) = pid_part.trim().parse::<u32>()
{
return Some(pid);
}
if let Ok(pid) = line.parse::<u32>() {
return Some(pid);
}
}
None
}
async fn is_pid_alive(host: &Host, pid: u32) -> bool {
host.command("kill")
.arg("-0")
.arg(pid.to_string())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.is_ok_and(|s| s.success())
}
async fn wait_for_pid_exit(host: &Host, pid: u32) {
while is_pid_alive(host, pid).await {
Timer::after(Duration::from_millis(200)).await;
}
}
#[derive(Debug)]
pub enum AppleDevice {
Simulator(Box<AppleSimulator>),
Physical(ApplePhysicalDevice),
Current(Local),
}
impl Device for AppleDevice {
fn name(&self) -> &str {
match self {
Self::Simulator(simulator) => simulator.name(),
Self::Physical(device) => device.name(),
Self::Current(mac_os) => mac_os.name(),
}
}
async fn launch(&self, host: &Host) -> eyre::Result<()> {
match self {
Self::Simulator(simulator) => simulator.launch(host).await,
Self::Physical(device) => device.launch(host).await,
Self::Current(_) => {
Ok(())
}
}
}
async fn run(
&self,
host: &Host,
artifact: Artifact,
options: crate::device::RunOptions,
) -> Result<crate::device::Running, crate::device::FailToRun> {
match self {
Self::Simulator(simulator) => simulator.run(host, artifact, options).await,
Self::Physical(device) => device.run(host, artifact, options).await,
Self::Current(mac_os) => mac_os.run(host, artifact, options).await,
}
}
async fn scan(host: &Host) -> eyre::Result<Vec<Self>> {
let mut devices = Vec::new();
let simulators = AppleSimulator::scan(host).await?;
for sim in simulators {
devices.push(Self::Simulator(Box::new(sim)));
}
match ApplePhysicalDevice::scan(host).await {
Ok(physical) => devices.extend(physical.into_iter().map(Self::Physical)),
Err(error) => warn!("devicectl device scan failed: {error:#}"),
}
devices.push(Self::Current(Local));
Ok(devices)
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct AppleSimulator {
#[serde(rename = "dataPath")]
pub data_path: PathBuf,
#[serde(rename = "dataPathSize")]
pub data_path_size: Option<u64>,
#[serde(rename = "logPath")]
pub log_path: PathBuf,
#[serde(rename = "logPathSize")]
pub log_path_size: Option<u64>,
pub udid: String,
#[serde(rename = "isAvailable")]
pub is_available: bool,
#[serde(rename = "deviceTypeIdentifier")]
pub device_type_identifier: String,
pub state: String,
pub name: String,
#[serde(rename = "lastBootedAt")]
pub last_booted_at: Option<String>,
#[serde(skip)]
pub runtime_identifier: Option<String>,
#[serde(skip)]
pub runtime_version: Option<Version>,
}
impl Device for AppleSimulator {
fn name(&self) -> &str {
&self.name
}
async fn launch(&self, host: &Host) -> eyre::Result<()> {
if self.state != "Booted" {
host.run("xcrun", ["simctl", "boot", self.udid.as_str()])
.await?;
}
Ok(())
}
async fn run(
&self,
host: &Host,
artifact: Artifact,
options: crate::device::RunOptions,
) -> Result<crate::device::Running, crate::device::FailToRun> {
info!("Installing app on apple simulator {}", self.name);
install_simulator_artifact(host, &self.udid, artifact.path()).await?;
info!("Launching app on apple simulator {}", self.name);
let start_time = Timestamp::now();
let start_instant = Instant::now();
let bundle_id = artifact.bundle_id().to_string();
let process_name = simulator_process_name(&artifact)?;
let log_level = options.log_level();
let native_logs = options.native_logs();
let env_vars = simulator_env_vars(&options);
let pid = launch_simulator_app(host, &self.udid, &bundle_id, &env_vars).await?;
let host_for_termination = host.clone();
let udid = self.udid.clone();
let bundle_id_for_termination = bundle_id.clone();
let (mut running, sender) = Running::new(move || {
spawn_simulator_termination(&host_for_termination, udid, bundle_id_for_termination);
});
let (panic_rx, log_child) = start_log_stream(
host,
sender.clone(),
log_level,
pid,
native_logs,
&self.udid,
)
.map_err(FailToRun::Launch)?;
running.retain(log_child);
spawn_simulator_exit_monitor(
host,
sender,
panic_rx,
SimulatorExitContext {
device_name: self.name.clone(),
device_identifier: self.udid.clone(),
bundle_id,
process_name,
pid,
start_time,
start_instant,
},
);
Ok(running)
}
async fn scan(host: &Host) -> eyre::Result<Vec<Self>> {
#[derive(Deserialize)]
struct Runtime {
identifier: String,
version: Option<String>,
}
#[derive(Deserialize)]
struct Root {
devices: HashMap<String, Vec<AppleSimulator>>,
runtimes: Vec<Runtime>,
}
let content = host.run("xcrun", ["simctl", "list", "--json"]).await?;
let root = serde_json::from_str::<Root>(&content)?;
let mut runtime_versions = HashMap::with_capacity(root.runtimes.len());
for runtime in root.runtimes {
let Some(version) = runtime.version.as_deref() else {
warn!("simctl runtime {} reports no version", runtime.identifier);
continue;
};
match parse_semver_version(version) {
Ok(version) => {
runtime_versions.insert(runtime.identifier, version);
}
Err(error) => {
warn!("Ignoring simctl runtime {}: {error}", runtime.identifier);
}
}
}
let mut simulators = Vec::new();
for (runtime_identifier, sims) in root.devices {
for mut sim in sims {
sim.runtime_version = runtime_versions.get(&runtime_identifier).cloned();
sim.runtime_identifier = Some(runtime_identifier.clone());
simulators.push(sim);
}
}
Ok(simulators)
}
}
impl AppleSimulator {
pub async fn scan_ios(host: &Host) -> eyre::Result<Vec<Self>> {
let ios_filter = |s: &Self| {
s.is_available
&& s.runtime_identifier
.as_deref()
.is_some_and(|r| r.contains("SimRuntime.iOS-"))
};
let simulators = Self::scan(host).await?;
let mut ios_sims: Vec<Self> = simulators.into_iter().filter(ios_filter).collect();
let mut healthy: Vec<Self> = ios_sims
.iter()
.filter(|s| s.data_path.exists())
.cloned()
.collect();
if !healthy.is_empty() {
return Ok(healthy);
}
if ios_sims.is_empty() {
return Ok(Vec::new());
}
warn!(
"No healthy iOS simulators found (missing data paths). Attempting automatic simulator repair."
);
if let Err(error) = host.run("xcrun", ["simctl", "delete", "unavailable"]).await {
warn!("Failed to delete unavailable simulators: {error}");
}
ios_sims = Self::scan(host)
.await?
.into_iter()
.filter(ios_filter)
.collect();
healthy = ios_sims
.iter()
.filter(|s| s.data_path.exists())
.cloned()
.collect();
if !healthy.is_empty() {
return Ok(healthy);
}
if let Some(template) = ios_sims
.iter()
.find(|s| s.device_type_identifier.contains("iPhone"))
.or_else(|| ios_sims.first())
.cloned()
&& let Some(runtime) = template.runtime_identifier.as_deref()
{
let generated_name = format!("{} (WaterUI)", template.name);
match host
.run(
"xcrun",
[
"simctl",
"create",
&generated_name,
&template.device_type_identifier,
runtime,
],
)
.await
{
Ok(udid) => {
info!(
"Created replacement iOS simulator: {} ({})",
generated_name,
udid.trim()
);
}
Err(error) => {
warn!("Failed to create replacement iOS simulator: {error}");
}
}
}
Ok(Self::scan(host)
.await?
.into_iter()
.filter(ios_filter)
.filter(|s| s.data_path.exists())
.collect())
}
#[must_use]
pub fn supports_deployment_target(&self, deployment_target: &Version) -> bool {
self.runtime_version
.as_ref()
.is_some_and(|runtime| runtime >= deployment_target)
}
pub async fn select_ios(
host: &Host,
project: &Project,
device: Option<&str>,
) -> eyre::Result<Self> {
let (_, target) = apple_deployment_target(project, TargetPlatform::IOSSimulator).await?;
let deployment_target = parse_semver_version(&target).wrap_err_with(|| {
format!("Failed to parse the project's IPHONEOS_DEPLOYMENT_TARGET `{target}`")
})?;
let simulators = Self::scan_ios(host).await?;
Self::select(&simulators, &deployment_target, device)
}
fn select(
simulators: &[Self],
deployment_target: &Version,
device: Option<&str>,
) -> eyre::Result<Self> {
if let Some(query) = device {
return Self::select_matching(simulators, deployment_target, query);
}
simulators
.iter()
.filter(|sim| sim.supports_deployment_target(deployment_target))
.min_by_key(|sim| usize::from(sim.state != "Booted"))
.cloned()
.ok_or_else(|| no_qualifying_simulator_error(simulators, deployment_target))
}
fn select_matching(
simulators: &[Self],
deployment_target: &Version,
query: &str,
) -> eyre::Result<Self> {
let matches: Vec<&Self> = simulators
.iter()
.filter(|sim| sim.udid == query || sim.name == query)
.collect();
if matches.is_empty() {
bail!("Device not found: {query}");
}
let qualifying: Vec<&Self> = matches
.iter()
.copied()
.filter(|sim| sim.supports_deployment_target(deployment_target))
.collect();
match qualifying.as_slice() {
[sim] => Ok((*sim).clone()),
[] => Err(unqualified_simulator_error(
&matches,
deployment_target,
query,
)),
candidates => Err(ambiguous_simulator_error(
candidates,
deployment_target,
query,
)),
}
}
fn runtime_label(&self) -> String {
self.runtime_version.as_ref().map_or_else(
|| {
self.runtime_identifier
.clone()
.unwrap_or_else(|| String::from("an unknown runtime"))
},
|version| format!("iOS {version}"),
)
}
}
fn simulator_candidates<'a>(simulators: impl IntoIterator<Item = &'a AppleSimulator>) -> String {
use std::fmt::Write as _;
simulators.into_iter().fold(String::new(), |mut out, sim| {
write!(
out,
"\n {} ({}) — {}",
sim.name,
sim.udid,
sim.runtime_label()
)
.expect("writing to a String cannot fail");
out
})
}
fn unqualified_simulator_error(
matches: &[&AppleSimulator],
deployment_target: &Version,
query: &str,
) -> eyre::Report {
if let [sim] = matches {
return eyre!(
"Simulator \"{}\" ({}) runs {}, but this app requires iOS {deployment_target} (IPHONEOS_DEPLOYMENT_TARGET)",
sim.name,
sim.udid,
sim.runtime_label(),
);
}
eyre!(
"Device \"{query}\" matches {} simulators, but none can run this app, which requires iOS {deployment_target} (IPHONEOS_DEPLOYMENT_TARGET):{}",
matches.len(),
simulator_candidates(matches.iter().copied()),
)
}
fn ambiguous_simulator_error(
candidates: &[&AppleSimulator],
deployment_target: &Version,
query: &str,
) -> eyre::Report {
eyre!(
"Device \"{query}\" matches {} simulators that can run this app (iOS {deployment_target} or newer); select one by UDID:{}",
candidates.len(),
simulator_candidates(candidates.iter().copied()),
)
}
fn no_qualifying_simulator_error(
simulators: &[AppleSimulator],
deployment_target: &Version,
) -> eyre::Report {
if simulators.is_empty() {
return eyre!("No iOS simulators available. Create one in Xcode.");
}
eyre!(
"No iOS simulator can run this app: it requires iOS {deployment_target} (IPHONEOS_DEPLOYMENT_TARGET). Available simulators:{}",
simulator_candidates(simulators.iter()),
)
}
pub async fn screenshot(host: &Host, udid: &str, output: &Path) -> eyre::Result<()> {
host.run(
"xcrun",
[
"simctl",
"io",
udid,
"screenshot",
output
.to_str()
.ok_or_else(|| eyre!("Invalid output path"))?,
],
)
.await?;
Ok(())
}
pub async fn screenshot_bytes(host: &Host, udid: &str) -> eyre::Result<Vec<u8>> {
let output = host
.output("xcrun", ["simctl", "io", udid, "screenshot", "-"])
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eyre::bail!("Failed to capture screenshot: {}", stderr.trim());
}
Ok(output.stdout)
}
async fn check_idb_installed(host: &Host) -> eyre::Result<()> {
if host.which("idb").await.is_err() {
eyre::bail!(
"IDB (iOS Development Bridge) is not installed.\n\n\
Gesture commands require IDB for iOS simulator automation.\n\n\
To install IDB:\n\
\x20 brew tap facebook/fb && brew install idb-companion\n\
\x20 pipx install fb-idb --python python3.12\n\n\
For more information: https://fbidb.io/"
);
}
Ok(())
}
pub async fn tap(host: &Host, udid: &str, x: u32, y: u32) -> eyre::Result<()> {
check_idb_installed(host).await?;
let output = host
.output(
"idb",
["ui", "tap", "--udid", udid, &x.to_string(), &y.to_string()],
)
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eyre::bail!("Failed to tap: {}", stderr.trim());
}
Ok(())
}
pub async fn swipe(
host: &Host,
udid: &str,
from: (u32, u32),
to: (u32, u32),
duration_ms: Option<u32>,
) -> eyre::Result<()> {
check_idb_installed(host).await?;
let mut args = vec![
"ui".to_string(),
"swipe".to_string(),
"--udid".to_string(),
udid.to_string(),
from.0.to_string(),
from.1.to_string(),
to.0.to_string(),
to.1.to_string(),
];
if let Some(duration) = duration_ms {
let duration_sec = f64::from(duration) / 1000.0;
args.push("--duration".to_string());
args.push(format!("{duration_sec:.2}"));
}
let output = host.output("idb", args).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eyre::bail!("Failed to swipe: {}", stderr.trim());
}
Ok(())
}
pub async fn text(host: &Host, udid: &str, input: &str) -> eyre::Result<()> {
check_idb_installed(host).await?;
let output = host
.output("idb", ["ui", "text", "--udid", udid, input])
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eyre::bail!("Failed to input text: {}", stderr.trim());
}
Ok(())
}
pub async fn describe(host: &Host, udid: &str) -> eyre::Result<String> {
check_idb_installed(host).await?;
let output = host
.output("idb", ["ui", "describe-all", "--udid", udid, "--json"])
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eyre::bail!("Failed to describe UI: {}", stderr.trim());
}
let json = String::from_utf8_lossy(&output.stdout).to_string();
Ok(json)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use semver::Version;
use super::{AppleSimulator, parse_simctl_launch_pid};
use crate::utils::parse_semver_version;
fn ios_simulator(name: &str, udid: &str, state: &str, runtime: &str) -> AppleSimulator {
AppleSimulator {
data_path: PathBuf::new(),
data_path_size: None,
log_path: PathBuf::new(),
log_path_size: None,
udid: udid.to_string(),
is_available: true,
device_type_identifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-16-Pro"
.to_string(),
state: state.to_string(),
name: name.to_string(),
last_booted_at: None,
runtime_identifier: Some(format!(
"com.apple.CoreSimulator.SimRuntime.iOS-{}",
runtime.replace('.', "-")
)),
runtime_version: Some(
parse_semver_version(runtime).expect("test runtime version should parse"),
),
}
}
fn target(version: &str) -> Version {
parse_semver_version(version).expect("test target should parse")
}
#[test]
fn parses_simctl_launch_pid_from_bundle_prefix() {
let stdout = "com.example.app: 12345\n";
assert_eq!(parse_simctl_launch_pid(stdout), Some(12345));
}
#[test]
fn parses_simctl_launch_pid_from_plain_pid() {
let stdout = "12345\n";
assert_eq!(parse_simctl_launch_pid(stdout), Some(12345));
}
#[test]
fn returns_none_when_no_pid_present() {
let stdout = "com.example.app: not-a-pid\n";
assert_eq!(parse_simctl_launch_pid(stdout), None);
}
#[test]
fn simulator_below_target_does_not_qualify() {
let sim = ios_simulator("iPhone 16 Pro", "UDID-18", "Booted", "18.5");
assert!(!sim.supports_deployment_target(&target("26.0")));
assert!(sim.supports_deployment_target(&target("18.5")));
assert!(sim.supports_deployment_target(&target("17.0")));
}
#[test]
fn simulator_without_runtime_version_never_qualifies() {
let mut sim = ios_simulator("iPhone 16 Pro", "UDID-X", "Booted", "18.5");
sim.runtime_version = None;
assert!(!sim.supports_deployment_target(&target("1.0")));
}
#[test]
fn automatic_selection_prefers_booted_qualifying_simulator() {
let sims = vec![
ios_simulator("iPhone 16 Pro", "UDID-18-BOOTED", "Booted", "18.5"),
ios_simulator("iPhone 16 Pro", "UDID-26-BOOTED", "Booted", "26.5"),
ios_simulator("iPhone 16 Pro", "UDID-26-SHUTDOWN", "Shutdown", "26.5"),
];
let selected = AppleSimulator::select(&sims, &target("26.0"), None)
.expect("a qualifying booted simulator exists");
assert_eq!(selected.udid, "UDID-26-BOOTED");
}
#[test]
fn automatic_selection_falls_back_to_shutdown_qualifying_simulator() {
let sims = vec![
ios_simulator("iPhone 16 Pro", "UDID-18-BOOTED", "Booted", "18.5"),
ios_simulator("iPhone 16 Pro", "UDID-26-SHUTDOWN", "Shutdown", "26.5"),
];
let selected = AppleSimulator::select(&sims, &target("26.0"), None)
.expect("a qualifying simulator exists");
assert_eq!(selected.udid, "UDID-26-SHUTDOWN");
}
#[test]
fn automatic_selection_names_target_when_nothing_qualifies() {
let sims = vec![ios_simulator("iPhone 16 Pro", "UDID-18", "Booted", "18.5")];
let error = AppleSimulator::select(&sims, &target("26.0"), None).unwrap_err();
let message = error.to_string();
assert!(message.contains("26.0.0"), "{message}");
assert!(message.contains("UDID-18"), "{message}");
}
#[test]
fn explicit_device_below_target_is_rejected() {
let sims = vec![ios_simulator("iPhone 16 Pro", "UDID-18", "Booted", "18.5")];
let error = AppleSimulator::select(&sims, &target("26.0"), Some("UDID-18")).unwrap_err();
let message = error.to_string();
assert!(message.contains("iPhone 16 Pro"), "{message}");
assert!(message.contains("UDID-18"), "{message}");
assert!(message.contains("18.5"), "{message}");
assert!(message.contains("26.0.0"), "{message}");
}
#[test]
fn explicit_name_selects_the_qualifying_simulator() {
let sims = vec![
ios_simulator("iPhone 16 Pro", "UDID-18", "Booted", "18.5"),
ios_simulator("iPhone 16 Pro", "UDID-26", "Shutdown", "26.5"),
];
let selected = AppleSimulator::select(&sims, &target("26.0"), Some("iPhone 16 Pro"))
.expect("exactly one match qualifies");
assert_eq!(selected.udid, "UDID-26");
}
#[test]
fn explicit_name_matching_several_qualifying_simulators_is_ambiguous() {
let sims = vec![
ios_simulator("iPhone 16 Pro", "UDID-26-A", "Booted", "26.5"),
ios_simulator("iPhone 16 Pro", "UDID-26-B", "Shutdown", "26.5"),
];
let error =
AppleSimulator::select(&sims, &target("26.0"), Some("iPhone 16 Pro")).unwrap_err();
let message = error.to_string();
assert!(message.contains("UDID-26-A"), "{message}");
assert!(message.contains("UDID-26-B"), "{message}");
}
#[test]
fn explicit_device_not_found() {
let sims = vec![ios_simulator("iPhone 16 Pro", "UDID-26", "Booted", "26.5")];
let error = AppleSimulator::select(&sims, &target("26.0"), Some("iPhone 17")).unwrap_err();
assert!(error.to_string().contains("iPhone 17"));
}
}