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;
19pub mod screenshot_pacer;
23
24use screenshot_pacer::{ScreenshotPacer, ScreenshotPacerConfig};
25use serde::{Deserialize, Serialize};
26use std::io;
27use std::sync::Arc;
28use std::time::Duration;
29use thiserror::Error;
30use tokio::process::Command;
31use tokio::time::sleep;
32
33#[derive(Debug, Error)]
35#[non_exhaustive]
36pub enum SimctlError {
37 #[error("spawn xcrun simctl failed: {0}")]
39 Spawn(#[from] io::Error),
40 #[error("xcrun simctl {} exited {code} ({wall_ms}ms): {stderr}", .argv.join(" "))]
49 NonZeroExit {
50 subcommand: String,
52 argv: Vec<String>,
57 code: i32,
59 stderr: String,
61 #[allow(dead_code)]
65 wall_ms: u64,
66 },
67 #[error("xcrun simctl {subcommand} returned malformed output: {detail}")]
69 Malformed {
70 subcommand: String,
72 detail: String,
74 },
75 #[error("xcrun simctl {subcommand} timed out after {ms}ms")]
77 Timeout {
78 subcommand: String,
80 ms: u64,
82 },
83 #[error("screenshot pacer circuit open; retry after {retry_after:?}")]
91 CaptureBackpressure {
92 retry_after: Duration,
94 },
95}
96
97impl SimctlError {
98 pub fn non_zero_exit(
104 subcommand: impl Into<String>,
105 code: i32,
106 stderr: impl Into<String>,
107 ) -> Self {
108 let sub = subcommand.into();
109 Self::NonZeroExit {
110 argv: vec![sub.clone()],
111 subcommand: sub,
112 code,
113 stderr: stderr.into(),
114 wall_ms: 0,
115 }
116 }
117}
118
119#[derive(Debug)]
124pub struct RecordingHandle {
125 pub(crate) child: tokio::process::Child,
126 pub path: String,
128 pub started_at: std::time::Instant,
130}
131
132#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
136pub struct SimctlRuntime {
137 pub identifier: String,
139 pub name: String,
141 pub version: String,
143 pub is_available: bool,
145}
146
147#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
149pub struct SimctlDevice {
150 pub udid: String,
152 pub name: String,
154 pub state: String,
156 pub is_available: bool,
158 #[serde(rename = "deviceTypeIdentifier", default)]
160 pub device_type_identifier: String,
161 #[serde(rename = "runtimeIdentifier", default)]
163 pub runtime_identifier: String,
164}
165
166#[derive(Clone, Copy, Debug, PartialEq, Eq)]
168pub enum SimctlPermission {
169 Camera,
171 Photos,
173 Location,
175 LocationAlways,
177 Notifications,
179 Microphone,
181 Contacts,
183 Calendar,
185 Reminders,
187 Media,
189 Motion,
191 HomeKit,
193 Health,
195 Bluetooth,
197 Faceid,
199 AddressBook,
201}
202
203impl SimctlPermission {
204 pub fn as_str(self) -> &'static str {
206 match self {
207 SimctlPermission::Camera => "camera",
208 SimctlPermission::Photos => "photos",
209 SimctlPermission::Location => "location",
210 SimctlPermission::LocationAlways => "location-always",
211 SimctlPermission::Notifications => "notifications",
212 SimctlPermission::Microphone => "microphone",
213 SimctlPermission::Contacts => "contacts",
214 SimctlPermission::Calendar => "calendar",
215 SimctlPermission::Reminders => "reminders",
216 SimctlPermission::Media => "media-library",
217 SimctlPermission::Motion => "motion",
218 SimctlPermission::HomeKit => "homekit",
219 SimctlPermission::Health => "health",
220 SimctlPermission::Bluetooth => "bluetooth",
221 SimctlPermission::Faceid => "faceid",
222 SimctlPermission::AddressBook => "addressbook",
223 }
224 }
225}
226
227#[derive(Clone, Copy, Debug, PartialEq, Eq)]
229pub enum Appearance {
230 Light,
232 Dark,
234}
235
236impl Appearance {
237 pub fn as_str(self) -> &'static str {
239 match self {
240 Appearance::Light => "light",
241 Appearance::Dark => "dark",
242 }
243 }
244}
245
246#[derive(Clone, Debug, PartialEq, Eq)]
248pub struct LaunchResult {
249 pub pid: u32,
251}
252
253#[derive(Clone, Debug)]
259pub struct SubprocessRecord {
260 pub argv: Vec<String>,
263 pub exit_code: Option<i32>,
266 pub wall_ms: u64,
268 pub stderr_head: String,
270 pub timestamp: std::time::SystemTime,
272}
273
274mod subprocess_ring {
275 use super::SubprocessRecord;
276 use std::collections::VecDeque;
277 use std::io::Write;
278 use std::path::{Path, PathBuf};
279 use std::sync::{Mutex, OnceLock};
280 use std::time::{Duration, UNIX_EPOCH};
281
282 fn cell() -> &'static Mutex<VecDeque<SubprocessRecord>> {
283 static INSTANCE: OnceLock<Mutex<VecDeque<SubprocessRecord>>> = OnceLock::new();
284 INSTANCE.get_or_init(|| Mutex::new(VecDeque::with_capacity(128)))
285 }
286
287 fn persist_cell() -> &'static Mutex<Option<PathBuf>> {
297 static INSTANCE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
298 INSTANCE.get_or_init(|| Mutex::new(None))
299 }
300
301 pub fn set_persist_path(path: PathBuf) {
305 let mut g = match persist_cell().lock() {
306 Ok(g) => g,
307 Err(p) => p.into_inner(),
308 };
309 *g = Some(path);
310 }
311
312 fn persist_path_copy() -> Option<PathBuf> {
313 let g = match persist_cell().lock() {
314 Ok(g) => g,
315 Err(p) => p.into_inner(),
316 };
317 g.clone()
318 }
319
320 pub(super) fn record(entry: SubprocessRecord) {
325 {
326 let mut g = match cell().lock() {
327 Ok(g) => g,
328 Err(p) => p.into_inner(),
329 };
330 if g.len() >= 128 {
331 g.pop_front();
332 }
333 g.push_back(entry);
334 }
335 if let Some(path) = persist_path_copy() {
336 let snapshot = snapshot();
337 let _ = write_json_atomic(&path, &snapshot);
341 }
342 }
343
344 pub fn snapshot() -> Vec<SubprocessRecord> {
346 let g = match cell().lock() {
347 Ok(g) => g,
348 Err(p) => p.into_inner(),
349 };
350 g.iter().cloned().collect()
351 }
352
353 pub fn load_persisted() {
359 let Some(path) = persist_path_copy() else {
360 return;
361 };
362 let Ok(bytes) = std::fs::read(&path) else {
363 return;
364 };
365 let Ok(records) = serde_json::from_slice::<Vec<PersistedRecord>>(&bytes) else {
366 return;
367 };
368 let mut g = match cell().lock() {
369 Ok(g) => g,
370 Err(p) => p.into_inner(),
371 };
372 for r in records.into_iter().rev().take(128).rev() {
373 g.push_back(r.into_record());
374 }
375 }
376
377 fn write_json_atomic(path: &Path, records: &[SubprocessRecord]) -> std::io::Result<()> {
378 if let Some(parent) = path.parent() {
379 std::fs::create_dir_all(parent)?;
380 }
381 let tmp = path.with_extension("json.tmp");
382 {
383 let payload: Vec<PersistedRecord> = records.iter().cloned().map(Into::into).collect();
384 let serialized = serde_json::to_vec(&payload)
385 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
386 let mut f = std::fs::File::create(&tmp)?;
387 f.write_all(&serialized)?;
388 f.sync_all()?;
389 }
390 std::fs::rename(&tmp, path)
391 }
392
393 #[derive(Clone, serde::Serialize, serde::Deserialize)]
398 struct PersistedRecord {
399 argv: Vec<String>,
400 exit_code: Option<i32>,
401 wall_ms: u64,
402 stderr_head: String,
403 timestamp_ms: u64,
404 }
405
406 impl From<SubprocessRecord> for PersistedRecord {
407 fn from(r: SubprocessRecord) -> Self {
408 let timestamp_ms = r
409 .timestamp
410 .duration_since(UNIX_EPOCH)
411 .map(|d| d.as_millis() as u64)
412 .unwrap_or(0);
413 Self {
414 argv: r.argv,
415 exit_code: r.exit_code,
416 wall_ms: r.wall_ms,
417 stderr_head: r.stderr_head,
418 timestamp_ms,
419 }
420 }
421 }
422
423 impl PersistedRecord {
424 fn into_record(self) -> SubprocessRecord {
425 let timestamp =
426 UNIX_EPOCH + Duration::from_millis(self.timestamp_ms);
427 SubprocessRecord {
428 argv: self.argv,
429 exit_code: self.exit_code,
430 wall_ms: self.wall_ms,
431 stderr_head: self.stderr_head,
432 timestamp,
433 }
434 }
435 }
436
437 #[cfg(test)]
438 mod tests {
439 use super::*;
440 use std::time::SystemTime;
441
442 #[test]
443 fn persist_roundtrip_after_supervisor_cycle_simulation() {
444 let dir = tempfile::tempdir().expect("tempdir");
445 let path = dir.path().join("ring.json");
446 set_persist_path(path.clone());
447
448 record(SubprocessRecord {
449 argv: vec!["shutdown".into(), "UDID-A".into()],
450 exit_code: Some(0),
451 wall_ms: 42,
452 stderr_head: String::new(),
453 timestamp: SystemTime::now(),
454 });
455 assert!(path.exists(), "persist file must exist after record");
456
457 {
459 let mut g = cell().lock().unwrap();
460 g.clear();
461 }
462 assert!(snapshot().is_empty());
463
464 load_persisted();
465 let after = snapshot();
466 assert_eq!(after.len(), 1);
467 assert_eq!(after[0].argv, vec!["shutdown".to_string(), "UDID-A".into()]);
468 assert_eq!(after[0].exit_code, Some(0));
469 assert_eq!(after[0].wall_ms, 42);
470 }
471 }
472}
473
474pub fn set_subprocess_ring_persist_path(path: std::path::PathBuf) {
479 subprocess_ring::set_persist_path(path);
480 subprocess_ring::load_persisted();
481}
482
483mod reset_app_data_counters {
494 use std::path::{Path, PathBuf};
495 use std::sync::{Mutex, OnceLock};
496
497 fn cell() -> &'static Mutex<Counters> {
498 static INSTANCE: OnceLock<Mutex<Counters>> = OnceLock::new();
499 INSTANCE.get_or_init(|| Mutex::new(Counters::default()))
500 }
501 fn persist_cell() -> &'static Mutex<Option<PathBuf>> {
502 static INSTANCE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
503 INSTANCE.get_or_init(|| Mutex::new(None))
504 }
505
506 #[derive(Clone, Copy, Debug, Default, serde::Serialize, serde::Deserialize)]
507 pub struct Counters {
508 pub reset_app_data_total: u64,
509 pub reset_app_data_timed_out: u64,
510 }
511
512 pub fn set_persist_path(path: PathBuf) {
513 let mut g = match persist_cell().lock() {
514 Ok(g) => g,
515 Err(p) => p.into_inner(),
516 };
517 *g = Some(path);
518 }
519
520 fn persist_path_copy() -> Option<PathBuf> {
521 let g = match persist_cell().lock() {
522 Ok(g) => g,
523 Err(p) => p.into_inner(),
524 };
525 g.clone()
526 }
527
528 pub fn load_persisted() {
529 let Some(path) = persist_path_copy() else {
530 return;
531 };
532 let Ok(bytes) = std::fs::read(&path) else {
533 return;
534 };
535 let Ok(loaded) = serde_json::from_slice::<Counters>(&bytes) else {
536 return;
537 };
538 let mut g = match cell().lock() {
539 Ok(g) => g,
540 Err(p) => p.into_inner(),
541 };
542 *g = loaded;
543 }
544
545 pub fn increment_total() {
546 {
547 let mut g = match cell().lock() {
548 Ok(g) => g,
549 Err(p) => p.into_inner(),
550 };
551 g.reset_app_data_total = g.reset_app_data_total.saturating_add(1);
552 }
553 persist_best_effort();
554 }
555
556 pub fn increment_timed_out() {
557 {
558 let mut g = match cell().lock() {
559 Ok(g) => g,
560 Err(p) => p.into_inner(),
561 };
562 g.reset_app_data_timed_out = g.reset_app_data_timed_out.saturating_add(1);
563 }
564 persist_best_effort();
565 }
566
567 pub fn snapshot() -> Counters {
568 let g = match cell().lock() {
569 Ok(g) => g,
570 Err(p) => p.into_inner(),
571 };
572 *g
573 }
574
575 fn persist_best_effort() {
576 let Some(path) = persist_path_copy() else {
577 return;
578 };
579 let snapshot = snapshot();
580 let _ = write_json_atomic(&path, &snapshot);
581 }
582
583 fn write_json_atomic(path: &Path, counters: &Counters) -> std::io::Result<()> {
584 use std::io::Write;
585 if let Some(parent) = path.parent() {
586 std::fs::create_dir_all(parent)?;
587 }
588 let tmp = path.with_extension("json.tmp");
589 let bytes = serde_json::to_vec(counters)
590 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
591 {
592 let mut f = std::fs::File::create(&tmp)?;
593 f.write_all(&bytes)?;
594 f.sync_all()?;
595 }
596 std::fs::rename(&tmp, path)
597 }
598
599 #[cfg(test)]
600 mod tests {
601 use super::*;
602
603 #[test]
604 fn increment_and_persist_roundtrip() {
605 let dir = tempfile::tempdir().expect("tempdir");
606 let path = dir.path().join("counters.json");
607 set_persist_path(path.clone());
608 {
610 let mut g = cell().lock().unwrap();
611 *g = Counters::default();
612 }
613 increment_total();
614 increment_total();
615 increment_timed_out();
616 assert!(path.exists());
617 let raw = std::fs::read_to_string(&path).unwrap();
618 let loaded: Counters = serde_json::from_str(&raw).unwrap();
619 assert_eq!(loaded.reset_app_data_total, 2);
620 assert_eq!(loaded.reset_app_data_timed_out, 1);
621 }
622 }
623}
624
625#[derive(Clone, Copy, Debug, Default)]
631pub struct ResetAppDataCounters {
632 pub reset_app_data_total: u64,
634 pub reset_app_data_timed_out: u64,
638}
639
640pub fn set_reset_app_data_counters_persist_path(path: std::path::PathBuf) {
646 reset_app_data_counters::set_persist_path(path);
647 reset_app_data_counters::load_persisted();
648}
649
650pub fn increment_reset_app_data_total() {
653 reset_app_data_counters::increment_total();
654}
655
656pub fn increment_reset_app_data_timed_out() {
662 reset_app_data_counters::increment_timed_out();
663}
664
665pub fn reset_app_data_counters_snapshot() -> ResetAppDataCounters {
669 let s = reset_app_data_counters::snapshot();
670 ResetAppDataCounters {
671 reset_app_data_total: s.reset_app_data_total,
672 reset_app_data_timed_out: s.reset_app_data_timed_out,
673 }
674}
675
676pub fn recent_subprocesses() -> Vec<SubprocessRecord> {
680 subprocess_ring::snapshot()
681}
682
683async fn simctl_capture(args: &[&str]) -> Result<(Vec<u8>, String), SimctlError> {
687 simctl_capture_env(args, &[]).await
688}
689
690async fn simctl_capture_env(
697 args: &[&str],
698 env: &[(String, String)],
699) -> Result<(Vec<u8>, String), SimctlError> {
700 let mut cmd = Command::new("xcrun");
701 cmd.arg("simctl");
702 for a in args {
703 cmd.arg(a);
704 }
705 for (k, v) in env {
706 cmd.env(k, v);
707 }
708 let started = std::time::Instant::now();
709 let output = cmd.output().await?;
710 let wall_ms = started.elapsed().as_millis() as u64;
711 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
712 subprocess_ring::record(SubprocessRecord {
715 argv: args.iter().map(|s| s.to_string()).collect(),
716 exit_code: output.status.code(),
717 wall_ms,
718 stderr_head: {
719 let mut s = stderr.clone();
720 if s.len() > 256 {
721 s.truncate(256);
722 }
723 s
724 },
725 timestamp: std::time::SystemTime::now(),
726 });
727 if !output.status.success() {
728 return Err(SimctlError::NonZeroExit {
729 subcommand: args.first().map(|s| s.to_string()).unwrap_or_default(),
730 argv: args.iter().map(|s| s.to_string()).collect(),
731 code: output.status.code().unwrap_or(-1),
732 stderr,
733 wall_ms,
734 });
735 }
736 Ok((output.stdout, stderr))
737}
738
739async fn simctl_run(args: &[&str]) -> Result<String, SimctlError> {
740 let (stdout, _) = simctl_capture(args).await?;
741 Ok(String::from_utf8_lossy(&stdout).into_owned())
742}
743
744async fn simctl_run_env(args: &[&str], env: &[(String, String)]) -> Result<String, SimctlError> {
748 let (stdout, _) = simctl_capture_env(args, env).await?;
749 Ok(String::from_utf8_lossy(&stdout).into_owned())
750}
751
752pub fn compose_child_env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
771 pairs
772 .iter()
773 .map(|(k, v)| {
774 let key = if k.starts_with("SIMCTL_CHILD_") {
775 (*k).to_string()
776 } else {
777 format!("SIMCTL_CHILD_{k}")
778 };
779 (key, (*v).to_string())
780 })
781 .collect()
782}
783
784#[derive(Debug)]
795pub struct SimctlClient {
796 screenshot_pacer: Arc<std::sync::Mutex<ScreenshotPacer>>,
801}
802
803impl Default for SimctlClient {
804 fn default() -> Self {
805 Self::new()
806 }
807}
808
809impl SimctlClient {
810 pub fn new() -> Self {
814 SimctlClient {
815 screenshot_pacer: Arc::new(std::sync::Mutex::new(ScreenshotPacer::new(
816 ScreenshotPacerConfig::default(),
817 ))),
818 }
819 }
820
821 #[must_use]
825 pub fn with_screenshot_pacer(self, config: ScreenshotPacerConfig) -> Self {
826 {
827 let mut guard = self
828 .screenshot_pacer
829 .lock()
830 .expect("screenshot pacer mutex must not be poisoned");
831 *guard = ScreenshotPacer::new(config);
832 }
833 self
834 }
835
836 #[must_use]
844 pub fn with_sim_health(self, monitor: smix_sim_health::SimHealthMonitor) -> Self {
845 {
846 let mut guard = self
847 .screenshot_pacer
848 .lock()
849 .expect("screenshot pacer mutex must not be poisoned");
850 guard.set_monitor(monitor);
851 }
852 self
853 }
854
855 pub async fn list_runtimes(&self) -> Result<Vec<SimctlRuntime>, SimctlError> {
859 let raw = simctl_run(&["list", "runtimes", "-j"]).await?;
860 #[derive(Deserialize)]
861 struct Wrap {
862 runtimes: Vec<RawRuntime>,
863 }
864 #[derive(Deserialize)]
865 struct RawRuntime {
866 identifier: String,
867 name: String,
868 version: String,
869 #[serde(rename = "isAvailable", default)]
870 is_available: bool,
871 }
872 let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
873 subcommand: "list runtimes".into(),
874 detail: e.to_string(),
875 })?;
876 Ok(w.runtimes
877 .into_iter()
878 .map(|r| SimctlRuntime {
879 identifier: r.identifier,
880 name: r.name,
881 version: r.version,
882 is_available: r.is_available,
883 })
884 .collect())
885 }
886
887 pub async fn list_devices(&self) -> Result<Vec<SimctlDevice>, SimctlError> {
889 let raw = simctl_run(&["list", "devices", "-j"]).await?;
890 #[derive(Deserialize)]
891 struct Wrap {
892 devices: std::collections::BTreeMap<String, Vec<RawDevice>>,
893 }
894 #[derive(Deserialize)]
895 struct RawDevice {
896 udid: String,
897 name: String,
898 state: String,
899 #[serde(rename = "isAvailable", default)]
900 is_available: bool,
901 #[serde(rename = "deviceTypeIdentifier", default)]
902 device_type_identifier: String,
903 }
904 let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
905 subcommand: "list devices".into(),
906 detail: e.to_string(),
907 })?;
908 let mut out = Vec::new();
909 for (runtime_id, devices) in w.devices {
910 for d in devices {
911 out.push(SimctlDevice {
912 udid: d.udid,
913 name: d.name,
914 state: d.state,
915 is_available: d.is_available,
916 device_type_identifier: d.device_type_identifier,
917 runtime_identifier: runtime_id.clone(),
918 });
919 }
920 }
921 Ok(out)
922 }
923
924 pub async fn boot(&self, udid: &str) -> Result<(), SimctlError> {
928 simctl_run(&["boot", udid]).await?;
929 Ok(())
930 }
931
932 pub async fn shutdown(&self, udid: &str) -> Result<(), SimctlError> {
934 simctl_run(&["shutdown", udid]).await?;
935 Ok(())
936 }
937
938 pub async fn current_locale(&self, udid: &str) -> Result<Option<String>, SimctlError> {
945 let out =
946 match simctl_run(&["spawn", udid, "/usr/bin/defaults", "read", "-g", "AppleLanguages"]).await {
947 Ok(s) => s,
948 Err(SimctlError::NonZeroExit { .. }) => return Ok(None),
951 Err(e) => return Err(e),
952 };
953 if let Some(start) = out.find('"') {
955 let rest = &out[start + 1..];
956 if let Some(end) = rest.find('"') {
957 return Ok(Some(rest[..end].to_string()));
958 }
959 }
960 Ok(None)
961 }
962
963 pub async fn set_locale(&self, udid: &str, locale: &str) -> Result<(), SimctlError> {
970 simctl_run(&[
971 "spawn",
972 udid,
973 "defaults",
974 "write",
975 "-g",
976 "AppleLanguages",
977 "-array",
978 locale,
979 ])
980 .await?;
981 let locale_underscore = locale.replace('-', "_");
982 simctl_run(&[
983 "spawn",
984 udid,
985 "defaults",
986 "write",
987 "-g",
988 "AppleLocale",
989 &locale_underscore,
990 ])
991 .await?;
992 Ok(())
993 }
994
995 pub async fn boot_and_wait(&self, udid: &str, timeout: Duration) -> Result<(), SimctlError> {
1000 let _ = simctl_run(&["boot", udid]).await;
1002 let start = std::time::Instant::now();
1003 loop {
1004 let devices = self.list_devices().await?;
1005 if devices
1006 .iter()
1007 .any(|d| d.udid == udid && d.state == "Booted")
1008 {
1009 return Ok(());
1010 }
1011 if start.elapsed() > timeout {
1012 return Err(SimctlError::Timeout {
1013 subcommand: format!("boot {}", udid),
1014 ms: timeout.as_millis() as u64,
1015 });
1016 }
1017 sleep(Duration::from_millis(500)).await;
1018 }
1019 }
1020
1021 pub async fn erase(&self, udid: &str) -> Result<(), SimctlError> {
1023 simctl_run(&["erase", udid]).await?;
1024 Ok(())
1025 }
1026
1027 pub async fn install(&self, udid: &str, app_path: &str) -> Result<(), SimctlError> {
1029 simctl_run(&["install", udid, app_path]).await?;
1030 Ok(())
1031 }
1032
1033 pub async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
1035 simctl_run(&["uninstall", udid, bundle_id]).await?;
1036 Ok(())
1037 }
1038
1039 pub async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
1041 simctl_run(&["terminate", udid, bundle_id]).await?;
1042 Ok(())
1043 }
1044
1045 pub async fn launch(&self, udid: &str, bundle_id: &str) -> Result<LaunchResult, SimctlError> {
1047 self.launch_with_args(udid, bundle_id, &[]).await
1048 }
1049
1050 pub async fn launch_with_args(
1054 &self,
1055 udid: &str,
1056 bundle_id: &str,
1057 args: &[String],
1058 ) -> Result<LaunchResult, SimctlError> {
1059 self.launch_with_args_and_env(udid, bundle_id, args, &[])
1060 .await
1061 }
1062
1063 pub async fn launch_with_args_and_env(
1073 &self,
1074 udid: &str,
1075 bundle_id: &str,
1076 args: &[String],
1077 child_env: &[(&str, &str)],
1078 ) -> Result<LaunchResult, SimctlError> {
1079 let mut argv: Vec<&str> = vec!["launch", udid, bundle_id];
1080 if !args.is_empty() {
1081 argv.push("--");
1082 for a in args {
1083 argv.push(a.as_str());
1084 }
1085 }
1086 let composed = compose_child_env(child_env);
1087 let out = simctl_run_env(&argv, &composed).await?;
1088 let pid_str =
1090 out.rsplit(':')
1091 .next()
1092 .map(str::trim)
1093 .ok_or_else(|| SimctlError::Malformed {
1094 subcommand: "launch".into(),
1095 detail: format!("unexpected stdout shape: {}", out.trim()),
1096 })?;
1097 let pid: u32 = pid_str.parse().map_err(|_| SimctlError::Malformed {
1098 subcommand: "launch".into(),
1099 detail: format!("non-numeric pid in stdout: {}", out.trim()),
1100 })?;
1101 Ok(LaunchResult { pid })
1102 }
1103
1104 pub async fn privacy_reset_all(
1111 &self,
1112 udid: &str,
1113 bundle_id: &str,
1114 ) -> Result<(), SimctlError> {
1115 simctl_run(&["privacy", udid, "reset", "all", bundle_id]).await?;
1116 Ok(())
1117 }
1118
1119 pub async fn clear_app_sandbox(
1127 &self,
1128 udid: &str,
1129 bundle_id: &str,
1130 ) -> Result<(), SimctlError> {
1131 let raw = simctl_run(&["get_app_container", udid, bundle_id, "data"]).await?;
1132 let container = raw.trim();
1133 if container.is_empty() {
1134 return Err(SimctlError::Malformed {
1135 subcommand: "clear_app_sandbox".into(),
1136 detail: format!("empty Data container path for bundle {bundle_id}"),
1137 });
1138 }
1139 let documents = format!("{container}/Documents");
1140 let library = format!("{container}/Library");
1141 let tmp = format!("{container}/tmp");
1142 simctl_run(&[
1152 "spawn", udid, "/bin/rm", "-rf", &documents, &library, &tmp,
1153 ])
1154 .await?;
1155 Ok(())
1156 }
1157
1158 pub async fn open_url(&self, udid: &str, url: &str) -> Result<(), SimctlError> {
1170 let argv = openurl_argv(udid, url);
1171 let refs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1172 simctl_run(&refs).await?;
1173 Ok(())
1174 }
1175}
1176
1177#[doc(hidden)]
1181pub fn openurl_argv(udid: &str, url: &str) -> [String; 3] {
1182 ["openurl".to_string(), udid.to_string(), url.to_string()]
1183}
1184
1185impl SimctlClient {
1186
1187 pub async fn send_push(
1194 &self,
1195 udid: &str,
1196 bundle_id: &str,
1197 apns_json_path: &str,
1198 ) -> Result<(), SimctlError> {
1199 simctl_run(&["push", udid, bundle_id, apns_json_path]).await?;
1200 Ok(())
1201 }
1202
1203 pub async fn set_appearance(&self, udid: &str, mode: Appearance) -> Result<(), SimctlError> {
1205 simctl_run(&["ui", udid, "appearance", mode.as_str()]).await?;
1206 Ok(())
1207 }
1208
1209 pub async fn grant_permission(
1211 &self,
1212 udid: &str,
1213 permission: SimctlPermission,
1214 bundle_id: &str,
1215 ) -> Result<(), SimctlError> {
1216 simctl_run(&["privacy", udid, "grant", permission.as_str(), bundle_id]).await?;
1217 Ok(())
1218 }
1219
1220 pub async fn revoke_permission(
1225 &self,
1226 udid: &str,
1227 permission: SimctlPermission,
1228 bundle_id: &str,
1229 ) -> Result<(), SimctlError> {
1230 simctl_run(&["privacy", udid, "revoke", permission.as_str(), bundle_id]).await?;
1231 Ok(())
1232 }
1233
1234 pub async fn location_set(
1237 &self,
1238 udid: &str,
1239 latitude: f64,
1240 longitude: f64,
1241 ) -> Result<(), SimctlError> {
1242 let coord = format!("{latitude},{longitude}");
1243 simctl_run(&["location", udid, "set", &coord]).await?;
1244 Ok(())
1245 }
1246
1247 pub async fn location_start(
1252 &self,
1253 udid: &str,
1254 points: &[(f64, f64)],
1255 speed_mps: Option<f64>,
1256 ) -> Result<(), SimctlError> {
1257 if points.len() < 2 {
1258 return Err(SimctlError::Malformed {
1259 subcommand: "location-start".into(),
1260 detail: format!("requires ≥2 waypoints, got {}", points.len()),
1261 });
1262 }
1263 let mut args: Vec<String> = vec!["location".into(), udid.into(), "start".into()];
1264 if let Some(s) = speed_mps {
1265 args.push(format!("--speed={s}"));
1266 }
1267 for (lat, lng) in points {
1268 args.push(format!("{lat},{lng}"));
1269 }
1270 let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
1271 simctl_run(&args_ref).await?;
1272 Ok(())
1273 }
1274
1275 pub async fn location_clear(&self, udid: &str) -> Result<(), SimctlError> {
1278 simctl_run(&["location", udid, "clear"]).await?;
1279 Ok(())
1280 }
1281
1282 pub async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), SimctlError> {
1286 if paths.is_empty() {
1287 return Err(SimctlError::Malformed {
1288 subcommand: "addmedia".into(),
1289 detail: "no paths supplied".into(),
1290 });
1291 }
1292 let mut args: Vec<&str> = vec!["addmedia", udid];
1293 for p in paths {
1294 args.push(p.as_str());
1295 }
1296 simctl_run(&args).await?;
1297 Ok(())
1298 }
1299
1300 pub async fn record_video_start(
1306 &self,
1307 udid: &str,
1308 path: &str,
1309 ) -> Result<RecordingHandle, SimctlError> {
1310 let child = tokio::process::Command::new("xcrun")
1311 .args(["simctl", "io", udid, "recordVideo", path])
1312 .stdin(std::process::Stdio::null())
1313 .stdout(std::process::Stdio::piped())
1314 .stderr(std::process::Stdio::piped())
1315 .spawn()?;
1316 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1318 Ok(RecordingHandle {
1319 child,
1320 path: path.to_string(),
1321 started_at: std::time::Instant::now(),
1322 })
1323 }
1324
1325 pub async fn record_video_stop(&self, mut handle: RecordingHandle) -> Result<(), SimctlError> {
1329 let pid = handle.child.id().ok_or_else(|| SimctlError::Malformed {
1330 subcommand: "recordVideo-stop".into(),
1331 detail: "child already reaped".into(),
1332 })?;
1333 let rc = unsafe { libc::kill(pid as i32, libc::SIGINT) };
1336 if rc != 0 {
1337 return Err(SimctlError::Malformed {
1338 subcommand: "recordVideo-stop".into(),
1339 detail: format!(
1340 "kill SIGINT failed: errno={}",
1341 std::io::Error::last_os_error()
1342 ),
1343 });
1344 }
1345 let wait_result =
1346 tokio::time::timeout(std::time::Duration::from_secs(10), handle.child.wait()).await;
1347 match wait_result {
1348 Ok(Ok(_status)) => Ok(()),
1349 Ok(Err(e)) => Err(SimctlError::Malformed {
1350 subcommand: "recordVideo-stop".into(),
1351 detail: format!("wait failed: {e}"),
1352 }),
1353 Err(_timeout) => {
1354 let _ = handle.child.kill().await;
1355 Err(SimctlError::Malformed {
1356 subcommand: "recordVideo-stop".into(),
1357 detail: "SIGINT timeout (10s) — escalated SIGKILL; output mp4 likely truncated. Inspect simctl recordVideo stderr.".into(),
1358 })
1359 }
1360 }
1361 }
1362
1363 pub async fn reset_permission(
1368 &self,
1369 udid: &str,
1370 permission: SimctlPermission,
1371 bundle_id: &str,
1372 ) -> Result<(), SimctlError> {
1373 simctl_run(&["privacy", udid, "reset", permission.as_str(), bundle_id]).await?;
1374 Ok(())
1375 }
1376
1377 pub async fn keychain_reset(&self, udid: &str) -> Result<(), SimctlError> {
1379 simctl_run(&["keychain", udid, "reset"]).await?;
1380 Ok(())
1381 }
1382
1383 pub async fn pasteboard_get(&self, udid: &str) -> Result<String, SimctlError> {
1385 simctl_run(&["pbpaste", udid]).await
1386 }
1387
1388 pub async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), SimctlError> {
1390 use tokio::io::AsyncWriteExt;
1393 let mut cmd = Command::new("xcrun");
1394 cmd.arg("simctl").arg("pbcopy").arg(udid);
1395 cmd.stdin(std::process::Stdio::piped());
1396 let mut child = cmd.spawn()?;
1397 if let Some(mut stdin) = child.stdin.take() {
1398 stdin.write_all(text.as_bytes()).await?;
1399 drop(stdin); }
1401 let status = child.wait().await?;
1402 if !status.success() {
1403 return Err(SimctlError::NonZeroExit {
1404 subcommand: "pbcopy".into(),
1405 argv: vec!["pbcopy".to_string()],
1406 code: status.code().unwrap_or(-1),
1407 stderr: String::new(),
1408 wall_ms: 0,
1409 });
1410 }
1411 Ok(())
1412 }
1413
1414 pub async fn set_reduce_motion(&self, udid: &str, enabled: bool) -> Result<(), SimctlError> {
1416 let val = if enabled { "1" } else { "0" };
1417 simctl_run(&[
1419 "spawn",
1420 udid,
1421 "defaults",
1422 "write",
1423 "com.apple.UIKit",
1424 "UIAccessibilityReduceMotionEnabled",
1425 "-bool",
1426 val,
1427 ])
1428 .await?;
1429 Ok(())
1430 }
1431
1432 pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, SimctlError> {
1449 let wait = {
1451 let mut pacer = self
1452 .screenshot_pacer
1453 .lock()
1454 .expect("screenshot pacer mutex must not be poisoned");
1455 pacer
1456 .compute_wait()
1457 .map_err(|retry_after| SimctlError::CaptureBackpressure { retry_after })?
1458 };
1459 if !wait.is_zero() {
1460 sleep(wait).await;
1461 }
1462
1463 let call_start = std::time::Instant::now();
1464 let tmp =
1465 std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
1466 let tmp_str = tmp.display().to_string();
1467 let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
1468 let bytes = result.and_then(|_| {
1469 std::fs::read(&tmp).map_err(|e| SimctlError::Malformed {
1470 subcommand: "screenshot".into(),
1471 detail: format!("read {tmp_str}: {e}"),
1472 })
1473 });
1474 let _ = std::fs::remove_file(&tmp);
1475
1476 let wall = call_start.elapsed();
1477 let failed = bytes.is_err();
1478 {
1479 let mut pacer = self
1480 .screenshot_pacer
1481 .lock()
1482 .expect("screenshot pacer mutex must not be poisoned");
1483 pacer.record(wall, failed);
1484 }
1485
1486 let bytes = bytes?;
1487 if bytes.len() < 8 {
1488 return Err(SimctlError::Malformed {
1489 subcommand: "screenshot".into(),
1490 detail: format!("screenshot file too short: {} bytes", bytes.len()),
1491 });
1492 }
1493 Ok(ensure_srgb_chunk(bytes))
1494 }
1495
1496 pub async fn create_device(
1498 &self,
1499 name: &str,
1500 device_type: &str,
1501 runtime_id: &str,
1502 ) -> Result<String, SimctlError> {
1503 let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
1504 Ok(out.trim().to_string())
1505 }
1506
1507 pub async fn delete_device(&self, udid: &str) -> Result<(), SimctlError> {
1509 simctl_run(&["delete", udid]).await?;
1510 Ok(())
1511 }
1512}
1513
1514const PNG_MAGIC: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
1534
1535pub fn ensure_srgb_chunk(bytes: Vec<u8>) -> Vec<u8> {
1543 if bytes.len() < 8 || &bytes[..8] != PNG_MAGIC {
1544 return bytes;
1545 }
1546 let Some((idat_offset, has_srgb)) = scan_png_chunks(&bytes) else {
1547 return bytes;
1548 };
1549 if has_srgb {
1550 return bytes;
1551 }
1552 let mut out = Vec::with_capacity(bytes.len() + 13);
1554 out.extend_from_slice(&bytes[..idat_offset]);
1555 out.extend_from_slice(&synthesized_srgb_chunk());
1556 out.extend_from_slice(&bytes[idat_offset..]);
1557 out
1558}
1559
1560fn scan_png_chunks(bytes: &[u8]) -> Option<(usize, bool)> {
1565 let mut i: usize = 8;
1566 let mut has_srgb = false;
1567 while i + 8 <= bytes.len() {
1568 let length =
1569 u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1570 let ctype = &bytes[i + 4..i + 8];
1571 if ctype == b"IDAT" {
1572 return Some((i, has_srgb));
1573 }
1574 if ctype == b"sRGB" {
1575 has_srgb = true;
1576 }
1577 let end = i.checked_add(12)?.checked_add(length)?;
1579 if end > bytes.len() {
1580 return None;
1581 }
1582 i = end;
1583 }
1584 None
1585}
1586
1587fn synthesized_srgb_chunk() -> [u8; 13] {
1590 let mut crc_input = [0u8; 5];
1592 crc_input[0..4].copy_from_slice(b"sRGB");
1593 crc_input[4] = 0; let crc = crc32_ieee(&crc_input);
1595 let mut chunk = [0u8; 13];
1596 chunk[0..4].copy_from_slice(&1u32.to_be_bytes()); chunk[4..8].copy_from_slice(b"sRGB");
1598 chunk[8] = 0;
1599 chunk[9..13].copy_from_slice(&crc.to_be_bytes());
1600 chunk
1601}
1602
1603fn crc32_ieee(bytes: &[u8]) -> u32 {
1607 let mut crc: u32 = 0xFFFF_FFFF;
1608 for &b in bytes {
1609 crc ^= u32::from(b);
1610 for _ in 0..8 {
1611 let mask = 0u32.wrapping_sub(crc & 1);
1612 crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
1613 }
1614 }
1615 !crc
1616}
1617
1618#[cfg(test)]
1619mod tests {
1620 use super::*;
1621
1622 #[test]
1623 fn compose_child_env_adds_prefix() {
1624 let composed = compose_child_env(&[
1625 ("SMIX_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
1626 ("LAUNCH_FORCE_PUSH", "true"),
1627 ]);
1628 assert_eq!(
1629 composed,
1630 vec![
1631 (
1632 "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
1633 "http://127.0.0.1:9999".to_string(),
1634 ),
1635 (
1636 "SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
1637 "true".to_string(),
1638 ),
1639 ]
1640 );
1641 }
1642
1643 #[test]
1644 fn compose_child_env_already_prefixed_passes_through() {
1645 let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
1647 assert_eq!(
1648 composed,
1649 vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
1650 );
1651 }
1652
1653 #[test]
1654 fn compose_child_env_empty_input_is_empty_output() {
1655 assert!(compose_child_env(&[]).is_empty());
1656 }
1657
1658 #[test]
1661 fn openurl_argv_preserves_url_verbatim() {
1662 let udid = "12345678-1234-5678-1234-567812345678";
1663 let url = "exp+focus-ai-app://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081";
1664 let argv = super::openurl_argv(udid, url);
1665 assert_eq!(argv[0], "openurl");
1666 assert_eq!(argv[1], udid);
1667 assert_eq!(argv[2], url);
1669 assert!(argv[2].contains("?url="));
1670 assert!(argv[2].contains("%3A"));
1671 assert!(argv[2].contains("%2F"));
1672 }
1673
1674 #[test]
1675 fn openurl_argv_preserves_ampersand_and_hash() {
1676 let udid = "12345678-1234-5678-1234-567812345678";
1677 let url = "insight://dev-mutate?action=env&value=staging#anchor";
1678 let argv = super::openurl_argv(udid, url);
1679 assert_eq!(argv[2], url);
1680 assert!(argv[2].contains('&'));
1681 assert!(argv[2].contains('#'));
1682 }
1683
1684 #[test]
1685 fn openurl_argv_preserves_unicode() {
1686 let udid = "12345678-1234-5678-1234-567812345678";
1687 let url = "insight://route?name=%E7%94%B0%E4%B8%AD";
1688 let argv = super::openurl_argv(udid, url);
1689 assert_eq!(argv[2], url);
1690 }
1691
1692 fn synth_png(with_srgb: bool) -> Vec<u8> {
1698 let mut out = Vec::new();
1699 out.extend_from_slice(super::PNG_MAGIC);
1700 let ihdr_data: [u8; 13] = [
1702 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0,
1707 ];
1708 emit_chunk(&mut out, b"IHDR", &ihdr_data);
1709 if with_srgb {
1710 emit_chunk(&mut out, b"sRGB", &[0]);
1711 }
1712 emit_chunk(&mut out, b"IDAT", &[0x78, 0x01, 0x00, 0x00]);
1714 emit_chunk(&mut out, b"IEND", &[]);
1715 out
1716 }
1717
1718 fn emit_chunk(out: &mut Vec<u8>, ctype: &[u8; 4], data: &[u8]) {
1719 out.extend_from_slice(&(data.len() as u32).to_be_bytes());
1720 out.extend_from_slice(ctype);
1721 out.extend_from_slice(data);
1722 let mut crc_in = Vec::with_capacity(4 + data.len());
1723 crc_in.extend_from_slice(ctype);
1724 crc_in.extend_from_slice(data);
1725 out.extend_from_slice(&super::crc32_ieee(&crc_in).to_be_bytes());
1726 }
1727
1728 #[test]
1729 fn ensure_srgb_passthrough_when_chunk_present() {
1730 let png = synth_png(true);
1731 let original_len = png.len();
1732 let out = super::ensure_srgb_chunk(png.clone());
1733 assert_eq!(out.len(), original_len);
1734 assert_eq!(out, png);
1735 }
1736
1737 #[test]
1738 fn ensure_srgb_inserts_chunk_when_absent() {
1739 let png = synth_png(false);
1740 let original_len = png.len();
1741 let out = super::ensure_srgb_chunk(png);
1742 assert_eq!(out.len(), original_len + 13);
1743 assert_eq!(&out[..8], super::PNG_MAGIC);
1745 let mut found = false;
1747 for w in out.windows(4) {
1748 if w == b"sRGB" {
1749 found = true;
1750 break;
1751 }
1752 }
1753 assert!(found, "sRGB chunk should have been spliced in");
1754 }
1755
1756 #[test]
1757 fn ensure_srgb_preserves_idat_bytes_verbatim() {
1758 let png = synth_png(false);
1762 let out = super::ensure_srgb_chunk(png.clone());
1763 assert_eq!(extract_idat_data(&png), extract_idat_data(&out));
1764 }
1765
1766 fn extract_idat_data(bytes: &[u8]) -> Vec<u8> {
1767 let mut i = 8;
1768 while i + 8 <= bytes.len() {
1769 let length =
1770 u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1771 let ctype = &bytes[i + 4..i + 8];
1772 if ctype == b"IDAT" {
1773 return bytes[i + 8..i + 8 + length].to_vec();
1774 }
1775 i += 12 + length;
1776 }
1777 vec![]
1778 }
1779
1780 #[test]
1781 fn ensure_srgb_passthrough_on_bad_magic() {
1782 let bytes = vec![0u8; 32];
1784 let out = super::ensure_srgb_chunk(bytes.clone());
1785 assert_eq!(out, bytes);
1786 }
1787
1788 #[test]
1789 fn crc32_matches_known_iend() {
1790 assert_eq!(super::crc32_ieee(b"IEND"), 0xAE42_6082);
1793 }
1794}