Skip to main content

smix_simctl/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5//! smix-simctl — xcrun simctl child_process wrapper (outer crate).
6//!
7//! All operations shell out to `xcrun simctl <subcommand>`; JSON-formatted
8//! outputs (list runtimes, list devices, screenshot binary) are parsed with
9//! serde_json / raw bytes. Tokio's `process::Command` is the async spawn
10//! primitive.
11//!
12//! This is an outer crate — allowed to depend on the wider tokio ecosystem.
13//! Use it from cement (smix-cli / smix-mcp) or from a higher-level driver
14//! wrapper.
15
16#![doc(html_root_url = "https://docs.smix.dev/smix-simctl")]
17
18pub mod registry;
19/// v1.0.4 — adaptive `xcrun simctl io screenshot` pacer. See
20/// [`screenshot_pacer::ScreenshotPacer`] and
21/// `.claude/rfcs/1.0.4-sim-health-and-backpressure.md` §D3.
22pub 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/// Failure variants for any `xcrun simctl` invocation.
34#[derive(Debug, Error)]
35pub enum SimctlError {
36    /// Failed to spawn the `xcrun` process itself (PATH lookup / fork failure).
37    #[error("spawn xcrun simctl failed: {0}")]
38    Spawn(#[from] io::Error),
39    /// `xcrun simctl <sub>` exited non-zero.
40    #[error("xcrun simctl {subcommand} exited {code}: {stderr}")]
41    NonZeroExit {
42        /// Subcommand name (e.g. `"boot"`, `"launch"`).
43        subcommand: String,
44        /// Exit code from `xcrun simctl`.
45        code: i32,
46        /// Captured stderr (truncated for log-friendliness).
47        stderr: String,
48    },
49    /// `xcrun simctl <sub>` exited 0 but stdout didn't match the expected shape.
50    #[error("xcrun simctl {subcommand} returned malformed output: {detail}")]
51    Malformed {
52        /// Subcommand name.
53        subcommand: String,
54        /// Parser-side detail.
55        detail: String,
56    },
57    /// `xcrun simctl <sub>` did not complete within the deadline.
58    #[error("xcrun simctl {subcommand} timed out after {ms}ms")]
59    Timeout {
60        /// Subcommand name.
61        subcommand: String,
62        /// Deadline that was exceeded (milliseconds).
63        ms: u64,
64    },
65    /// The screenshot pacer's circuit is open — a recent screenshot
66    /// wall time exceeded the circuit threshold, or a screenshot
67    /// failed. Callers should back off for `retry_after` and try
68    /// again. See [`screenshot_pacer::ScreenshotPacer`] and
69    /// `.claude/rfcs/1.0.4-sim-health-and-backpressure.md` §D3.
70    ///
71    /// Since smix 1.0.4.
72    #[error("screenshot pacer circuit open; retry after {retry_after:?}")]
73    CaptureBackpressure {
74        /// Suggested minimum delay before the next attempt.
75        retry_after: Duration,
76    },
77}
78
79/// Handle to an active `xcrun simctl io recordVideo` child process. Pair
80/// with [`SimctlClient::record_video_stop`] for SIGINT-and-wait shutdown
81/// (so the mp4 trailer is flushed). Dropping the handle without `stop`
82/// would tokio-SIGKILL on Drop and truncate the output file.
83#[derive(Debug)]
84pub struct RecordingHandle {
85    pub(crate) child: tokio::process::Child,
86    /// Output mp4 path verbatim as passed to `record_video_start`.
87    pub path: String,
88    /// Wall-clock start time for "recording in progress for Xs" diagnostics.
89    pub started_at: std::time::Instant,
90}
91
92// -------------------- types ----------------------------------------------
93
94/// One iOS / watchOS / tvOS runtime installed on the host.
95#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
96pub struct SimctlRuntime {
97    /// Fully-qualified runtime identifier (e.g. `"com.apple.CoreSimulator.SimRuntime.iOS-17-0"`).
98    pub identifier: String,
99    /// Human-readable name (e.g. `"iOS 17.0"`).
100    pub name: String,
101    /// Version string (e.g. `"17.0"`).
102    pub version: String,
103    /// Whether the runtime is available for booting devices.
104    pub is_available: bool,
105}
106
107/// One simulator device known to `xcrun simctl`.
108#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
109pub struct SimctlDevice {
110    /// Device UDID (stable identifier).
111    pub udid: String,
112    /// Human-readable name.
113    pub name: String,
114    /// Current state (`"Booted"` / `"Shutdown"` / `"Creating"` / etc.).
115    pub state: String,
116    /// Whether the device is available for booting.
117    pub is_available: bool,
118    /// Device-type identifier (e.g. `"com.apple.CoreSimulator.SimDeviceType.iPhone-15"`).
119    #[serde(rename = "deviceTypeIdentifier", default)]
120    pub device_type_identifier: String,
121    /// Runtime identifier this device was created against.
122    #[serde(rename = "runtimeIdentifier", default)]
123    pub runtime_identifier: String,
124}
125
126/// Permission names accepted by `xcrun simctl privacy <udid> grant <name>`.
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub enum SimctlPermission {
129    /// Camera access.
130    Camera,
131    /// Photos library access.
132    Photos,
133    /// Location access (while-in-use).
134    Location,
135    /// Background location access (always).
136    LocationAlways,
137    /// Notification posting permission.
138    Notifications,
139    /// Microphone access.
140    Microphone,
141    /// Contacts access.
142    Contacts,
143    /// Calendar events access.
144    Calendar,
145    /// Reminders access.
146    Reminders,
147    /// Media library (music / video) access.
148    Media,
149    /// Motion / fitness sensor access.
150    Motion,
151    /// HomeKit accessory access.
152    HomeKit,
153    /// HealthKit data access.
154    Health,
155    /// Bluetooth device discovery / connection.
156    Bluetooth,
157    /// FaceID / TouchID biometric prompt.
158    Faceid,
159    /// Address-book (deprecated alias for `Contacts`).
160    AddressBook,
161}
162
163impl SimctlPermission {
164    /// Wire string used by `xcrun simctl privacy <udid> grant <name>`.
165    pub fn as_str(self) -> &'static str {
166        match self {
167            SimctlPermission::Camera => "camera",
168            SimctlPermission::Photos => "photos",
169            SimctlPermission::Location => "location",
170            SimctlPermission::LocationAlways => "location-always",
171            SimctlPermission::Notifications => "notifications",
172            SimctlPermission::Microphone => "microphone",
173            SimctlPermission::Contacts => "contacts",
174            SimctlPermission::Calendar => "calendar",
175            SimctlPermission::Reminders => "reminders",
176            SimctlPermission::Media => "media-library",
177            SimctlPermission::Motion => "motion",
178            SimctlPermission::HomeKit => "homekit",
179            SimctlPermission::Health => "health",
180            SimctlPermission::Bluetooth => "bluetooth",
181            SimctlPermission::Faceid => "faceid",
182            SimctlPermission::AddressBook => "addressbook",
183        }
184    }
185}
186
187/// UI appearance mode for `xcrun simctl ui <udid> appearance`.
188#[derive(Clone, Copy, Debug, PartialEq, Eq)]
189pub enum Appearance {
190    /// Light mode.
191    Light,
192    /// Dark mode.
193    Dark,
194}
195
196impl Appearance {
197    /// Wire string used by `xcrun simctl ui <udid> appearance <mode>`.
198    pub fn as_str(self) -> &'static str {
199        match self {
200            Appearance::Light => "light",
201            Appearance::Dark => "dark",
202        }
203    }
204}
205
206/// Launched-app result.
207#[derive(Clone, Debug, PartialEq, Eq)]
208pub struct LaunchResult {
209    /// Process ID of the launched app.
210    pub pid: u32,
211}
212
213// -------------------- raw spawn primitive --------------------------------
214
215/// Execute `xcrun simctl <args>` and capture stdout/stderr.
216async fn simctl_capture(args: &[&str]) -> Result<(Vec<u8>, String), SimctlError> {
217    simctl_capture_env(args, &[]).await
218}
219
220/// `simctl_capture` with extra envp pairs set on the spawned process.
221/// The `xcrun simctl launch` subcommand uses this to inject
222/// `SIMCTL_CHILD_<KEY>=<VAL>` vars that the launched app sees as
223/// `ProcessInfo().environment["KEY"]`. `env` entries here are passed
224/// verbatim — caller composes the `SIMCTL_CHILD_` prefix via
225/// [`compose_child_env`].
226async fn simctl_capture_env(
227    args: &[&str],
228    env: &[(String, String)],
229) -> Result<(Vec<u8>, String), SimctlError> {
230    let mut cmd = Command::new("xcrun");
231    cmd.arg("simctl");
232    for a in args {
233        cmd.arg(a);
234    }
235    for (k, v) in env {
236        cmd.env(k, v);
237    }
238    let output = cmd.output().await?;
239    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
240    if !output.status.success() {
241        return Err(SimctlError::NonZeroExit {
242            subcommand: args.first().map(|s| s.to_string()).unwrap_or_default(),
243            code: output.status.code().unwrap_or(-1),
244            stderr,
245        });
246    }
247    Ok((output.stdout, stderr))
248}
249
250async fn simctl_run(args: &[&str]) -> Result<String, SimctlError> {
251    let (stdout, _) = simctl_capture(args).await?;
252    Ok(String::from_utf8_lossy(&stdout).into_owned())
253}
254
255/// Like [`simctl_run`] but injects `child_env` envp on the spawned
256/// process. Used by the env-aware launch path so the launched app can
257/// read deploy-time secrets / endpoints via `ProcessInfo`.
258async fn simctl_run_env(args: &[&str], env: &[(String, String)]) -> Result<String, SimctlError> {
259    let (stdout, _) = simctl_capture_env(args, env).await?;
260    Ok(String::from_utf8_lossy(&stdout).into_owned())
261}
262
263/// Compose user-provided `(key, value)` pairs into the `SIMCTL_CHILD_*`
264/// envp that `xcrun simctl launch` strips and delivers to the launched
265/// app. Idempotent: a key that already starts with `SIMCTL_CHILD_` is
266/// passed through unchanged.
267///
268/// # Example
269///
270/// ```
271/// use smix_simctl::compose_child_env;
272/// let composed = compose_child_env(&[("SMIX_PERF_RECEIVER_URL", "http://h:9999")]);
273/// assert_eq!(
274///     composed,
275///     vec![(
276///         "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
277///         "http://h:9999".to_string(),
278///     )]
279/// );
280/// ```
281pub fn compose_child_env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
282    pairs
283        .iter()
284        .map(|(k, v)| {
285            let key = if k.starts_with("SIMCTL_CHILD_") {
286                (*k).to_string()
287            } else {
288                format!("SIMCTL_CHILD_{k}")
289            };
290            (key, (*v).to_string())
291        })
292        .collect()
293}
294
295// -------------------- client --------------------------------------------
296
297/// Stateless wrapper around xcrun simctl. Methods are free functions
298/// in spirit (no instance state beyond optionally-cached `xcrun` path);
299/// kept as a struct for API ergonomics + future caching.
300///
301/// Since v1.0.4, the client also holds a [`ScreenshotPacer`] that
302/// throttles `xcrun simctl io screenshot` under high-frequency
303/// load. Defaults are conservative (100 ms interval floor);
304/// consumers whose flows are already loose are unaffected.
305#[derive(Debug)]
306pub struct SimctlClient {
307    /// Screenshot pacer — enforces the RFC 1.0.4 §D3 interval floor,
308    /// slow-path lift, and circuit breaker. Shared via `Arc<Mutex<_>>`
309    /// so a cloned client (which callers occasionally do) still shares
310    /// pressure accounting.
311    screenshot_pacer: Arc<std::sync::Mutex<ScreenshotPacer>>,
312}
313
314impl Default for SimctlClient {
315    fn default() -> Self {
316        Self::new()
317    }
318}
319
320impl SimctlClient {
321    /// Construct a new client with default screenshot pacing (100 ms
322    /// interval floor, adaptive slow-path lift to 1500 ms, circuit
323    /// breaker on ≥ 1500 ms walls or failures).
324    pub fn new() -> Self {
325        SimctlClient {
326            screenshot_pacer: Arc::new(std::sync::Mutex::new(ScreenshotPacer::new(
327                ScreenshotPacerConfig::default(),
328            ))),
329        }
330    }
331
332    /// Override the screenshot pacer with a custom config.
333    ///
334    /// Since smix 1.0.4.
335    #[must_use]
336    pub fn with_screenshot_pacer(self, config: ScreenshotPacerConfig) -> Self {
337        {
338            let mut guard = self
339                .screenshot_pacer
340                .lock()
341                .expect("screenshot pacer mutex must not be poisoned");
342            *guard = ScreenshotPacer::new(config);
343        }
344        self
345    }
346
347    /// Attach a [`smix_sim_health::SimHealthMonitor`] to receive
348    /// screenshot wall-time observations from every `screenshot`
349    /// call. Composes with the pacer — the pacer still enforces its
350    /// interval / circuit locally, and the monitor sees the same
351    /// walls for global state classification.
352    ///
353    /// Since smix 1.0.4.
354    #[must_use]
355    pub fn with_sim_health(self, monitor: smix_sim_health::SimHealthMonitor) -> Self {
356        {
357            let mut guard = self
358                .screenshot_pacer
359                .lock()
360                .expect("screenshot pacer mutex must not be poisoned");
361            guard.set_monitor(monitor);
362        }
363        self
364    }
365
366    // ---- inventory ------------------------------------------------------
367
368    /// `xcrun simctl list runtimes -j` → `Vec<SimctlRuntime>`.
369    pub async fn list_runtimes(&self) -> Result<Vec<SimctlRuntime>, SimctlError> {
370        let raw = simctl_run(&["list", "runtimes", "-j"]).await?;
371        #[derive(Deserialize)]
372        struct Wrap {
373            runtimes: Vec<RawRuntime>,
374        }
375        #[derive(Deserialize)]
376        struct RawRuntime {
377            identifier: String,
378            name: String,
379            version: String,
380            #[serde(rename = "isAvailable", default)]
381            is_available: bool,
382        }
383        let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
384            subcommand: "list runtimes".into(),
385            detail: e.to_string(),
386        })?;
387        Ok(w.runtimes
388            .into_iter()
389            .map(|r| SimctlRuntime {
390                identifier: r.identifier,
391                name: r.name,
392                version: r.version,
393                is_available: r.is_available,
394            })
395            .collect())
396    }
397
398    /// `xcrun simctl list devices -j` → flattened `Vec<SimctlDevice>`.
399    pub async fn list_devices(&self) -> Result<Vec<SimctlDevice>, SimctlError> {
400        let raw = simctl_run(&["list", "devices", "-j"]).await?;
401        #[derive(Deserialize)]
402        struct Wrap {
403            devices: std::collections::BTreeMap<String, Vec<RawDevice>>,
404        }
405        #[derive(Deserialize)]
406        struct RawDevice {
407            udid: String,
408            name: String,
409            state: String,
410            #[serde(rename = "isAvailable", default)]
411            is_available: bool,
412            #[serde(rename = "deviceTypeIdentifier", default)]
413            device_type_identifier: String,
414        }
415        let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
416            subcommand: "list devices".into(),
417            detail: e.to_string(),
418        })?;
419        let mut out = Vec::new();
420        for (runtime_id, devices) in w.devices {
421            for d in devices {
422                out.push(SimctlDevice {
423                    udid: d.udid,
424                    name: d.name,
425                    state: d.state,
426                    is_available: d.is_available,
427                    device_type_identifier: d.device_type_identifier,
428                    runtime_identifier: runtime_id.clone(),
429                });
430            }
431        }
432        Ok(out)
433    }
434
435    // ---- lifecycle ------------------------------------------------------
436
437    /// `xcrun simctl boot <udid>` — fire-and-forget boot request.
438    pub async fn boot(&self, udid: &str) -> Result<(), SimctlError> {
439        simctl_run(&["boot", udid]).await?;
440        Ok(())
441    }
442
443    /// `xcrun simctl shutdown <udid>`.
444    pub async fn shutdown(&self, udid: &str) -> Result<(), SimctlError> {
445        simctl_run(&["shutdown", udid]).await?;
446        Ok(())
447    }
448
449    /// Read the sim's current BCP-47 locale (first entry of
450    /// `NSGlobalDomain AppleLanguages`). Returns `Ok(None)` when the
451    /// preference is unset (defaults read exits non-zero) or unparseable.
452    /// Wire format: `simctl spawn <udid> defaults read -g AppleLanguages`
453    /// stdout looks like `"(\n    \"en-US\"\n)\n"`; we extract the first
454    /// quoted token.
455    pub async fn current_locale(&self, udid: &str) -> Result<Option<String>, SimctlError> {
456        let out =
457            match simctl_run(&["spawn", udid, "defaults", "read", "-g", "AppleLanguages"]).await {
458                Ok(s) => s,
459                // `defaults read` returns non-zero when the key is unset; that
460                // is a legitimate "no opinion" state, not an error.
461                Err(SimctlError::NonZeroExit { .. }) => return Ok(None),
462                Err(e) => return Err(e),
463            };
464        // First quoted substring.
465        if let Some(start) = out.find('"') {
466            let rest = &out[start + 1..];
467            if let Some(end) = rest.find('"') {
468                return Ok(Some(rest[..end].to_string()));
469            }
470        }
471        Ok(None)
472    }
473
474    /// Write `AppleLanguages` (array) + `AppleLocale` (scalar) to the
475    /// sim's NSGlobalDomain so SpringBoard + apps re-localize on next
476    /// launch. AppleLocale is BCP-47 with hyphen replaced by underscore
477    /// (`en_US`); AppleLanguages is the BCP-47 tag verbatim.
478    /// **The caller must shutdown + reboot the sim for the change to
479    /// take effect** — running apps cache the locale at process start.
480    pub async fn set_locale(&self, udid: &str, locale: &str) -> Result<(), SimctlError> {
481        simctl_run(&[
482            "spawn",
483            udid,
484            "defaults",
485            "write",
486            "-g",
487            "AppleLanguages",
488            "-array",
489            locale,
490        ])
491        .await?;
492        let locale_underscore = locale.replace('-', "_");
493        simctl_run(&[
494            "spawn",
495            udid,
496            "defaults",
497            "write",
498            "-g",
499            "AppleLocale",
500            &locale_underscore,
501        ])
502        .await?;
503        Ok(())
504    }
505
506    /// Boot + poll device state == "Booted" within timeout. Tries every
507    /// 500 ms until success or `timeout_ms` elapses. Idempotent on
508    /// already-booted devices (`xcrun simctl boot` returns non-zero when
509    /// the device is already booted; we swallow that).
510    pub async fn boot_and_wait(&self, udid: &str, timeout: Duration) -> Result<(), SimctlError> {
511        // Issue boot; ignore already-booted error (the only friendly path).
512        let _ = simctl_run(&["boot", udid]).await;
513        let start = std::time::Instant::now();
514        loop {
515            let devices = self.list_devices().await?;
516            if devices
517                .iter()
518                .any(|d| d.udid == udid && d.state == "Booted")
519            {
520                return Ok(());
521            }
522            if start.elapsed() > timeout {
523                return Err(SimctlError::Timeout {
524                    subcommand: format!("boot {}", udid),
525                    ms: timeout.as_millis() as u64,
526                });
527            }
528            sleep(Duration::from_millis(500)).await;
529        }
530    }
531
532    /// `xcrun simctl erase <udid>` — wipe device contents.
533    pub async fn erase(&self, udid: &str) -> Result<(), SimctlError> {
534        simctl_run(&["erase", udid]).await?;
535        Ok(())
536    }
537
538    /// `xcrun simctl install <udid> <app-path>` — install a `.app` bundle.
539    pub async fn install(&self, udid: &str, app_path: &str) -> Result<(), SimctlError> {
540        simctl_run(&["install", udid, app_path]).await?;
541        Ok(())
542    }
543
544    /// `xcrun simctl uninstall <udid> <bundle-id>`.
545    pub async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
546        simctl_run(&["uninstall", udid, bundle_id]).await?;
547        Ok(())
548    }
549
550    /// `xcrun simctl terminate <udid> <bundle-id>` — kill a running app.
551    pub async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
552        simctl_run(&["terminate", udid, bundle_id]).await?;
553        Ok(())
554    }
555
556    /// `xcrun simctl launch <udid> <bundleId>` → parse `"<bundle>: <pid>"`.
557    pub async fn launch(&self, udid: &str, bundle_id: &str) -> Result<LaunchResult, SimctlError> {
558        self.launch_with_args(udid, bundle_id, &[]).await
559    }
560
561    /// `xcrun simctl launch <udid> <bundleId> -- <arg>...` — launch with a
562    /// process-level argument vector. Empty `args` is equivalent to
563    /// [`Self::launch`]. Mirrors maestro yaml `launchApp.arguments`.
564    pub async fn launch_with_args(
565        &self,
566        udid: &str,
567        bundle_id: &str,
568        args: &[String],
569    ) -> Result<LaunchResult, SimctlError> {
570        self.launch_with_args_and_env(udid, bundle_id, args, &[])
571            .await
572    }
573
574    /// Like [`Self::launch_with_args`] but also sets `SIMCTL_CHILD_*`
575    /// envp on the simctl process so the launched app can read
576    /// deploy-time vars via `ProcessInfo().environment["KEY"]`.
577    /// `child_env` keys without the `SIMCTL_CHILD_` prefix get it added
578    /// automatically (per [`compose_child_env`] semantics). Useful for
579    /// prelaunching an app before any `openLink` so iOS treats the
580    /// subsequent URL handoff as in-app routing instead of cross-app,
581    /// side-stepping the SpringBoard "Open in '`<App>`'?" confirmation
582    /// dialog.
583    pub async fn launch_with_args_and_env(
584        &self,
585        udid: &str,
586        bundle_id: &str,
587        args: &[String],
588        child_env: &[(&str, &str)],
589    ) -> Result<LaunchResult, SimctlError> {
590        let mut argv: Vec<&str> = vec!["launch", udid, bundle_id];
591        if !args.is_empty() {
592            argv.push("--");
593            for a in args {
594                argv.push(a.as_str());
595            }
596        }
597        let composed = compose_child_env(child_env);
598        let out = simctl_run_env(&argv, &composed).await?;
599        // Output format: `com.example.app: 12345\n`
600        let pid_str =
601            out.rsplit(':')
602                .next()
603                .map(str::trim)
604                .ok_or_else(|| SimctlError::Malformed {
605                    subcommand: "launch".into(),
606                    detail: format!("unexpected stdout shape: {}", out.trim()),
607                })?;
608        let pid: u32 = pid_str.parse().map_err(|_| SimctlError::Malformed {
609            subcommand: "launch".into(),
610            detail: format!("non-numeric pid in stdout: {}", out.trim()),
611        })?;
612        Ok(LaunchResult { pid })
613    }
614
615    /// v1.0.4 §D12 — reset every privacy permission granted to
616    /// `bundle_id` on the sim: `xcrun simctl privacy <udid> reset all
617    /// <bundle-id>`. Companion to [`Self::clear_app_sandbox`] on the
618    /// in-place `launchApp: clearState: true` path that replaces
619    /// `simctl uninstall + install` (which triggers iOS 26.5 XCUITest
620    /// binding loss + ReportCrash's "Insight quit unexpectedly" dialog).
621    pub async fn privacy_reset_all(
622        &self,
623        udid: &str,
624        bundle_id: &str,
625    ) -> Result<(), SimctlError> {
626        simctl_run(&["privacy", udid, "reset", "all", bundle_id]).await?;
627        Ok(())
628    }
629
630    /// v1.0.4 §D12 — wipe the app's sandbox on the sim: locate the
631    /// Data container via `simctl get_app_container <udid> <bundle>
632    /// data`, then `simctl spawn <udid> rm -rf <container>/Documents
633    /// <container>/Library <container>/tmp`. The app remains installed
634    /// (no `simctl uninstall`), so the XCUITest binding is preserved
635    /// and macOS `ReportCrash` does not misinterpret a missing
636    /// install-receipt as a crash.
637    pub async fn clear_app_sandbox(
638        &self,
639        udid: &str,
640        bundle_id: &str,
641    ) -> Result<(), SimctlError> {
642        let raw = simctl_run(&["get_app_container", udid, bundle_id, "data"]).await?;
643        let container = raw.trim();
644        if container.is_empty() {
645            return Err(SimctlError::Malformed {
646                subcommand: "clear_app_sandbox".into(),
647                detail: format!("empty Data container path for bundle {bundle_id}"),
648            });
649        }
650        let documents = format!("{container}/Documents");
651        let library = format!("{container}/Library");
652        let tmp = format!("{container}/tmp");
653        // Best-effort: any missing subdir is fine (fresh app that never
654        // wrote to that path). `rm -rf` treats absent targets as no-ops.
655        simctl_run(&[
656            "spawn", udid, "rm", "-rf", &documents, &library, &tmp,
657        ])
658        .await?;
659        Ok(())
660    }
661
662    /// `xcrun simctl openurl <udid> <url>` — open a URL on the device.
663    ///
664    /// **URL bytes are passed to `xcrun simctl` verbatim** — no
665    /// parsing, no percent-encoding rewrite, no query-string
666    /// stripping. Verified by [`openurl_argv`] (test-visible helper)
667    /// and its unit test asserting query-params like
668    /// `?url=http%3A%2F%2Flocalhost%3A8081` reach the argv byte-for-byte.
669    /// Feedback §G verification — if the target app's URL router
670    /// (e.g. expo-dev-client 57.0.5) shows a picker instead of
671    /// auto-connecting, the URL made it through smix intact and the
672    /// finding lives on the URL-router side.
673    pub async fn open_url(&self, udid: &str, url: &str) -> Result<(), SimctlError> {
674        let argv = openurl_argv(udid, url);
675        let refs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
676        simctl_run(&refs).await?;
677        Ok(())
678    }
679}
680
681/// v1.0.4 §G — argv construction for `xcrun simctl openurl`. Extracted
682/// as a test-visible helper so the URL-preservation contract is
683/// unit-testable without invoking `xcrun`.
684#[doc(hidden)]
685pub fn openurl_argv(udid: &str, url: &str) -> [String; 3] {
686    ["openurl".to_string(), udid.to_string(), url.to_string()]
687}
688
689impl SimctlClient {
690
691    /// `xcrun simctl push <udid> <bundle-id> <apns-json-path>`.
692    /// Deliver an APNS payload to a sim-installed app. The payload file is
693    /// a JSON document whose top-level dictionary mirrors what an APNS
694    /// provider would send; `aps.alert.body` / `aps.alert.title` surface
695    /// as banner content and reach the app's
696    /// `UNUserNotificationCenterDelegate`.
697    pub async fn send_push(
698        &self,
699        udid: &str,
700        bundle_id: &str,
701        apns_json_path: &str,
702    ) -> Result<(), SimctlError> {
703        simctl_run(&["push", udid, bundle_id, apns_json_path]).await?;
704        Ok(())
705    }
706
707    /// `xcrun simctl ui <udid> appearance <light|dark>` — set UI appearance.
708    pub async fn set_appearance(&self, udid: &str, mode: Appearance) -> Result<(), SimctlError> {
709        simctl_run(&["ui", udid, "appearance", mode.as_str()]).await?;
710        Ok(())
711    }
712
713    /// `xcrun simctl privacy <udid> grant <perm> <bundle-id>`.
714    pub async fn grant_permission(
715        &self,
716        udid: &str,
717        permission: SimctlPermission,
718        bundle_id: &str,
719    ) -> Result<(), SimctlError> {
720        simctl_run(&["privacy", udid, "grant", permission.as_str(), bundle_id]).await?;
721        Ok(())
722    }
723
724    /// `xcrun simctl privacy <udid> revoke <perm> <bundle-id>` — explicitly
725    /// deny the permission. Mirrors maestro yaml `permissions: { x: deny }`
726    /// (the reverse of `grant`). Distinct from `reset`, which returns the
727    /// permission to "not determined".
728    pub async fn revoke_permission(
729        &self,
730        udid: &str,
731        permission: SimctlPermission,
732        bundle_id: &str,
733    ) -> Result<(), SimctlError> {
734        simctl_run(&["privacy", udid, "revoke", permission.as_str(), bundle_id]).await?;
735        Ok(())
736    }
737
738    /// `xcrun simctl location <udid> set <lat>,<lng>` — set sim location
739    /// to a fixed point. Mirrors maestro `setLocation`.
740    pub async fn location_set(
741        &self,
742        udid: &str,
743        latitude: f64,
744        longitude: f64,
745    ) -> Result<(), SimctlError> {
746        let coord = format!("{latitude},{longitude}");
747        simctl_run(&["location", udid, "set", &coord]).await?;
748        Ok(())
749    }
750
751    /// `xcrun simctl location <udid> start [--speed=<m/s>] <waypoints>`
752    /// — interpolate sim location along waypoints. Fire-and-return: simctl
753    /// injects scenario and returns; sim continues interpolation in background.
754    /// Mirrors maestro `travel`.
755    pub async fn location_start(
756        &self,
757        udid: &str,
758        points: &[(f64, f64)],
759        speed_mps: Option<f64>,
760    ) -> Result<(), SimctlError> {
761        if points.len() < 2 {
762            return Err(SimctlError::Malformed {
763                subcommand: "location-start".into(),
764                detail: format!("requires ≥2 waypoints, got {}", points.len()),
765            });
766        }
767        let mut args: Vec<String> = vec!["location".into(), udid.into(), "start".into()];
768        if let Some(s) = speed_mps {
769            args.push(format!("--speed={s}"));
770        }
771        for (lat, lng) in points {
772            args.push(format!("{lat},{lng}"));
773        }
774        let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
775        simctl_run(&args_ref).await?;
776        Ok(())
777    }
778
779    /// `xcrun simctl location <udid> clear` — reset active location
780    /// scenario.
781    pub async fn location_clear(&self, udid: &str) -> Result<(), SimctlError> {
782        simctl_run(&["location", udid, "clear"]).await?;
783        Ok(())
784    }
785
786    /// `xcrun simctl addmedia <udid> <path>...` — add photos / videos /
787    /// contacts to sim library. Mirrors maestro `addMedia` (scalar or
788    /// array form already flattened on adapter side).
789    pub async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), SimctlError> {
790        if paths.is_empty() {
791            return Err(SimctlError::Malformed {
792                subcommand: "addmedia".into(),
793                detail: "no paths supplied".into(),
794            });
795        }
796        let mut args: Vec<&str> = vec!["addmedia", udid];
797        for p in paths {
798            args.push(p.as_str());
799        }
800        simctl_run(&args).await?;
801        Ok(())
802    }
803
804    /// Start recording sim display to `path`. Spawns
805    /// `xcrun simctl io <udid> recordVideo <path>` as a long-running child;
806    /// returns handle immediately. Caller must pair with
807    /// [`Self::record_video_stop`] for clean SIGINT-and-wait shutdown —
808    /// dropping the handle would SIGKILL via tokio + lose mp4 trailer.
809    pub async fn record_video_start(
810        &self,
811        udid: &str,
812        path: &str,
813    ) -> Result<RecordingHandle, SimctlError> {
814        let child = tokio::process::Command::new("xcrun")
815            .args(["simctl", "io", udid, "recordVideo", path])
816            .stdin(std::process::Stdio::null())
817            .stdout(std::process::Stdio::piped())
818            .stderr(std::process::Stdio::piped())
819            .spawn()?;
820        // brief settle for simctl to initialize encoder + open output file.
821        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
822        Ok(RecordingHandle {
823            child,
824            path: path.to_string(),
825            started_at: std::time::Instant::now(),
826        })
827    }
828
829    /// Stop a recording via SIGINT + wait (≤10s). SIGINT lets simctl
830    /// trap and flush the mp4 trailer; SIGKILL would corrupt output.
831    /// Timeout escalates to SIGKILL with explicit error mentioning truncation.
832    pub async fn record_video_stop(&self, mut handle: RecordingHandle) -> Result<(), SimctlError> {
833        let pid = handle.child.id().ok_or_else(|| SimctlError::Malformed {
834            subcommand: "recordVideo-stop".into(),
835            detail: "child already reaped".into(),
836        })?;
837        // SAFETY: libc::kill is a thin POSIX syscall wrapper; pid is owned by
838        // this Child instance (no race) and SIGINT is signal-safe.
839        let rc = unsafe { libc::kill(pid as i32, libc::SIGINT) };
840        if rc != 0 {
841            return Err(SimctlError::Malformed {
842                subcommand: "recordVideo-stop".into(),
843                detail: format!(
844                    "kill SIGINT failed: errno={}",
845                    std::io::Error::last_os_error()
846                ),
847            });
848        }
849        let wait_result =
850            tokio::time::timeout(std::time::Duration::from_secs(10), handle.child.wait()).await;
851        match wait_result {
852            Ok(Ok(_status)) => Ok(()),
853            Ok(Err(e)) => Err(SimctlError::Malformed {
854                subcommand: "recordVideo-stop".into(),
855                detail: format!("wait failed: {e}"),
856            }),
857            Err(_timeout) => {
858                let _ = handle.child.kill().await;
859                Err(SimctlError::Malformed {
860                    subcommand: "recordVideo-stop".into(),
861                    detail: "SIGINT timeout (10s) — escalated SIGKILL; output mp4 likely truncated. Inspect simctl recordVideo stderr.".into(),
862                })
863            }
864        }
865    }
866
867    /// `xcrun simctl privacy <udid> reset <perm> <bundle-id>` — return the
868    /// permission to "not determined" so the next request re-prompts.
869    /// May terminate a running instance of the target app (Apple
870    /// behavior) — call before launch, not mid-flow.
871    pub async fn reset_permission(
872        &self,
873        udid: &str,
874        permission: SimctlPermission,
875        bundle_id: &str,
876    ) -> Result<(), SimctlError> {
877        simctl_run(&["privacy", udid, "reset", permission.as_str(), bundle_id]).await?;
878        Ok(())
879    }
880
881    /// `xcrun simctl keychain <udid> reset` — clear all keychain entries.
882    pub async fn keychain_reset(&self, udid: &str) -> Result<(), SimctlError> {
883        simctl_run(&["keychain", udid, "reset"]).await?;
884        Ok(())
885    }
886
887    /// `xcrun simctl pbpaste <udid>` — read clipboard contents.
888    pub async fn pasteboard_get(&self, udid: &str) -> Result<String, SimctlError> {
889        simctl_run(&["pbpaste", udid]).await
890    }
891
892    /// `xcrun simctl pbcopy <udid>` — write clipboard contents (via piped stdin).
893    pub async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), SimctlError> {
894        // pbcopy reads stdin — we pipe via shell echo for simplicity.
895        // Long-term: spawn with stdin pipe.
896        use tokio::io::AsyncWriteExt;
897        let mut cmd = Command::new("xcrun");
898        cmd.arg("simctl").arg("pbcopy").arg(udid);
899        cmd.stdin(std::process::Stdio::piped());
900        let mut child = cmd.spawn()?;
901        if let Some(mut stdin) = child.stdin.take() {
902            stdin.write_all(text.as_bytes()).await?;
903            drop(stdin); // close stdin so pbcopy returns
904        }
905        let status = child.wait().await?;
906        if !status.success() {
907            return Err(SimctlError::NonZeroExit {
908                subcommand: "pbcopy".into(),
909                code: status.code().unwrap_or(-1),
910                stderr: String::new(),
911            });
912        }
913        Ok(())
914    }
915
916    /// Toggle "Reduce Motion" accessibility setting via `defaults write`.
917    pub async fn set_reduce_motion(&self, udid: &str, enabled: bool) -> Result<(), SimctlError> {
918        let val = if enabled { "1" } else { "0" };
919        // `defaults write` lives under spawn; routed via simctl spawn.
920        simctl_run(&[
921            "spawn",
922            udid,
923            "defaults",
924            "write",
925            "com.apple.UIKit",
926            "UIAccessibilityReduceMotionEnabled",
927            "-bool",
928            val,
929        ])
930        .await?;
931        Ok(())
932    }
933
934    /// `xcrun simctl io <udid> screenshot <tmpfile>` → raw PNG bytes,
935    /// with a byte-level sRGB metadata splice if the produced PNG lacks
936    /// an `sRGB` chunk.
937    ///
938    /// Goes through a temp file: current Xcode's `screenshot -` does not
939    /// treat `-` as stdout — it writes a literal file named `-` in cwd
940    /// and emits nothing on stdout (observed on Xcode/iOS 26.5).
941    ///
942    /// **Pixel-preservation invariant**: the returned bytes are
943    /// byte-identical to whatever `simctl io screenshot` wrote to disk
944    /// EXCEPT for one narrow case — if the PNG does not carry an
945    /// `sRGB` ancillary chunk (observed on iOS 26.5 sub-builds
946    /// mid-2026), a 13-byte `sRGB` chunk is spliced in immediately
947    /// before the first `IDAT`. Pixel data (IDAT bytes) is never
948    /// decoded or modified. See [`ensure_srgb_chunk`] for the exact
949    /// splice operation.
950    pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, SimctlError> {
951        // v1.0.4 — pace + circuit-check before invoking simctl.
952        let wait = {
953            let mut pacer = self
954                .screenshot_pacer
955                .lock()
956                .expect("screenshot pacer mutex must not be poisoned");
957            pacer
958                .compute_wait()
959                .map_err(|retry_after| SimctlError::CaptureBackpressure { retry_after })?
960        };
961        if !wait.is_zero() {
962            sleep(wait).await;
963        }
964
965        let call_start = std::time::Instant::now();
966        let tmp =
967            std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
968        let tmp_str = tmp.display().to_string();
969        let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
970        let bytes = result.and_then(|_| {
971            std::fs::read(&tmp).map_err(|e| SimctlError::Malformed {
972                subcommand: "screenshot".into(),
973                detail: format!("read {tmp_str}: {e}"),
974            })
975        });
976        let _ = std::fs::remove_file(&tmp);
977
978        let wall = call_start.elapsed();
979        let failed = bytes.is_err();
980        {
981            let mut pacer = self
982                .screenshot_pacer
983                .lock()
984                .expect("screenshot pacer mutex must not be poisoned");
985            pacer.record(wall, failed);
986        }
987
988        let bytes = bytes?;
989        if bytes.len() < 8 {
990            return Err(SimctlError::Malformed {
991                subcommand: "screenshot".into(),
992                detail: format!("screenshot file too short: {} bytes", bytes.len()),
993            });
994        }
995        Ok(ensure_srgb_chunk(bytes))
996    }
997
998    /// `xcrun simctl create <name> <device-type-id> <runtime-id>` → udid.
999    pub async fn create_device(
1000        &self,
1001        name: &str,
1002        device_type: &str,
1003        runtime_id: &str,
1004    ) -> Result<String, SimctlError> {
1005        let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
1006        Ok(out.trim().to_string())
1007    }
1008
1009    /// `xcrun simctl delete <udid>` — delete a simulator device.
1010    pub async fn delete_device(&self, udid: &str) -> Result<(), SimctlError> {
1011        simctl_run(&["delete", udid]).await?;
1012        Ok(())
1013    }
1014}
1015
1016// -------------------- PNG sRGB chunk normalization (v1.0.2) --------------------
1017//
1018// iOS 26.5 sub-builds (mid-2026) started omitting the `sRGB` ancillary
1019// chunk from `simctl io screenshot` output. macOS Preview.app and other
1020// viewers that fall back to Display P3 when no ICC profile is embedded
1021// then over-saturate the image (red gets pushed, text anti-alias picks
1022// up yellow fringing).
1023//
1024// This does NOT affect pixel-comparison (dhash decodes IDAT to RGBA and
1025// ignores ancillary chunks), but does affect any downstream tool that
1026// renders the PNG for human review. The normalizer runs on the raw byte
1027// stream — walks chunks, and if no `sRGB` chunk is seen before the first
1028// `IDAT`, splices in a synthesized 13-byte `sRGB` chunk (length=1,
1029// type="sRGB", data=[0 = perceptual intent], CRC over type+data).
1030//
1031// Pixel-preservation invariant: IDAT bytes are never decoded. Every
1032// existing chunk is copied verbatim. Only 13 bytes of new metadata are
1033// inserted.
1034
1035const PNG_MAGIC: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
1036
1037/// Ensure the PNG carries an `sRGB` ancillary chunk. Called on the raw
1038/// bytes returned by `xcrun simctl io <udid> screenshot`. If the PNG
1039/// already has an `sRGB` chunk, returns the input unchanged; otherwise
1040/// splices in a 13-byte `sRGB` chunk (rendering intent = 0, perceptual)
1041/// immediately before the first `IDAT`. Returns the input unchanged on
1042/// any structural anomaly (missing magic, malformed chunk) so a
1043/// corrupted PNG is passed through untouched for the caller to diagnose.
1044pub fn ensure_srgb_chunk(bytes: Vec<u8>) -> Vec<u8> {
1045    if bytes.len() < 8 || &bytes[..8] != PNG_MAGIC {
1046        return bytes;
1047    }
1048    let Some((idat_offset, has_srgb)) = scan_png_chunks(&bytes) else {
1049        return bytes;
1050    };
1051    if has_srgb {
1052        return bytes;
1053    }
1054    // Splice the synthesized sRGB chunk right before the first IDAT.
1055    let mut out = Vec::with_capacity(bytes.len() + 13);
1056    out.extend_from_slice(&bytes[..idat_offset]);
1057    out.extend_from_slice(&synthesized_srgb_chunk());
1058    out.extend_from_slice(&bytes[idat_offset..]);
1059    out
1060}
1061
1062/// Walk PNG chunks starting after the 8-byte magic. Returns
1063/// `(offset_of_first_IDAT, has_srgb_chunk_before_it)` when the walk
1064/// reaches an IDAT chunk. Returns `None` if the walk hits EOF or a
1065/// malformed chunk without seeing an IDAT.
1066fn scan_png_chunks(bytes: &[u8]) -> Option<(usize, bool)> {
1067    let mut i: usize = 8;
1068    let mut has_srgb = false;
1069    while i + 8 <= bytes.len() {
1070        let length =
1071            u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1072        let ctype = &bytes[i + 4..i + 8];
1073        if ctype == b"IDAT" {
1074            return Some((i, has_srgb));
1075        }
1076        if ctype == b"sRGB" {
1077            has_srgb = true;
1078        }
1079        // 4 (length) + 4 (type) + length (data) + 4 (crc)
1080        let end = i.checked_add(12)?.checked_add(length)?;
1081        if end > bytes.len() {
1082            return None;
1083        }
1084        i = end;
1085    }
1086    None
1087}
1088
1089/// Build the 13-byte `sRGB` chunk with rendering intent = 0 (perceptual).
1090/// Format: `[len:4][type:4][data:1][crc:4]` = 13 bytes total.
1091fn synthesized_srgb_chunk() -> [u8; 13] {
1092    // The CRC is computed over `type || data`.
1093    let mut crc_input = [0u8; 5];
1094    crc_input[0..4].copy_from_slice(b"sRGB");
1095    crc_input[4] = 0; // perceptual
1096    let crc = crc32_ieee(&crc_input);
1097    let mut chunk = [0u8; 13];
1098    chunk[0..4].copy_from_slice(&1u32.to_be_bytes()); // length = 1 (data byte)
1099    chunk[4..8].copy_from_slice(b"sRGB");
1100    chunk[8] = 0;
1101    chunk[9..13].copy_from_slice(&crc.to_be_bytes());
1102    chunk
1103}
1104
1105/// Table-less CRC-32 IEEE 802.3 (polynomial 0xEDB88320) as used by
1106/// PNG. Small enough for this crate's single call site — avoids
1107/// pulling in a `crc32fast` dependency.
1108fn crc32_ieee(bytes: &[u8]) -> u32 {
1109    let mut crc: u32 = 0xFFFF_FFFF;
1110    for &b in bytes {
1111        crc ^= u32::from(b);
1112        for _ in 0..8 {
1113            let mask = 0u32.wrapping_sub(crc & 1);
1114            crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
1115        }
1116    }
1117    !crc
1118}
1119
1120#[cfg(test)]
1121mod tests {
1122    use super::*;
1123
1124    #[test]
1125    fn compose_child_env_adds_prefix() {
1126        let composed = compose_child_env(&[
1127            ("SMIX_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
1128            ("LAUNCH_FORCE_PUSH", "true"),
1129        ]);
1130        assert_eq!(
1131            composed,
1132            vec![
1133                (
1134                    "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
1135                    "http://127.0.0.1:9999".to_string(),
1136                ),
1137                (
1138                    "SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
1139                    "true".to_string(),
1140                ),
1141            ]
1142        );
1143    }
1144
1145    #[test]
1146    fn compose_child_env_already_prefixed_passes_through() {
1147        // Defensive: caller may pre-prefix; we must not double-prefix.
1148        let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
1149        assert_eq!(
1150            composed,
1151            vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
1152        );
1153    }
1154
1155    #[test]
1156    fn compose_child_env_empty_input_is_empty_output() {
1157        assert!(compose_child_env(&[]).is_empty());
1158    }
1159
1160    // -- v1.0.4 §G openurl URL preservation -----------------------------
1161
1162    #[test]
1163    fn openurl_argv_preserves_url_verbatim() {
1164        let udid = "12345678-1234-5678-1234-567812345678";
1165        let url = "exp+focus-ai-app://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081";
1166        let argv = super::openurl_argv(udid, url);
1167        assert_eq!(argv[0], "openurl");
1168        assert_eq!(argv[1], udid);
1169        // Byte-identical URL — no percent-decoding, no query-strip.
1170        assert_eq!(argv[2], url);
1171        assert!(argv[2].contains("?url="));
1172        assert!(argv[2].contains("%3A"));
1173        assert!(argv[2].contains("%2F"));
1174    }
1175
1176    #[test]
1177    fn openurl_argv_preserves_ampersand_and_hash() {
1178        let udid = "12345678-1234-5678-1234-567812345678";
1179        let url = "insight://dev-mutate?action=env&value=staging#anchor";
1180        let argv = super::openurl_argv(udid, url);
1181        assert_eq!(argv[2], url);
1182        assert!(argv[2].contains('&'));
1183        assert!(argv[2].contains('#'));
1184    }
1185
1186    #[test]
1187    fn openurl_argv_preserves_unicode() {
1188        let udid = "12345678-1234-5678-1234-567812345678";
1189        let url = "insight://route?name=%E7%94%B0%E4%B8%AD";
1190        let argv = super::openurl_argv(udid, url);
1191        assert_eq!(argv[2], url);
1192    }
1193
1194    // -- sRGB chunk normalization ---------------------------------------
1195
1196    /// Build a minimal PNG: 1×1 8-bit RGBA, one IDAT (zlib-empty-safe),
1197    /// with or without an sRGB chunk. Returns synthetic bytes suitable
1198    /// for exercising the chunk-walking logic; no rendering intent.
1199    fn synth_png(with_srgb: bool) -> Vec<u8> {
1200        let mut out = Vec::new();
1201        out.extend_from_slice(super::PNG_MAGIC);
1202        // IHDR: 1x1, bit_depth=8, color_type=6 (RGBA), rest=0
1203        let ihdr_data: [u8; 13] = [
1204            0, 0, 0, 1, // width = 1
1205            0, 0, 0, 1, // height = 1
1206            8, // bit depth
1207            6, // color type = RGBA
1208            0, 0, 0,
1209        ];
1210        emit_chunk(&mut out, b"IHDR", &ihdr_data);
1211        if with_srgb {
1212            emit_chunk(&mut out, b"sRGB", &[0]);
1213        }
1214        // Placeholder IDAT — content doesn't matter for chunk-walking tests
1215        emit_chunk(&mut out, b"IDAT", &[0x78, 0x01, 0x00, 0x00]);
1216        emit_chunk(&mut out, b"IEND", &[]);
1217        out
1218    }
1219
1220    fn emit_chunk(out: &mut Vec<u8>, ctype: &[u8; 4], data: &[u8]) {
1221        out.extend_from_slice(&(data.len() as u32).to_be_bytes());
1222        out.extend_from_slice(ctype);
1223        out.extend_from_slice(data);
1224        let mut crc_in = Vec::with_capacity(4 + data.len());
1225        crc_in.extend_from_slice(ctype);
1226        crc_in.extend_from_slice(data);
1227        out.extend_from_slice(&super::crc32_ieee(&crc_in).to_be_bytes());
1228    }
1229
1230    #[test]
1231    fn ensure_srgb_passthrough_when_chunk_present() {
1232        let png = synth_png(true);
1233        let original_len = png.len();
1234        let out = super::ensure_srgb_chunk(png.clone());
1235        assert_eq!(out.len(), original_len);
1236        assert_eq!(out, png);
1237    }
1238
1239    #[test]
1240    fn ensure_srgb_inserts_chunk_when_absent() {
1241        let png = synth_png(false);
1242        let original_len = png.len();
1243        let out = super::ensure_srgb_chunk(png);
1244        assert_eq!(out.len(), original_len + 13);
1245        // First 8 bytes = PNG magic
1246        assert_eq!(&out[..8], super::PNG_MAGIC);
1247        // Search for the injected sRGB chunk
1248        let mut found = false;
1249        for w in out.windows(4) {
1250            if w == b"sRGB" {
1251                found = true;
1252                break;
1253            }
1254        }
1255        assert!(found, "sRGB chunk should have been spliced in");
1256    }
1257
1258    #[test]
1259    fn ensure_srgb_preserves_idat_bytes_verbatim() {
1260        // Any pixel corruption at the IDAT level would break the
1261        // pixel-preservation invariant. Extract IDAT payload from
1262        // input and output, assert byte-identical.
1263        let png = synth_png(false);
1264        let out = super::ensure_srgb_chunk(png.clone());
1265        assert_eq!(extract_idat_data(&png), extract_idat_data(&out));
1266    }
1267
1268    fn extract_idat_data(bytes: &[u8]) -> Vec<u8> {
1269        let mut i = 8;
1270        while i + 8 <= bytes.len() {
1271            let length =
1272                u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1273            let ctype = &bytes[i + 4..i + 8];
1274            if ctype == b"IDAT" {
1275                return bytes[i + 8..i + 8 + length].to_vec();
1276            }
1277            i += 12 + length;
1278        }
1279        vec![]
1280    }
1281
1282    #[test]
1283    fn ensure_srgb_passthrough_on_bad_magic() {
1284        // Corrupted / non-PNG input must not be modified.
1285        let bytes = vec![0u8; 32];
1286        let out = super::ensure_srgb_chunk(bytes.clone());
1287        assert_eq!(out, bytes);
1288    }
1289
1290    #[test]
1291    fn crc32_matches_known_iend() {
1292        // The empty-data IEND CRC is a well-known constant.
1293        // CRC over "IEND" alone: 0xAE_42_60_82.
1294        assert_eq!(super::crc32_ieee(b"IEND"), 0xAE42_6082);
1295    }
1296}