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
676mod flow_attempts {
683 use serde::{Deserialize, Serialize};
684 use std::path::{Path, PathBuf};
685 use std::sync::{Mutex, OnceLock};
686
687 #[derive(Clone, Debug, Serialize, Deserialize)]
688 pub struct PersistedAttempt {
689 pub attempt_index: u32,
690 pub status: String,
691 pub error_class: Option<String>,
692 pub ips_generated: Option<String>,
693 pub wall_ms: u64,
694 }
695
696 #[derive(Clone, Debug, Serialize, Deserialize)]
697 pub struct PersistedFlow {
698 pub flow_name: String,
699 pub attempts: Vec<PersistedAttempt>,
700 }
701
702 fn cell() -> &'static Mutex<Vec<PersistedFlow>> {
703 static INSTANCE: OnceLock<Mutex<Vec<PersistedFlow>>> = OnceLock::new();
704 INSTANCE.get_or_init(|| Mutex::new(Vec::new()))
705 }
706 fn persist_cell() -> &'static Mutex<Option<PathBuf>> {
707 static INSTANCE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
708 INSTANCE.get_or_init(|| Mutex::new(None))
709 }
710
711 pub fn set_persist_path(path: PathBuf) {
712 let mut g = match persist_cell().lock() {
713 Ok(g) => g,
714 Err(p) => p.into_inner(),
715 };
716 *g = Some(path);
717 }
718
719 fn persist_path_copy() -> Option<PathBuf> {
720 let g = match persist_cell().lock() {
721 Ok(g) => g,
722 Err(p) => p.into_inner(),
723 };
724 g.clone()
725 }
726
727 pub fn load_persisted() {
728 let Some(path) = persist_path_copy() else {
729 return;
730 };
731 let Ok(bytes) = std::fs::read(&path) else {
732 return;
733 };
734 let Ok(loaded) = serde_json::from_slice::<Vec<PersistedFlow>>(&bytes) else {
735 return;
736 };
737 let mut g = match cell().lock() {
738 Ok(g) => g,
739 Err(p) => p.into_inner(),
740 };
741 *g = loaded;
742 }
743
744 pub fn record(flow_name: &str, attempts: &[PersistedAttempt]) {
745 {
746 let mut g = match cell().lock() {
747 Ok(g) => g,
748 Err(p) => p.into_inner(),
749 };
750 g.push(PersistedFlow {
751 flow_name: flow_name.to_string(),
752 attempts: attempts.to_vec(),
753 });
754 if g.len() > 32 {
755 let drop = g.len() - 32;
756 g.drain(0..drop);
757 }
758 }
759 persist_best_effort();
760 }
761
762 pub fn snapshot() -> Vec<PersistedFlow> {
763 let g = match cell().lock() {
764 Ok(g) => g,
765 Err(p) => p.into_inner(),
766 };
767 g.clone()
768 }
769
770 fn persist_best_effort() {
771 let Some(path) = persist_path_copy() else {
772 return;
773 };
774 let flows = snapshot();
775 let _ = write_json_atomic(&path, &flows);
776 }
777
778 fn write_json_atomic(path: &Path, flows: &[PersistedFlow]) -> std::io::Result<()> {
779 use std::io::Write;
780 if let Some(parent) = path.parent() {
781 std::fs::create_dir_all(parent)?;
782 }
783 let tmp = path.with_extension("json.tmp");
784 let bytes = serde_json::to_vec(flows)
785 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
786 {
787 let mut f = std::fs::File::create(&tmp)?;
788 f.write_all(&bytes)?;
789 f.sync_all()?;
790 }
791 std::fs::rename(&tmp, path)
792 }
793}
794
795pub fn set_flow_attempts_persist_path(path: std::path::PathBuf) {
800 flow_attempts::set_persist_path(path);
801 flow_attempts::load_persisted();
802}
803
804#[derive(Clone, Debug)]
807pub struct FlowAttemptData {
808 pub attempt_index: u32,
810 pub status: String,
812 pub error_class: Option<String>,
814 pub ips_generated: Option<String>,
816 pub wall_ms: u64,
818}
819
820#[derive(Clone, Debug)]
822pub struct FlowAttemptRecordData {
823 pub flow_name: String,
825 pub attempts: Vec<FlowAttemptData>,
827}
828
829pub fn record_flow_attempts<A>(flow_name: &str, attempts: &[A])
833where
834 A: FlowAttemptShape,
835{
836 let converted: Vec<flow_attempts::PersistedAttempt> = attempts
837 .iter()
838 .map(|a| flow_attempts::PersistedAttempt {
839 attempt_index: a.attempt_index(),
840 status: a.status().to_string(),
841 error_class: a.error_class().map(str::to_string),
842 ips_generated: a.ips_generated().map(str::to_string),
843 wall_ms: a.wall_ms(),
844 })
845 .collect();
846 flow_attempts::record(flow_name, &converted);
847}
848
849pub trait FlowAttemptShape {
853 fn attempt_index(&self) -> u32;
855 fn status(&self) -> &str;
857 fn error_class(&self) -> Option<&str>;
859 fn ips_generated(&self) -> Option<&str>;
861 fn wall_ms(&self) -> u64;
863}
864
865pub fn recent_flow_attempts() -> Vec<FlowAttemptRecordData> {
868 flow_attempts::snapshot()
869 .into_iter()
870 .map(|f| FlowAttemptRecordData {
871 flow_name: f.flow_name,
872 attempts: f
873 .attempts
874 .into_iter()
875 .map(|a| FlowAttemptData {
876 attempt_index: a.attempt_index,
877 status: a.status,
878 error_class: a.error_class,
879 ips_generated: a.ips_generated,
880 wall_ms: a.wall_ms,
881 })
882 .collect(),
883 })
884 .collect()
885}
886
887pub fn recent_subprocesses() -> Vec<SubprocessRecord> {
891 subprocess_ring::snapshot()
892}
893
894async fn simctl_capture(args: &[&str]) -> Result<(Vec<u8>, String), SimctlError> {
898 simctl_capture_env(args, &[]).await
899}
900
901async fn simctl_capture_env(
908 args: &[&str],
909 env: &[(String, String)],
910) -> Result<(Vec<u8>, String), SimctlError> {
911 let mut cmd = Command::new("xcrun");
912 cmd.arg("simctl");
913 for a in args {
914 cmd.arg(a);
915 }
916 for (k, v) in env {
917 cmd.env(k, v);
918 }
919 let started = std::time::Instant::now();
920 let output = cmd.output().await?;
921 let wall_ms = started.elapsed().as_millis() as u64;
922 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
923 subprocess_ring::record(SubprocessRecord {
926 argv: args.iter().map(|s| s.to_string()).collect(),
927 exit_code: output.status.code(),
928 wall_ms,
929 stderr_head: {
930 let mut s = stderr.clone();
931 if s.len() > 256 {
932 s.truncate(256);
933 }
934 s
935 },
936 timestamp: std::time::SystemTime::now(),
937 });
938 if !output.status.success() {
939 return Err(SimctlError::NonZeroExit {
940 subcommand: args.first().map(|s| s.to_string()).unwrap_or_default(),
941 argv: args.iter().map(|s| s.to_string()).collect(),
942 code: output.status.code().unwrap_or(-1),
943 stderr,
944 wall_ms,
945 });
946 }
947 Ok((output.stdout, stderr))
948}
949
950async fn simctl_run(args: &[&str]) -> Result<String, SimctlError> {
951 let (stdout, _) = simctl_capture(args).await?;
952 Ok(String::from_utf8_lossy(&stdout).into_owned())
953}
954
955async fn simctl_run_env(args: &[&str], env: &[(String, String)]) -> Result<String, SimctlError> {
959 let (stdout, _) = simctl_capture_env(args, env).await?;
960 Ok(String::from_utf8_lossy(&stdout).into_owned())
961}
962
963pub fn compose_child_env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
982 pairs
983 .iter()
984 .map(|(k, v)| {
985 let key = if k.starts_with("SIMCTL_CHILD_") {
986 (*k).to_string()
987 } else {
988 format!("SIMCTL_CHILD_{k}")
989 };
990 (key, (*v).to_string())
991 })
992 .collect()
993}
994
995#[derive(Debug)]
1006pub struct SimctlClient {
1007 screenshot_pacer: Arc<std::sync::Mutex<ScreenshotPacer>>,
1012}
1013
1014impl Default for SimctlClient {
1015 fn default() -> Self {
1016 Self::new()
1017 }
1018}
1019
1020impl SimctlClient {
1021 pub fn new() -> Self {
1025 SimctlClient {
1026 screenshot_pacer: Arc::new(std::sync::Mutex::new(ScreenshotPacer::new(
1027 ScreenshotPacerConfig::default(),
1028 ))),
1029 }
1030 }
1031
1032 #[must_use]
1036 pub fn with_screenshot_pacer(self, config: ScreenshotPacerConfig) -> Self {
1037 {
1038 let mut guard = self
1039 .screenshot_pacer
1040 .lock()
1041 .expect("screenshot pacer mutex must not be poisoned");
1042 *guard = ScreenshotPacer::new(config);
1043 }
1044 self
1045 }
1046
1047 #[must_use]
1055 pub fn with_sim_health(self, monitor: smix_sim_health::SimHealthMonitor) -> Self {
1056 {
1057 let mut guard = self
1058 .screenshot_pacer
1059 .lock()
1060 .expect("screenshot pacer mutex must not be poisoned");
1061 guard.set_monitor(monitor);
1062 }
1063 self
1064 }
1065
1066 pub async fn list_runtimes(&self) -> Result<Vec<SimctlRuntime>, SimctlError> {
1070 let raw = simctl_run(&["list", "runtimes", "-j"]).await?;
1071 #[derive(Deserialize)]
1072 struct Wrap {
1073 runtimes: Vec<RawRuntime>,
1074 }
1075 #[derive(Deserialize)]
1076 struct RawRuntime {
1077 identifier: String,
1078 name: String,
1079 version: String,
1080 #[serde(rename = "isAvailable", default)]
1081 is_available: bool,
1082 }
1083 let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
1084 subcommand: "list runtimes".into(),
1085 detail: e.to_string(),
1086 })?;
1087 Ok(w.runtimes
1088 .into_iter()
1089 .map(|r| SimctlRuntime {
1090 identifier: r.identifier,
1091 name: r.name,
1092 version: r.version,
1093 is_available: r.is_available,
1094 })
1095 .collect())
1096 }
1097
1098 pub async fn list_devices(&self) -> Result<Vec<SimctlDevice>, SimctlError> {
1100 let raw = simctl_run(&["list", "devices", "-j"]).await?;
1101 #[derive(Deserialize)]
1102 struct Wrap {
1103 devices: std::collections::BTreeMap<String, Vec<RawDevice>>,
1104 }
1105 #[derive(Deserialize)]
1106 struct RawDevice {
1107 udid: String,
1108 name: String,
1109 state: String,
1110 #[serde(rename = "isAvailable", default)]
1111 is_available: bool,
1112 #[serde(rename = "deviceTypeIdentifier", default)]
1113 device_type_identifier: String,
1114 }
1115 let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
1116 subcommand: "list devices".into(),
1117 detail: e.to_string(),
1118 })?;
1119 let mut out = Vec::new();
1120 for (runtime_id, devices) in w.devices {
1121 for d in devices {
1122 out.push(SimctlDevice {
1123 udid: d.udid,
1124 name: d.name,
1125 state: d.state,
1126 is_available: d.is_available,
1127 device_type_identifier: d.device_type_identifier,
1128 runtime_identifier: runtime_id.clone(),
1129 });
1130 }
1131 }
1132 Ok(out)
1133 }
1134
1135 pub async fn boot(&self, udid: &str) -> Result<(), SimctlError> {
1139 simctl_run(&["boot", udid]).await?;
1140 Ok(())
1141 }
1142
1143 pub async fn shutdown(&self, udid: &str) -> Result<(), SimctlError> {
1145 simctl_run(&["shutdown", udid]).await?;
1146 Ok(())
1147 }
1148
1149 pub async fn current_locale(&self, udid: &str) -> Result<Option<String>, SimctlError> {
1156 let out =
1157 match simctl_run(&["spawn", udid, "/usr/bin/defaults", "read", "-g", "AppleLanguages"]).await {
1158 Ok(s) => s,
1159 Err(SimctlError::NonZeroExit { .. }) => return Ok(None),
1162 Err(e) => return Err(e),
1163 };
1164 if let Some(start) = out.find('"') {
1166 let rest = &out[start + 1..];
1167 if let Some(end) = rest.find('"') {
1168 return Ok(Some(rest[..end].to_string()));
1169 }
1170 }
1171 Ok(None)
1172 }
1173
1174 pub async fn user_defaults_delete(
1195 &self,
1196 udid: &str,
1197 bundle_id: &str,
1198 key: &str,
1199 ) -> Result<bool, SimctlError> {
1200 match simctl_run(&[
1201 "spawn",
1202 udid,
1203 "/usr/bin/defaults",
1204 "delete",
1205 bundle_id,
1206 key,
1207 ])
1208 .await
1209 {
1210 Ok(_) => Ok(true),
1211 Err(SimctlError::NonZeroExit { stderr, .. })
1215 if stderr.contains("does not exist") =>
1216 {
1217 Ok(false)
1218 }
1219 Err(e) => Err(e),
1220 }
1221 }
1222
1223 pub async fn set_locale(&self, udid: &str, locale: &str) -> Result<(), SimctlError> {
1230 simctl_run(&[
1231 "spawn",
1232 udid,
1233 "defaults",
1234 "write",
1235 "-g",
1236 "AppleLanguages",
1237 "-array",
1238 locale,
1239 ])
1240 .await?;
1241 let locale_underscore = locale.replace('-', "_");
1242 simctl_run(&[
1243 "spawn",
1244 udid,
1245 "defaults",
1246 "write",
1247 "-g",
1248 "AppleLocale",
1249 &locale_underscore,
1250 ])
1251 .await?;
1252 Ok(())
1253 }
1254
1255 pub async fn boot_and_wait(&self, udid: &str, timeout: Duration) -> Result<(), SimctlError> {
1260 let _ = simctl_run(&["boot", udid]).await;
1262 let start = std::time::Instant::now();
1263 loop {
1264 let devices = self.list_devices().await?;
1265 if devices
1266 .iter()
1267 .any(|d| d.udid == udid && d.state == "Booted")
1268 {
1269 return Ok(());
1270 }
1271 if start.elapsed() > timeout {
1272 return Err(SimctlError::Timeout {
1273 subcommand: format!("boot {}", udid),
1274 ms: timeout.as_millis() as u64,
1275 });
1276 }
1277 sleep(Duration::from_millis(500)).await;
1278 }
1279 }
1280
1281 pub async fn erase(&self, udid: &str) -> Result<(), SimctlError> {
1283 simctl_run(&["erase", udid]).await?;
1284 Ok(())
1285 }
1286
1287 pub async fn install(&self, udid: &str, app_path: &str) -> Result<(), SimctlError> {
1289 simctl_run(&["install", udid, app_path]).await?;
1290 Ok(())
1291 }
1292
1293 pub async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
1295 simctl_run(&["uninstall", udid, bundle_id]).await?;
1296 Ok(())
1297 }
1298
1299 pub async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
1301 simctl_run(&["terminate", udid, bundle_id]).await?;
1302 Ok(())
1303 }
1304
1305 pub async fn launch(&self, udid: &str, bundle_id: &str) -> Result<LaunchResult, SimctlError> {
1307 self.launch_with_args(udid, bundle_id, &[]).await
1308 }
1309
1310 pub async fn launch_with_args(
1314 &self,
1315 udid: &str,
1316 bundle_id: &str,
1317 args: &[String],
1318 ) -> Result<LaunchResult, SimctlError> {
1319 self.launch_with_args_and_env(udid, bundle_id, args, &[])
1320 .await
1321 }
1322
1323 pub async fn launch_with_args_and_env(
1333 &self,
1334 udid: &str,
1335 bundle_id: &str,
1336 args: &[String],
1337 child_env: &[(&str, &str)],
1338 ) -> Result<LaunchResult, SimctlError> {
1339 let mut argv: Vec<&str> = vec!["launch", udid, bundle_id];
1340 if !args.is_empty() {
1341 argv.push("--");
1342 for a in args {
1343 argv.push(a.as_str());
1344 }
1345 }
1346 let composed = compose_child_env(child_env);
1347 let out = simctl_run_env(&argv, &composed).await?;
1348 let pid_str =
1350 out.rsplit(':')
1351 .next()
1352 .map(str::trim)
1353 .ok_or_else(|| SimctlError::Malformed {
1354 subcommand: "launch".into(),
1355 detail: format!("unexpected stdout shape: {}", out.trim()),
1356 })?;
1357 let pid: u32 = pid_str.parse().map_err(|_| SimctlError::Malformed {
1358 subcommand: "launch".into(),
1359 detail: format!("non-numeric pid in stdout: {}", out.trim()),
1360 })?;
1361 Ok(LaunchResult { pid })
1362 }
1363
1364 pub async fn privacy_reset_all(
1371 &self,
1372 udid: &str,
1373 bundle_id: &str,
1374 ) -> Result<(), SimctlError> {
1375 simctl_run(&["privacy", udid, "reset", "all", bundle_id]).await?;
1376 Ok(())
1377 }
1378
1379 pub async fn clear_app_sandbox(
1387 &self,
1388 udid: &str,
1389 bundle_id: &str,
1390 ) -> Result<(), SimctlError> {
1391 let raw = simctl_run(&["get_app_container", udid, bundle_id, "data"]).await?;
1392 let container = raw.trim();
1393 if container.is_empty() {
1394 return Err(SimctlError::Malformed {
1395 subcommand: "clear_app_sandbox".into(),
1396 detail: format!("empty Data container path for bundle {bundle_id}"),
1397 });
1398 }
1399 let documents = format!("{container}/Documents");
1400 let library = format!("{container}/Library");
1401 let tmp = format!("{container}/tmp");
1402 simctl_run(&[
1412 "spawn", udid, "/bin/rm", "-rf", &documents, &library, &tmp,
1413 ])
1414 .await?;
1415 Ok(())
1416 }
1417
1418 pub async fn open_url(&self, udid: &str, url: &str) -> Result<(), SimctlError> {
1430 let argv = openurl_argv(udid, url);
1431 let refs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1432 simctl_run(&refs).await?;
1433 Ok(())
1434 }
1435}
1436
1437#[doc(hidden)]
1441pub fn openurl_argv(udid: &str, url: &str) -> [String; 3] {
1442 ["openurl".to_string(), udid.to_string(), url.to_string()]
1443}
1444
1445impl SimctlClient {
1446
1447 pub async fn send_push(
1454 &self,
1455 udid: &str,
1456 bundle_id: &str,
1457 apns_json_path: &str,
1458 ) -> Result<(), SimctlError> {
1459 simctl_run(&["push", udid, bundle_id, apns_json_path]).await?;
1460 Ok(())
1461 }
1462
1463 pub async fn set_appearance(&self, udid: &str, mode: Appearance) -> Result<(), SimctlError> {
1465 simctl_run(&["ui", udid, "appearance", mode.as_str()]).await?;
1466 Ok(())
1467 }
1468
1469 pub async fn grant_permission(
1471 &self,
1472 udid: &str,
1473 permission: SimctlPermission,
1474 bundle_id: &str,
1475 ) -> Result<(), SimctlError> {
1476 simctl_run(&["privacy", udid, "grant", permission.as_str(), bundle_id]).await?;
1477 Ok(())
1478 }
1479
1480 pub async fn revoke_permission(
1485 &self,
1486 udid: &str,
1487 permission: SimctlPermission,
1488 bundle_id: &str,
1489 ) -> Result<(), SimctlError> {
1490 simctl_run(&["privacy", udid, "revoke", permission.as_str(), bundle_id]).await?;
1491 Ok(())
1492 }
1493
1494 pub async fn location_set(
1497 &self,
1498 udid: &str,
1499 latitude: f64,
1500 longitude: f64,
1501 ) -> Result<(), SimctlError> {
1502 let coord = format!("{latitude},{longitude}");
1503 simctl_run(&["location", udid, "set", &coord]).await?;
1504 Ok(())
1505 }
1506
1507 pub async fn location_start(
1512 &self,
1513 udid: &str,
1514 points: &[(f64, f64)],
1515 speed_mps: Option<f64>,
1516 ) -> Result<(), SimctlError> {
1517 if points.len() < 2 {
1518 return Err(SimctlError::Malformed {
1519 subcommand: "location-start".into(),
1520 detail: format!("requires ≥2 waypoints, got {}", points.len()),
1521 });
1522 }
1523 let mut args: Vec<String> = vec!["location".into(), udid.into(), "start".into()];
1524 if let Some(s) = speed_mps {
1525 args.push(format!("--speed={s}"));
1526 }
1527 for (lat, lng) in points {
1528 args.push(format!("{lat},{lng}"));
1529 }
1530 let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
1531 simctl_run(&args_ref).await?;
1532 Ok(())
1533 }
1534
1535 pub async fn location_clear(&self, udid: &str) -> Result<(), SimctlError> {
1538 simctl_run(&["location", udid, "clear"]).await?;
1539 Ok(())
1540 }
1541
1542 pub async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), SimctlError> {
1546 if paths.is_empty() {
1547 return Err(SimctlError::Malformed {
1548 subcommand: "addmedia".into(),
1549 detail: "no paths supplied".into(),
1550 });
1551 }
1552 let mut args: Vec<&str> = vec!["addmedia", udid];
1553 for p in paths {
1554 args.push(p.as_str());
1555 }
1556 simctl_run(&args).await?;
1557 Ok(())
1558 }
1559
1560 pub async fn record_video_start(
1566 &self,
1567 udid: &str,
1568 path: &str,
1569 ) -> Result<RecordingHandle, SimctlError> {
1570 let child = tokio::process::Command::new("xcrun")
1571 .args(["simctl", "io", udid, "recordVideo", path])
1572 .stdin(std::process::Stdio::null())
1573 .stdout(std::process::Stdio::piped())
1574 .stderr(std::process::Stdio::piped())
1575 .spawn()?;
1576 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1578 Ok(RecordingHandle {
1579 child,
1580 path: path.to_string(),
1581 started_at: std::time::Instant::now(),
1582 })
1583 }
1584
1585 pub async fn record_video_stop(&self, mut handle: RecordingHandle) -> Result<(), SimctlError> {
1589 let pid = handle.child.id().ok_or_else(|| SimctlError::Malformed {
1590 subcommand: "recordVideo-stop".into(),
1591 detail: "child already reaped".into(),
1592 })?;
1593 let rc = unsafe { libc::kill(pid as i32, libc::SIGINT) };
1596 if rc != 0 {
1597 return Err(SimctlError::Malformed {
1598 subcommand: "recordVideo-stop".into(),
1599 detail: format!(
1600 "kill SIGINT failed: errno={}",
1601 std::io::Error::last_os_error()
1602 ),
1603 });
1604 }
1605 let wait_result =
1606 tokio::time::timeout(std::time::Duration::from_secs(10), handle.child.wait()).await;
1607 match wait_result {
1608 Ok(Ok(_status)) => Ok(()),
1609 Ok(Err(e)) => Err(SimctlError::Malformed {
1610 subcommand: "recordVideo-stop".into(),
1611 detail: format!("wait failed: {e}"),
1612 }),
1613 Err(_timeout) => {
1614 let _ = handle.child.kill().await;
1615 Err(SimctlError::Malformed {
1616 subcommand: "recordVideo-stop".into(),
1617 detail: "SIGINT timeout (10s) — escalated SIGKILL; output mp4 likely truncated. Inspect simctl recordVideo stderr.".into(),
1618 })
1619 }
1620 }
1621 }
1622
1623 pub async fn reset_permission(
1628 &self,
1629 udid: &str,
1630 permission: SimctlPermission,
1631 bundle_id: &str,
1632 ) -> Result<(), SimctlError> {
1633 simctl_run(&["privacy", udid, "reset", permission.as_str(), bundle_id]).await?;
1634 Ok(())
1635 }
1636
1637 pub async fn keychain_reset(&self, udid: &str) -> Result<(), SimctlError> {
1639 simctl_run(&["keychain", udid, "reset"]).await?;
1640 Ok(())
1641 }
1642
1643 pub async fn pasteboard_get(&self, udid: &str) -> Result<String, SimctlError> {
1645 simctl_run(&["pbpaste", udid]).await
1646 }
1647
1648 pub async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), SimctlError> {
1650 use tokio::io::AsyncWriteExt;
1653 let mut cmd = Command::new("xcrun");
1654 cmd.arg("simctl").arg("pbcopy").arg(udid);
1655 cmd.stdin(std::process::Stdio::piped());
1656 let mut child = cmd.spawn()?;
1657 if let Some(mut stdin) = child.stdin.take() {
1658 stdin.write_all(text.as_bytes()).await?;
1659 drop(stdin); }
1661 let status = child.wait().await?;
1662 if !status.success() {
1663 return Err(SimctlError::NonZeroExit {
1664 subcommand: "pbcopy".into(),
1665 argv: vec!["pbcopy".to_string()],
1666 code: status.code().unwrap_or(-1),
1667 stderr: String::new(),
1668 wall_ms: 0,
1669 });
1670 }
1671 Ok(())
1672 }
1673
1674 pub async fn set_reduce_motion(&self, udid: &str, enabled: bool) -> Result<(), SimctlError> {
1676 let val = if enabled { "1" } else { "0" };
1677 simctl_run(&[
1679 "spawn",
1680 udid,
1681 "defaults",
1682 "write",
1683 "com.apple.UIKit",
1684 "UIAccessibilityReduceMotionEnabled",
1685 "-bool",
1686 val,
1687 ])
1688 .await?;
1689 Ok(())
1690 }
1691
1692 pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, SimctlError> {
1709 let wait = {
1711 let mut pacer = self
1712 .screenshot_pacer
1713 .lock()
1714 .expect("screenshot pacer mutex must not be poisoned");
1715 pacer
1716 .compute_wait()
1717 .map_err(|retry_after| SimctlError::CaptureBackpressure { retry_after })?
1718 };
1719 if !wait.is_zero() {
1720 sleep(wait).await;
1721 }
1722
1723 let call_start = std::time::Instant::now();
1724 let tmp =
1725 std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
1726 let tmp_str = tmp.display().to_string();
1727 let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
1728 let bytes = result.and_then(|_| {
1729 std::fs::read(&tmp).map_err(|e| SimctlError::Malformed {
1730 subcommand: "screenshot".into(),
1731 detail: format!("read {tmp_str}: {e}"),
1732 })
1733 });
1734 let _ = std::fs::remove_file(&tmp);
1735
1736 let wall = call_start.elapsed();
1737 let failed = bytes.is_err();
1738 {
1739 let mut pacer = self
1740 .screenshot_pacer
1741 .lock()
1742 .expect("screenshot pacer mutex must not be poisoned");
1743 pacer.record(wall, failed);
1744 }
1745
1746 let bytes = bytes?;
1747 if bytes.len() < 8 {
1748 return Err(SimctlError::Malformed {
1749 subcommand: "screenshot".into(),
1750 detail: format!("screenshot file too short: {} bytes", bytes.len()),
1751 });
1752 }
1753 Ok(ensure_srgb_chunk(bytes))
1754 }
1755
1756 pub async fn create_device(
1758 &self,
1759 name: &str,
1760 device_type: &str,
1761 runtime_id: &str,
1762 ) -> Result<String, SimctlError> {
1763 let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
1764 Ok(out.trim().to_string())
1765 }
1766
1767 pub async fn delete_device(&self, udid: &str) -> Result<(), SimctlError> {
1769 simctl_run(&["delete", udid]).await?;
1770 Ok(())
1771 }
1772}
1773
1774const PNG_MAGIC: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
1794
1795pub fn ensure_srgb_chunk(bytes: Vec<u8>) -> Vec<u8> {
1803 if bytes.len() < 8 || &bytes[..8] != PNG_MAGIC {
1804 return bytes;
1805 }
1806 let Some((idat_offset, has_srgb)) = scan_png_chunks(&bytes) else {
1807 return bytes;
1808 };
1809 if has_srgb {
1810 return bytes;
1811 }
1812 let mut out = Vec::with_capacity(bytes.len() + 13);
1814 out.extend_from_slice(&bytes[..idat_offset]);
1815 out.extend_from_slice(&synthesized_srgb_chunk());
1816 out.extend_from_slice(&bytes[idat_offset..]);
1817 out
1818}
1819
1820fn scan_png_chunks(bytes: &[u8]) -> Option<(usize, bool)> {
1825 let mut i: usize = 8;
1826 let mut has_srgb = false;
1827 while i + 8 <= bytes.len() {
1828 let length =
1829 u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1830 let ctype = &bytes[i + 4..i + 8];
1831 if ctype == b"IDAT" {
1832 return Some((i, has_srgb));
1833 }
1834 if ctype == b"sRGB" {
1835 has_srgb = true;
1836 }
1837 let end = i.checked_add(12)?.checked_add(length)?;
1839 if end > bytes.len() {
1840 return None;
1841 }
1842 i = end;
1843 }
1844 None
1845}
1846
1847fn synthesized_srgb_chunk() -> [u8; 13] {
1850 let mut crc_input = [0u8; 5];
1852 crc_input[0..4].copy_from_slice(b"sRGB");
1853 crc_input[4] = 0; let crc = crc32_ieee(&crc_input);
1855 let mut chunk = [0u8; 13];
1856 chunk[0..4].copy_from_slice(&1u32.to_be_bytes()); chunk[4..8].copy_from_slice(b"sRGB");
1858 chunk[8] = 0;
1859 chunk[9..13].copy_from_slice(&crc.to_be_bytes());
1860 chunk
1861}
1862
1863fn crc32_ieee(bytes: &[u8]) -> u32 {
1867 let mut crc: u32 = 0xFFFF_FFFF;
1868 for &b in bytes {
1869 crc ^= u32::from(b);
1870 for _ in 0..8 {
1871 let mask = 0u32.wrapping_sub(crc & 1);
1872 crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
1873 }
1874 }
1875 !crc
1876}
1877
1878#[cfg(test)]
1879mod tests {
1880 use super::*;
1881
1882 #[test]
1883 fn compose_child_env_adds_prefix() {
1884 let composed = compose_child_env(&[
1885 ("SMIX_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
1886 ("LAUNCH_FORCE_PUSH", "true"),
1887 ]);
1888 assert_eq!(
1889 composed,
1890 vec![
1891 (
1892 "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
1893 "http://127.0.0.1:9999".to_string(),
1894 ),
1895 (
1896 "SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
1897 "true".to_string(),
1898 ),
1899 ]
1900 );
1901 }
1902
1903 #[test]
1904 fn compose_child_env_already_prefixed_passes_through() {
1905 let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
1907 assert_eq!(
1908 composed,
1909 vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
1910 );
1911 }
1912
1913 #[test]
1914 fn compose_child_env_empty_input_is_empty_output() {
1915 assert!(compose_child_env(&[]).is_empty());
1916 }
1917
1918 #[test]
1921 fn openurl_argv_preserves_url_verbatim() {
1922 let udid = "12345678-1234-5678-1234-567812345678";
1923 let url = "exp+focus-ai-app://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081";
1924 let argv = super::openurl_argv(udid, url);
1925 assert_eq!(argv[0], "openurl");
1926 assert_eq!(argv[1], udid);
1927 assert_eq!(argv[2], url);
1929 assert!(argv[2].contains("?url="));
1930 assert!(argv[2].contains("%3A"));
1931 assert!(argv[2].contains("%2F"));
1932 }
1933
1934 #[test]
1935 fn openurl_argv_preserves_ampersand_and_hash() {
1936 let udid = "12345678-1234-5678-1234-567812345678";
1937 let url = "insight://dev-mutate?action=env&value=staging#anchor";
1938 let argv = super::openurl_argv(udid, url);
1939 assert_eq!(argv[2], url);
1940 assert!(argv[2].contains('&'));
1941 assert!(argv[2].contains('#'));
1942 }
1943
1944 #[test]
1945 fn openurl_argv_preserves_unicode() {
1946 let udid = "12345678-1234-5678-1234-567812345678";
1947 let url = "insight://route?name=%E7%94%B0%E4%B8%AD";
1948 let argv = super::openurl_argv(udid, url);
1949 assert_eq!(argv[2], url);
1950 }
1951
1952 fn synth_png(with_srgb: bool) -> Vec<u8> {
1958 let mut out = Vec::new();
1959 out.extend_from_slice(super::PNG_MAGIC);
1960 let ihdr_data: [u8; 13] = [
1962 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0,
1967 ];
1968 emit_chunk(&mut out, b"IHDR", &ihdr_data);
1969 if with_srgb {
1970 emit_chunk(&mut out, b"sRGB", &[0]);
1971 }
1972 emit_chunk(&mut out, b"IDAT", &[0x78, 0x01, 0x00, 0x00]);
1974 emit_chunk(&mut out, b"IEND", &[]);
1975 out
1976 }
1977
1978 fn emit_chunk(out: &mut Vec<u8>, ctype: &[u8; 4], data: &[u8]) {
1979 out.extend_from_slice(&(data.len() as u32).to_be_bytes());
1980 out.extend_from_slice(ctype);
1981 out.extend_from_slice(data);
1982 let mut crc_in = Vec::with_capacity(4 + data.len());
1983 crc_in.extend_from_slice(ctype);
1984 crc_in.extend_from_slice(data);
1985 out.extend_from_slice(&super::crc32_ieee(&crc_in).to_be_bytes());
1986 }
1987
1988 #[test]
1989 fn ensure_srgb_passthrough_when_chunk_present() {
1990 let png = synth_png(true);
1991 let original_len = png.len();
1992 let out = super::ensure_srgb_chunk(png.clone());
1993 assert_eq!(out.len(), original_len);
1994 assert_eq!(out, png);
1995 }
1996
1997 #[test]
1998 fn ensure_srgb_inserts_chunk_when_absent() {
1999 let png = synth_png(false);
2000 let original_len = png.len();
2001 let out = super::ensure_srgb_chunk(png);
2002 assert_eq!(out.len(), original_len + 13);
2003 assert_eq!(&out[..8], super::PNG_MAGIC);
2005 let mut found = false;
2007 for w in out.windows(4) {
2008 if w == b"sRGB" {
2009 found = true;
2010 break;
2011 }
2012 }
2013 assert!(found, "sRGB chunk should have been spliced in");
2014 }
2015
2016 #[test]
2017 fn ensure_srgb_preserves_idat_bytes_verbatim() {
2018 let png = synth_png(false);
2022 let out = super::ensure_srgb_chunk(png.clone());
2023 assert_eq!(extract_idat_data(&png), extract_idat_data(&out));
2024 }
2025
2026 fn extract_idat_data(bytes: &[u8]) -> Vec<u8> {
2027 let mut i = 8;
2028 while i + 8 <= bytes.len() {
2029 let length =
2030 u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
2031 let ctype = &bytes[i + 4..i + 8];
2032 if ctype == b"IDAT" {
2033 return bytes[i + 8..i + 8 + length].to_vec();
2034 }
2035 i += 12 + length;
2036 }
2037 vec![]
2038 }
2039
2040 #[test]
2041 fn ensure_srgb_passthrough_on_bad_magic() {
2042 let bytes = vec![0u8; 32];
2044 let out = super::ensure_srgb_chunk(bytes.clone());
2045 assert_eq!(out, bytes);
2046 }
2047
2048 #[test]
2049 fn crc32_matches_known_iend() {
2050 assert_eq!(super::crc32_ieee(b"IEND"), 0xAE42_6082);
2053 }
2054}