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)]
35pub enum SimctlError {
36 #[error("spawn xcrun simctl failed: {0}")]
38 Spawn(#[from] io::Error),
39 #[error("xcrun simctl {subcommand} exited {code}: {stderr}")]
41 NonZeroExit {
42 subcommand: String,
44 code: i32,
46 stderr: String,
48 },
49 #[error("xcrun simctl {subcommand} returned malformed output: {detail}")]
51 Malformed {
52 subcommand: String,
54 detail: String,
56 },
57 #[error("xcrun simctl {subcommand} timed out after {ms}ms")]
59 Timeout {
60 subcommand: String,
62 ms: u64,
64 },
65 #[error("screenshot pacer circuit open; retry after {retry_after:?}")]
73 CaptureBackpressure {
74 retry_after: Duration,
76 },
77}
78
79#[derive(Debug)]
84pub struct RecordingHandle {
85 pub(crate) child: tokio::process::Child,
86 pub path: String,
88 pub started_at: std::time::Instant,
90}
91
92#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
96pub struct SimctlRuntime {
97 pub identifier: String,
99 pub name: String,
101 pub version: String,
103 pub is_available: bool,
105}
106
107#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
109pub struct SimctlDevice {
110 pub udid: String,
112 pub name: String,
114 pub state: String,
116 pub is_available: bool,
118 #[serde(rename = "deviceTypeIdentifier", default)]
120 pub device_type_identifier: String,
121 #[serde(rename = "runtimeIdentifier", default)]
123 pub runtime_identifier: String,
124}
125
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub enum SimctlPermission {
129 Camera,
131 Photos,
133 Location,
135 LocationAlways,
137 Notifications,
139 Microphone,
141 Contacts,
143 Calendar,
145 Reminders,
147 Media,
149 Motion,
151 HomeKit,
153 Health,
155 Bluetooth,
157 Faceid,
159 AddressBook,
161}
162
163impl SimctlPermission {
164 pub fn as_str(self) -> &'static str {
166 match self {
167 SimctlPermission::Camera => "camera",
168 SimctlPermission::Photos => "photos",
169 SimctlPermission::Location => "location",
170 SimctlPermission::LocationAlways => "location-always",
171 SimctlPermission::Notifications => "notifications",
172 SimctlPermission::Microphone => "microphone",
173 SimctlPermission::Contacts => "contacts",
174 SimctlPermission::Calendar => "calendar",
175 SimctlPermission::Reminders => "reminders",
176 SimctlPermission::Media => "media-library",
177 SimctlPermission::Motion => "motion",
178 SimctlPermission::HomeKit => "homekit",
179 SimctlPermission::Health => "health",
180 SimctlPermission::Bluetooth => "bluetooth",
181 SimctlPermission::Faceid => "faceid",
182 SimctlPermission::AddressBook => "addressbook",
183 }
184 }
185}
186
187#[derive(Clone, Copy, Debug, PartialEq, Eq)]
189pub enum Appearance {
190 Light,
192 Dark,
194}
195
196impl Appearance {
197 pub fn as_str(self) -> &'static str {
199 match self {
200 Appearance::Light => "light",
201 Appearance::Dark => "dark",
202 }
203 }
204}
205
206#[derive(Clone, Debug, PartialEq, Eq)]
208pub struct LaunchResult {
209 pub pid: u32,
211}
212
213async fn simctl_capture(args: &[&str]) -> Result<(Vec<u8>, String), SimctlError> {
217 simctl_capture_env(args, &[]).await
218}
219
220async fn simctl_capture_env(
227 args: &[&str],
228 env: &[(String, String)],
229) -> Result<(Vec<u8>, String), SimctlError> {
230 let mut cmd = Command::new("xcrun");
231 cmd.arg("simctl");
232 for a in args {
233 cmd.arg(a);
234 }
235 for (k, v) in env {
236 cmd.env(k, v);
237 }
238 let output = cmd.output().await?;
239 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
240 if !output.status.success() {
241 return Err(SimctlError::NonZeroExit {
242 subcommand: args.first().map(|s| s.to_string()).unwrap_or_default(),
243 code: output.status.code().unwrap_or(-1),
244 stderr,
245 });
246 }
247 Ok((output.stdout, stderr))
248}
249
250async fn simctl_run(args: &[&str]) -> Result<String, SimctlError> {
251 let (stdout, _) = simctl_capture(args).await?;
252 Ok(String::from_utf8_lossy(&stdout).into_owned())
253}
254
255async fn simctl_run_env(args: &[&str], env: &[(String, String)]) -> Result<String, SimctlError> {
259 let (stdout, _) = simctl_capture_env(args, env).await?;
260 Ok(String::from_utf8_lossy(&stdout).into_owned())
261}
262
263pub fn compose_child_env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
282 pairs
283 .iter()
284 .map(|(k, v)| {
285 let key = if k.starts_with("SIMCTL_CHILD_") {
286 (*k).to_string()
287 } else {
288 format!("SIMCTL_CHILD_{k}")
289 };
290 (key, (*v).to_string())
291 })
292 .collect()
293}
294
295#[derive(Debug)]
306pub struct SimctlClient {
307 screenshot_pacer: Arc<std::sync::Mutex<ScreenshotPacer>>,
312}
313
314impl Default for SimctlClient {
315 fn default() -> Self {
316 Self::new()
317 }
318}
319
320impl SimctlClient {
321 pub fn new() -> Self {
325 SimctlClient {
326 screenshot_pacer: Arc::new(std::sync::Mutex::new(ScreenshotPacer::new(
327 ScreenshotPacerConfig::default(),
328 ))),
329 }
330 }
331
332 #[must_use]
336 pub fn with_screenshot_pacer(self, config: ScreenshotPacerConfig) -> Self {
337 {
338 let mut guard = self
339 .screenshot_pacer
340 .lock()
341 .expect("screenshot pacer mutex must not be poisoned");
342 *guard = ScreenshotPacer::new(config);
343 }
344 self
345 }
346
347 #[must_use]
355 pub fn with_sim_health(self, monitor: smix_sim_health::SimHealthMonitor) -> Self {
356 {
357 let mut guard = self
358 .screenshot_pacer
359 .lock()
360 .expect("screenshot pacer mutex must not be poisoned");
361 guard.set_monitor(monitor);
362 }
363 self
364 }
365
366 pub async fn list_runtimes(&self) -> Result<Vec<SimctlRuntime>, SimctlError> {
370 let raw = simctl_run(&["list", "runtimes", "-j"]).await?;
371 #[derive(Deserialize)]
372 struct Wrap {
373 runtimes: Vec<RawRuntime>,
374 }
375 #[derive(Deserialize)]
376 struct RawRuntime {
377 identifier: String,
378 name: String,
379 version: String,
380 #[serde(rename = "isAvailable", default)]
381 is_available: bool,
382 }
383 let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
384 subcommand: "list runtimes".into(),
385 detail: e.to_string(),
386 })?;
387 Ok(w.runtimes
388 .into_iter()
389 .map(|r| SimctlRuntime {
390 identifier: r.identifier,
391 name: r.name,
392 version: r.version,
393 is_available: r.is_available,
394 })
395 .collect())
396 }
397
398 pub async fn list_devices(&self) -> Result<Vec<SimctlDevice>, SimctlError> {
400 let raw = simctl_run(&["list", "devices", "-j"]).await?;
401 #[derive(Deserialize)]
402 struct Wrap {
403 devices: std::collections::BTreeMap<String, Vec<RawDevice>>,
404 }
405 #[derive(Deserialize)]
406 struct RawDevice {
407 udid: String,
408 name: String,
409 state: String,
410 #[serde(rename = "isAvailable", default)]
411 is_available: bool,
412 #[serde(rename = "deviceTypeIdentifier", default)]
413 device_type_identifier: String,
414 }
415 let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
416 subcommand: "list devices".into(),
417 detail: e.to_string(),
418 })?;
419 let mut out = Vec::new();
420 for (runtime_id, devices) in w.devices {
421 for d in devices {
422 out.push(SimctlDevice {
423 udid: d.udid,
424 name: d.name,
425 state: d.state,
426 is_available: d.is_available,
427 device_type_identifier: d.device_type_identifier,
428 runtime_identifier: runtime_id.clone(),
429 });
430 }
431 }
432 Ok(out)
433 }
434
435 pub async fn boot(&self, udid: &str) -> Result<(), SimctlError> {
439 simctl_run(&["boot", udid]).await?;
440 Ok(())
441 }
442
443 pub async fn shutdown(&self, udid: &str) -> Result<(), SimctlError> {
445 simctl_run(&["shutdown", udid]).await?;
446 Ok(())
447 }
448
449 pub async fn current_locale(&self, udid: &str) -> Result<Option<String>, SimctlError> {
456 let out =
457 match simctl_run(&["spawn", udid, "defaults", "read", "-g", "AppleLanguages"]).await {
458 Ok(s) => s,
459 Err(SimctlError::NonZeroExit { .. }) => return Ok(None),
462 Err(e) => return Err(e),
463 };
464 if let Some(start) = out.find('"') {
466 let rest = &out[start + 1..];
467 if let Some(end) = rest.find('"') {
468 return Ok(Some(rest[..end].to_string()));
469 }
470 }
471 Ok(None)
472 }
473
474 pub async fn set_locale(&self, udid: &str, locale: &str) -> Result<(), SimctlError> {
481 simctl_run(&[
482 "spawn",
483 udid,
484 "defaults",
485 "write",
486 "-g",
487 "AppleLanguages",
488 "-array",
489 locale,
490 ])
491 .await?;
492 let locale_underscore = locale.replace('-', "_");
493 simctl_run(&[
494 "spawn",
495 udid,
496 "defaults",
497 "write",
498 "-g",
499 "AppleLocale",
500 &locale_underscore,
501 ])
502 .await?;
503 Ok(())
504 }
505
506 pub async fn boot_and_wait(&self, udid: &str, timeout: Duration) -> Result<(), SimctlError> {
511 let _ = simctl_run(&["boot", udid]).await;
513 let start = std::time::Instant::now();
514 loop {
515 let devices = self.list_devices().await?;
516 if devices
517 .iter()
518 .any(|d| d.udid == udid && d.state == "Booted")
519 {
520 return Ok(());
521 }
522 if start.elapsed() > timeout {
523 return Err(SimctlError::Timeout {
524 subcommand: format!("boot {}", udid),
525 ms: timeout.as_millis() as u64,
526 });
527 }
528 sleep(Duration::from_millis(500)).await;
529 }
530 }
531
532 pub async fn erase(&self, udid: &str) -> Result<(), SimctlError> {
534 simctl_run(&["erase", udid]).await?;
535 Ok(())
536 }
537
538 pub async fn install(&self, udid: &str, app_path: &str) -> Result<(), SimctlError> {
540 simctl_run(&["install", udid, app_path]).await?;
541 Ok(())
542 }
543
544 pub async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
546 simctl_run(&["uninstall", udid, bundle_id]).await?;
547 Ok(())
548 }
549
550 pub async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
552 simctl_run(&["terminate", udid, bundle_id]).await?;
553 Ok(())
554 }
555
556 pub async fn launch(&self, udid: &str, bundle_id: &str) -> Result<LaunchResult, SimctlError> {
558 self.launch_with_args(udid, bundle_id, &[]).await
559 }
560
561 pub async fn launch_with_args(
565 &self,
566 udid: &str,
567 bundle_id: &str,
568 args: &[String],
569 ) -> Result<LaunchResult, SimctlError> {
570 self.launch_with_args_and_env(udid, bundle_id, args, &[])
571 .await
572 }
573
574 pub async fn launch_with_args_and_env(
584 &self,
585 udid: &str,
586 bundle_id: &str,
587 args: &[String],
588 child_env: &[(&str, &str)],
589 ) -> Result<LaunchResult, SimctlError> {
590 let mut argv: Vec<&str> = vec!["launch", udid, bundle_id];
591 if !args.is_empty() {
592 argv.push("--");
593 for a in args {
594 argv.push(a.as_str());
595 }
596 }
597 let composed = compose_child_env(child_env);
598 let out = simctl_run_env(&argv, &composed).await?;
599 let pid_str =
601 out.rsplit(':')
602 .next()
603 .map(str::trim)
604 .ok_or_else(|| SimctlError::Malformed {
605 subcommand: "launch".into(),
606 detail: format!("unexpected stdout shape: {}", out.trim()),
607 })?;
608 let pid: u32 = pid_str.parse().map_err(|_| SimctlError::Malformed {
609 subcommand: "launch".into(),
610 detail: format!("non-numeric pid in stdout: {}", out.trim()),
611 })?;
612 Ok(LaunchResult { pid })
613 }
614
615 pub async fn privacy_reset_all(
622 &self,
623 udid: &str,
624 bundle_id: &str,
625 ) -> Result<(), SimctlError> {
626 simctl_run(&["privacy", udid, "reset", "all", bundle_id]).await?;
627 Ok(())
628 }
629
630 pub async fn clear_app_sandbox(
638 &self,
639 udid: &str,
640 bundle_id: &str,
641 ) -> Result<(), SimctlError> {
642 let raw = simctl_run(&["get_app_container", udid, bundle_id, "data"]).await?;
643 let container = raw.trim();
644 if container.is_empty() {
645 return Err(SimctlError::Malformed {
646 subcommand: "clear_app_sandbox".into(),
647 detail: format!("empty Data container path for bundle {bundle_id}"),
648 });
649 }
650 let documents = format!("{container}/Documents");
651 let library = format!("{container}/Library");
652 let tmp = format!("{container}/tmp");
653 simctl_run(&[
656 "spawn", udid, "rm", "-rf", &documents, &library, &tmp,
657 ])
658 .await?;
659 Ok(())
660 }
661
662 pub async fn open_url(&self, udid: &str, url: &str) -> Result<(), SimctlError> {
674 let argv = openurl_argv(udid, url);
675 let refs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
676 simctl_run(&refs).await?;
677 Ok(())
678 }
679}
680
681#[doc(hidden)]
685pub fn openurl_argv(udid: &str, url: &str) -> [String; 3] {
686 ["openurl".to_string(), udid.to_string(), url.to_string()]
687}
688
689impl SimctlClient {
690
691 pub async fn send_push(
698 &self,
699 udid: &str,
700 bundle_id: &str,
701 apns_json_path: &str,
702 ) -> Result<(), SimctlError> {
703 simctl_run(&["push", udid, bundle_id, apns_json_path]).await?;
704 Ok(())
705 }
706
707 pub async fn set_appearance(&self, udid: &str, mode: Appearance) -> Result<(), SimctlError> {
709 simctl_run(&["ui", udid, "appearance", mode.as_str()]).await?;
710 Ok(())
711 }
712
713 pub async fn grant_permission(
715 &self,
716 udid: &str,
717 permission: SimctlPermission,
718 bundle_id: &str,
719 ) -> Result<(), SimctlError> {
720 simctl_run(&["privacy", udid, "grant", permission.as_str(), bundle_id]).await?;
721 Ok(())
722 }
723
724 pub async fn revoke_permission(
729 &self,
730 udid: &str,
731 permission: SimctlPermission,
732 bundle_id: &str,
733 ) -> Result<(), SimctlError> {
734 simctl_run(&["privacy", udid, "revoke", permission.as_str(), bundle_id]).await?;
735 Ok(())
736 }
737
738 pub async fn location_set(
741 &self,
742 udid: &str,
743 latitude: f64,
744 longitude: f64,
745 ) -> Result<(), SimctlError> {
746 let coord = format!("{latitude},{longitude}");
747 simctl_run(&["location", udid, "set", &coord]).await?;
748 Ok(())
749 }
750
751 pub async fn location_start(
756 &self,
757 udid: &str,
758 points: &[(f64, f64)],
759 speed_mps: Option<f64>,
760 ) -> Result<(), SimctlError> {
761 if points.len() < 2 {
762 return Err(SimctlError::Malformed {
763 subcommand: "location-start".into(),
764 detail: format!("requires ≥2 waypoints, got {}", points.len()),
765 });
766 }
767 let mut args: Vec<String> = vec!["location".into(), udid.into(), "start".into()];
768 if let Some(s) = speed_mps {
769 args.push(format!("--speed={s}"));
770 }
771 for (lat, lng) in points {
772 args.push(format!("{lat},{lng}"));
773 }
774 let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
775 simctl_run(&args_ref).await?;
776 Ok(())
777 }
778
779 pub async fn location_clear(&self, udid: &str) -> Result<(), SimctlError> {
782 simctl_run(&["location", udid, "clear"]).await?;
783 Ok(())
784 }
785
786 pub async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), SimctlError> {
790 if paths.is_empty() {
791 return Err(SimctlError::Malformed {
792 subcommand: "addmedia".into(),
793 detail: "no paths supplied".into(),
794 });
795 }
796 let mut args: Vec<&str> = vec!["addmedia", udid];
797 for p in paths {
798 args.push(p.as_str());
799 }
800 simctl_run(&args).await?;
801 Ok(())
802 }
803
804 pub async fn record_video_start(
810 &self,
811 udid: &str,
812 path: &str,
813 ) -> Result<RecordingHandle, SimctlError> {
814 let child = tokio::process::Command::new("xcrun")
815 .args(["simctl", "io", udid, "recordVideo", path])
816 .stdin(std::process::Stdio::null())
817 .stdout(std::process::Stdio::piped())
818 .stderr(std::process::Stdio::piped())
819 .spawn()?;
820 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
822 Ok(RecordingHandle {
823 child,
824 path: path.to_string(),
825 started_at: std::time::Instant::now(),
826 })
827 }
828
829 pub async fn record_video_stop(&self, mut handle: RecordingHandle) -> Result<(), SimctlError> {
833 let pid = handle.child.id().ok_or_else(|| SimctlError::Malformed {
834 subcommand: "recordVideo-stop".into(),
835 detail: "child already reaped".into(),
836 })?;
837 let rc = unsafe { libc::kill(pid as i32, libc::SIGINT) };
840 if rc != 0 {
841 return Err(SimctlError::Malformed {
842 subcommand: "recordVideo-stop".into(),
843 detail: format!(
844 "kill SIGINT failed: errno={}",
845 std::io::Error::last_os_error()
846 ),
847 });
848 }
849 let wait_result =
850 tokio::time::timeout(std::time::Duration::from_secs(10), handle.child.wait()).await;
851 match wait_result {
852 Ok(Ok(_status)) => Ok(()),
853 Ok(Err(e)) => Err(SimctlError::Malformed {
854 subcommand: "recordVideo-stop".into(),
855 detail: format!("wait failed: {e}"),
856 }),
857 Err(_timeout) => {
858 let _ = handle.child.kill().await;
859 Err(SimctlError::Malformed {
860 subcommand: "recordVideo-stop".into(),
861 detail: "SIGINT timeout (10s) — escalated SIGKILL; output mp4 likely truncated. Inspect simctl recordVideo stderr.".into(),
862 })
863 }
864 }
865 }
866
867 pub async fn reset_permission(
872 &self,
873 udid: &str,
874 permission: SimctlPermission,
875 bundle_id: &str,
876 ) -> Result<(), SimctlError> {
877 simctl_run(&["privacy", udid, "reset", permission.as_str(), bundle_id]).await?;
878 Ok(())
879 }
880
881 pub async fn keychain_reset(&self, udid: &str) -> Result<(), SimctlError> {
883 simctl_run(&["keychain", udid, "reset"]).await?;
884 Ok(())
885 }
886
887 pub async fn pasteboard_get(&self, udid: &str) -> Result<String, SimctlError> {
889 simctl_run(&["pbpaste", udid]).await
890 }
891
892 pub async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), SimctlError> {
894 use tokio::io::AsyncWriteExt;
897 let mut cmd = Command::new("xcrun");
898 cmd.arg("simctl").arg("pbcopy").arg(udid);
899 cmd.stdin(std::process::Stdio::piped());
900 let mut child = cmd.spawn()?;
901 if let Some(mut stdin) = child.stdin.take() {
902 stdin.write_all(text.as_bytes()).await?;
903 drop(stdin); }
905 let status = child.wait().await?;
906 if !status.success() {
907 return Err(SimctlError::NonZeroExit {
908 subcommand: "pbcopy".into(),
909 code: status.code().unwrap_or(-1),
910 stderr: String::new(),
911 });
912 }
913 Ok(())
914 }
915
916 pub async fn set_reduce_motion(&self, udid: &str, enabled: bool) -> Result<(), SimctlError> {
918 let val = if enabled { "1" } else { "0" };
919 simctl_run(&[
921 "spawn",
922 udid,
923 "defaults",
924 "write",
925 "com.apple.UIKit",
926 "UIAccessibilityReduceMotionEnabled",
927 "-bool",
928 val,
929 ])
930 .await?;
931 Ok(())
932 }
933
934 pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, SimctlError> {
951 let wait = {
953 let mut pacer = self
954 .screenshot_pacer
955 .lock()
956 .expect("screenshot pacer mutex must not be poisoned");
957 pacer
958 .compute_wait()
959 .map_err(|retry_after| SimctlError::CaptureBackpressure { retry_after })?
960 };
961 if !wait.is_zero() {
962 sleep(wait).await;
963 }
964
965 let call_start = std::time::Instant::now();
966 let tmp =
967 std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
968 let tmp_str = tmp.display().to_string();
969 let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
970 let bytes = result.and_then(|_| {
971 std::fs::read(&tmp).map_err(|e| SimctlError::Malformed {
972 subcommand: "screenshot".into(),
973 detail: format!("read {tmp_str}: {e}"),
974 })
975 });
976 let _ = std::fs::remove_file(&tmp);
977
978 let wall = call_start.elapsed();
979 let failed = bytes.is_err();
980 {
981 let mut pacer = self
982 .screenshot_pacer
983 .lock()
984 .expect("screenshot pacer mutex must not be poisoned");
985 pacer.record(wall, failed);
986 }
987
988 let bytes = bytes?;
989 if bytes.len() < 8 {
990 return Err(SimctlError::Malformed {
991 subcommand: "screenshot".into(),
992 detail: format!("screenshot file too short: {} bytes", bytes.len()),
993 });
994 }
995 Ok(ensure_srgb_chunk(bytes))
996 }
997
998 pub async fn create_device(
1000 &self,
1001 name: &str,
1002 device_type: &str,
1003 runtime_id: &str,
1004 ) -> Result<String, SimctlError> {
1005 let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
1006 Ok(out.trim().to_string())
1007 }
1008
1009 pub async fn delete_device(&self, udid: &str) -> Result<(), SimctlError> {
1011 simctl_run(&["delete", udid]).await?;
1012 Ok(())
1013 }
1014}
1015
1016const PNG_MAGIC: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
1036
1037pub fn ensure_srgb_chunk(bytes: Vec<u8>) -> Vec<u8> {
1045 if bytes.len() < 8 || &bytes[..8] != PNG_MAGIC {
1046 return bytes;
1047 }
1048 let Some((idat_offset, has_srgb)) = scan_png_chunks(&bytes) else {
1049 return bytes;
1050 };
1051 if has_srgb {
1052 return bytes;
1053 }
1054 let mut out = Vec::with_capacity(bytes.len() + 13);
1056 out.extend_from_slice(&bytes[..idat_offset]);
1057 out.extend_from_slice(&synthesized_srgb_chunk());
1058 out.extend_from_slice(&bytes[idat_offset..]);
1059 out
1060}
1061
1062fn scan_png_chunks(bytes: &[u8]) -> Option<(usize, bool)> {
1067 let mut i: usize = 8;
1068 let mut has_srgb = false;
1069 while i + 8 <= bytes.len() {
1070 let length =
1071 u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1072 let ctype = &bytes[i + 4..i + 8];
1073 if ctype == b"IDAT" {
1074 return Some((i, has_srgb));
1075 }
1076 if ctype == b"sRGB" {
1077 has_srgb = true;
1078 }
1079 let end = i.checked_add(12)?.checked_add(length)?;
1081 if end > bytes.len() {
1082 return None;
1083 }
1084 i = end;
1085 }
1086 None
1087}
1088
1089fn synthesized_srgb_chunk() -> [u8; 13] {
1092 let mut crc_input = [0u8; 5];
1094 crc_input[0..4].copy_from_slice(b"sRGB");
1095 crc_input[4] = 0; let crc = crc32_ieee(&crc_input);
1097 let mut chunk = [0u8; 13];
1098 chunk[0..4].copy_from_slice(&1u32.to_be_bytes()); chunk[4..8].copy_from_slice(b"sRGB");
1100 chunk[8] = 0;
1101 chunk[9..13].copy_from_slice(&crc.to_be_bytes());
1102 chunk
1103}
1104
1105fn crc32_ieee(bytes: &[u8]) -> u32 {
1109 let mut crc: u32 = 0xFFFF_FFFF;
1110 for &b in bytes {
1111 crc ^= u32::from(b);
1112 for _ in 0..8 {
1113 let mask = 0u32.wrapping_sub(crc & 1);
1114 crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
1115 }
1116 }
1117 !crc
1118}
1119
1120#[cfg(test)]
1121mod tests {
1122 use super::*;
1123
1124 #[test]
1125 fn compose_child_env_adds_prefix() {
1126 let composed = compose_child_env(&[
1127 ("SMIX_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
1128 ("LAUNCH_FORCE_PUSH", "true"),
1129 ]);
1130 assert_eq!(
1131 composed,
1132 vec![
1133 (
1134 "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
1135 "http://127.0.0.1:9999".to_string(),
1136 ),
1137 (
1138 "SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
1139 "true".to_string(),
1140 ),
1141 ]
1142 );
1143 }
1144
1145 #[test]
1146 fn compose_child_env_already_prefixed_passes_through() {
1147 let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
1149 assert_eq!(
1150 composed,
1151 vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
1152 );
1153 }
1154
1155 #[test]
1156 fn compose_child_env_empty_input_is_empty_output() {
1157 assert!(compose_child_env(&[]).is_empty());
1158 }
1159
1160 #[test]
1163 fn openurl_argv_preserves_url_verbatim() {
1164 let udid = "12345678-1234-5678-1234-567812345678";
1165 let url = "exp+focus-ai-app://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081";
1166 let argv = super::openurl_argv(udid, url);
1167 assert_eq!(argv[0], "openurl");
1168 assert_eq!(argv[1], udid);
1169 assert_eq!(argv[2], url);
1171 assert!(argv[2].contains("?url="));
1172 assert!(argv[2].contains("%3A"));
1173 assert!(argv[2].contains("%2F"));
1174 }
1175
1176 #[test]
1177 fn openurl_argv_preserves_ampersand_and_hash() {
1178 let udid = "12345678-1234-5678-1234-567812345678";
1179 let url = "insight://dev-mutate?action=env&value=staging#anchor";
1180 let argv = super::openurl_argv(udid, url);
1181 assert_eq!(argv[2], url);
1182 assert!(argv[2].contains('&'));
1183 assert!(argv[2].contains('#'));
1184 }
1185
1186 #[test]
1187 fn openurl_argv_preserves_unicode() {
1188 let udid = "12345678-1234-5678-1234-567812345678";
1189 let url = "insight://route?name=%E7%94%B0%E4%B8%AD";
1190 let argv = super::openurl_argv(udid, url);
1191 assert_eq!(argv[2], url);
1192 }
1193
1194 fn synth_png(with_srgb: bool) -> Vec<u8> {
1200 let mut out = Vec::new();
1201 out.extend_from_slice(super::PNG_MAGIC);
1202 let ihdr_data: [u8; 13] = [
1204 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0,
1209 ];
1210 emit_chunk(&mut out, b"IHDR", &ihdr_data);
1211 if with_srgb {
1212 emit_chunk(&mut out, b"sRGB", &[0]);
1213 }
1214 emit_chunk(&mut out, b"IDAT", &[0x78, 0x01, 0x00, 0x00]);
1216 emit_chunk(&mut out, b"IEND", &[]);
1217 out
1218 }
1219
1220 fn emit_chunk(out: &mut Vec<u8>, ctype: &[u8; 4], data: &[u8]) {
1221 out.extend_from_slice(&(data.len() as u32).to_be_bytes());
1222 out.extend_from_slice(ctype);
1223 out.extend_from_slice(data);
1224 let mut crc_in = Vec::with_capacity(4 + data.len());
1225 crc_in.extend_from_slice(ctype);
1226 crc_in.extend_from_slice(data);
1227 out.extend_from_slice(&super::crc32_ieee(&crc_in).to_be_bytes());
1228 }
1229
1230 #[test]
1231 fn ensure_srgb_passthrough_when_chunk_present() {
1232 let png = synth_png(true);
1233 let original_len = png.len();
1234 let out = super::ensure_srgb_chunk(png.clone());
1235 assert_eq!(out.len(), original_len);
1236 assert_eq!(out, png);
1237 }
1238
1239 #[test]
1240 fn ensure_srgb_inserts_chunk_when_absent() {
1241 let png = synth_png(false);
1242 let original_len = png.len();
1243 let out = super::ensure_srgb_chunk(png);
1244 assert_eq!(out.len(), original_len + 13);
1245 assert_eq!(&out[..8], super::PNG_MAGIC);
1247 let mut found = false;
1249 for w in out.windows(4) {
1250 if w == b"sRGB" {
1251 found = true;
1252 break;
1253 }
1254 }
1255 assert!(found, "sRGB chunk should have been spliced in");
1256 }
1257
1258 #[test]
1259 fn ensure_srgb_preserves_idat_bytes_verbatim() {
1260 let png = synth_png(false);
1264 let out = super::ensure_srgb_chunk(png.clone());
1265 assert_eq!(extract_idat_data(&png), extract_idat_data(&out));
1266 }
1267
1268 fn extract_idat_data(bytes: &[u8]) -> Vec<u8> {
1269 let mut i = 8;
1270 while i + 8 <= bytes.len() {
1271 let length =
1272 u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1273 let ctype = &bytes[i + 4..i + 8];
1274 if ctype == b"IDAT" {
1275 return bytes[i + 8..i + 8 + length].to_vec();
1276 }
1277 i += 12 + length;
1278 }
1279 vec![]
1280 }
1281
1282 #[test]
1283 fn ensure_srgb_passthrough_on_bad_magic() {
1284 let bytes = vec![0u8; 32];
1286 let out = super::ensure_srgb_chunk(bytes.clone());
1287 assert_eq!(out, bytes);
1288 }
1289
1290 #[test]
1291 fn crc32_matches_known_iend() {
1292 assert_eq!(super::crc32_ieee(b"IEND"), 0xAE42_6082);
1295 }
1296}