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;
19
20use serde::{Deserialize, Serialize};
21use std::io;
22use std::time::Duration;
23use thiserror::Error;
24use tokio::process::Command;
25use tokio::time::sleep;
26
27#[derive(Debug, Error)]
29pub enum SimctlError {
30 #[error("spawn xcrun simctl failed: {0}")]
32 Spawn(#[from] io::Error),
33 #[error("xcrun simctl {subcommand} exited {code}: {stderr}")]
35 NonZeroExit {
36 subcommand: String,
38 code: i32,
40 stderr: String,
42 },
43 #[error("xcrun simctl {subcommand} returned malformed output: {detail}")]
45 Malformed {
46 subcommand: String,
48 detail: String,
50 },
51 #[error("xcrun simctl {subcommand} timed out after {ms}ms")]
53 Timeout {
54 subcommand: String,
56 ms: u64,
58 },
59}
60
61#[derive(Debug)]
66pub struct RecordingHandle {
67 pub(crate) child: tokio::process::Child,
68 pub path: String,
70 pub started_at: std::time::Instant,
72}
73
74#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
78pub struct SimctlRuntime {
79 pub identifier: String,
81 pub name: String,
83 pub version: String,
85 pub is_available: bool,
87}
88
89#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
91pub struct SimctlDevice {
92 pub udid: String,
94 pub name: String,
96 pub state: String,
98 pub is_available: bool,
100 #[serde(rename = "deviceTypeIdentifier", default)]
102 pub device_type_identifier: String,
103 #[serde(rename = "runtimeIdentifier", default)]
105 pub runtime_identifier: String,
106}
107
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110pub enum SimctlPermission {
111 Camera,
113 Photos,
115 Location,
117 LocationAlways,
119 Notifications,
121 Microphone,
123 Contacts,
125 Calendar,
127 Reminders,
129 Media,
131 Motion,
133 HomeKit,
135 Health,
137 Bluetooth,
139 Faceid,
141 AddressBook,
143}
144
145impl SimctlPermission {
146 pub fn as_str(self) -> &'static str {
148 match self {
149 SimctlPermission::Camera => "camera",
150 SimctlPermission::Photos => "photos",
151 SimctlPermission::Location => "location",
152 SimctlPermission::LocationAlways => "location-always",
153 SimctlPermission::Notifications => "notifications",
154 SimctlPermission::Microphone => "microphone",
155 SimctlPermission::Contacts => "contacts",
156 SimctlPermission::Calendar => "calendar",
157 SimctlPermission::Reminders => "reminders",
158 SimctlPermission::Media => "media-library",
159 SimctlPermission::Motion => "motion",
160 SimctlPermission::HomeKit => "homekit",
161 SimctlPermission::Health => "health",
162 SimctlPermission::Bluetooth => "bluetooth",
163 SimctlPermission::Faceid => "faceid",
164 SimctlPermission::AddressBook => "addressbook",
165 }
166 }
167}
168
169#[derive(Clone, Copy, Debug, PartialEq, Eq)]
171pub enum Appearance {
172 Light,
174 Dark,
176}
177
178impl Appearance {
179 pub fn as_str(self) -> &'static str {
181 match self {
182 Appearance::Light => "light",
183 Appearance::Dark => "dark",
184 }
185 }
186}
187
188#[derive(Clone, Debug, PartialEq, Eq)]
190pub struct LaunchResult {
191 pub pid: u32,
193}
194
195async fn simctl_capture(args: &[&str]) -> Result<(Vec<u8>, String), SimctlError> {
199 simctl_capture_env(args, &[]).await
200}
201
202async fn simctl_capture_env(
209 args: &[&str],
210 env: &[(String, String)],
211) -> Result<(Vec<u8>, String), SimctlError> {
212 let mut cmd = Command::new("xcrun");
213 cmd.arg("simctl");
214 for a in args {
215 cmd.arg(a);
216 }
217 for (k, v) in env {
218 cmd.env(k, v);
219 }
220 let output = cmd.output().await?;
221 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
222 if !output.status.success() {
223 return Err(SimctlError::NonZeroExit {
224 subcommand: args.first().map(|s| s.to_string()).unwrap_or_default(),
225 code: output.status.code().unwrap_or(-1),
226 stderr,
227 });
228 }
229 Ok((output.stdout, stderr))
230}
231
232async fn simctl_run(args: &[&str]) -> Result<String, SimctlError> {
233 let (stdout, _) = simctl_capture(args).await?;
234 Ok(String::from_utf8_lossy(&stdout).into_owned())
235}
236
237async fn simctl_run_env(args: &[&str], env: &[(String, String)]) -> Result<String, SimctlError> {
241 let (stdout, _) = simctl_capture_env(args, env).await?;
242 Ok(String::from_utf8_lossy(&stdout).into_owned())
243}
244
245pub fn compose_child_env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
264 pairs
265 .iter()
266 .map(|(k, v)| {
267 let key = if k.starts_with("SIMCTL_CHILD_") {
268 (*k).to_string()
269 } else {
270 format!("SIMCTL_CHILD_{k}")
271 };
272 (key, (*v).to_string())
273 })
274 .collect()
275}
276
277#[derive(Debug, Default)]
283pub struct SimctlClient {}
284
285impl SimctlClient {
286 pub fn new() -> Self {
288 SimctlClient {}
289 }
290
291 pub async fn list_runtimes(&self) -> Result<Vec<SimctlRuntime>, SimctlError> {
295 let raw = simctl_run(&["list", "runtimes", "-j"]).await?;
296 #[derive(Deserialize)]
297 struct Wrap {
298 runtimes: Vec<RawRuntime>,
299 }
300 #[derive(Deserialize)]
301 struct RawRuntime {
302 identifier: String,
303 name: String,
304 version: String,
305 #[serde(rename = "isAvailable", default)]
306 is_available: bool,
307 }
308 let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
309 subcommand: "list runtimes".into(),
310 detail: e.to_string(),
311 })?;
312 Ok(w.runtimes
313 .into_iter()
314 .map(|r| SimctlRuntime {
315 identifier: r.identifier,
316 name: r.name,
317 version: r.version,
318 is_available: r.is_available,
319 })
320 .collect())
321 }
322
323 pub async fn list_devices(&self) -> Result<Vec<SimctlDevice>, SimctlError> {
325 let raw = simctl_run(&["list", "devices", "-j"]).await?;
326 #[derive(Deserialize)]
327 struct Wrap {
328 devices: std::collections::BTreeMap<String, Vec<RawDevice>>,
329 }
330 #[derive(Deserialize)]
331 struct RawDevice {
332 udid: String,
333 name: String,
334 state: String,
335 #[serde(rename = "isAvailable", default)]
336 is_available: bool,
337 #[serde(rename = "deviceTypeIdentifier", default)]
338 device_type_identifier: String,
339 }
340 let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
341 subcommand: "list devices".into(),
342 detail: e.to_string(),
343 })?;
344 let mut out = Vec::new();
345 for (runtime_id, devices) in w.devices {
346 for d in devices {
347 out.push(SimctlDevice {
348 udid: d.udid,
349 name: d.name,
350 state: d.state,
351 is_available: d.is_available,
352 device_type_identifier: d.device_type_identifier,
353 runtime_identifier: runtime_id.clone(),
354 });
355 }
356 }
357 Ok(out)
358 }
359
360 pub async fn boot(&self, udid: &str) -> Result<(), SimctlError> {
364 simctl_run(&["boot", udid]).await?;
365 Ok(())
366 }
367
368 pub async fn shutdown(&self, udid: &str) -> Result<(), SimctlError> {
370 simctl_run(&["shutdown", udid]).await?;
371 Ok(())
372 }
373
374 pub async fn current_locale(&self, udid: &str) -> Result<Option<String>, SimctlError> {
381 let out =
382 match simctl_run(&["spawn", udid, "defaults", "read", "-g", "AppleLanguages"]).await {
383 Ok(s) => s,
384 Err(SimctlError::NonZeroExit { .. }) => return Ok(None),
387 Err(e) => return Err(e),
388 };
389 if let Some(start) = out.find('"') {
391 let rest = &out[start + 1..];
392 if let Some(end) = rest.find('"') {
393 return Ok(Some(rest[..end].to_string()));
394 }
395 }
396 Ok(None)
397 }
398
399 pub async fn set_locale(&self, udid: &str, locale: &str) -> Result<(), SimctlError> {
406 simctl_run(&[
407 "spawn",
408 udid,
409 "defaults",
410 "write",
411 "-g",
412 "AppleLanguages",
413 "-array",
414 locale,
415 ])
416 .await?;
417 let locale_underscore = locale.replace('-', "_");
418 simctl_run(&[
419 "spawn",
420 udid,
421 "defaults",
422 "write",
423 "-g",
424 "AppleLocale",
425 &locale_underscore,
426 ])
427 .await?;
428 Ok(())
429 }
430
431 pub async fn boot_and_wait(&self, udid: &str, timeout: Duration) -> Result<(), SimctlError> {
436 let _ = simctl_run(&["boot", udid]).await;
438 let start = std::time::Instant::now();
439 loop {
440 let devices = self.list_devices().await?;
441 if devices
442 .iter()
443 .any(|d| d.udid == udid && d.state == "Booted")
444 {
445 return Ok(());
446 }
447 if start.elapsed() > timeout {
448 return Err(SimctlError::Timeout {
449 subcommand: format!("boot {}", udid),
450 ms: timeout.as_millis() as u64,
451 });
452 }
453 sleep(Duration::from_millis(500)).await;
454 }
455 }
456
457 pub async fn erase(&self, udid: &str) -> Result<(), SimctlError> {
459 simctl_run(&["erase", udid]).await?;
460 Ok(())
461 }
462
463 pub async fn install(&self, udid: &str, app_path: &str) -> Result<(), SimctlError> {
465 simctl_run(&["install", udid, app_path]).await?;
466 Ok(())
467 }
468
469 pub async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
471 simctl_run(&["uninstall", udid, bundle_id]).await?;
472 Ok(())
473 }
474
475 pub async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
477 simctl_run(&["terminate", udid, bundle_id]).await?;
478 Ok(())
479 }
480
481 pub async fn launch(&self, udid: &str, bundle_id: &str) -> Result<LaunchResult, SimctlError> {
483 self.launch_with_args(udid, bundle_id, &[]).await
484 }
485
486 pub async fn launch_with_args(
490 &self,
491 udid: &str,
492 bundle_id: &str,
493 args: &[String],
494 ) -> Result<LaunchResult, SimctlError> {
495 self.launch_with_args_and_env(udid, bundle_id, args, &[])
496 .await
497 }
498
499 pub async fn launch_with_args_and_env(
509 &self,
510 udid: &str,
511 bundle_id: &str,
512 args: &[String],
513 child_env: &[(&str, &str)],
514 ) -> Result<LaunchResult, SimctlError> {
515 let mut argv: Vec<&str> = vec!["launch", udid, bundle_id];
516 if !args.is_empty() {
517 argv.push("--");
518 for a in args {
519 argv.push(a.as_str());
520 }
521 }
522 let composed = compose_child_env(child_env);
523 let out = simctl_run_env(&argv, &composed).await?;
524 let pid_str =
526 out.rsplit(':')
527 .next()
528 .map(str::trim)
529 .ok_or_else(|| SimctlError::Malformed {
530 subcommand: "launch".into(),
531 detail: format!("unexpected stdout shape: {}", out.trim()),
532 })?;
533 let pid: u32 = pid_str.parse().map_err(|_| SimctlError::Malformed {
534 subcommand: "launch".into(),
535 detail: format!("non-numeric pid in stdout: {}", out.trim()),
536 })?;
537 Ok(LaunchResult { pid })
538 }
539
540 pub async fn open_url(&self, udid: &str, url: &str) -> Result<(), SimctlError> {
542 simctl_run(&["openurl", udid, url]).await?;
543 Ok(())
544 }
545
546 pub async fn send_push(
553 &self,
554 udid: &str,
555 bundle_id: &str,
556 apns_json_path: &str,
557 ) -> Result<(), SimctlError> {
558 simctl_run(&["push", udid, bundle_id, apns_json_path]).await?;
559 Ok(())
560 }
561
562 pub async fn set_appearance(&self, udid: &str, mode: Appearance) -> Result<(), SimctlError> {
564 simctl_run(&["ui", udid, "appearance", mode.as_str()]).await?;
565 Ok(())
566 }
567
568 pub async fn grant_permission(
570 &self,
571 udid: &str,
572 permission: SimctlPermission,
573 bundle_id: &str,
574 ) -> Result<(), SimctlError> {
575 simctl_run(&["privacy", udid, "grant", permission.as_str(), bundle_id]).await?;
576 Ok(())
577 }
578
579 pub async fn revoke_permission(
584 &self,
585 udid: &str,
586 permission: SimctlPermission,
587 bundle_id: &str,
588 ) -> Result<(), SimctlError> {
589 simctl_run(&["privacy", udid, "revoke", permission.as_str(), bundle_id]).await?;
590 Ok(())
591 }
592
593 pub async fn location_set(
596 &self,
597 udid: &str,
598 latitude: f64,
599 longitude: f64,
600 ) -> Result<(), SimctlError> {
601 let coord = format!("{latitude},{longitude}");
602 simctl_run(&["location", udid, "set", &coord]).await?;
603 Ok(())
604 }
605
606 pub async fn location_start(
611 &self,
612 udid: &str,
613 points: &[(f64, f64)],
614 speed_mps: Option<f64>,
615 ) -> Result<(), SimctlError> {
616 if points.len() < 2 {
617 return Err(SimctlError::Malformed {
618 subcommand: "location-start".into(),
619 detail: format!("requires ≥2 waypoints, got {}", points.len()),
620 });
621 }
622 let mut args: Vec<String> = vec!["location".into(), udid.into(), "start".into()];
623 if let Some(s) = speed_mps {
624 args.push(format!("--speed={s}"));
625 }
626 for (lat, lng) in points {
627 args.push(format!("{lat},{lng}"));
628 }
629 let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
630 simctl_run(&args_ref).await?;
631 Ok(())
632 }
633
634 pub async fn location_clear(&self, udid: &str) -> Result<(), SimctlError> {
637 simctl_run(&["location", udid, "clear"]).await?;
638 Ok(())
639 }
640
641 pub async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), SimctlError> {
645 if paths.is_empty() {
646 return Err(SimctlError::Malformed {
647 subcommand: "addmedia".into(),
648 detail: "no paths supplied".into(),
649 });
650 }
651 let mut args: Vec<&str> = vec!["addmedia", udid];
652 for p in paths {
653 args.push(p.as_str());
654 }
655 simctl_run(&args).await?;
656 Ok(())
657 }
658
659 pub async fn record_video_start(
665 &self,
666 udid: &str,
667 path: &str,
668 ) -> Result<RecordingHandle, SimctlError> {
669 let child = tokio::process::Command::new("xcrun")
670 .args(["simctl", "io", udid, "recordVideo", path])
671 .stdin(std::process::Stdio::null())
672 .stdout(std::process::Stdio::piped())
673 .stderr(std::process::Stdio::piped())
674 .spawn()?;
675 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
677 Ok(RecordingHandle {
678 child,
679 path: path.to_string(),
680 started_at: std::time::Instant::now(),
681 })
682 }
683
684 pub async fn record_video_stop(&self, mut handle: RecordingHandle) -> Result<(), SimctlError> {
688 let pid = handle.child.id().ok_or_else(|| SimctlError::Malformed {
689 subcommand: "recordVideo-stop".into(),
690 detail: "child already reaped".into(),
691 })?;
692 let rc = unsafe { libc::kill(pid as i32, libc::SIGINT) };
695 if rc != 0 {
696 return Err(SimctlError::Malformed {
697 subcommand: "recordVideo-stop".into(),
698 detail: format!(
699 "kill SIGINT failed: errno={}",
700 std::io::Error::last_os_error()
701 ),
702 });
703 }
704 let wait_result =
705 tokio::time::timeout(std::time::Duration::from_secs(10), handle.child.wait()).await;
706 match wait_result {
707 Ok(Ok(_status)) => Ok(()),
708 Ok(Err(e)) => Err(SimctlError::Malformed {
709 subcommand: "recordVideo-stop".into(),
710 detail: format!("wait failed: {e}"),
711 }),
712 Err(_timeout) => {
713 let _ = handle.child.kill().await;
714 Err(SimctlError::Malformed {
715 subcommand: "recordVideo-stop".into(),
716 detail: "SIGINT timeout (10s) — escalated SIGKILL; output mp4 likely truncated. Inspect simctl recordVideo stderr.".into(),
717 })
718 }
719 }
720 }
721
722 pub async fn reset_permission(
727 &self,
728 udid: &str,
729 permission: SimctlPermission,
730 bundle_id: &str,
731 ) -> Result<(), SimctlError> {
732 simctl_run(&["privacy", udid, "reset", permission.as_str(), bundle_id]).await?;
733 Ok(())
734 }
735
736 pub async fn keychain_reset(&self, udid: &str) -> Result<(), SimctlError> {
738 simctl_run(&["keychain", udid, "reset"]).await?;
739 Ok(())
740 }
741
742 pub async fn pasteboard_get(&self, udid: &str) -> Result<String, SimctlError> {
744 simctl_run(&["pbpaste", udid]).await
745 }
746
747 pub async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), SimctlError> {
749 use tokio::io::AsyncWriteExt;
752 let mut cmd = Command::new("xcrun");
753 cmd.arg("simctl").arg("pbcopy").arg(udid);
754 cmd.stdin(std::process::Stdio::piped());
755 let mut child = cmd.spawn()?;
756 if let Some(mut stdin) = child.stdin.take() {
757 stdin.write_all(text.as_bytes()).await?;
758 drop(stdin); }
760 let status = child.wait().await?;
761 if !status.success() {
762 return Err(SimctlError::NonZeroExit {
763 subcommand: "pbcopy".into(),
764 code: status.code().unwrap_or(-1),
765 stderr: String::new(),
766 });
767 }
768 Ok(())
769 }
770
771 pub async fn set_reduce_motion(&self, udid: &str, enabled: bool) -> Result<(), SimctlError> {
773 let val = if enabled { "1" } else { "0" };
774 simctl_run(&[
776 "spawn",
777 udid,
778 "defaults",
779 "write",
780 "com.apple.UIKit",
781 "UIAccessibilityReduceMotionEnabled",
782 "-bool",
783 val,
784 ])
785 .await?;
786 Ok(())
787 }
788
789 pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, SimctlError> {
806 let tmp =
807 std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
808 let tmp_str = tmp.display().to_string();
809 let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
810 let bytes = result.and_then(|_| {
811 std::fs::read(&tmp).map_err(|e| SimctlError::Malformed {
812 subcommand: "screenshot".into(),
813 detail: format!("read {tmp_str}: {e}"),
814 })
815 });
816 let _ = std::fs::remove_file(&tmp);
817 let bytes = bytes?;
818 if bytes.len() < 8 {
819 return Err(SimctlError::Malformed {
820 subcommand: "screenshot".into(),
821 detail: format!("screenshot file too short: {} bytes", bytes.len()),
822 });
823 }
824 Ok(ensure_srgb_chunk(bytes))
825 }
826
827 pub async fn create_device(
829 &self,
830 name: &str,
831 device_type: &str,
832 runtime_id: &str,
833 ) -> Result<String, SimctlError> {
834 let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
835 Ok(out.trim().to_string())
836 }
837
838 pub async fn delete_device(&self, udid: &str) -> Result<(), SimctlError> {
840 simctl_run(&["delete", udid]).await?;
841 Ok(())
842 }
843}
844
845const PNG_MAGIC: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
865
866pub fn ensure_srgb_chunk(bytes: Vec<u8>) -> Vec<u8> {
874 if bytes.len() < 8 || &bytes[..8] != PNG_MAGIC {
875 return bytes;
876 }
877 let Some((idat_offset, has_srgb)) = scan_png_chunks(&bytes) else {
878 return bytes;
879 };
880 if has_srgb {
881 return bytes;
882 }
883 let mut out = Vec::with_capacity(bytes.len() + 13);
885 out.extend_from_slice(&bytes[..idat_offset]);
886 out.extend_from_slice(&synthesized_srgb_chunk());
887 out.extend_from_slice(&bytes[idat_offset..]);
888 out
889}
890
891fn scan_png_chunks(bytes: &[u8]) -> Option<(usize, bool)> {
896 let mut i: usize = 8;
897 let mut has_srgb = false;
898 while i + 8 <= bytes.len() {
899 let length =
900 u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
901 let ctype = &bytes[i + 4..i + 8];
902 if ctype == b"IDAT" {
903 return Some((i, has_srgb));
904 }
905 if ctype == b"sRGB" {
906 has_srgb = true;
907 }
908 let end = i.checked_add(12)?.checked_add(length)?;
910 if end > bytes.len() {
911 return None;
912 }
913 i = end;
914 }
915 None
916}
917
918fn synthesized_srgb_chunk() -> [u8; 13] {
921 let mut crc_input = [0u8; 5];
923 crc_input[0..4].copy_from_slice(b"sRGB");
924 crc_input[4] = 0; let crc = crc32_ieee(&crc_input);
926 let mut chunk = [0u8; 13];
927 chunk[0..4].copy_from_slice(&1u32.to_be_bytes()); chunk[4..8].copy_from_slice(b"sRGB");
929 chunk[8] = 0;
930 chunk[9..13].copy_from_slice(&crc.to_be_bytes());
931 chunk
932}
933
934fn crc32_ieee(bytes: &[u8]) -> u32 {
938 let mut crc: u32 = 0xFFFF_FFFF;
939 for &b in bytes {
940 crc ^= u32::from(b);
941 for _ in 0..8 {
942 let mask = 0u32.wrapping_sub(crc & 1);
943 crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
944 }
945 }
946 !crc
947}
948
949#[cfg(test)]
950mod tests {
951 use super::*;
952
953 #[test]
954 fn compose_child_env_adds_prefix() {
955 let composed = compose_child_env(&[
956 ("SMIX_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
957 ("LAUNCH_FORCE_PUSH", "true"),
958 ]);
959 assert_eq!(
960 composed,
961 vec![
962 (
963 "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
964 "http://127.0.0.1:9999".to_string(),
965 ),
966 (
967 "SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
968 "true".to_string(),
969 ),
970 ]
971 );
972 }
973
974 #[test]
975 fn compose_child_env_already_prefixed_passes_through() {
976 let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
978 assert_eq!(
979 composed,
980 vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
981 );
982 }
983
984 #[test]
985 fn compose_child_env_empty_input_is_empty_output() {
986 assert!(compose_child_env(&[]).is_empty());
987 }
988
989 fn synth_png(with_srgb: bool) -> Vec<u8> {
995 let mut out = Vec::new();
996 out.extend_from_slice(super::PNG_MAGIC);
997 let ihdr_data: [u8; 13] = [
999 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0,
1004 ];
1005 emit_chunk(&mut out, b"IHDR", &ihdr_data);
1006 if with_srgb {
1007 emit_chunk(&mut out, b"sRGB", &[0]);
1008 }
1009 emit_chunk(&mut out, b"IDAT", &[0x78, 0x01, 0x00, 0x00]);
1011 emit_chunk(&mut out, b"IEND", &[]);
1012 out
1013 }
1014
1015 fn emit_chunk(out: &mut Vec<u8>, ctype: &[u8; 4], data: &[u8]) {
1016 out.extend_from_slice(&(data.len() as u32).to_be_bytes());
1017 out.extend_from_slice(ctype);
1018 out.extend_from_slice(data);
1019 let mut crc_in = Vec::with_capacity(4 + data.len());
1020 crc_in.extend_from_slice(ctype);
1021 crc_in.extend_from_slice(data);
1022 out.extend_from_slice(&super::crc32_ieee(&crc_in).to_be_bytes());
1023 }
1024
1025 #[test]
1026 fn ensure_srgb_passthrough_when_chunk_present() {
1027 let png = synth_png(true);
1028 let original_len = png.len();
1029 let out = super::ensure_srgb_chunk(png.clone());
1030 assert_eq!(out.len(), original_len);
1031 assert_eq!(out, png);
1032 }
1033
1034 #[test]
1035 fn ensure_srgb_inserts_chunk_when_absent() {
1036 let png = synth_png(false);
1037 let original_len = png.len();
1038 let out = super::ensure_srgb_chunk(png);
1039 assert_eq!(out.len(), original_len + 13);
1040 assert_eq!(&out[..8], super::PNG_MAGIC);
1042 let mut found = false;
1044 for w in out.windows(4) {
1045 if w == b"sRGB" {
1046 found = true;
1047 break;
1048 }
1049 }
1050 assert!(found, "sRGB chunk should have been spliced in");
1051 }
1052
1053 #[test]
1054 fn ensure_srgb_preserves_idat_bytes_verbatim() {
1055 let png = synth_png(false);
1059 let out = super::ensure_srgb_chunk(png.clone());
1060 assert_eq!(extract_idat_data(&png), extract_idat_data(&out));
1061 }
1062
1063 fn extract_idat_data(bytes: &[u8]) -> Vec<u8> {
1064 let mut i = 8;
1065 while i + 8 <= bytes.len() {
1066 let length =
1067 u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1068 let ctype = &bytes[i + 4..i + 8];
1069 if ctype == b"IDAT" {
1070 return bytes[i + 8..i + 8 + length].to_vec();
1071 }
1072 i += 12 + length;
1073 }
1074 vec![]
1075 }
1076
1077 #[test]
1078 fn ensure_srgb_passthrough_on_bad_magic() {
1079 let bytes = vec![0u8; 32];
1081 let out = super::ensure_srgb_chunk(bytes.clone());
1082 assert_eq!(out, bytes);
1083 }
1084
1085 #[test]
1086 fn crc32_matches_known_iend() {
1087 assert_eq!(super::crc32_ieee(b"IEND"), 0xAE42_6082);
1090 }
1091}