#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]
#![doc(html_root_url = "https://docs.smix.dev/smix-simctl")]
pub mod registry;
use serde::{Deserialize, Serialize};
use std::io;
use std::time::Duration;
use thiserror::Error;
use tokio::process::Command;
use tokio::time::sleep;
#[derive(Debug, Error)]
pub enum SimctlError {
#[error("spawn xcrun simctl failed: {0}")]
Spawn(#[from] io::Error),
#[error("xcrun simctl {subcommand} exited {code}: {stderr}")]
NonZeroExit {
subcommand: String,
code: i32,
stderr: String,
},
#[error("xcrun simctl {subcommand} returned malformed output: {detail}")]
Malformed {
subcommand: String,
detail: String,
},
#[error("xcrun simctl {subcommand} timed out after {ms}ms")]
Timeout {
subcommand: String,
ms: u64,
},
}
#[derive(Debug)]
pub struct RecordingHandle {
pub(crate) child: tokio::process::Child,
pub path: String,
pub started_at: std::time::Instant,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SimctlRuntime {
pub identifier: String,
pub name: String,
pub version: String,
pub is_available: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SimctlDevice {
pub udid: String,
pub name: String,
pub state: String,
pub is_available: bool,
#[serde(rename = "deviceTypeIdentifier", default)]
pub device_type_identifier: String,
#[serde(rename = "runtimeIdentifier", default)]
pub runtime_identifier: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SimctlPermission {
Camera,
Photos,
Location,
LocationAlways,
Notifications,
Microphone,
Contacts,
Calendar,
Reminders,
Media,
Motion,
HomeKit,
Health,
Bluetooth,
Faceid,
AddressBook,
}
impl SimctlPermission {
pub fn as_str(self) -> &'static str {
match self {
SimctlPermission::Camera => "camera",
SimctlPermission::Photos => "photos",
SimctlPermission::Location => "location",
SimctlPermission::LocationAlways => "location-always",
SimctlPermission::Notifications => "notifications",
SimctlPermission::Microphone => "microphone",
SimctlPermission::Contacts => "contacts",
SimctlPermission::Calendar => "calendar",
SimctlPermission::Reminders => "reminders",
SimctlPermission::Media => "media-library",
SimctlPermission::Motion => "motion",
SimctlPermission::HomeKit => "homekit",
SimctlPermission::Health => "health",
SimctlPermission::Bluetooth => "bluetooth",
SimctlPermission::Faceid => "faceid",
SimctlPermission::AddressBook => "addressbook",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Appearance {
Light,
Dark,
}
impl Appearance {
pub fn as_str(self) -> &'static str {
match self {
Appearance::Light => "light",
Appearance::Dark => "dark",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LaunchResult {
pub pid: u32,
}
async fn simctl_capture(args: &[&str]) -> Result<(Vec<u8>, String), SimctlError> {
simctl_capture_env(args, &[]).await
}
async fn simctl_capture_env(
args: &[&str],
env: &[(String, String)],
) -> Result<(Vec<u8>, String), SimctlError> {
let mut cmd = Command::new("xcrun");
cmd.arg("simctl");
for a in args {
cmd.arg(a);
}
for (k, v) in env {
cmd.env(k, v);
}
let output = cmd.output().await?;
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
if !output.status.success() {
return Err(SimctlError::NonZeroExit {
subcommand: args.first().map(|s| s.to_string()).unwrap_or_default(),
code: output.status.code().unwrap_or(-1),
stderr,
});
}
Ok((output.stdout, stderr))
}
async fn simctl_run(args: &[&str]) -> Result<String, SimctlError> {
let (stdout, _) = simctl_capture(args).await?;
Ok(String::from_utf8_lossy(&stdout).into_owned())
}
async fn simctl_run_env(args: &[&str], env: &[(String, String)]) -> Result<String, SimctlError> {
let (stdout, _) = simctl_capture_env(args, env).await?;
Ok(String::from_utf8_lossy(&stdout).into_owned())
}
pub fn compose_child_env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(k, v)| {
let key = if k.starts_with("SIMCTL_CHILD_") {
(*k).to_string()
} else {
format!("SIMCTL_CHILD_{k}")
};
(key, (*v).to_string())
})
.collect()
}
#[derive(Debug, Default)]
pub struct SimctlClient {}
impl SimctlClient {
pub fn new() -> Self {
SimctlClient {}
}
pub async fn list_runtimes(&self) -> Result<Vec<SimctlRuntime>, SimctlError> {
let raw = simctl_run(&["list", "runtimes", "-j"]).await?;
#[derive(Deserialize)]
struct Wrap {
runtimes: Vec<RawRuntime>,
}
#[derive(Deserialize)]
struct RawRuntime {
identifier: String,
name: String,
version: String,
#[serde(rename = "isAvailable", default)]
is_available: bool,
}
let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
subcommand: "list runtimes".into(),
detail: e.to_string(),
})?;
Ok(w.runtimes
.into_iter()
.map(|r| SimctlRuntime {
identifier: r.identifier,
name: r.name,
version: r.version,
is_available: r.is_available,
})
.collect())
}
pub async fn list_devices(&self) -> Result<Vec<SimctlDevice>, SimctlError> {
let raw = simctl_run(&["list", "devices", "-j"]).await?;
#[derive(Deserialize)]
struct Wrap {
devices: std::collections::BTreeMap<String, Vec<RawDevice>>,
}
#[derive(Deserialize)]
struct RawDevice {
udid: String,
name: String,
state: String,
#[serde(rename = "isAvailable", default)]
is_available: bool,
#[serde(rename = "deviceTypeIdentifier", default)]
device_type_identifier: String,
}
let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
subcommand: "list devices".into(),
detail: e.to_string(),
})?;
let mut out = Vec::new();
for (runtime_id, devices) in w.devices {
for d in devices {
out.push(SimctlDevice {
udid: d.udid,
name: d.name,
state: d.state,
is_available: d.is_available,
device_type_identifier: d.device_type_identifier,
runtime_identifier: runtime_id.clone(),
});
}
}
Ok(out)
}
pub async fn boot(&self, udid: &str) -> Result<(), SimctlError> {
simctl_run(&["boot", udid]).await?;
Ok(())
}
pub async fn shutdown(&self, udid: &str) -> Result<(), SimctlError> {
simctl_run(&["shutdown", udid]).await?;
Ok(())
}
pub async fn current_locale(&self, udid: &str) -> Result<Option<String>, SimctlError> {
let out =
match simctl_run(&["spawn", udid, "defaults", "read", "-g", "AppleLanguages"]).await {
Ok(s) => s,
Err(SimctlError::NonZeroExit { .. }) => return Ok(None),
Err(e) => return Err(e),
};
if let Some(start) = out.find('"') {
let rest = &out[start + 1..];
if let Some(end) = rest.find('"') {
return Ok(Some(rest[..end].to_string()));
}
}
Ok(None)
}
pub async fn set_locale(&self, udid: &str, locale: &str) -> Result<(), SimctlError> {
simctl_run(&[
"spawn",
udid,
"defaults",
"write",
"-g",
"AppleLanguages",
"-array",
locale,
])
.await?;
let locale_underscore = locale.replace('-', "_");
simctl_run(&[
"spawn",
udid,
"defaults",
"write",
"-g",
"AppleLocale",
&locale_underscore,
])
.await?;
Ok(())
}
pub async fn boot_and_wait(&self, udid: &str, timeout: Duration) -> Result<(), SimctlError> {
let _ = simctl_run(&["boot", udid]).await;
let start = std::time::Instant::now();
loop {
let devices = self.list_devices().await?;
if devices
.iter()
.any(|d| d.udid == udid && d.state == "Booted")
{
return Ok(());
}
if start.elapsed() > timeout {
return Err(SimctlError::Timeout {
subcommand: format!("boot {}", udid),
ms: timeout.as_millis() as u64,
});
}
sleep(Duration::from_millis(500)).await;
}
}
pub async fn erase(&self, udid: &str) -> Result<(), SimctlError> {
simctl_run(&["erase", udid]).await?;
Ok(())
}
pub async fn install(&self, udid: &str, app_path: &str) -> Result<(), SimctlError> {
simctl_run(&["install", udid, app_path]).await?;
Ok(())
}
pub async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
simctl_run(&["uninstall", udid, bundle_id]).await?;
Ok(())
}
pub async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
simctl_run(&["terminate", udid, bundle_id]).await?;
Ok(())
}
pub async fn launch(&self, udid: &str, bundle_id: &str) -> Result<LaunchResult, SimctlError> {
self.launch_with_args(udid, bundle_id, &[]).await
}
pub async fn launch_with_args(
&self,
udid: &str,
bundle_id: &str,
args: &[String],
) -> Result<LaunchResult, SimctlError> {
self.launch_with_args_and_env(udid, bundle_id, args, &[])
.await
}
pub async fn launch_with_args_and_env(
&self,
udid: &str,
bundle_id: &str,
args: &[String],
child_env: &[(&str, &str)],
) -> Result<LaunchResult, SimctlError> {
let mut argv: Vec<&str> = vec!["launch", udid, bundle_id];
if !args.is_empty() {
argv.push("--");
for a in args {
argv.push(a.as_str());
}
}
let composed = compose_child_env(child_env);
let out = simctl_run_env(&argv, &composed).await?;
let pid_str =
out.rsplit(':')
.next()
.map(str::trim)
.ok_or_else(|| SimctlError::Malformed {
subcommand: "launch".into(),
detail: format!("unexpected stdout shape: {}", out.trim()),
})?;
let pid: u32 = pid_str.parse().map_err(|_| SimctlError::Malformed {
subcommand: "launch".into(),
detail: format!("non-numeric pid in stdout: {}", out.trim()),
})?;
Ok(LaunchResult { pid })
}
pub async fn open_url(&self, udid: &str, url: &str) -> Result<(), SimctlError> {
simctl_run(&["openurl", udid, url]).await?;
Ok(())
}
pub async fn send_push(
&self,
udid: &str,
bundle_id: &str,
apns_json_path: &str,
) -> Result<(), SimctlError> {
simctl_run(&["push", udid, bundle_id, apns_json_path]).await?;
Ok(())
}
pub async fn set_appearance(&self, udid: &str, mode: Appearance) -> Result<(), SimctlError> {
simctl_run(&["ui", udid, "appearance", mode.as_str()]).await?;
Ok(())
}
pub async fn grant_permission(
&self,
udid: &str,
permission: SimctlPermission,
bundle_id: &str,
) -> Result<(), SimctlError> {
simctl_run(&["privacy", udid, "grant", permission.as_str(), bundle_id]).await?;
Ok(())
}
pub async fn revoke_permission(
&self,
udid: &str,
permission: SimctlPermission,
bundle_id: &str,
) -> Result<(), SimctlError> {
simctl_run(&["privacy", udid, "revoke", permission.as_str(), bundle_id]).await?;
Ok(())
}
pub async fn location_set(
&self,
udid: &str,
latitude: f64,
longitude: f64,
) -> Result<(), SimctlError> {
let coord = format!("{latitude},{longitude}");
simctl_run(&["location", udid, "set", &coord]).await?;
Ok(())
}
pub async fn location_start(
&self,
udid: &str,
points: &[(f64, f64)],
speed_mps: Option<f64>,
) -> Result<(), SimctlError> {
if points.len() < 2 {
return Err(SimctlError::Malformed {
subcommand: "location-start".into(),
detail: format!("requires ≥2 waypoints, got {}", points.len()),
});
}
let mut args: Vec<String> = vec!["location".into(), udid.into(), "start".into()];
if let Some(s) = speed_mps {
args.push(format!("--speed={s}"));
}
for (lat, lng) in points {
args.push(format!("{lat},{lng}"));
}
let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
simctl_run(&args_ref).await?;
Ok(())
}
pub async fn location_clear(&self, udid: &str) -> Result<(), SimctlError> {
simctl_run(&["location", udid, "clear"]).await?;
Ok(())
}
pub async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), SimctlError> {
if paths.is_empty() {
return Err(SimctlError::Malformed {
subcommand: "addmedia".into(),
detail: "no paths supplied".into(),
});
}
let mut args: Vec<&str> = vec!["addmedia", udid];
for p in paths {
args.push(p.as_str());
}
simctl_run(&args).await?;
Ok(())
}
pub async fn record_video_start(
&self,
udid: &str,
path: &str,
) -> Result<RecordingHandle, SimctlError> {
let child = tokio::process::Command::new("xcrun")
.args(["simctl", "io", udid, "recordVideo", path])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
Ok(RecordingHandle {
child,
path: path.to_string(),
started_at: std::time::Instant::now(),
})
}
pub async fn record_video_stop(&self, mut handle: RecordingHandle) -> Result<(), SimctlError> {
let pid = handle.child.id().ok_or_else(|| SimctlError::Malformed {
subcommand: "recordVideo-stop".into(),
detail: "child already reaped".into(),
})?;
let rc = unsafe { libc::kill(pid as i32, libc::SIGINT) };
if rc != 0 {
return Err(SimctlError::Malformed {
subcommand: "recordVideo-stop".into(),
detail: format!(
"kill SIGINT failed: errno={}",
std::io::Error::last_os_error()
),
});
}
let wait_result =
tokio::time::timeout(std::time::Duration::from_secs(10), handle.child.wait()).await;
match wait_result {
Ok(Ok(_status)) => Ok(()),
Ok(Err(e)) => Err(SimctlError::Malformed {
subcommand: "recordVideo-stop".into(),
detail: format!("wait failed: {e}"),
}),
Err(_timeout) => {
let _ = handle.child.kill().await;
Err(SimctlError::Malformed {
subcommand: "recordVideo-stop".into(),
detail: "SIGINT timeout (10s) — escalated SIGKILL; output mp4 likely truncated. Inspect simctl recordVideo stderr.".into(),
})
}
}
}
pub async fn reset_permission(
&self,
udid: &str,
permission: SimctlPermission,
bundle_id: &str,
) -> Result<(), SimctlError> {
simctl_run(&["privacy", udid, "reset", permission.as_str(), bundle_id]).await?;
Ok(())
}
pub async fn keychain_reset(&self, udid: &str) -> Result<(), SimctlError> {
simctl_run(&["keychain", udid, "reset"]).await?;
Ok(())
}
pub async fn pasteboard_get(&self, udid: &str) -> Result<String, SimctlError> {
simctl_run(&["pbpaste", udid]).await
}
pub async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), SimctlError> {
use tokio::io::AsyncWriteExt;
let mut cmd = Command::new("xcrun");
cmd.arg("simctl").arg("pbcopy").arg(udid);
cmd.stdin(std::process::Stdio::piped());
let mut child = cmd.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(text.as_bytes()).await?;
drop(stdin); }
let status = child.wait().await?;
if !status.success() {
return Err(SimctlError::NonZeroExit {
subcommand: "pbcopy".into(),
code: status.code().unwrap_or(-1),
stderr: String::new(),
});
}
Ok(())
}
pub async fn set_reduce_motion(&self, udid: &str, enabled: bool) -> Result<(), SimctlError> {
let val = if enabled { "1" } else { "0" };
simctl_run(&[
"spawn",
udid,
"defaults",
"write",
"com.apple.UIKit",
"UIAccessibilityReduceMotionEnabled",
"-bool",
val,
])
.await?;
Ok(())
}
pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, SimctlError> {
let tmp =
std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
let tmp_str = tmp.display().to_string();
let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
let bytes = result.and_then(|_| {
std::fs::read(&tmp).map_err(|e| SimctlError::Malformed {
subcommand: "screenshot".into(),
detail: format!("read {tmp_str}: {e}"),
})
});
let _ = std::fs::remove_file(&tmp);
let bytes = bytes?;
if bytes.len() < 8 {
return Err(SimctlError::Malformed {
subcommand: "screenshot".into(),
detail: format!("screenshot file too short: {} bytes", bytes.len()),
});
}
Ok(bytes)
}
pub async fn create_device(
&self,
name: &str,
device_type: &str,
runtime_id: &str,
) -> Result<String, SimctlError> {
let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
Ok(out.trim().to_string())
}
pub async fn delete_device(&self, udid: &str) -> Result<(), SimctlError> {
simctl_run(&["delete", udid]).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compose_child_env_adds_prefix() {
let composed = compose_child_env(&[
("INSIGHT_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
("LAUNCH_FORCE_PUSH", "true"),
]);
assert_eq!(
composed,
vec![
(
"SIMCTL_CHILD_INSIGHT_PERF_RECEIVER_URL".to_string(),
"http://127.0.0.1:9999".to_string(),
),
(
"SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
"true".to_string(),
),
]
);
}
#[test]
fn compose_child_env_already_prefixed_passes_through() {
let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
assert_eq!(
composed,
vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
);
}
#[test]
fn compose_child_env_empty_input_is_empty_output() {
assert!(compose_child_env(&[]).is_empty());
}
}