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