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
483pub fn recent_subprocesses() -> Vec<SubprocessRecord> {
487 subprocess_ring::snapshot()
488}
489
490async fn simctl_capture(args: &[&str]) -> Result<(Vec<u8>, String), SimctlError> {
494 simctl_capture_env(args, &[]).await
495}
496
497async fn simctl_capture_env(
504 args: &[&str],
505 env: &[(String, String)],
506) -> Result<(Vec<u8>, String), SimctlError> {
507 let mut cmd = Command::new("xcrun");
508 cmd.arg("simctl");
509 for a in args {
510 cmd.arg(a);
511 }
512 for (k, v) in env {
513 cmd.env(k, v);
514 }
515 let started = std::time::Instant::now();
516 let output = cmd.output().await?;
517 let wall_ms = started.elapsed().as_millis() as u64;
518 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
519 subprocess_ring::record(SubprocessRecord {
522 argv: args.iter().map(|s| s.to_string()).collect(),
523 exit_code: output.status.code(),
524 wall_ms,
525 stderr_head: {
526 let mut s = stderr.clone();
527 if s.len() > 256 {
528 s.truncate(256);
529 }
530 s
531 },
532 timestamp: std::time::SystemTime::now(),
533 });
534 if !output.status.success() {
535 return Err(SimctlError::NonZeroExit {
536 subcommand: args.first().map(|s| s.to_string()).unwrap_or_default(),
537 argv: args.iter().map(|s| s.to_string()).collect(),
538 code: output.status.code().unwrap_or(-1),
539 stderr,
540 wall_ms,
541 });
542 }
543 Ok((output.stdout, stderr))
544}
545
546async fn simctl_run(args: &[&str]) -> Result<String, SimctlError> {
547 let (stdout, _) = simctl_capture(args).await?;
548 Ok(String::from_utf8_lossy(&stdout).into_owned())
549}
550
551async fn simctl_run_env(args: &[&str], env: &[(String, String)]) -> Result<String, SimctlError> {
555 let (stdout, _) = simctl_capture_env(args, env).await?;
556 Ok(String::from_utf8_lossy(&stdout).into_owned())
557}
558
559pub fn compose_child_env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
578 pairs
579 .iter()
580 .map(|(k, v)| {
581 let key = if k.starts_with("SIMCTL_CHILD_") {
582 (*k).to_string()
583 } else {
584 format!("SIMCTL_CHILD_{k}")
585 };
586 (key, (*v).to_string())
587 })
588 .collect()
589}
590
591#[derive(Debug)]
602pub struct SimctlClient {
603 screenshot_pacer: Arc<std::sync::Mutex<ScreenshotPacer>>,
608}
609
610impl Default for SimctlClient {
611 fn default() -> Self {
612 Self::new()
613 }
614}
615
616impl SimctlClient {
617 pub fn new() -> Self {
621 SimctlClient {
622 screenshot_pacer: Arc::new(std::sync::Mutex::new(ScreenshotPacer::new(
623 ScreenshotPacerConfig::default(),
624 ))),
625 }
626 }
627
628 #[must_use]
632 pub fn with_screenshot_pacer(self, config: ScreenshotPacerConfig) -> Self {
633 {
634 let mut guard = self
635 .screenshot_pacer
636 .lock()
637 .expect("screenshot pacer mutex must not be poisoned");
638 *guard = ScreenshotPacer::new(config);
639 }
640 self
641 }
642
643 #[must_use]
651 pub fn with_sim_health(self, monitor: smix_sim_health::SimHealthMonitor) -> Self {
652 {
653 let mut guard = self
654 .screenshot_pacer
655 .lock()
656 .expect("screenshot pacer mutex must not be poisoned");
657 guard.set_monitor(monitor);
658 }
659 self
660 }
661
662 pub async fn list_runtimes(&self) -> Result<Vec<SimctlRuntime>, SimctlError> {
666 let raw = simctl_run(&["list", "runtimes", "-j"]).await?;
667 #[derive(Deserialize)]
668 struct Wrap {
669 runtimes: Vec<RawRuntime>,
670 }
671 #[derive(Deserialize)]
672 struct RawRuntime {
673 identifier: String,
674 name: String,
675 version: String,
676 #[serde(rename = "isAvailable", default)]
677 is_available: bool,
678 }
679 let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
680 subcommand: "list runtimes".into(),
681 detail: e.to_string(),
682 })?;
683 Ok(w.runtimes
684 .into_iter()
685 .map(|r| SimctlRuntime {
686 identifier: r.identifier,
687 name: r.name,
688 version: r.version,
689 is_available: r.is_available,
690 })
691 .collect())
692 }
693
694 pub async fn list_devices(&self) -> Result<Vec<SimctlDevice>, SimctlError> {
696 let raw = simctl_run(&["list", "devices", "-j"]).await?;
697 #[derive(Deserialize)]
698 struct Wrap {
699 devices: std::collections::BTreeMap<String, Vec<RawDevice>>,
700 }
701 #[derive(Deserialize)]
702 struct RawDevice {
703 udid: String,
704 name: String,
705 state: String,
706 #[serde(rename = "isAvailable", default)]
707 is_available: bool,
708 #[serde(rename = "deviceTypeIdentifier", default)]
709 device_type_identifier: String,
710 }
711 let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
712 subcommand: "list devices".into(),
713 detail: e.to_string(),
714 })?;
715 let mut out = Vec::new();
716 for (runtime_id, devices) in w.devices {
717 for d in devices {
718 out.push(SimctlDevice {
719 udid: d.udid,
720 name: d.name,
721 state: d.state,
722 is_available: d.is_available,
723 device_type_identifier: d.device_type_identifier,
724 runtime_identifier: runtime_id.clone(),
725 });
726 }
727 }
728 Ok(out)
729 }
730
731 pub async fn boot(&self, udid: &str) -> Result<(), SimctlError> {
735 simctl_run(&["boot", udid]).await?;
736 Ok(())
737 }
738
739 pub async fn shutdown(&self, udid: &str) -> Result<(), SimctlError> {
741 simctl_run(&["shutdown", udid]).await?;
742 Ok(())
743 }
744
745 pub async fn current_locale(&self, udid: &str) -> Result<Option<String>, SimctlError> {
752 let out =
753 match simctl_run(&["spawn", udid, "/usr/bin/defaults", "read", "-g", "AppleLanguages"]).await {
754 Ok(s) => s,
755 Err(SimctlError::NonZeroExit { .. }) => return Ok(None),
758 Err(e) => return Err(e),
759 };
760 if let Some(start) = out.find('"') {
762 let rest = &out[start + 1..];
763 if let Some(end) = rest.find('"') {
764 return Ok(Some(rest[..end].to_string()));
765 }
766 }
767 Ok(None)
768 }
769
770 pub async fn set_locale(&self, udid: &str, locale: &str) -> Result<(), SimctlError> {
777 simctl_run(&[
778 "spawn",
779 udid,
780 "defaults",
781 "write",
782 "-g",
783 "AppleLanguages",
784 "-array",
785 locale,
786 ])
787 .await?;
788 let locale_underscore = locale.replace('-', "_");
789 simctl_run(&[
790 "spawn",
791 udid,
792 "defaults",
793 "write",
794 "-g",
795 "AppleLocale",
796 &locale_underscore,
797 ])
798 .await?;
799 Ok(())
800 }
801
802 pub async fn boot_and_wait(&self, udid: &str, timeout: Duration) -> Result<(), SimctlError> {
807 let _ = simctl_run(&["boot", udid]).await;
809 let start = std::time::Instant::now();
810 loop {
811 let devices = self.list_devices().await?;
812 if devices
813 .iter()
814 .any(|d| d.udid == udid && d.state == "Booted")
815 {
816 return Ok(());
817 }
818 if start.elapsed() > timeout {
819 return Err(SimctlError::Timeout {
820 subcommand: format!("boot {}", udid),
821 ms: timeout.as_millis() as u64,
822 });
823 }
824 sleep(Duration::from_millis(500)).await;
825 }
826 }
827
828 pub async fn erase(&self, udid: &str) -> Result<(), SimctlError> {
830 simctl_run(&["erase", udid]).await?;
831 Ok(())
832 }
833
834 pub async fn install(&self, udid: &str, app_path: &str) -> Result<(), SimctlError> {
836 simctl_run(&["install", udid, app_path]).await?;
837 Ok(())
838 }
839
840 pub async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
842 simctl_run(&["uninstall", udid, bundle_id]).await?;
843 Ok(())
844 }
845
846 pub async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
848 simctl_run(&["terminate", udid, bundle_id]).await?;
849 Ok(())
850 }
851
852 pub async fn launch(&self, udid: &str, bundle_id: &str) -> Result<LaunchResult, SimctlError> {
854 self.launch_with_args(udid, bundle_id, &[]).await
855 }
856
857 pub async fn launch_with_args(
861 &self,
862 udid: &str,
863 bundle_id: &str,
864 args: &[String],
865 ) -> Result<LaunchResult, SimctlError> {
866 self.launch_with_args_and_env(udid, bundle_id, args, &[])
867 .await
868 }
869
870 pub async fn launch_with_args_and_env(
880 &self,
881 udid: &str,
882 bundle_id: &str,
883 args: &[String],
884 child_env: &[(&str, &str)],
885 ) -> Result<LaunchResult, SimctlError> {
886 let mut argv: Vec<&str> = vec!["launch", udid, bundle_id];
887 if !args.is_empty() {
888 argv.push("--");
889 for a in args {
890 argv.push(a.as_str());
891 }
892 }
893 let composed = compose_child_env(child_env);
894 let out = simctl_run_env(&argv, &composed).await?;
895 let pid_str =
897 out.rsplit(':')
898 .next()
899 .map(str::trim)
900 .ok_or_else(|| SimctlError::Malformed {
901 subcommand: "launch".into(),
902 detail: format!("unexpected stdout shape: {}", out.trim()),
903 })?;
904 let pid: u32 = pid_str.parse().map_err(|_| SimctlError::Malformed {
905 subcommand: "launch".into(),
906 detail: format!("non-numeric pid in stdout: {}", out.trim()),
907 })?;
908 Ok(LaunchResult { pid })
909 }
910
911 pub async fn privacy_reset_all(
918 &self,
919 udid: &str,
920 bundle_id: &str,
921 ) -> Result<(), SimctlError> {
922 simctl_run(&["privacy", udid, "reset", "all", bundle_id]).await?;
923 Ok(())
924 }
925
926 pub async fn clear_app_sandbox(
934 &self,
935 udid: &str,
936 bundle_id: &str,
937 ) -> Result<(), SimctlError> {
938 let raw = simctl_run(&["get_app_container", udid, bundle_id, "data"]).await?;
939 let container = raw.trim();
940 if container.is_empty() {
941 return Err(SimctlError::Malformed {
942 subcommand: "clear_app_sandbox".into(),
943 detail: format!("empty Data container path for bundle {bundle_id}"),
944 });
945 }
946 let documents = format!("{container}/Documents");
947 let library = format!("{container}/Library");
948 let tmp = format!("{container}/tmp");
949 simctl_run(&[
959 "spawn", udid, "/bin/rm", "-rf", &documents, &library, &tmp,
960 ])
961 .await?;
962 Ok(())
963 }
964
965 pub async fn open_url(&self, udid: &str, url: &str) -> Result<(), SimctlError> {
977 let argv = openurl_argv(udid, url);
978 let refs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
979 simctl_run(&refs).await?;
980 Ok(())
981 }
982}
983
984#[doc(hidden)]
988pub fn openurl_argv(udid: &str, url: &str) -> [String; 3] {
989 ["openurl".to_string(), udid.to_string(), url.to_string()]
990}
991
992impl SimctlClient {
993
994 pub async fn send_push(
1001 &self,
1002 udid: &str,
1003 bundle_id: &str,
1004 apns_json_path: &str,
1005 ) -> Result<(), SimctlError> {
1006 simctl_run(&["push", udid, bundle_id, apns_json_path]).await?;
1007 Ok(())
1008 }
1009
1010 pub async fn set_appearance(&self, udid: &str, mode: Appearance) -> Result<(), SimctlError> {
1012 simctl_run(&["ui", udid, "appearance", mode.as_str()]).await?;
1013 Ok(())
1014 }
1015
1016 pub async fn grant_permission(
1018 &self,
1019 udid: &str,
1020 permission: SimctlPermission,
1021 bundle_id: &str,
1022 ) -> Result<(), SimctlError> {
1023 simctl_run(&["privacy", udid, "grant", permission.as_str(), bundle_id]).await?;
1024 Ok(())
1025 }
1026
1027 pub async fn revoke_permission(
1032 &self,
1033 udid: &str,
1034 permission: SimctlPermission,
1035 bundle_id: &str,
1036 ) -> Result<(), SimctlError> {
1037 simctl_run(&["privacy", udid, "revoke", permission.as_str(), bundle_id]).await?;
1038 Ok(())
1039 }
1040
1041 pub async fn location_set(
1044 &self,
1045 udid: &str,
1046 latitude: f64,
1047 longitude: f64,
1048 ) -> Result<(), SimctlError> {
1049 let coord = format!("{latitude},{longitude}");
1050 simctl_run(&["location", udid, "set", &coord]).await?;
1051 Ok(())
1052 }
1053
1054 pub async fn location_start(
1059 &self,
1060 udid: &str,
1061 points: &[(f64, f64)],
1062 speed_mps: Option<f64>,
1063 ) -> Result<(), SimctlError> {
1064 if points.len() < 2 {
1065 return Err(SimctlError::Malformed {
1066 subcommand: "location-start".into(),
1067 detail: format!("requires ≥2 waypoints, got {}", points.len()),
1068 });
1069 }
1070 let mut args: Vec<String> = vec!["location".into(), udid.into(), "start".into()];
1071 if let Some(s) = speed_mps {
1072 args.push(format!("--speed={s}"));
1073 }
1074 for (lat, lng) in points {
1075 args.push(format!("{lat},{lng}"));
1076 }
1077 let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
1078 simctl_run(&args_ref).await?;
1079 Ok(())
1080 }
1081
1082 pub async fn location_clear(&self, udid: &str) -> Result<(), SimctlError> {
1085 simctl_run(&["location", udid, "clear"]).await?;
1086 Ok(())
1087 }
1088
1089 pub async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), SimctlError> {
1093 if paths.is_empty() {
1094 return Err(SimctlError::Malformed {
1095 subcommand: "addmedia".into(),
1096 detail: "no paths supplied".into(),
1097 });
1098 }
1099 let mut args: Vec<&str> = vec!["addmedia", udid];
1100 for p in paths {
1101 args.push(p.as_str());
1102 }
1103 simctl_run(&args).await?;
1104 Ok(())
1105 }
1106
1107 pub async fn record_video_start(
1113 &self,
1114 udid: &str,
1115 path: &str,
1116 ) -> Result<RecordingHandle, SimctlError> {
1117 let child = tokio::process::Command::new("xcrun")
1118 .args(["simctl", "io", udid, "recordVideo", path])
1119 .stdin(std::process::Stdio::null())
1120 .stdout(std::process::Stdio::piped())
1121 .stderr(std::process::Stdio::piped())
1122 .spawn()?;
1123 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1125 Ok(RecordingHandle {
1126 child,
1127 path: path.to_string(),
1128 started_at: std::time::Instant::now(),
1129 })
1130 }
1131
1132 pub async fn record_video_stop(&self, mut handle: RecordingHandle) -> Result<(), SimctlError> {
1136 let pid = handle.child.id().ok_or_else(|| SimctlError::Malformed {
1137 subcommand: "recordVideo-stop".into(),
1138 detail: "child already reaped".into(),
1139 })?;
1140 let rc = unsafe { libc::kill(pid as i32, libc::SIGINT) };
1143 if rc != 0 {
1144 return Err(SimctlError::Malformed {
1145 subcommand: "recordVideo-stop".into(),
1146 detail: format!(
1147 "kill SIGINT failed: errno={}",
1148 std::io::Error::last_os_error()
1149 ),
1150 });
1151 }
1152 let wait_result =
1153 tokio::time::timeout(std::time::Duration::from_secs(10), handle.child.wait()).await;
1154 match wait_result {
1155 Ok(Ok(_status)) => Ok(()),
1156 Ok(Err(e)) => Err(SimctlError::Malformed {
1157 subcommand: "recordVideo-stop".into(),
1158 detail: format!("wait failed: {e}"),
1159 }),
1160 Err(_timeout) => {
1161 let _ = handle.child.kill().await;
1162 Err(SimctlError::Malformed {
1163 subcommand: "recordVideo-stop".into(),
1164 detail: "SIGINT timeout (10s) — escalated SIGKILL; output mp4 likely truncated. Inspect simctl recordVideo stderr.".into(),
1165 })
1166 }
1167 }
1168 }
1169
1170 pub async fn reset_permission(
1175 &self,
1176 udid: &str,
1177 permission: SimctlPermission,
1178 bundle_id: &str,
1179 ) -> Result<(), SimctlError> {
1180 simctl_run(&["privacy", udid, "reset", permission.as_str(), bundle_id]).await?;
1181 Ok(())
1182 }
1183
1184 pub async fn keychain_reset(&self, udid: &str) -> Result<(), SimctlError> {
1186 simctl_run(&["keychain", udid, "reset"]).await?;
1187 Ok(())
1188 }
1189
1190 pub async fn pasteboard_get(&self, udid: &str) -> Result<String, SimctlError> {
1192 simctl_run(&["pbpaste", udid]).await
1193 }
1194
1195 pub async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), SimctlError> {
1197 use tokio::io::AsyncWriteExt;
1200 let mut cmd = Command::new("xcrun");
1201 cmd.arg("simctl").arg("pbcopy").arg(udid);
1202 cmd.stdin(std::process::Stdio::piped());
1203 let mut child = cmd.spawn()?;
1204 if let Some(mut stdin) = child.stdin.take() {
1205 stdin.write_all(text.as_bytes()).await?;
1206 drop(stdin); }
1208 let status = child.wait().await?;
1209 if !status.success() {
1210 return Err(SimctlError::NonZeroExit {
1211 subcommand: "pbcopy".into(),
1212 argv: vec!["pbcopy".to_string()],
1213 code: status.code().unwrap_or(-1),
1214 stderr: String::new(),
1215 wall_ms: 0,
1216 });
1217 }
1218 Ok(())
1219 }
1220
1221 pub async fn set_reduce_motion(&self, udid: &str, enabled: bool) -> Result<(), SimctlError> {
1223 let val = if enabled { "1" } else { "0" };
1224 simctl_run(&[
1226 "spawn",
1227 udid,
1228 "defaults",
1229 "write",
1230 "com.apple.UIKit",
1231 "UIAccessibilityReduceMotionEnabled",
1232 "-bool",
1233 val,
1234 ])
1235 .await?;
1236 Ok(())
1237 }
1238
1239 pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, SimctlError> {
1256 let wait = {
1258 let mut pacer = self
1259 .screenshot_pacer
1260 .lock()
1261 .expect("screenshot pacer mutex must not be poisoned");
1262 pacer
1263 .compute_wait()
1264 .map_err(|retry_after| SimctlError::CaptureBackpressure { retry_after })?
1265 };
1266 if !wait.is_zero() {
1267 sleep(wait).await;
1268 }
1269
1270 let call_start = std::time::Instant::now();
1271 let tmp =
1272 std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
1273 let tmp_str = tmp.display().to_string();
1274 let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
1275 let bytes = result.and_then(|_| {
1276 std::fs::read(&tmp).map_err(|e| SimctlError::Malformed {
1277 subcommand: "screenshot".into(),
1278 detail: format!("read {tmp_str}: {e}"),
1279 })
1280 });
1281 let _ = std::fs::remove_file(&tmp);
1282
1283 let wall = call_start.elapsed();
1284 let failed = bytes.is_err();
1285 {
1286 let mut pacer = self
1287 .screenshot_pacer
1288 .lock()
1289 .expect("screenshot pacer mutex must not be poisoned");
1290 pacer.record(wall, failed);
1291 }
1292
1293 let bytes = bytes?;
1294 if bytes.len() < 8 {
1295 return Err(SimctlError::Malformed {
1296 subcommand: "screenshot".into(),
1297 detail: format!("screenshot file too short: {} bytes", bytes.len()),
1298 });
1299 }
1300 Ok(ensure_srgb_chunk(bytes))
1301 }
1302
1303 pub async fn create_device(
1305 &self,
1306 name: &str,
1307 device_type: &str,
1308 runtime_id: &str,
1309 ) -> Result<String, SimctlError> {
1310 let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
1311 Ok(out.trim().to_string())
1312 }
1313
1314 pub async fn delete_device(&self, udid: &str) -> Result<(), SimctlError> {
1316 simctl_run(&["delete", udid]).await?;
1317 Ok(())
1318 }
1319}
1320
1321const PNG_MAGIC: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
1341
1342pub fn ensure_srgb_chunk(bytes: Vec<u8>) -> Vec<u8> {
1350 if bytes.len() < 8 || &bytes[..8] != PNG_MAGIC {
1351 return bytes;
1352 }
1353 let Some((idat_offset, has_srgb)) = scan_png_chunks(&bytes) else {
1354 return bytes;
1355 };
1356 if has_srgb {
1357 return bytes;
1358 }
1359 let mut out = Vec::with_capacity(bytes.len() + 13);
1361 out.extend_from_slice(&bytes[..idat_offset]);
1362 out.extend_from_slice(&synthesized_srgb_chunk());
1363 out.extend_from_slice(&bytes[idat_offset..]);
1364 out
1365}
1366
1367fn scan_png_chunks(bytes: &[u8]) -> Option<(usize, bool)> {
1372 let mut i: usize = 8;
1373 let mut has_srgb = false;
1374 while i + 8 <= bytes.len() {
1375 let length =
1376 u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1377 let ctype = &bytes[i + 4..i + 8];
1378 if ctype == b"IDAT" {
1379 return Some((i, has_srgb));
1380 }
1381 if ctype == b"sRGB" {
1382 has_srgb = true;
1383 }
1384 let end = i.checked_add(12)?.checked_add(length)?;
1386 if end > bytes.len() {
1387 return None;
1388 }
1389 i = end;
1390 }
1391 None
1392}
1393
1394fn synthesized_srgb_chunk() -> [u8; 13] {
1397 let mut crc_input = [0u8; 5];
1399 crc_input[0..4].copy_from_slice(b"sRGB");
1400 crc_input[4] = 0; let crc = crc32_ieee(&crc_input);
1402 let mut chunk = [0u8; 13];
1403 chunk[0..4].copy_from_slice(&1u32.to_be_bytes()); chunk[4..8].copy_from_slice(b"sRGB");
1405 chunk[8] = 0;
1406 chunk[9..13].copy_from_slice(&crc.to_be_bytes());
1407 chunk
1408}
1409
1410fn crc32_ieee(bytes: &[u8]) -> u32 {
1414 let mut crc: u32 = 0xFFFF_FFFF;
1415 for &b in bytes {
1416 crc ^= u32::from(b);
1417 for _ in 0..8 {
1418 let mask = 0u32.wrapping_sub(crc & 1);
1419 crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
1420 }
1421 }
1422 !crc
1423}
1424
1425#[cfg(test)]
1426mod tests {
1427 use super::*;
1428
1429 #[test]
1430 fn compose_child_env_adds_prefix() {
1431 let composed = compose_child_env(&[
1432 ("SMIX_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
1433 ("LAUNCH_FORCE_PUSH", "true"),
1434 ]);
1435 assert_eq!(
1436 composed,
1437 vec![
1438 (
1439 "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
1440 "http://127.0.0.1:9999".to_string(),
1441 ),
1442 (
1443 "SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
1444 "true".to_string(),
1445 ),
1446 ]
1447 );
1448 }
1449
1450 #[test]
1451 fn compose_child_env_already_prefixed_passes_through() {
1452 let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
1454 assert_eq!(
1455 composed,
1456 vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
1457 );
1458 }
1459
1460 #[test]
1461 fn compose_child_env_empty_input_is_empty_output() {
1462 assert!(compose_child_env(&[]).is_empty());
1463 }
1464
1465 #[test]
1468 fn openurl_argv_preserves_url_verbatim() {
1469 let udid = "12345678-1234-5678-1234-567812345678";
1470 let url = "exp+focus-ai-app://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081";
1471 let argv = super::openurl_argv(udid, url);
1472 assert_eq!(argv[0], "openurl");
1473 assert_eq!(argv[1], udid);
1474 assert_eq!(argv[2], url);
1476 assert!(argv[2].contains("?url="));
1477 assert!(argv[2].contains("%3A"));
1478 assert!(argv[2].contains("%2F"));
1479 }
1480
1481 #[test]
1482 fn openurl_argv_preserves_ampersand_and_hash() {
1483 let udid = "12345678-1234-5678-1234-567812345678";
1484 let url = "insight://dev-mutate?action=env&value=staging#anchor";
1485 let argv = super::openurl_argv(udid, url);
1486 assert_eq!(argv[2], url);
1487 assert!(argv[2].contains('&'));
1488 assert!(argv[2].contains('#'));
1489 }
1490
1491 #[test]
1492 fn openurl_argv_preserves_unicode() {
1493 let udid = "12345678-1234-5678-1234-567812345678";
1494 let url = "insight://route?name=%E7%94%B0%E4%B8%AD";
1495 let argv = super::openurl_argv(udid, url);
1496 assert_eq!(argv[2], url);
1497 }
1498
1499 fn synth_png(with_srgb: bool) -> Vec<u8> {
1505 let mut out = Vec::new();
1506 out.extend_from_slice(super::PNG_MAGIC);
1507 let ihdr_data: [u8; 13] = [
1509 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0,
1514 ];
1515 emit_chunk(&mut out, b"IHDR", &ihdr_data);
1516 if with_srgb {
1517 emit_chunk(&mut out, b"sRGB", &[0]);
1518 }
1519 emit_chunk(&mut out, b"IDAT", &[0x78, 0x01, 0x00, 0x00]);
1521 emit_chunk(&mut out, b"IEND", &[]);
1522 out
1523 }
1524
1525 fn emit_chunk(out: &mut Vec<u8>, ctype: &[u8; 4], data: &[u8]) {
1526 out.extend_from_slice(&(data.len() as u32).to_be_bytes());
1527 out.extend_from_slice(ctype);
1528 out.extend_from_slice(data);
1529 let mut crc_in = Vec::with_capacity(4 + data.len());
1530 crc_in.extend_from_slice(ctype);
1531 crc_in.extend_from_slice(data);
1532 out.extend_from_slice(&super::crc32_ieee(&crc_in).to_be_bytes());
1533 }
1534
1535 #[test]
1536 fn ensure_srgb_passthrough_when_chunk_present() {
1537 let png = synth_png(true);
1538 let original_len = png.len();
1539 let out = super::ensure_srgb_chunk(png.clone());
1540 assert_eq!(out.len(), original_len);
1541 assert_eq!(out, png);
1542 }
1543
1544 #[test]
1545 fn ensure_srgb_inserts_chunk_when_absent() {
1546 let png = synth_png(false);
1547 let original_len = png.len();
1548 let out = super::ensure_srgb_chunk(png);
1549 assert_eq!(out.len(), original_len + 13);
1550 assert_eq!(&out[..8], super::PNG_MAGIC);
1552 let mut found = false;
1554 for w in out.windows(4) {
1555 if w == b"sRGB" {
1556 found = true;
1557 break;
1558 }
1559 }
1560 assert!(found, "sRGB chunk should have been spliced in");
1561 }
1562
1563 #[test]
1564 fn ensure_srgb_preserves_idat_bytes_verbatim() {
1565 let png = synth_png(false);
1569 let out = super::ensure_srgb_chunk(png.clone());
1570 assert_eq!(extract_idat_data(&png), extract_idat_data(&out));
1571 }
1572
1573 fn extract_idat_data(bytes: &[u8]) -> Vec<u8> {
1574 let mut i = 8;
1575 while i + 8 <= bytes.len() {
1576 let length =
1577 u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1578 let ctype = &bytes[i + 4..i + 8];
1579 if ctype == b"IDAT" {
1580 return bytes[i + 8..i + 8 + length].to_vec();
1581 }
1582 i += 12 + length;
1583 }
1584 vec![]
1585 }
1586
1587 #[test]
1588 fn ensure_srgb_passthrough_on_bad_magic() {
1589 let bytes = vec![0u8; 32];
1591 let out = super::ensure_srgb_chunk(bytes.clone());
1592 assert_eq!(out, bytes);
1593 }
1594
1595 #[test]
1596 fn crc32_matches_known_iend() {
1597 assert_eq!(super::crc32_ieee(b"IEND"), 0xAE42_6082);
1600 }
1601}