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")]
18
19pub mod registry;
20
21use serde::{Deserialize, Serialize};
22use std::io;
23use std::time::Duration;
24use thiserror::Error;
25use tokio::process::Command;
26use tokio::time::sleep;
27
28#[derive(Debug, Error)]
30pub enum SimctlError {
31 #[error("spawn xcrun simctl failed: {0}")]
33 Spawn(#[from] io::Error),
34 #[error("xcrun simctl {subcommand} exited {code}: {stderr}")]
36 NonZeroExit {
37 subcommand: String,
39 code: i32,
41 stderr: String,
43 },
44 #[error("xcrun simctl {subcommand} returned malformed output: {detail}")]
46 Malformed {
47 subcommand: String,
49 detail: String,
51 },
52 #[error("xcrun simctl {subcommand} timed out after {ms}ms")]
54 Timeout {
55 subcommand: String,
57 ms: u64,
59 },
60}
61
62#[derive(Debug)]
67pub struct RecordingHandle {
68 pub(crate) child: tokio::process::Child,
69 pub path: String,
71 pub started_at: std::time::Instant,
73}
74
75#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
79pub struct SimctlRuntime {
80 pub identifier: String,
82 pub name: String,
84 pub version: String,
86 pub is_available: bool,
88}
89
90#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
92pub struct SimctlDevice {
93 pub udid: String,
95 pub name: String,
97 pub state: String,
99 pub is_available: bool,
101 #[serde(rename = "deviceTypeIdentifier", default)]
103 pub device_type_identifier: String,
104 #[serde(rename = "runtimeIdentifier", default)]
106 pub runtime_identifier: String,
107}
108
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
112pub enum SimctlPermission {
113 Camera,
115 Photos,
117 Location,
119 LocationAlways,
121 Notifications,
123 Microphone,
125 Contacts,
127 Calendar,
129 Reminders,
131 Media,
133 Motion,
135 HomeKit,
137 Health,
139 Bluetooth,
141 Faceid,
143 AddressBook,
145}
146
147impl SimctlPermission {
148 pub fn as_str(self) -> &'static str {
150 match self {
151 SimctlPermission::Camera => "camera",
152 SimctlPermission::Photos => "photos",
153 SimctlPermission::Location => "location",
154 SimctlPermission::LocationAlways => "location-always",
155 SimctlPermission::Notifications => "notifications",
156 SimctlPermission::Microphone => "microphone",
157 SimctlPermission::Contacts => "contacts",
158 SimctlPermission::Calendar => "calendar",
159 SimctlPermission::Reminders => "reminders",
160 SimctlPermission::Media => "media-library",
161 SimctlPermission::Motion => "motion",
162 SimctlPermission::HomeKit => "homekit",
163 SimctlPermission::Health => "health",
164 SimctlPermission::Bluetooth => "bluetooth",
165 SimctlPermission::Faceid => "faceid",
166 SimctlPermission::AddressBook => "addressbook",
167 }
168 }
169}
170
171#[derive(Clone, Copy, Debug, PartialEq, Eq)]
173pub enum Appearance {
174 Light,
176 Dark,
178}
179
180impl Appearance {
181 pub fn as_str(self) -> &'static str {
183 match self {
184 Appearance::Light => "light",
185 Appearance::Dark => "dark",
186 }
187 }
188}
189
190#[derive(Clone, Debug, PartialEq, Eq)]
192pub struct LaunchResult {
193 pub pid: u32,
195}
196
197async fn simctl_capture(args: &[&str]) -> Result<(Vec<u8>, String), SimctlError> {
201 simctl_capture_env(args, &[]).await
202}
203
204async fn simctl_capture_env(
211 args: &[&str],
212 env: &[(String, String)],
213) -> Result<(Vec<u8>, String), SimctlError> {
214 let mut cmd = Command::new("xcrun");
215 cmd.arg("simctl");
216 for a in args {
217 cmd.arg(a);
218 }
219 for (k, v) in env {
220 cmd.env(k, v);
221 }
222 let output = cmd.output().await?;
223 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
224 if !output.status.success() {
225 return Err(SimctlError::NonZeroExit {
226 subcommand: args.first().map(|s| s.to_string()).unwrap_or_default(),
227 code: output.status.code().unwrap_or(-1),
228 stderr,
229 });
230 }
231 Ok((output.stdout, stderr))
232}
233
234async fn simctl_run(args: &[&str]) -> Result<String, SimctlError> {
235 let (stdout, _) = simctl_capture(args).await?;
236 Ok(String::from_utf8_lossy(&stdout).into_owned())
237}
238
239async fn simctl_run_env(args: &[&str], env: &[(String, String)]) -> Result<String, SimctlError> {
243 let (stdout, _) = simctl_capture_env(args, env).await?;
244 Ok(String::from_utf8_lossy(&stdout).into_owned())
245}
246
247pub fn compose_child_env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
267 pairs
268 .iter()
269 .map(|(k, v)| {
270 let key = if k.starts_with("SIMCTL_CHILD_") {
271 (*k).to_string()
272 } else {
273 format!("SIMCTL_CHILD_{k}")
274 };
275 (key, (*v).to_string())
276 })
277 .collect()
278}
279
280#[derive(Debug, Default)]
286pub struct SimctlClient {}
287
288impl SimctlClient {
289 pub fn new() -> Self {
291 SimctlClient {}
292 }
293
294 pub async fn list_runtimes(&self) -> Result<Vec<SimctlRuntime>, SimctlError> {
298 let raw = simctl_run(&["list", "runtimes", "-j"]).await?;
299 #[derive(Deserialize)]
300 struct Wrap {
301 runtimes: Vec<RawRuntime>,
302 }
303 #[derive(Deserialize)]
304 struct RawRuntime {
305 identifier: String,
306 name: String,
307 version: String,
308 #[serde(rename = "isAvailable", default)]
309 is_available: bool,
310 }
311 let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
312 subcommand: "list runtimes".into(),
313 detail: e.to_string(),
314 })?;
315 Ok(w.runtimes
316 .into_iter()
317 .map(|r| SimctlRuntime {
318 identifier: r.identifier,
319 name: r.name,
320 version: r.version,
321 is_available: r.is_available,
322 })
323 .collect())
324 }
325
326 pub async fn list_devices(&self) -> Result<Vec<SimctlDevice>, SimctlError> {
328 let raw = simctl_run(&["list", "devices", "-j"]).await?;
329 #[derive(Deserialize)]
330 struct Wrap {
331 devices: std::collections::BTreeMap<String, Vec<RawDevice>>,
332 }
333 #[derive(Deserialize)]
334 struct RawDevice {
335 udid: String,
336 name: String,
337 state: String,
338 #[serde(rename = "isAvailable", default)]
339 is_available: bool,
340 #[serde(rename = "deviceTypeIdentifier", default)]
341 device_type_identifier: String,
342 }
343 let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
344 subcommand: "list devices".into(),
345 detail: e.to_string(),
346 })?;
347 let mut out = Vec::new();
348 for (runtime_id, devices) in w.devices {
349 for d in devices {
350 out.push(SimctlDevice {
351 udid: d.udid,
352 name: d.name,
353 state: d.state,
354 is_available: d.is_available,
355 device_type_identifier: d.device_type_identifier,
356 runtime_identifier: runtime_id.clone(),
357 });
358 }
359 }
360 Ok(out)
361 }
362
363 pub async fn boot(&self, udid: &str) -> Result<(), SimctlError> {
367 simctl_run(&["boot", udid]).await?;
368 Ok(())
369 }
370
371 pub async fn shutdown(&self, udid: &str) -> Result<(), SimctlError> {
373 simctl_run(&["shutdown", udid]).await?;
374 Ok(())
375 }
376
377 pub async fn current_locale(&self, udid: &str) -> Result<Option<String>, SimctlError> {
384 let out =
385 match simctl_run(&["spawn", udid, "defaults", "read", "-g", "AppleLanguages"]).await {
386 Ok(s) => s,
387 Err(SimctlError::NonZeroExit { .. }) => return Ok(None),
390 Err(e) => return Err(e),
391 };
392 if let Some(start) = out.find('"') {
394 let rest = &out[start + 1..];
395 if let Some(end) = rest.find('"') {
396 return Ok(Some(rest[..end].to_string()));
397 }
398 }
399 Ok(None)
400 }
401
402 pub async fn set_locale(&self, udid: &str, locale: &str) -> Result<(), SimctlError> {
410 simctl_run(&[
411 "spawn",
412 udid,
413 "defaults",
414 "write",
415 "-g",
416 "AppleLanguages",
417 "-array",
418 locale,
419 ])
420 .await?;
421 let locale_underscore = locale.replace('-', "_");
422 simctl_run(&[
423 "spawn",
424 udid,
425 "defaults",
426 "write",
427 "-g",
428 "AppleLocale",
429 &locale_underscore,
430 ])
431 .await?;
432 Ok(())
433 }
434
435 pub async fn boot_and_wait(&self, udid: &str, timeout: Duration) -> Result<(), SimctlError> {
439 let _ = simctl_run(&["boot", udid]).await;
441 let start = std::time::Instant::now();
442 loop {
443 let devices = self.list_devices().await?;
444 if devices
445 .iter()
446 .any(|d| d.udid == udid && d.state == "Booted")
447 {
448 return Ok(());
449 }
450 if start.elapsed() > timeout {
451 return Err(SimctlError::Timeout {
452 subcommand: format!("boot {}", udid),
453 ms: timeout.as_millis() as u64,
454 });
455 }
456 sleep(Duration::from_millis(500)).await;
457 }
458 }
459
460 pub async fn erase(&self, udid: &str) -> Result<(), SimctlError> {
462 simctl_run(&["erase", udid]).await?;
463 Ok(())
464 }
465
466 pub async fn install(&self, udid: &str, app_path: &str) -> Result<(), SimctlError> {
468 simctl_run(&["install", udid, app_path]).await?;
469 Ok(())
470 }
471
472 pub async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
474 simctl_run(&["uninstall", udid, bundle_id]).await?;
475 Ok(())
476 }
477
478 pub async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
480 simctl_run(&["terminate", udid, bundle_id]).await?;
481 Ok(())
482 }
483
484 pub async fn launch(&self, udid: &str, bundle_id: &str) -> Result<LaunchResult, SimctlError> {
486 self.launch_with_args(udid, bundle_id, &[]).await
487 }
488
489 pub async fn launch_with_args(
493 &self,
494 udid: &str,
495 bundle_id: &str,
496 args: &[String],
497 ) -> Result<LaunchResult, SimctlError> {
498 self.launch_with_args_and_env(udid, bundle_id, args, &[])
499 .await
500 }
501
502 pub async fn launch_with_args_and_env(
512 &self,
513 udid: &str,
514 bundle_id: &str,
515 args: &[String],
516 child_env: &[(&str, &str)],
517 ) -> Result<LaunchResult, SimctlError> {
518 let mut argv: Vec<&str> = vec!["launch", udid, bundle_id];
519 if !args.is_empty() {
520 argv.push("--");
521 for a in args {
522 argv.push(a.as_str());
523 }
524 }
525 let composed = compose_child_env(child_env);
526 let out = simctl_run_env(&argv, &composed).await?;
527 let pid_str =
529 out.rsplit(':')
530 .next()
531 .map(str::trim)
532 .ok_or_else(|| SimctlError::Malformed {
533 subcommand: "launch".into(),
534 detail: format!("unexpected stdout shape: {}", out.trim()),
535 })?;
536 let pid: u32 = pid_str.parse().map_err(|_| SimctlError::Malformed {
537 subcommand: "launch".into(),
538 detail: format!("non-numeric pid in stdout: {}", out.trim()),
539 })?;
540 Ok(LaunchResult { pid })
541 }
542
543 pub async fn open_url(&self, udid: &str, url: &str) -> Result<(), SimctlError> {
545 simctl_run(&["openurl", udid, url]).await?;
546 Ok(())
547 }
548
549 pub async fn send_push(
556 &self,
557 udid: &str,
558 bundle_id: &str,
559 apns_json_path: &str,
560 ) -> Result<(), SimctlError> {
561 simctl_run(&["push", udid, bundle_id, apns_json_path]).await?;
562 Ok(())
563 }
564
565 pub async fn set_appearance(&self, udid: &str, mode: Appearance) -> Result<(), SimctlError> {
567 simctl_run(&["ui", udid, "appearance", mode.as_str()]).await?;
568 Ok(())
569 }
570
571 pub async fn grant_permission(
573 &self,
574 udid: &str,
575 permission: SimctlPermission,
576 bundle_id: &str,
577 ) -> Result<(), SimctlError> {
578 simctl_run(&["privacy", udid, "grant", permission.as_str(), bundle_id]).await?;
579 Ok(())
580 }
581
582 pub async fn revoke_permission(
586 &self,
587 udid: &str,
588 permission: SimctlPermission,
589 bundle_id: &str,
590 ) -> Result<(), SimctlError> {
591 simctl_run(&["privacy", udid, "revoke", permission.as_str(), bundle_id]).await?;
592 Ok(())
593 }
594
595 pub async fn location_set(
598 &self,
599 udid: &str,
600 latitude: f64,
601 longitude: f64,
602 ) -> Result<(), SimctlError> {
603 let coord = format!("{latitude},{longitude}");
604 simctl_run(&["location", udid, "set", &coord]).await?;
605 Ok(())
606 }
607
608 pub async fn location_start(
613 &self,
614 udid: &str,
615 points: &[(f64, f64)],
616 speed_mps: Option<f64>,
617 ) -> Result<(), SimctlError> {
618 if points.len() < 2 {
619 return Err(SimctlError::Malformed {
620 subcommand: "location-start".into(),
621 detail: format!("requires ≥2 waypoints, got {}", points.len()),
622 });
623 }
624 let mut args: Vec<String> = vec!["location".into(), udid.into(), "start".into()];
625 if let Some(s) = speed_mps {
626 args.push(format!("--speed={s}"));
627 }
628 for (lat, lng) in points {
629 args.push(format!("{lat},{lng}"));
630 }
631 let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
632 simctl_run(&args_ref).await?;
633 Ok(())
634 }
635
636 pub async fn location_clear(&self, udid: &str) -> Result<(), SimctlError> {
639 simctl_run(&["location", udid, "clear"]).await?;
640 Ok(())
641 }
642
643 pub async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), SimctlError> {
647 if paths.is_empty() {
648 return Err(SimctlError::Malformed {
649 subcommand: "addmedia".into(),
650 detail: "no paths supplied".into(),
651 });
652 }
653 let mut args: Vec<&str> = vec!["addmedia", udid];
654 for p in paths {
655 args.push(p.as_str());
656 }
657 simctl_run(&args).await?;
658 Ok(())
659 }
660
661 pub async fn record_video_start(
667 &self,
668 udid: &str,
669 path: &str,
670 ) -> Result<RecordingHandle, SimctlError> {
671 let child = tokio::process::Command::new("xcrun")
672 .args(["simctl", "io", udid, "recordVideo", path])
673 .stdin(std::process::Stdio::null())
674 .stdout(std::process::Stdio::piped())
675 .stderr(std::process::Stdio::piped())
676 .spawn()?;
677 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
679 Ok(RecordingHandle {
680 child,
681 path: path.to_string(),
682 started_at: std::time::Instant::now(),
683 })
684 }
685
686 pub async fn record_video_stop(&self, mut handle: RecordingHandle) -> Result<(), SimctlError> {
690 let pid = handle.child.id().ok_or_else(|| SimctlError::Malformed {
691 subcommand: "recordVideo-stop".into(),
692 detail: "child already reaped".into(),
693 })?;
694 let rc = unsafe { libc::kill(pid as i32, libc::SIGINT) };
697 if rc != 0 {
698 return Err(SimctlError::Malformed {
699 subcommand: "recordVideo-stop".into(),
700 detail: format!(
701 "kill SIGINT failed: errno={}",
702 std::io::Error::last_os_error()
703 ),
704 });
705 }
706 let wait_result =
707 tokio::time::timeout(std::time::Duration::from_secs(10), handle.child.wait()).await;
708 match wait_result {
709 Ok(Ok(_status)) => Ok(()),
710 Ok(Err(e)) => Err(SimctlError::Malformed {
711 subcommand: "recordVideo-stop".into(),
712 detail: format!("wait failed: {e}"),
713 }),
714 Err(_timeout) => {
715 let _ = handle.child.kill().await;
716 Err(SimctlError::Malformed {
717 subcommand: "recordVideo-stop".into(),
718 detail: "SIGINT timeout (10s) — escalated SIGKILL; output mp4 likely truncated. Inspect simctl recordVideo stderr.".into(),
719 })
720 }
721 }
722 }
723
724 pub async fn reset_permission(
729 &self,
730 udid: &str,
731 permission: SimctlPermission,
732 bundle_id: &str,
733 ) -> Result<(), SimctlError> {
734 simctl_run(&["privacy", udid, "reset", permission.as_str(), bundle_id]).await?;
735 Ok(())
736 }
737
738 pub async fn keychain_reset(&self, udid: &str) -> Result<(), SimctlError> {
740 simctl_run(&["keychain", udid, "reset"]).await?;
741 Ok(())
742 }
743
744 pub async fn pasteboard_get(&self, udid: &str) -> Result<String, SimctlError> {
746 simctl_run(&["pbpaste", udid]).await
747 }
748
749 pub async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), SimctlError> {
751 use tokio::io::AsyncWriteExt;
754 let mut cmd = Command::new("xcrun");
755 cmd.arg("simctl").arg("pbcopy").arg(udid);
756 cmd.stdin(std::process::Stdio::piped());
757 let mut child = cmd.spawn()?;
758 if let Some(mut stdin) = child.stdin.take() {
759 stdin.write_all(text.as_bytes()).await?;
760 drop(stdin); }
762 let status = child.wait().await?;
763 if !status.success() {
764 return Err(SimctlError::NonZeroExit {
765 subcommand: "pbcopy".into(),
766 code: status.code().unwrap_or(-1),
767 stderr: String::new(),
768 });
769 }
770 Ok(())
771 }
772
773 pub async fn set_reduce_motion(&self, udid: &str, enabled: bool) -> Result<(), SimctlError> {
775 let val = if enabled { "1" } else { "0" };
776 simctl_run(&[
778 "spawn",
779 udid,
780 "defaults",
781 "write",
782 "com.apple.UIKit",
783 "UIAccessibilityReduceMotionEnabled",
784 "-bool",
785 val,
786 ])
787 .await?;
788 Ok(())
789 }
790
791 pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, SimctlError> {
797 let tmp =
798 std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
799 let tmp_str = tmp.display().to_string();
800 let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
801 let bytes = result.and_then(|_| {
802 std::fs::read(&tmp).map_err(|e| SimctlError::Malformed {
803 subcommand: "screenshot".into(),
804 detail: format!("read {tmp_str}: {e}"),
805 })
806 });
807 let _ = std::fs::remove_file(&tmp);
808 let bytes = bytes?;
809 if bytes.len() < 8 {
810 return Err(SimctlError::Malformed {
811 subcommand: "screenshot".into(),
812 detail: format!("screenshot file too short: {} bytes", bytes.len()),
813 });
814 }
815 Ok(bytes)
816 }
817
818 pub async fn create_device(
820 &self,
821 name: &str,
822 device_type: &str,
823 runtime_id: &str,
824 ) -> Result<String, SimctlError> {
825 let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
826 Ok(out.trim().to_string())
827 }
828
829 pub async fn delete_device(&self, udid: &str) -> Result<(), SimctlError> {
831 simctl_run(&["delete", udid]).await?;
832 Ok(())
833 }
834}
835
836#[cfg(test)]
837mod tests {
838 use super::*;
839
840 #[test]
841 fn compose_child_env_adds_prefix() {
842 let composed = compose_child_env(&[
843 ("INSIGHT_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
844 ("LAUNCH_FORCE_PUSH", "true"),
845 ]);
846 assert_eq!(
847 composed,
848 vec![
849 (
850 "SIMCTL_CHILD_INSIGHT_PERF_RECEIVER_URL".to_string(),
851 "http://127.0.0.1:9999".to_string(),
852 ),
853 (
854 "SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
855 "true".to_string(),
856 ),
857 ]
858 );
859 }
860
861 #[test]
862 fn compose_child_env_already_prefixed_passes_through() {
863 let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
865 assert_eq!(
866 composed,
867 vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
868 );
869 }
870
871 #[test]
872 fn compose_child_env_empty_input_is_empty_output() {
873 assert!(compose_child_env(&[]).is_empty());
874 }
875}