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 set_locale(&self, udid: &str, locale: &str) -> Result<(), SimctlError> {
1181 simctl_run(&[
1182 "spawn",
1183 udid,
1184 "defaults",
1185 "write",
1186 "-g",
1187 "AppleLanguages",
1188 "-array",
1189 locale,
1190 ])
1191 .await?;
1192 let locale_underscore = locale.replace('-', "_");
1193 simctl_run(&[
1194 "spawn",
1195 udid,
1196 "defaults",
1197 "write",
1198 "-g",
1199 "AppleLocale",
1200 &locale_underscore,
1201 ])
1202 .await?;
1203 Ok(())
1204 }
1205
1206 pub async fn boot_and_wait(&self, udid: &str, timeout: Duration) -> Result<(), SimctlError> {
1211 let _ = simctl_run(&["boot", udid]).await;
1213 let start = std::time::Instant::now();
1214 loop {
1215 let devices = self.list_devices().await?;
1216 if devices
1217 .iter()
1218 .any(|d| d.udid == udid && d.state == "Booted")
1219 {
1220 return Ok(());
1221 }
1222 if start.elapsed() > timeout {
1223 return Err(SimctlError::Timeout {
1224 subcommand: format!("boot {}", udid),
1225 ms: timeout.as_millis() as u64,
1226 });
1227 }
1228 sleep(Duration::from_millis(500)).await;
1229 }
1230 }
1231
1232 pub async fn erase(&self, udid: &str) -> Result<(), SimctlError> {
1234 simctl_run(&["erase", udid]).await?;
1235 Ok(())
1236 }
1237
1238 pub async fn install(&self, udid: &str, app_path: &str) -> Result<(), SimctlError> {
1240 simctl_run(&["install", udid, app_path]).await?;
1241 Ok(())
1242 }
1243
1244 pub async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
1246 simctl_run(&["uninstall", udid, bundle_id]).await?;
1247 Ok(())
1248 }
1249
1250 pub async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
1252 simctl_run(&["terminate", udid, bundle_id]).await?;
1253 Ok(())
1254 }
1255
1256 pub async fn launch(&self, udid: &str, bundle_id: &str) -> Result<LaunchResult, SimctlError> {
1258 self.launch_with_args(udid, bundle_id, &[]).await
1259 }
1260
1261 pub async fn launch_with_args(
1265 &self,
1266 udid: &str,
1267 bundle_id: &str,
1268 args: &[String],
1269 ) -> Result<LaunchResult, SimctlError> {
1270 self.launch_with_args_and_env(udid, bundle_id, args, &[])
1271 .await
1272 }
1273
1274 pub async fn launch_with_args_and_env(
1284 &self,
1285 udid: &str,
1286 bundle_id: &str,
1287 args: &[String],
1288 child_env: &[(&str, &str)],
1289 ) -> Result<LaunchResult, SimctlError> {
1290 let mut argv: Vec<&str> = vec!["launch", udid, bundle_id];
1291 if !args.is_empty() {
1292 argv.push("--");
1293 for a in args {
1294 argv.push(a.as_str());
1295 }
1296 }
1297 let composed = compose_child_env(child_env);
1298 let out = simctl_run_env(&argv, &composed).await?;
1299 let pid_str =
1301 out.rsplit(':')
1302 .next()
1303 .map(str::trim)
1304 .ok_or_else(|| SimctlError::Malformed {
1305 subcommand: "launch".into(),
1306 detail: format!("unexpected stdout shape: {}", out.trim()),
1307 })?;
1308 let pid: u32 = pid_str.parse().map_err(|_| SimctlError::Malformed {
1309 subcommand: "launch".into(),
1310 detail: format!("non-numeric pid in stdout: {}", out.trim()),
1311 })?;
1312 Ok(LaunchResult { pid })
1313 }
1314
1315 pub async fn privacy_reset_all(
1322 &self,
1323 udid: &str,
1324 bundle_id: &str,
1325 ) -> Result<(), SimctlError> {
1326 simctl_run(&["privacy", udid, "reset", "all", bundle_id]).await?;
1327 Ok(())
1328 }
1329
1330 pub async fn clear_app_sandbox(
1338 &self,
1339 udid: &str,
1340 bundle_id: &str,
1341 ) -> Result<(), SimctlError> {
1342 let raw = simctl_run(&["get_app_container", udid, bundle_id, "data"]).await?;
1343 let container = raw.trim();
1344 if container.is_empty() {
1345 return Err(SimctlError::Malformed {
1346 subcommand: "clear_app_sandbox".into(),
1347 detail: format!("empty Data container path for bundle {bundle_id}"),
1348 });
1349 }
1350 let documents = format!("{container}/Documents");
1351 let library = format!("{container}/Library");
1352 let tmp = format!("{container}/tmp");
1353 simctl_run(&[
1363 "spawn", udid, "/bin/rm", "-rf", &documents, &library, &tmp,
1364 ])
1365 .await?;
1366 Ok(())
1367 }
1368
1369 pub async fn open_url(&self, udid: &str, url: &str) -> Result<(), SimctlError> {
1381 let argv = openurl_argv(udid, url);
1382 let refs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1383 simctl_run(&refs).await?;
1384 Ok(())
1385 }
1386}
1387
1388#[doc(hidden)]
1392pub fn openurl_argv(udid: &str, url: &str) -> [String; 3] {
1393 ["openurl".to_string(), udid.to_string(), url.to_string()]
1394}
1395
1396impl SimctlClient {
1397
1398 pub async fn send_push(
1405 &self,
1406 udid: &str,
1407 bundle_id: &str,
1408 apns_json_path: &str,
1409 ) -> Result<(), SimctlError> {
1410 simctl_run(&["push", udid, bundle_id, apns_json_path]).await?;
1411 Ok(())
1412 }
1413
1414 pub async fn set_appearance(&self, udid: &str, mode: Appearance) -> Result<(), SimctlError> {
1416 simctl_run(&["ui", udid, "appearance", mode.as_str()]).await?;
1417 Ok(())
1418 }
1419
1420 pub async fn grant_permission(
1422 &self,
1423 udid: &str,
1424 permission: SimctlPermission,
1425 bundle_id: &str,
1426 ) -> Result<(), SimctlError> {
1427 simctl_run(&["privacy", udid, "grant", permission.as_str(), bundle_id]).await?;
1428 Ok(())
1429 }
1430
1431 pub async fn revoke_permission(
1436 &self,
1437 udid: &str,
1438 permission: SimctlPermission,
1439 bundle_id: &str,
1440 ) -> Result<(), SimctlError> {
1441 simctl_run(&["privacy", udid, "revoke", permission.as_str(), bundle_id]).await?;
1442 Ok(())
1443 }
1444
1445 pub async fn location_set(
1448 &self,
1449 udid: &str,
1450 latitude: f64,
1451 longitude: f64,
1452 ) -> Result<(), SimctlError> {
1453 let coord = format!("{latitude},{longitude}");
1454 simctl_run(&["location", udid, "set", &coord]).await?;
1455 Ok(())
1456 }
1457
1458 pub async fn location_start(
1463 &self,
1464 udid: &str,
1465 points: &[(f64, f64)],
1466 speed_mps: Option<f64>,
1467 ) -> Result<(), SimctlError> {
1468 if points.len() < 2 {
1469 return Err(SimctlError::Malformed {
1470 subcommand: "location-start".into(),
1471 detail: format!("requires ≥2 waypoints, got {}", points.len()),
1472 });
1473 }
1474 let mut args: Vec<String> = vec!["location".into(), udid.into(), "start".into()];
1475 if let Some(s) = speed_mps {
1476 args.push(format!("--speed={s}"));
1477 }
1478 for (lat, lng) in points {
1479 args.push(format!("{lat},{lng}"));
1480 }
1481 let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
1482 simctl_run(&args_ref).await?;
1483 Ok(())
1484 }
1485
1486 pub async fn location_clear(&self, udid: &str) -> Result<(), SimctlError> {
1489 simctl_run(&["location", udid, "clear"]).await?;
1490 Ok(())
1491 }
1492
1493 pub async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), SimctlError> {
1497 if paths.is_empty() {
1498 return Err(SimctlError::Malformed {
1499 subcommand: "addmedia".into(),
1500 detail: "no paths supplied".into(),
1501 });
1502 }
1503 let mut args: Vec<&str> = vec!["addmedia", udid];
1504 for p in paths {
1505 args.push(p.as_str());
1506 }
1507 simctl_run(&args).await?;
1508 Ok(())
1509 }
1510
1511 pub async fn record_video_start(
1517 &self,
1518 udid: &str,
1519 path: &str,
1520 ) -> Result<RecordingHandle, SimctlError> {
1521 let child = tokio::process::Command::new("xcrun")
1522 .args(["simctl", "io", udid, "recordVideo", path])
1523 .stdin(std::process::Stdio::null())
1524 .stdout(std::process::Stdio::piped())
1525 .stderr(std::process::Stdio::piped())
1526 .spawn()?;
1527 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1529 Ok(RecordingHandle {
1530 child,
1531 path: path.to_string(),
1532 started_at: std::time::Instant::now(),
1533 })
1534 }
1535
1536 pub async fn record_video_stop(&self, mut handle: RecordingHandle) -> Result<(), SimctlError> {
1540 let pid = handle.child.id().ok_or_else(|| SimctlError::Malformed {
1541 subcommand: "recordVideo-stop".into(),
1542 detail: "child already reaped".into(),
1543 })?;
1544 let rc = unsafe { libc::kill(pid as i32, libc::SIGINT) };
1547 if rc != 0 {
1548 return Err(SimctlError::Malformed {
1549 subcommand: "recordVideo-stop".into(),
1550 detail: format!(
1551 "kill SIGINT failed: errno={}",
1552 std::io::Error::last_os_error()
1553 ),
1554 });
1555 }
1556 let wait_result =
1557 tokio::time::timeout(std::time::Duration::from_secs(10), handle.child.wait()).await;
1558 match wait_result {
1559 Ok(Ok(_status)) => Ok(()),
1560 Ok(Err(e)) => Err(SimctlError::Malformed {
1561 subcommand: "recordVideo-stop".into(),
1562 detail: format!("wait failed: {e}"),
1563 }),
1564 Err(_timeout) => {
1565 let _ = handle.child.kill().await;
1566 Err(SimctlError::Malformed {
1567 subcommand: "recordVideo-stop".into(),
1568 detail: "SIGINT timeout (10s) — escalated SIGKILL; output mp4 likely truncated. Inspect simctl recordVideo stderr.".into(),
1569 })
1570 }
1571 }
1572 }
1573
1574 pub async fn reset_permission(
1579 &self,
1580 udid: &str,
1581 permission: SimctlPermission,
1582 bundle_id: &str,
1583 ) -> Result<(), SimctlError> {
1584 simctl_run(&["privacy", udid, "reset", permission.as_str(), bundle_id]).await?;
1585 Ok(())
1586 }
1587
1588 pub async fn keychain_reset(&self, udid: &str) -> Result<(), SimctlError> {
1590 simctl_run(&["keychain", udid, "reset"]).await?;
1591 Ok(())
1592 }
1593
1594 pub async fn pasteboard_get(&self, udid: &str) -> Result<String, SimctlError> {
1596 simctl_run(&["pbpaste", udid]).await
1597 }
1598
1599 pub async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), SimctlError> {
1601 use tokio::io::AsyncWriteExt;
1604 let mut cmd = Command::new("xcrun");
1605 cmd.arg("simctl").arg("pbcopy").arg(udid);
1606 cmd.stdin(std::process::Stdio::piped());
1607 let mut child = cmd.spawn()?;
1608 if let Some(mut stdin) = child.stdin.take() {
1609 stdin.write_all(text.as_bytes()).await?;
1610 drop(stdin); }
1612 let status = child.wait().await?;
1613 if !status.success() {
1614 return Err(SimctlError::NonZeroExit {
1615 subcommand: "pbcopy".into(),
1616 argv: vec!["pbcopy".to_string()],
1617 code: status.code().unwrap_or(-1),
1618 stderr: String::new(),
1619 wall_ms: 0,
1620 });
1621 }
1622 Ok(())
1623 }
1624
1625 pub async fn set_reduce_motion(&self, udid: &str, enabled: bool) -> Result<(), SimctlError> {
1627 let val = if enabled { "1" } else { "0" };
1628 simctl_run(&[
1630 "spawn",
1631 udid,
1632 "defaults",
1633 "write",
1634 "com.apple.UIKit",
1635 "UIAccessibilityReduceMotionEnabled",
1636 "-bool",
1637 val,
1638 ])
1639 .await?;
1640 Ok(())
1641 }
1642
1643 pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, SimctlError> {
1660 let wait = {
1662 let mut pacer = self
1663 .screenshot_pacer
1664 .lock()
1665 .expect("screenshot pacer mutex must not be poisoned");
1666 pacer
1667 .compute_wait()
1668 .map_err(|retry_after| SimctlError::CaptureBackpressure { retry_after })?
1669 };
1670 if !wait.is_zero() {
1671 sleep(wait).await;
1672 }
1673
1674 let call_start = std::time::Instant::now();
1675 let tmp =
1676 std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
1677 let tmp_str = tmp.display().to_string();
1678 let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
1679 let bytes = result.and_then(|_| {
1680 std::fs::read(&tmp).map_err(|e| SimctlError::Malformed {
1681 subcommand: "screenshot".into(),
1682 detail: format!("read {tmp_str}: {e}"),
1683 })
1684 });
1685 let _ = std::fs::remove_file(&tmp);
1686
1687 let wall = call_start.elapsed();
1688 let failed = bytes.is_err();
1689 {
1690 let mut pacer = self
1691 .screenshot_pacer
1692 .lock()
1693 .expect("screenshot pacer mutex must not be poisoned");
1694 pacer.record(wall, failed);
1695 }
1696
1697 let bytes = bytes?;
1698 if bytes.len() < 8 {
1699 return Err(SimctlError::Malformed {
1700 subcommand: "screenshot".into(),
1701 detail: format!("screenshot file too short: {} bytes", bytes.len()),
1702 });
1703 }
1704 Ok(ensure_srgb_chunk(bytes))
1705 }
1706
1707 pub async fn create_device(
1709 &self,
1710 name: &str,
1711 device_type: &str,
1712 runtime_id: &str,
1713 ) -> Result<String, SimctlError> {
1714 let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
1715 Ok(out.trim().to_string())
1716 }
1717
1718 pub async fn delete_device(&self, udid: &str) -> Result<(), SimctlError> {
1720 simctl_run(&["delete", udid]).await?;
1721 Ok(())
1722 }
1723}
1724
1725const PNG_MAGIC: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
1745
1746pub fn ensure_srgb_chunk(bytes: Vec<u8>) -> Vec<u8> {
1754 if bytes.len() < 8 || &bytes[..8] != PNG_MAGIC {
1755 return bytes;
1756 }
1757 let Some((idat_offset, has_srgb)) = scan_png_chunks(&bytes) else {
1758 return bytes;
1759 };
1760 if has_srgb {
1761 return bytes;
1762 }
1763 let mut out = Vec::with_capacity(bytes.len() + 13);
1765 out.extend_from_slice(&bytes[..idat_offset]);
1766 out.extend_from_slice(&synthesized_srgb_chunk());
1767 out.extend_from_slice(&bytes[idat_offset..]);
1768 out
1769}
1770
1771fn scan_png_chunks(bytes: &[u8]) -> Option<(usize, bool)> {
1776 let mut i: usize = 8;
1777 let mut has_srgb = false;
1778 while i + 8 <= bytes.len() {
1779 let length =
1780 u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1781 let ctype = &bytes[i + 4..i + 8];
1782 if ctype == b"IDAT" {
1783 return Some((i, has_srgb));
1784 }
1785 if ctype == b"sRGB" {
1786 has_srgb = true;
1787 }
1788 let end = i.checked_add(12)?.checked_add(length)?;
1790 if end > bytes.len() {
1791 return None;
1792 }
1793 i = end;
1794 }
1795 None
1796}
1797
1798fn synthesized_srgb_chunk() -> [u8; 13] {
1801 let mut crc_input = [0u8; 5];
1803 crc_input[0..4].copy_from_slice(b"sRGB");
1804 crc_input[4] = 0; let crc = crc32_ieee(&crc_input);
1806 let mut chunk = [0u8; 13];
1807 chunk[0..4].copy_from_slice(&1u32.to_be_bytes()); chunk[4..8].copy_from_slice(b"sRGB");
1809 chunk[8] = 0;
1810 chunk[9..13].copy_from_slice(&crc.to_be_bytes());
1811 chunk
1812}
1813
1814fn crc32_ieee(bytes: &[u8]) -> u32 {
1818 let mut crc: u32 = 0xFFFF_FFFF;
1819 for &b in bytes {
1820 crc ^= u32::from(b);
1821 for _ in 0..8 {
1822 let mask = 0u32.wrapping_sub(crc & 1);
1823 crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
1824 }
1825 }
1826 !crc
1827}
1828
1829#[cfg(test)]
1830mod tests {
1831 use super::*;
1832
1833 #[test]
1834 fn compose_child_env_adds_prefix() {
1835 let composed = compose_child_env(&[
1836 ("SMIX_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
1837 ("LAUNCH_FORCE_PUSH", "true"),
1838 ]);
1839 assert_eq!(
1840 composed,
1841 vec![
1842 (
1843 "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
1844 "http://127.0.0.1:9999".to_string(),
1845 ),
1846 (
1847 "SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
1848 "true".to_string(),
1849 ),
1850 ]
1851 );
1852 }
1853
1854 #[test]
1855 fn compose_child_env_already_prefixed_passes_through() {
1856 let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
1858 assert_eq!(
1859 composed,
1860 vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
1861 );
1862 }
1863
1864 #[test]
1865 fn compose_child_env_empty_input_is_empty_output() {
1866 assert!(compose_child_env(&[]).is_empty());
1867 }
1868
1869 #[test]
1872 fn openurl_argv_preserves_url_verbatim() {
1873 let udid = "12345678-1234-5678-1234-567812345678";
1874 let url = "exp+focus-ai-app://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081";
1875 let argv = super::openurl_argv(udid, url);
1876 assert_eq!(argv[0], "openurl");
1877 assert_eq!(argv[1], udid);
1878 assert_eq!(argv[2], url);
1880 assert!(argv[2].contains("?url="));
1881 assert!(argv[2].contains("%3A"));
1882 assert!(argv[2].contains("%2F"));
1883 }
1884
1885 #[test]
1886 fn openurl_argv_preserves_ampersand_and_hash() {
1887 let udid = "12345678-1234-5678-1234-567812345678";
1888 let url = "insight://dev-mutate?action=env&value=staging#anchor";
1889 let argv = super::openurl_argv(udid, url);
1890 assert_eq!(argv[2], url);
1891 assert!(argv[2].contains('&'));
1892 assert!(argv[2].contains('#'));
1893 }
1894
1895 #[test]
1896 fn openurl_argv_preserves_unicode() {
1897 let udid = "12345678-1234-5678-1234-567812345678";
1898 let url = "insight://route?name=%E7%94%B0%E4%B8%AD";
1899 let argv = super::openurl_argv(udid, url);
1900 assert_eq!(argv[2], url);
1901 }
1902
1903 fn synth_png(with_srgb: bool) -> Vec<u8> {
1909 let mut out = Vec::new();
1910 out.extend_from_slice(super::PNG_MAGIC);
1911 let ihdr_data: [u8; 13] = [
1913 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0,
1918 ];
1919 emit_chunk(&mut out, b"IHDR", &ihdr_data);
1920 if with_srgb {
1921 emit_chunk(&mut out, b"sRGB", &[0]);
1922 }
1923 emit_chunk(&mut out, b"IDAT", &[0x78, 0x01, 0x00, 0x00]);
1925 emit_chunk(&mut out, b"IEND", &[]);
1926 out
1927 }
1928
1929 fn emit_chunk(out: &mut Vec<u8>, ctype: &[u8; 4], data: &[u8]) {
1930 out.extend_from_slice(&(data.len() as u32).to_be_bytes());
1931 out.extend_from_slice(ctype);
1932 out.extend_from_slice(data);
1933 let mut crc_in = Vec::with_capacity(4 + data.len());
1934 crc_in.extend_from_slice(ctype);
1935 crc_in.extend_from_slice(data);
1936 out.extend_from_slice(&super::crc32_ieee(&crc_in).to_be_bytes());
1937 }
1938
1939 #[test]
1940 fn ensure_srgb_passthrough_when_chunk_present() {
1941 let png = synth_png(true);
1942 let original_len = png.len();
1943 let out = super::ensure_srgb_chunk(png.clone());
1944 assert_eq!(out.len(), original_len);
1945 assert_eq!(out, png);
1946 }
1947
1948 #[test]
1949 fn ensure_srgb_inserts_chunk_when_absent() {
1950 let png = synth_png(false);
1951 let original_len = png.len();
1952 let out = super::ensure_srgb_chunk(png);
1953 assert_eq!(out.len(), original_len + 13);
1954 assert_eq!(&out[..8], super::PNG_MAGIC);
1956 let mut found = false;
1958 for w in out.windows(4) {
1959 if w == b"sRGB" {
1960 found = true;
1961 break;
1962 }
1963 }
1964 assert!(found, "sRGB chunk should have been spliced in");
1965 }
1966
1967 #[test]
1968 fn ensure_srgb_preserves_idat_bytes_verbatim() {
1969 let png = synth_png(false);
1973 let out = super::ensure_srgb_chunk(png.clone());
1974 assert_eq!(extract_idat_data(&png), extract_idat_data(&out));
1975 }
1976
1977 fn extract_idat_data(bytes: &[u8]) -> Vec<u8> {
1978 let mut i = 8;
1979 while i + 8 <= bytes.len() {
1980 let length =
1981 u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1982 let ctype = &bytes[i + 4..i + 8];
1983 if ctype == b"IDAT" {
1984 return bytes[i + 8..i + 8 + length].to_vec();
1985 }
1986 i += 12 + length;
1987 }
1988 vec![]
1989 }
1990
1991 #[test]
1992 fn ensure_srgb_passthrough_on_bad_magic() {
1993 let bytes = vec![0u8; 32];
1995 let out = super::ensure_srgb_chunk(bytes.clone());
1996 assert_eq!(out, bytes);
1997 }
1998
1999 #[test]
2000 fn crc32_matches_known_iend() {
2001 assert_eq!(super::crc32_ieee(b"IEND"), 0xAE42_6082);
2004 }
2005}