1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5#![doc(html_root_url = "https://docs.smix.dev/smix-simctl")]
17
18pub mod registry;
19
20use serde::{Deserialize, Serialize};
21use std::io;
22use std::time::Duration;
23use thiserror::Error;
24use tokio::process::Command;
25use tokio::time::sleep;
26
27#[derive(Debug, Error)]
29pub enum SimctlError {
30 #[error("spawn xcrun simctl failed: {0}")]
32 Spawn(#[from] io::Error),
33 #[error("xcrun simctl {subcommand} exited {code}: {stderr}")]
35 NonZeroExit {
36 subcommand: String,
38 code: i32,
40 stderr: String,
42 },
43 #[error("xcrun simctl {subcommand} returned malformed output: {detail}")]
45 Malformed {
46 subcommand: String,
48 detail: String,
50 },
51 #[error("xcrun simctl {subcommand} timed out after {ms}ms")]
53 Timeout {
54 subcommand: String,
56 ms: u64,
58 },
59}
60
61#[derive(Debug)]
66pub struct RecordingHandle {
67 pub(crate) child: tokio::process::Child,
68 pub path: String,
70 pub started_at: std::time::Instant,
72}
73
74#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
78pub struct SimctlRuntime {
79 pub identifier: String,
81 pub name: String,
83 pub version: String,
85 pub is_available: bool,
87}
88
89#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
91pub struct SimctlDevice {
92 pub udid: String,
94 pub name: String,
96 pub state: String,
98 pub is_available: bool,
100 #[serde(rename = "deviceTypeIdentifier", default)]
102 pub device_type_identifier: String,
103 #[serde(rename = "runtimeIdentifier", default)]
105 pub runtime_identifier: String,
106}
107
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110pub enum SimctlPermission {
111 Camera,
113 Photos,
115 Location,
117 LocationAlways,
119 Notifications,
121 Microphone,
123 Contacts,
125 Calendar,
127 Reminders,
129 Media,
131 Motion,
133 HomeKit,
135 Health,
137 Bluetooth,
139 Faceid,
141 AddressBook,
143}
144
145impl SimctlPermission {
146 pub fn as_str(self) -> &'static str {
148 match self {
149 SimctlPermission::Camera => "camera",
150 SimctlPermission::Photos => "photos",
151 SimctlPermission::Location => "location",
152 SimctlPermission::LocationAlways => "location-always",
153 SimctlPermission::Notifications => "notifications",
154 SimctlPermission::Microphone => "microphone",
155 SimctlPermission::Contacts => "contacts",
156 SimctlPermission::Calendar => "calendar",
157 SimctlPermission::Reminders => "reminders",
158 SimctlPermission::Media => "media-library",
159 SimctlPermission::Motion => "motion",
160 SimctlPermission::HomeKit => "homekit",
161 SimctlPermission::Health => "health",
162 SimctlPermission::Bluetooth => "bluetooth",
163 SimctlPermission::Faceid => "faceid",
164 SimctlPermission::AddressBook => "addressbook",
165 }
166 }
167}
168
169#[derive(Clone, Copy, Debug, PartialEq, Eq)]
171pub enum Appearance {
172 Light,
174 Dark,
176}
177
178impl Appearance {
179 pub fn as_str(self) -> &'static str {
181 match self {
182 Appearance::Light => "light",
183 Appearance::Dark => "dark",
184 }
185 }
186}
187
188#[derive(Clone, Debug, PartialEq, Eq)]
190pub struct LaunchResult {
191 pub pid: u32,
193}
194
195async fn simctl_capture(args: &[&str]) -> Result<(Vec<u8>, String), SimctlError> {
199 simctl_capture_env(args, &[]).await
200}
201
202async fn simctl_capture_env(
209 args: &[&str],
210 env: &[(String, String)],
211) -> Result<(Vec<u8>, String), SimctlError> {
212 let mut cmd = Command::new("xcrun");
213 cmd.arg("simctl");
214 for a in args {
215 cmd.arg(a);
216 }
217 for (k, v) in env {
218 cmd.env(k, v);
219 }
220 let output = cmd.output().await?;
221 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
222 if !output.status.success() {
223 return Err(SimctlError::NonZeroExit {
224 subcommand: args.first().map(|s| s.to_string()).unwrap_or_default(),
225 code: output.status.code().unwrap_or(-1),
226 stderr,
227 });
228 }
229 Ok((output.stdout, stderr))
230}
231
232async fn simctl_run(args: &[&str]) -> Result<String, SimctlError> {
233 let (stdout, _) = simctl_capture(args).await?;
234 Ok(String::from_utf8_lossy(&stdout).into_owned())
235}
236
237async fn simctl_run_env(args: &[&str], env: &[(String, String)]) -> Result<String, SimctlError> {
241 let (stdout, _) = simctl_capture_env(args, env).await?;
242 Ok(String::from_utf8_lossy(&stdout).into_owned())
243}
244
245pub fn compose_child_env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
264 pairs
265 .iter()
266 .map(|(k, v)| {
267 let key = if k.starts_with("SIMCTL_CHILD_") {
268 (*k).to_string()
269 } else {
270 format!("SIMCTL_CHILD_{k}")
271 };
272 (key, (*v).to_string())
273 })
274 .collect()
275}
276
277#[derive(Debug, Default)]
283pub struct SimctlClient {}
284
285impl SimctlClient {
286 pub fn new() -> Self {
288 SimctlClient {}
289 }
290
291 pub async fn list_runtimes(&self) -> Result<Vec<SimctlRuntime>, SimctlError> {
295 let raw = simctl_run(&["list", "runtimes", "-j"]).await?;
296 #[derive(Deserialize)]
297 struct Wrap {
298 runtimes: Vec<RawRuntime>,
299 }
300 #[derive(Deserialize)]
301 struct RawRuntime {
302 identifier: String,
303 name: String,
304 version: String,
305 #[serde(rename = "isAvailable", default)]
306 is_available: bool,
307 }
308 let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
309 subcommand: "list runtimes".into(),
310 detail: e.to_string(),
311 })?;
312 Ok(w.runtimes
313 .into_iter()
314 .map(|r| SimctlRuntime {
315 identifier: r.identifier,
316 name: r.name,
317 version: r.version,
318 is_available: r.is_available,
319 })
320 .collect())
321 }
322
323 pub async fn list_devices(&self) -> Result<Vec<SimctlDevice>, SimctlError> {
325 let raw = simctl_run(&["list", "devices", "-j"]).await?;
326 #[derive(Deserialize)]
327 struct Wrap {
328 devices: std::collections::BTreeMap<String, Vec<RawDevice>>,
329 }
330 #[derive(Deserialize)]
331 struct RawDevice {
332 udid: String,
333 name: String,
334 state: String,
335 #[serde(rename = "isAvailable", default)]
336 is_available: bool,
337 #[serde(rename = "deviceTypeIdentifier", default)]
338 device_type_identifier: String,
339 }
340 let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
341 subcommand: "list devices".into(),
342 detail: e.to_string(),
343 })?;
344 let mut out = Vec::new();
345 for (runtime_id, devices) in w.devices {
346 for d in devices {
347 out.push(SimctlDevice {
348 udid: d.udid,
349 name: d.name,
350 state: d.state,
351 is_available: d.is_available,
352 device_type_identifier: d.device_type_identifier,
353 runtime_identifier: runtime_id.clone(),
354 });
355 }
356 }
357 Ok(out)
358 }
359
360 pub async fn boot(&self, udid: &str) -> Result<(), SimctlError> {
364 simctl_run(&["boot", udid]).await?;
365 Ok(())
366 }
367
368 pub async fn shutdown(&self, udid: &str) -> Result<(), SimctlError> {
370 simctl_run(&["shutdown", udid]).await?;
371 Ok(())
372 }
373
374 pub async fn current_locale(&self, udid: &str) -> Result<Option<String>, SimctlError> {
381 let out =
382 match simctl_run(&["spawn", udid, "defaults", "read", "-g", "AppleLanguages"]).await {
383 Ok(s) => s,
384 Err(SimctlError::NonZeroExit { .. }) => return Ok(None),
387 Err(e) => return Err(e),
388 };
389 if let Some(start) = out.find('"') {
391 let rest = &out[start + 1..];
392 if let Some(end) = rest.find('"') {
393 return Ok(Some(rest[..end].to_string()));
394 }
395 }
396 Ok(None)
397 }
398
399 pub async fn set_locale(&self, udid: &str, locale: &str) -> Result<(), SimctlError> {
406 simctl_run(&[
407 "spawn",
408 udid,
409 "defaults",
410 "write",
411 "-g",
412 "AppleLanguages",
413 "-array",
414 locale,
415 ])
416 .await?;
417 let locale_underscore = locale.replace('-', "_");
418 simctl_run(&[
419 "spawn",
420 udid,
421 "defaults",
422 "write",
423 "-g",
424 "AppleLocale",
425 &locale_underscore,
426 ])
427 .await?;
428 Ok(())
429 }
430
431 pub async fn boot_and_wait(&self, udid: &str, timeout: Duration) -> Result<(), SimctlError> {
436 let _ = simctl_run(&["boot", udid]).await;
438 let start = std::time::Instant::now();
439 loop {
440 let devices = self.list_devices().await?;
441 if devices
442 .iter()
443 .any(|d| d.udid == udid && d.state == "Booted")
444 {
445 return Ok(());
446 }
447 if start.elapsed() > timeout {
448 return Err(SimctlError::Timeout {
449 subcommand: format!("boot {}", udid),
450 ms: timeout.as_millis() as u64,
451 });
452 }
453 sleep(Duration::from_millis(500)).await;
454 }
455 }
456
457 pub async fn erase(&self, udid: &str) -> Result<(), SimctlError> {
459 simctl_run(&["erase", udid]).await?;
460 Ok(())
461 }
462
463 pub async fn install(&self, udid: &str, app_path: &str) -> Result<(), SimctlError> {
465 simctl_run(&["install", udid, app_path]).await?;
466 Ok(())
467 }
468
469 pub async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
471 simctl_run(&["uninstall", udid, bundle_id]).await?;
472 Ok(())
473 }
474
475 pub async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
477 simctl_run(&["terminate", udid, bundle_id]).await?;
478 Ok(())
479 }
480
481 pub async fn launch(&self, udid: &str, bundle_id: &str) -> Result<LaunchResult, SimctlError> {
483 self.launch_with_args(udid, bundle_id, &[]).await
484 }
485
486 pub async fn launch_with_args(
490 &self,
491 udid: &str,
492 bundle_id: &str,
493 args: &[String],
494 ) -> Result<LaunchResult, SimctlError> {
495 self.launch_with_args_and_env(udid, bundle_id, args, &[])
496 .await
497 }
498
499 pub async fn launch_with_args_and_env(
509 &self,
510 udid: &str,
511 bundle_id: &str,
512 args: &[String],
513 child_env: &[(&str, &str)],
514 ) -> Result<LaunchResult, SimctlError> {
515 let mut argv: Vec<&str> = vec!["launch", udid, bundle_id];
516 if !args.is_empty() {
517 argv.push("--");
518 for a in args {
519 argv.push(a.as_str());
520 }
521 }
522 let composed = compose_child_env(child_env);
523 let out = simctl_run_env(&argv, &composed).await?;
524 let pid_str =
526 out.rsplit(':')
527 .next()
528 .map(str::trim)
529 .ok_or_else(|| SimctlError::Malformed {
530 subcommand: "launch".into(),
531 detail: format!("unexpected stdout shape: {}", out.trim()),
532 })?;
533 let pid: u32 = pid_str.parse().map_err(|_| SimctlError::Malformed {
534 subcommand: "launch".into(),
535 detail: format!("non-numeric pid in stdout: {}", out.trim()),
536 })?;
537 Ok(LaunchResult { pid })
538 }
539
540 pub async fn open_url(&self, udid: &str, url: &str) -> Result<(), SimctlError> {
542 simctl_run(&["openurl", udid, url]).await?;
543 Ok(())
544 }
545
546 pub async fn send_push(
553 &self,
554 udid: &str,
555 bundle_id: &str,
556 apns_json_path: &str,
557 ) -> Result<(), SimctlError> {
558 simctl_run(&["push", udid, bundle_id, apns_json_path]).await?;
559 Ok(())
560 }
561
562 pub async fn set_appearance(&self, udid: &str, mode: Appearance) -> Result<(), SimctlError> {
564 simctl_run(&["ui", udid, "appearance", mode.as_str()]).await?;
565 Ok(())
566 }
567
568 pub async fn grant_permission(
570 &self,
571 udid: &str,
572 permission: SimctlPermission,
573 bundle_id: &str,
574 ) -> Result<(), SimctlError> {
575 simctl_run(&["privacy", udid, "grant", permission.as_str(), bundle_id]).await?;
576 Ok(())
577 }
578
579 pub async fn revoke_permission(
584 &self,
585 udid: &str,
586 permission: SimctlPermission,
587 bundle_id: &str,
588 ) -> Result<(), SimctlError> {
589 simctl_run(&["privacy", udid, "revoke", permission.as_str(), bundle_id]).await?;
590 Ok(())
591 }
592
593 pub async fn location_set(
596 &self,
597 udid: &str,
598 latitude: f64,
599 longitude: f64,
600 ) -> Result<(), SimctlError> {
601 let coord = format!("{latitude},{longitude}");
602 simctl_run(&["location", udid, "set", &coord]).await?;
603 Ok(())
604 }
605
606 pub async fn location_start(
611 &self,
612 udid: &str,
613 points: &[(f64, f64)],
614 speed_mps: Option<f64>,
615 ) -> Result<(), SimctlError> {
616 if points.len() < 2 {
617 return Err(SimctlError::Malformed {
618 subcommand: "location-start".into(),
619 detail: format!("requires ≥2 waypoints, got {}", points.len()),
620 });
621 }
622 let mut args: Vec<String> = vec!["location".into(), udid.into(), "start".into()];
623 if let Some(s) = speed_mps {
624 args.push(format!("--speed={s}"));
625 }
626 for (lat, lng) in points {
627 args.push(format!("{lat},{lng}"));
628 }
629 let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
630 simctl_run(&args_ref).await?;
631 Ok(())
632 }
633
634 pub async fn location_clear(&self, udid: &str) -> Result<(), SimctlError> {
637 simctl_run(&["location", udid, "clear"]).await?;
638 Ok(())
639 }
640
641 pub async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), SimctlError> {
645 if paths.is_empty() {
646 return Err(SimctlError::Malformed {
647 subcommand: "addmedia".into(),
648 detail: "no paths supplied".into(),
649 });
650 }
651 let mut args: Vec<&str> = vec!["addmedia", udid];
652 for p in paths {
653 args.push(p.as_str());
654 }
655 simctl_run(&args).await?;
656 Ok(())
657 }
658
659 pub async fn record_video_start(
665 &self,
666 udid: &str,
667 path: &str,
668 ) -> Result<RecordingHandle, SimctlError> {
669 let child = tokio::process::Command::new("xcrun")
670 .args(["simctl", "io", udid, "recordVideo", path])
671 .stdin(std::process::Stdio::null())
672 .stdout(std::process::Stdio::piped())
673 .stderr(std::process::Stdio::piped())
674 .spawn()?;
675 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
677 Ok(RecordingHandle {
678 child,
679 path: path.to_string(),
680 started_at: std::time::Instant::now(),
681 })
682 }
683
684 pub async fn record_video_stop(&self, mut handle: RecordingHandle) -> Result<(), SimctlError> {
688 let pid = handle.child.id().ok_or_else(|| SimctlError::Malformed {
689 subcommand: "recordVideo-stop".into(),
690 detail: "child already reaped".into(),
691 })?;
692 let rc = unsafe { libc::kill(pid as i32, libc::SIGINT) };
695 if rc != 0 {
696 return Err(SimctlError::Malformed {
697 subcommand: "recordVideo-stop".into(),
698 detail: format!(
699 "kill SIGINT failed: errno={}",
700 std::io::Error::last_os_error()
701 ),
702 });
703 }
704 let wait_result =
705 tokio::time::timeout(std::time::Duration::from_secs(10), handle.child.wait()).await;
706 match wait_result {
707 Ok(Ok(_status)) => Ok(()),
708 Ok(Err(e)) => Err(SimctlError::Malformed {
709 subcommand: "recordVideo-stop".into(),
710 detail: format!("wait failed: {e}"),
711 }),
712 Err(_timeout) => {
713 let _ = handle.child.kill().await;
714 Err(SimctlError::Malformed {
715 subcommand: "recordVideo-stop".into(),
716 detail: "SIGINT timeout (10s) — escalated SIGKILL; output mp4 likely truncated. Inspect simctl recordVideo stderr.".into(),
717 })
718 }
719 }
720 }
721
722 pub async fn reset_permission(
727 &self,
728 udid: &str,
729 permission: SimctlPermission,
730 bundle_id: &str,
731 ) -> Result<(), SimctlError> {
732 simctl_run(&["privacy", udid, "reset", permission.as_str(), bundle_id]).await?;
733 Ok(())
734 }
735
736 pub async fn keychain_reset(&self, udid: &str) -> Result<(), SimctlError> {
738 simctl_run(&["keychain", udid, "reset"]).await?;
739 Ok(())
740 }
741
742 pub async fn pasteboard_get(&self, udid: &str) -> Result<String, SimctlError> {
744 simctl_run(&["pbpaste", udid]).await
745 }
746
747 pub async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), SimctlError> {
749 use tokio::io::AsyncWriteExt;
752 let mut cmd = Command::new("xcrun");
753 cmd.arg("simctl").arg("pbcopy").arg(udid);
754 cmd.stdin(std::process::Stdio::piped());
755 let mut child = cmd.spawn()?;
756 if let Some(mut stdin) = child.stdin.take() {
757 stdin.write_all(text.as_bytes()).await?;
758 drop(stdin); }
760 let status = child.wait().await?;
761 if !status.success() {
762 return Err(SimctlError::NonZeroExit {
763 subcommand: "pbcopy".into(),
764 code: status.code().unwrap_or(-1),
765 stderr: String::new(),
766 });
767 }
768 Ok(())
769 }
770
771 pub async fn set_reduce_motion(&self, udid: &str, enabled: bool) -> Result<(), SimctlError> {
773 let val = if enabled { "1" } else { "0" };
774 simctl_run(&[
776 "spawn",
777 udid,
778 "defaults",
779 "write",
780 "com.apple.UIKit",
781 "UIAccessibilityReduceMotionEnabled",
782 "-bool",
783 val,
784 ])
785 .await?;
786 Ok(())
787 }
788
789 pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, SimctlError> {
795 let tmp =
796 std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
797 let tmp_str = tmp.display().to_string();
798 let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
799 let bytes = result.and_then(|_| {
800 std::fs::read(&tmp).map_err(|e| SimctlError::Malformed {
801 subcommand: "screenshot".into(),
802 detail: format!("read {tmp_str}: {e}"),
803 })
804 });
805 let _ = std::fs::remove_file(&tmp);
806 let bytes = bytes?;
807 if bytes.len() < 8 {
808 return Err(SimctlError::Malformed {
809 subcommand: "screenshot".into(),
810 detail: format!("screenshot file too short: {} bytes", bytes.len()),
811 });
812 }
813 Ok(bytes)
814 }
815
816 pub async fn create_device(
818 &self,
819 name: &str,
820 device_type: &str,
821 runtime_id: &str,
822 ) -> Result<String, SimctlError> {
823 let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
824 Ok(out.trim().to_string())
825 }
826
827 pub async fn delete_device(&self, udid: &str) -> Result<(), SimctlError> {
829 simctl_run(&["delete", udid]).await?;
830 Ok(())
831 }
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837
838 #[test]
839 fn compose_child_env_adds_prefix() {
840 let composed = compose_child_env(&[
841 ("SMIX_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
842 ("LAUNCH_FORCE_PUSH", "true"),
843 ]);
844 assert_eq!(
845 composed,
846 vec![
847 (
848 "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
849 "http://127.0.0.1:9999".to_string(),
850 ),
851 (
852 "SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
853 "true".to_string(),
854 ),
855 ]
856 );
857 }
858
859 #[test]
860 fn compose_child_env_already_prefixed_passes_through() {
861 let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
863 assert_eq!(
864 composed,
865 vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
866 );
867 }
868
869 #[test]
870 fn compose_child_env_empty_input_is_empty_output() {
871 assert!(compose_child_env(&[]).is_empty());
872 }
873}