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::io::Write;
278    use std::path::{Path, PathBuf};
279    use std::sync::{Mutex, OnceLock};
280    use std::time::{Duration, UNIX_EPOCH};
281
282    fn cell() -> &'static Mutex<VecDeque<SubprocessRecord>> {
283        static INSTANCE: OnceLock<Mutex<VecDeque<SubprocessRecord>>> = OnceLock::new();
284        INSTANCE.get_or_init(|| Mutex::new(VecDeque::with_capacity(128)))
285    }
286
287    /// v1.0.10 §D6 — persist path for the ring buffer. Nil = in-memory
288    /// only (legacy pre-v1.0.10 behavior). Set once at process startup
289    /// via [`set_persist_path`]; unchanged for the lifetime of the
290    /// process. Insight's v1.0.7 dogfood reported empty
291    /// `/diagnostic/dump` payloads because supervisor cycles killed the
292    /// CLI faster than a dump could snapshot the in-memory buffer.
293    /// Persisting side-steps that: the file survives cycles, so
294    /// post-mortem tools read the file rather than the (now-gone)
295    /// in-memory state.
296    fn persist_cell() -> &'static Mutex<Option<PathBuf>> {
297        static INSTANCE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
298        INSTANCE.get_or_init(|| Mutex::new(None))
299    }
300
301    /// v1.0.10 §D6 — install a persist path. Callers should also call
302    /// [`load_persisted`] once after this so the in-memory ring starts
303    /// pre-populated with the last-known state.
304    pub fn set_persist_path(path: PathBuf) {
305        let mut g = match persist_cell().lock() {
306            Ok(g) => g,
307            Err(p) => p.into_inner(),
308        };
309        *g = Some(path);
310    }
311
312    fn persist_path_copy() -> Option<PathBuf> {
313        let g = match persist_cell().lock() {
314            Ok(g) => g,
315            Err(p) => p.into_inner(),
316        };
317        g.clone()
318    }
319
320    /// v1.0.10 §D6 — record one invocation. Ring buffer capped at 128
321    /// entries; oldest evicted on push. When [`set_persist_path`] is
322    /// active, atomically writes the buffer to disk after the mutation
323    /// so a subsequent supervisor-cycle doesn't lose the observation.
324    pub(super) fn record(entry: SubprocessRecord) {
325        {
326            let mut g = match cell().lock() {
327                Ok(g) => g,
328                Err(p) => p.into_inner(),
329            };
330            if g.len() >= 128 {
331                g.pop_front();
332            }
333            g.push_back(entry);
334        }
335        if let Some(path) = persist_path_copy() {
336            let snapshot = snapshot();
337            // Best-effort. Failure to persist must never affect the
338            // caller of `xcrun simctl` — we swallow errors here and
339            // let the next `record()` retry.
340            let _ = write_json_atomic(&path, &snapshot);
341        }
342    }
343
344    /// Snapshot the current ring buffer. Ordered oldest → newest.
345    pub fn snapshot() -> Vec<SubprocessRecord> {
346        let g = match cell().lock() {
347            Ok(g) => g,
348            Err(p) => p.into_inner(),
349        };
350        g.iter().cloned().collect()
351    }
352
353    /// v1.0.10 §D6 — load a previously-persisted ring from disk. No-op
354    /// when the file does not exist. Called by CLI startup after
355    /// [`set_persist_path`] so the in-memory view starts with the
356    /// last-known state. Silently drops parse failures — corrupt files
357    /// are noise, not fatal.
358    pub fn load_persisted() {
359        let Some(path) = persist_path_copy() else {
360            return;
361        };
362        let Ok(bytes) = std::fs::read(&path) else {
363            return;
364        };
365        let Ok(records) = serde_json::from_slice::<Vec<PersistedRecord>>(&bytes) else {
366            return;
367        };
368        let mut g = match cell().lock() {
369            Ok(g) => g,
370            Err(p) => p.into_inner(),
371        };
372        for r in records.into_iter().rev().take(128).rev() {
373            g.push_back(r.into_record());
374        }
375    }
376
377    fn write_json_atomic(path: &Path, records: &[SubprocessRecord]) -> std::io::Result<()> {
378        if let Some(parent) = path.parent() {
379            std::fs::create_dir_all(parent)?;
380        }
381        let tmp = path.with_extension("json.tmp");
382        {
383            let payload: Vec<PersistedRecord> = records.iter().cloned().map(Into::into).collect();
384            let serialized = serde_json::to_vec(&payload)
385                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
386            let mut f = std::fs::File::create(&tmp)?;
387            f.write_all(&serialized)?;
388            f.sync_all()?;
389        }
390        std::fs::rename(&tmp, path)
391    }
392
393    /// On-disk representation. Kept separate from
394    /// [`SubprocessRecord`] because the wall-clock timestamp is a
395    /// `SystemTime` which does not serde-derive cleanly; we convert to
396    /// UNIX millis for a stable JSON shape.
397    #[derive(Clone, serde::Serialize, serde::Deserialize)]
398    struct PersistedRecord {
399        argv: Vec<String>,
400        exit_code: Option<i32>,
401        wall_ms: u64,
402        stderr_head: String,
403        timestamp_ms: u64,
404    }
405
406    impl From<SubprocessRecord> for PersistedRecord {
407        fn from(r: SubprocessRecord) -> Self {
408            let timestamp_ms = r
409                .timestamp
410                .duration_since(UNIX_EPOCH)
411                .map(|d| d.as_millis() as u64)
412                .unwrap_or(0);
413            Self {
414                argv: r.argv,
415                exit_code: r.exit_code,
416                wall_ms: r.wall_ms,
417                stderr_head: r.stderr_head,
418                timestamp_ms,
419            }
420        }
421    }
422
423    impl PersistedRecord {
424        fn into_record(self) -> SubprocessRecord {
425            let timestamp =
426                UNIX_EPOCH + Duration::from_millis(self.timestamp_ms);
427            SubprocessRecord {
428                argv: self.argv,
429                exit_code: self.exit_code,
430                wall_ms: self.wall_ms,
431                stderr_head: self.stderr_head,
432                timestamp,
433            }
434        }
435    }
436
437    #[cfg(test)]
438    mod tests {
439        use super::*;
440        use std::time::SystemTime;
441
442        #[test]
443        fn persist_roundtrip_after_supervisor_cycle_simulation() {
444            let dir = tempfile::tempdir().expect("tempdir");
445            let path = dir.path().join("ring.json");
446            set_persist_path(path.clone());
447
448            record(SubprocessRecord {
449                argv: vec!["shutdown".into(), "UDID-A".into()],
450                exit_code: Some(0),
451                wall_ms: 42,
452                stderr_head: String::new(),
453                timestamp: SystemTime::now(),
454            });
455            assert!(path.exists(), "persist file must exist after record");
456
457            // Simulate supervisor cycle: clear in-memory then load.
458            {
459                let mut g = cell().lock().unwrap();
460                g.clear();
461            }
462            assert!(snapshot().is_empty());
463
464            load_persisted();
465            let after = snapshot();
466            assert_eq!(after.len(), 1);
467            assert_eq!(after[0].argv, vec!["shutdown".to_string(), "UDID-A".into()]);
468            assert_eq!(after[0].exit_code, Some(0));
469            assert_eq!(after[0].wall_ms, 42);
470        }
471    }
472}
473
474/// v1.0.10 §D6 — enable subprocess-ring persistence at the given path.
475/// CLI startup wires this to `~/.local/share/smix/subprocess-ring.json`
476/// so `/diagnostic/dump` payloads survive supervisor cycles. Optional;
477/// missing this call keeps pre-v1.0.10 in-memory-only behavior.
478pub fn set_subprocess_ring_persist_path(path: std::path::PathBuf) {
479    subprocess_ring::set_persist_path(path);
480    subprocess_ring::load_persisted();
481}
482
483// v1.0.14 Cluster A — CLI-side resetAppData counter tracking.
484//
485// The `resetAppData` verb dispatches host-side (simctl openurl + metro
486// log tail, no runner HTTP endpoint) so counters can't come from the
487// runner's `/diagnostic/dump` payload. This module owns them,
488// persisting to `~/.local/share/smix/reset-app-data-counters.json`
489// so counter deltas across `smix run` invocations + `smix diagnostic
490// dump` (later, separate process) all see the same data.
491//
492// Public API mirrors [`subprocess_ring`] shape for consistency.
493mod reset_app_data_counters {
494    use std::path::{Path, PathBuf};
495    use std::sync::{Mutex, OnceLock};
496
497    fn cell() -> &'static Mutex<Counters> {
498        static INSTANCE: OnceLock<Mutex<Counters>> = OnceLock::new();
499        INSTANCE.get_or_init(|| Mutex::new(Counters::default()))
500    }
501    fn persist_cell() -> &'static Mutex<Option<PathBuf>> {
502        static INSTANCE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
503        INSTANCE.get_or_init(|| Mutex::new(None))
504    }
505
506    #[derive(Clone, Copy, Debug, Default, serde::Serialize, serde::Deserialize)]
507    pub struct Counters {
508        pub reset_app_data_total: u64,
509        pub reset_app_data_timed_out: u64,
510    }
511
512    pub fn set_persist_path(path: PathBuf) {
513        let mut g = match persist_cell().lock() {
514            Ok(g) => g,
515            Err(p) => p.into_inner(),
516        };
517        *g = Some(path);
518    }
519
520    fn persist_path_copy() -> Option<PathBuf> {
521        let g = match persist_cell().lock() {
522            Ok(g) => g,
523            Err(p) => p.into_inner(),
524        };
525        g.clone()
526    }
527
528    pub fn load_persisted() {
529        let Some(path) = persist_path_copy() else {
530            return;
531        };
532        let Ok(bytes) = std::fs::read(&path) else {
533            return;
534        };
535        let Ok(loaded) = serde_json::from_slice::<Counters>(&bytes) else {
536            return;
537        };
538        let mut g = match cell().lock() {
539            Ok(g) => g,
540            Err(p) => p.into_inner(),
541        };
542        *g = loaded;
543    }
544
545    pub fn increment_total() {
546        {
547            let mut g = match cell().lock() {
548                Ok(g) => g,
549                Err(p) => p.into_inner(),
550            };
551            g.reset_app_data_total = g.reset_app_data_total.saturating_add(1);
552        }
553        persist_best_effort();
554    }
555
556    pub fn increment_timed_out() {
557        {
558            let mut g = match cell().lock() {
559                Ok(g) => g,
560                Err(p) => p.into_inner(),
561            };
562            g.reset_app_data_timed_out = g.reset_app_data_timed_out.saturating_add(1);
563        }
564        persist_best_effort();
565    }
566
567    pub fn snapshot() -> Counters {
568        let g = match cell().lock() {
569            Ok(g) => g,
570            Err(p) => p.into_inner(),
571        };
572        *g
573    }
574
575    fn persist_best_effort() {
576        let Some(path) = persist_path_copy() else {
577            return;
578        };
579        let snapshot = snapshot();
580        let _ = write_json_atomic(&path, &snapshot);
581    }
582
583    fn write_json_atomic(path: &Path, counters: &Counters) -> std::io::Result<()> {
584        use std::io::Write;
585        if let Some(parent) = path.parent() {
586            std::fs::create_dir_all(parent)?;
587        }
588        let tmp = path.with_extension("json.tmp");
589        let bytes = serde_json::to_vec(counters)
590            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
591        {
592            let mut f = std::fs::File::create(&tmp)?;
593            f.write_all(&bytes)?;
594            f.sync_all()?;
595        }
596        std::fs::rename(&tmp, path)
597    }
598
599    #[cfg(test)]
600    mod tests {
601        use super::*;
602
603        #[test]
604        fn increment_and_persist_roundtrip() {
605            let dir = tempfile::tempdir().expect("tempdir");
606            let path = dir.path().join("counters.json");
607            set_persist_path(path.clone());
608            // Reset in-memory to avoid cross-test pollution.
609            {
610                let mut g = cell().lock().unwrap();
611                *g = Counters::default();
612            }
613            increment_total();
614            increment_total();
615            increment_timed_out();
616            assert!(path.exists());
617            let raw = std::fs::read_to_string(&path).unwrap();
618            let loaded: Counters = serde_json::from_str(&raw).unwrap();
619            assert_eq!(loaded.reset_app_data_total, 2);
620            assert_eq!(loaded.reset_app_data_timed_out, 1);
621        }
622    }
623}
624
625/// v1.0.14 Cluster A — public snapshot of CLI-side resetAppData
626/// counter state. Populated by [`increment_reset_app_data_total`] +
627/// [`increment_reset_app_data_timed_out`] as the CLI dispatches the
628/// verb; loaded from disk on CLI startup if
629/// [`set_reset_app_data_counters_persist_path`] was called.
630#[derive(Clone, Copy, Debug, Default)]
631pub struct ResetAppDataCounters {
632    /// Total resetAppData dispatches (any outcome).
633    pub reset_app_data_total: u64,
634    /// resetAppData dispatches where the completion signal did not
635    /// arrive inside the timeout window. `> 0` = the URL was fired
636    /// but the app did not emit the expected reset-complete log line.
637    pub reset_app_data_timed_out: u64,
638}
639
640/// v1.0.14 Cluster A — enable resetAppData counter persistence at
641/// the given path. Callers pass
642/// `~/.local/share/smix/reset-app-data-counters.json` at CLI startup
643/// so counter state survives across `smix run` → `smix diagnostic
644/// dump` invocations.
645pub fn set_reset_app_data_counters_persist_path(path: std::path::PathBuf) {
646    reset_app_data_counters::set_persist_path(path);
647    reset_app_data_counters::load_persisted();
648}
649
650/// v1.0.14 Cluster A — advance the resetAppData total counter.
651/// Called by the CLI runtime after each dispatch (success or timeout).
652pub fn increment_reset_app_data_total() {
653    reset_app_data_counters::increment_total();
654}
655
656/// v1.0.14 Cluster A — advance the resetAppData timed-out counter.
657/// Called by the CLI runtime when the completion signal (log-line
658/// pattern match) did not arrive inside the timeout window. Always
659/// paired with a preceding [`increment_reset_app_data_total`] on the
660/// same dispatch.
661pub fn increment_reset_app_data_timed_out() {
662    reset_app_data_counters::increment_timed_out();
663}
664
665/// v1.0.14 Cluster A — snapshot the current counter state for
666/// display / wire emission. Returns zero-valued counters when
667/// persistence was never wired.
668pub fn reset_app_data_counters_snapshot() -> ResetAppDataCounters {
669    let s = reset_app_data_counters::snapshot();
670    ResetAppDataCounters {
671        reset_app_data_total: s.reset_app_data_total,
672        reset_app_data_timed_out: s.reset_app_data_timed_out,
673    }
674}
675
676// v1.0.15 §6 — flow-attempt persistence for retry attribution. Called
677// by `smix run` after each flow completes (all its attempts done);
678// read by `smix diagnostic dump` to render the attribution table.
679// Backed by `~/.local/share/smix/flow-attempts.json`; capped at last
680// 32 flows (heuristic — enough for a batch or two of insight bootstrap
681// while keeping the dump snapshot cheap to serialize).
682mod flow_attempts {
683    use serde::{Deserialize, Serialize};
684    use std::path::{Path, PathBuf};
685    use std::sync::{Mutex, OnceLock};
686
687    #[derive(Clone, Debug, Serialize, Deserialize)]
688    pub struct PersistedAttempt {
689        pub attempt_index: u32,
690        pub status: String,
691        pub error_class: Option<String>,
692        pub ips_generated: Option<String>,
693        pub wall_ms: u64,
694    }
695
696    #[derive(Clone, Debug, Serialize, Deserialize)]
697    pub struct PersistedFlow {
698        pub flow_name: String,
699        pub attempts: Vec<PersistedAttempt>,
700    }
701
702    fn cell() -> &'static Mutex<Vec<PersistedFlow>> {
703        static INSTANCE: OnceLock<Mutex<Vec<PersistedFlow>>> = OnceLock::new();
704        INSTANCE.get_or_init(|| Mutex::new(Vec::new()))
705    }
706    fn persist_cell() -> &'static Mutex<Option<PathBuf>> {
707        static INSTANCE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
708        INSTANCE.get_or_init(|| Mutex::new(None))
709    }
710
711    pub fn set_persist_path(path: PathBuf) {
712        let mut g = match persist_cell().lock() {
713            Ok(g) => g,
714            Err(p) => p.into_inner(),
715        };
716        *g = Some(path);
717    }
718
719    fn persist_path_copy() -> Option<PathBuf> {
720        let g = match persist_cell().lock() {
721            Ok(g) => g,
722            Err(p) => p.into_inner(),
723        };
724        g.clone()
725    }
726
727    pub fn load_persisted() {
728        let Some(path) = persist_path_copy() else {
729            return;
730        };
731        let Ok(bytes) = std::fs::read(&path) else {
732            return;
733        };
734        let Ok(loaded) = serde_json::from_slice::<Vec<PersistedFlow>>(&bytes) else {
735            return;
736        };
737        let mut g = match cell().lock() {
738            Ok(g) => g,
739            Err(p) => p.into_inner(),
740        };
741        *g = loaded;
742    }
743
744    pub fn record(flow_name: &str, attempts: &[PersistedAttempt]) {
745        {
746            let mut g = match cell().lock() {
747                Ok(g) => g,
748                Err(p) => p.into_inner(),
749            };
750            g.push(PersistedFlow {
751                flow_name: flow_name.to_string(),
752                attempts: attempts.to_vec(),
753            });
754            if g.len() > 32 {
755                let drop = g.len() - 32;
756                g.drain(0..drop);
757            }
758        }
759        persist_best_effort();
760    }
761
762    pub fn snapshot() -> Vec<PersistedFlow> {
763        let g = match cell().lock() {
764            Ok(g) => g,
765            Err(p) => p.into_inner(),
766        };
767        g.clone()
768    }
769
770    fn persist_best_effort() {
771        let Some(path) = persist_path_copy() else {
772            return;
773        };
774        let flows = snapshot();
775        let _ = write_json_atomic(&path, &flows);
776    }
777
778    fn write_json_atomic(path: &Path, flows: &[PersistedFlow]) -> std::io::Result<()> {
779        use std::io::Write;
780        if let Some(parent) = path.parent() {
781            std::fs::create_dir_all(parent)?;
782        }
783        let tmp = path.with_extension("json.tmp");
784        let bytes = serde_json::to_vec(flows)
785            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
786        {
787            let mut f = std::fs::File::create(&tmp)?;
788            f.write_all(&bytes)?;
789            f.sync_all()?;
790        }
791        std::fs::rename(&tmp, path)
792    }
793}
794
795/// v1.0.15 §6 — enable flow-attempts persistence at the given path.
796/// CLI startup wires this to `~/.local/share/smix/flow-attempts.json`
797/// so retry attribution survives across `smix run` → `smix diagnostic
798/// dump` invocations.
799pub fn set_flow_attempts_persist_path(path: std::path::PathBuf) {
800    flow_attempts::set_persist_path(path);
801    flow_attempts::load_persisted();
802}
803
804/// v1.0.15 §6 — public accessor with just the fields needed by callers.
805/// Mirrors [`smix_runner_wire::FlowAttempt`] shape.
806#[derive(Clone, Debug)]
807pub struct FlowAttemptData {
808    /// Zero-based retry index.
809    pub attempt_index: u32,
810    /// Overall outcome ("ok" / "timeout" / "error" / "crashed").
811    pub status: String,
812    /// Free-form error class code (`Some` on non-ok).
813    pub error_class: Option<String>,
814    /// `.ips` filename that appeared during this attempt, when detected.
815    pub ips_generated: Option<String>,
816    /// Wall-clock milliseconds.
817    pub wall_ms: u64,
818}
819
820/// v1.0.15 §6 — recorded flow with its attempt list.
821#[derive(Clone, Debug)]
822pub struct FlowAttemptRecordData {
823    /// Flow name (yaml basename or explicit id).
824    pub flow_name: String,
825    /// Ordered attempts, first try first.
826    pub attempts: Vec<FlowAttemptData>,
827}
828
829/// v1.0.15 §6 — record the outcome of a flow's attempts. Called from
830/// `smix run` after all retries for that flow have completed. `smix
831/// diagnostic dump` reads via [`recent_flow_attempts`] later.
832pub fn record_flow_attempts<A>(flow_name: &str, attempts: &[A])
833where
834    A: FlowAttemptShape,
835{
836    let converted: Vec<flow_attempts::PersistedAttempt> = attempts
837        .iter()
838        .map(|a| flow_attempts::PersistedAttempt {
839            attempt_index: a.attempt_index(),
840            status: a.status().to_string(),
841            error_class: a.error_class().map(str::to_string),
842            ips_generated: a.ips_generated().map(str::to_string),
843            wall_ms: a.wall_ms(),
844        })
845        .collect();
846    flow_attempts::record(flow_name, &converted);
847}
848
849/// v1.0.15 §6 — abstraction so callers pass either
850/// [`smix_runner_wire::FlowAttempt`] or a local struct with the same
851/// shape without a cross-crate dep on smix-runner-wire from smix-simctl.
852pub trait FlowAttemptShape {
853    /// Zero-based retry index.
854    fn attempt_index(&self) -> u32;
855    /// "ok" / "timeout" / "error" / "crashed".
856    fn status(&self) -> &str;
857    /// Error class code, if any.
858    fn error_class(&self) -> Option<&str>;
859    /// `.ips` filename attributable to this attempt, if any.
860    fn ips_generated(&self) -> Option<&str>;
861    /// Wall-clock milliseconds.
862    fn wall_ms(&self) -> u64;
863}
864
865/// v1.0.15 §6 — snapshot recent flow attempts for display / wire
866/// emission. Returns empty when persistence was never wired.
867pub fn recent_flow_attempts() -> Vec<FlowAttemptRecordData> {
868    flow_attempts::snapshot()
869        .into_iter()
870        .map(|f| FlowAttemptRecordData {
871            flow_name: f.flow_name,
872            attempts: f
873                .attempts
874                .into_iter()
875                .map(|a| FlowAttemptData {
876                    attempt_index: a.attempt_index,
877                    status: a.status,
878                    error_class: a.error_class,
879                    ips_generated: a.ips_generated,
880                    wall_ms: a.wall_ms,
881                })
882                .collect(),
883        })
884        .collect()
885}
886
887/// v1.0.7 §D3 — snapshot the process-wide ring buffer of recent
888/// `xcrun simctl` invocations. Ordered oldest → newest, capped at 128
889/// entries. Reset on process restart.
890pub fn recent_subprocesses() -> Vec<SubprocessRecord> {
891    subprocess_ring::snapshot()
892}
893
894// -------------------- raw spawn primitive --------------------------------
895
896/// Execute `xcrun simctl <args>` and capture stdout/stderr.
897async fn simctl_capture(args: &[&str]) -> Result<(Vec<u8>, String), SimctlError> {
898    simctl_capture_env(args, &[]).await
899}
900
901/// `simctl_capture` with extra envp pairs set on the spawned process.
902/// The `xcrun simctl launch` subcommand uses this to inject
903/// `SIMCTL_CHILD_<KEY>=<VAL>` vars that the launched app sees as
904/// `ProcessInfo().environment["KEY"]`. `env` entries here are passed
905/// verbatim — caller composes the `SIMCTL_CHILD_` prefix via
906/// [`compose_child_env`].
907async fn simctl_capture_env(
908    args: &[&str],
909    env: &[(String, String)],
910) -> Result<(Vec<u8>, String), SimctlError> {
911    let mut cmd = Command::new("xcrun");
912    cmd.arg("simctl");
913    for a in args {
914        cmd.arg(a);
915    }
916    for (k, v) in env {
917        cmd.env(k, v);
918    }
919    let started = std::time::Instant::now();
920    let output = cmd.output().await?;
921    let wall_ms = started.elapsed().as_millis() as u64;
922    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
923    // v1.0.7 §D3 — every simctl invocation records to the ring buffer
924    // regardless of exit status.
925    subprocess_ring::record(SubprocessRecord {
926        argv: args.iter().map(|s| s.to_string()).collect(),
927        exit_code: output.status.code(),
928        wall_ms,
929        stderr_head: {
930            let mut s = stderr.clone();
931            if s.len() > 256 {
932                s.truncate(256);
933            }
934            s
935        },
936        timestamp: std::time::SystemTime::now(),
937    });
938    if !output.status.success() {
939        return Err(SimctlError::NonZeroExit {
940            subcommand: args.first().map(|s| s.to_string()).unwrap_or_default(),
941            argv: args.iter().map(|s| s.to_string()).collect(),
942            code: output.status.code().unwrap_or(-1),
943            stderr,
944            wall_ms,
945        });
946    }
947    Ok((output.stdout, stderr))
948}
949
950async fn simctl_run(args: &[&str]) -> Result<String, SimctlError> {
951    let (stdout, _) = simctl_capture(args).await?;
952    Ok(String::from_utf8_lossy(&stdout).into_owned())
953}
954
955/// Like [`simctl_run`] but injects `child_env` envp on the spawned
956/// process. Used by the env-aware launch path so the launched app can
957/// read deploy-time secrets / endpoints via `ProcessInfo`.
958async fn simctl_run_env(args: &[&str], env: &[(String, String)]) -> Result<String, SimctlError> {
959    let (stdout, _) = simctl_capture_env(args, env).await?;
960    Ok(String::from_utf8_lossy(&stdout).into_owned())
961}
962
963/// Compose user-provided `(key, value)` pairs into the `SIMCTL_CHILD_*`
964/// envp that `xcrun simctl launch` strips and delivers to the launched
965/// app. Idempotent: a key that already starts with `SIMCTL_CHILD_` is
966/// passed through unchanged.
967///
968/// # Example
969///
970/// ```
971/// use smix_simctl::compose_child_env;
972/// let composed = compose_child_env(&[("SMIX_PERF_RECEIVER_URL", "http://h:9999")]);
973/// assert_eq!(
974///     composed,
975///     vec![(
976///         "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
977///         "http://h:9999".to_string(),
978///     )]
979/// );
980/// ```
981pub fn compose_child_env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
982    pairs
983        .iter()
984        .map(|(k, v)| {
985            let key = if k.starts_with("SIMCTL_CHILD_") {
986                (*k).to_string()
987            } else {
988                format!("SIMCTL_CHILD_{k}")
989            };
990            (key, (*v).to_string())
991        })
992        .collect()
993}
994
995// -------------------- client --------------------------------------------
996
997/// Stateless wrapper around xcrun simctl. Methods are free functions
998/// in spirit (no instance state beyond optionally-cached `xcrun` path);
999/// kept as a struct for API ergonomics + future caching.
1000///
1001/// Since v1.0.4, the client also holds a [`ScreenshotPacer`] that
1002/// throttles `xcrun simctl io screenshot` under high-frequency
1003/// load. Defaults are conservative (100 ms interval floor);
1004/// consumers whose flows are already loose are unaffected.
1005#[derive(Debug)]
1006pub struct SimctlClient {
1007    /// Screenshot pacer — enforces the RFC 1.0.4 §D3 interval floor,
1008    /// slow-path lift, and circuit breaker. Shared via `Arc<Mutex<_>>`
1009    /// so a cloned client (which callers occasionally do) still shares
1010    /// pressure accounting.
1011    screenshot_pacer: Arc<std::sync::Mutex<ScreenshotPacer>>,
1012}
1013
1014impl Default for SimctlClient {
1015    fn default() -> Self {
1016        Self::new()
1017    }
1018}
1019
1020impl SimctlClient {
1021    /// Construct a new client with default screenshot pacing (100 ms
1022    /// interval floor, adaptive slow-path lift to 1500 ms, circuit
1023    /// breaker on ≥ 1500 ms walls or failures).
1024    pub fn new() -> Self {
1025        SimctlClient {
1026            screenshot_pacer: Arc::new(std::sync::Mutex::new(ScreenshotPacer::new(
1027                ScreenshotPacerConfig::default(),
1028            ))),
1029        }
1030    }
1031
1032    /// Override the screenshot pacer with a custom config.
1033    ///
1034    /// Since smix 1.0.4.
1035    #[must_use]
1036    pub fn with_screenshot_pacer(self, config: ScreenshotPacerConfig) -> Self {
1037        {
1038            let mut guard = self
1039                .screenshot_pacer
1040                .lock()
1041                .expect("screenshot pacer mutex must not be poisoned");
1042            *guard = ScreenshotPacer::new(config);
1043        }
1044        self
1045    }
1046
1047    /// Attach a [`smix_sim_health::SimHealthMonitor`] to receive
1048    /// screenshot wall-time observations from every `screenshot`
1049    /// call. Composes with the pacer — the pacer still enforces its
1050    /// interval / circuit locally, and the monitor sees the same
1051    /// walls for global state classification.
1052    ///
1053    /// Since smix 1.0.4.
1054    #[must_use]
1055    pub fn with_sim_health(self, monitor: smix_sim_health::SimHealthMonitor) -> Self {
1056        {
1057            let mut guard = self
1058                .screenshot_pacer
1059                .lock()
1060                .expect("screenshot pacer mutex must not be poisoned");
1061            guard.set_monitor(monitor);
1062        }
1063        self
1064    }
1065
1066    // ---- inventory ------------------------------------------------------
1067
1068    /// `xcrun simctl list runtimes -j` → `Vec<SimctlRuntime>`.
1069    pub async fn list_runtimes(&self) -> Result<Vec<SimctlRuntime>, SimctlError> {
1070        let raw = simctl_run(&["list", "runtimes", "-j"]).await?;
1071        #[derive(Deserialize)]
1072        struct Wrap {
1073            runtimes: Vec<RawRuntime>,
1074        }
1075        #[derive(Deserialize)]
1076        struct RawRuntime {
1077            identifier: String,
1078            name: String,
1079            version: String,
1080            #[serde(rename = "isAvailable", default)]
1081            is_available: bool,
1082        }
1083        let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
1084            subcommand: "list runtimes".into(),
1085            detail: e.to_string(),
1086        })?;
1087        Ok(w.runtimes
1088            .into_iter()
1089            .map(|r| SimctlRuntime {
1090                identifier: r.identifier,
1091                name: r.name,
1092                version: r.version,
1093                is_available: r.is_available,
1094            })
1095            .collect())
1096    }
1097
1098    /// `xcrun simctl list devices -j` → flattened `Vec<SimctlDevice>`.
1099    pub async fn list_devices(&self) -> Result<Vec<SimctlDevice>, SimctlError> {
1100        let raw = simctl_run(&["list", "devices", "-j"]).await?;
1101        #[derive(Deserialize)]
1102        struct Wrap {
1103            devices: std::collections::BTreeMap<String, Vec<RawDevice>>,
1104        }
1105        #[derive(Deserialize)]
1106        struct RawDevice {
1107            udid: String,
1108            name: String,
1109            state: String,
1110            #[serde(rename = "isAvailable", default)]
1111            is_available: bool,
1112            #[serde(rename = "deviceTypeIdentifier", default)]
1113            device_type_identifier: String,
1114        }
1115        let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
1116            subcommand: "list devices".into(),
1117            detail: e.to_string(),
1118        })?;
1119        let mut out = Vec::new();
1120        for (runtime_id, devices) in w.devices {
1121            for d in devices {
1122                out.push(SimctlDevice {
1123                    udid: d.udid,
1124                    name: d.name,
1125                    state: d.state,
1126                    is_available: d.is_available,
1127                    device_type_identifier: d.device_type_identifier,
1128                    runtime_identifier: runtime_id.clone(),
1129                });
1130            }
1131        }
1132        Ok(out)
1133    }
1134
1135    // ---- lifecycle ------------------------------------------------------
1136
1137    /// `xcrun simctl boot <udid>` — fire-and-forget boot request.
1138    pub async fn boot(&self, udid: &str) -> Result<(), SimctlError> {
1139        simctl_run(&["boot", udid]).await?;
1140        Ok(())
1141    }
1142
1143    /// `xcrun simctl shutdown <udid>`.
1144    pub async fn shutdown(&self, udid: &str) -> Result<(), SimctlError> {
1145        simctl_run(&["shutdown", udid]).await?;
1146        Ok(())
1147    }
1148
1149    /// Read the sim's current BCP-47 locale (first entry of
1150    /// `NSGlobalDomain AppleLanguages`). Returns `Ok(None)` when the
1151    /// preference is unset (defaults read exits non-zero) or unparseable.
1152    /// Wire format: `simctl spawn <udid> defaults read -g AppleLanguages`
1153    /// stdout looks like `"(\n    \"en-US\"\n)\n"`; we extract the first
1154    /// quoted token.
1155    pub async fn current_locale(&self, udid: &str) -> Result<Option<String>, SimctlError> {
1156        let out =
1157            match simctl_run(&["spawn", udid, "/usr/bin/defaults", "read", "-g", "AppleLanguages"]).await {
1158                Ok(s) => s,
1159                // `defaults read` returns non-zero when the key is unset; that
1160                // is a legitimate "no opinion" state, not an error.
1161                Err(SimctlError::NonZeroExit { .. }) => return Ok(None),
1162                Err(e) => return Err(e),
1163            };
1164        // First quoted substring.
1165        if let Some(start) = out.find('"') {
1166            let rest = &out[start + 1..];
1167            if let Some(end) = rest.find('"') {
1168                return Ok(Some(rest[..end].to_string()));
1169            }
1170        }
1171        Ok(None)
1172    }
1173
1174    /// v1.0.27 — delete a single key from an app's NSUserDefaults
1175    /// domain via `simctl spawn <udid> defaults delete <bundleId>
1176    /// <key>`. Running `defaults` INSIDE the sim (spawn) goes through
1177    /// the sim's cfprefsd, so the deletion is coherent with what the
1178    /// app reads on next launch (editing the container plist from the
1179    /// host would race cfprefsd's cache).
1180    ///
1181    /// Returns `Ok(true)` when the key existed and was deleted,
1182    /// `Ok(false)` when the key (or the whole domain) was absent —
1183    /// the verb contract is "ensure key absent", so an already-absent
1184    /// key is success, not an error. Any other failure surfaces as
1185    /// the underlying [`SimctlError`].
1186    ///
1187    /// Motivating case (insight round-5 Ask 12): expo-dev-launcher
1188    /// persists the most recent deep link and re-delivers it after
1189    /// every JS bundle load; deleting its storage key between
1190    /// terminate and relaunch neutralizes the replay at the source.
1191    ///
1192    /// **Terminate the app first** — a running process has its
1193    /// defaults cached in-memory and may rewrite the key at exit.
1194    pub async fn user_defaults_delete(
1195        &self,
1196        udid: &str,
1197        bundle_id: &str,
1198        key: &str,
1199    ) -> Result<bool, SimctlError> {
1200        match simctl_run(&[
1201            "spawn",
1202            udid,
1203            "/usr/bin/defaults",
1204            "delete",
1205            bundle_id,
1206            key,
1207        ])
1208        .await
1209        {
1210            Ok(_) => Ok(true),
1211            // `defaults delete` exits non-zero with "does not exist"
1212            // on stderr for both a missing key and a missing domain.
1213            // Both are the target state.
1214            Err(SimctlError::NonZeroExit { stderr, .. })
1215                if stderr.contains("does not exist") =>
1216            {
1217                Ok(false)
1218            }
1219            Err(e) => Err(e),
1220        }
1221    }
1222
1223    /// Write `AppleLanguages` (array) + `AppleLocale` (scalar) to the
1224    /// sim's NSGlobalDomain so SpringBoard + apps re-localize on next
1225    /// launch. AppleLocale is BCP-47 with hyphen replaced by underscore
1226    /// (`en_US`); AppleLanguages is the BCP-47 tag verbatim.
1227    /// **The caller must shutdown + reboot the sim for the change to
1228    /// take effect** — running apps cache the locale at process start.
1229    pub async fn set_locale(&self, udid: &str, locale: &str) -> Result<(), SimctlError> {
1230        simctl_run(&[
1231            "spawn",
1232            udid,
1233            "defaults",
1234            "write",
1235            "-g",
1236            "AppleLanguages",
1237            "-array",
1238            locale,
1239        ])
1240        .await?;
1241        let locale_underscore = locale.replace('-', "_");
1242        simctl_run(&[
1243            "spawn",
1244            udid,
1245            "defaults",
1246            "write",
1247            "-g",
1248            "AppleLocale",
1249            &locale_underscore,
1250        ])
1251        .await?;
1252        Ok(())
1253    }
1254
1255    /// Boot + poll device state == "Booted" within timeout. Tries every
1256    /// 500 ms until success or `timeout_ms` elapses. Idempotent on
1257    /// already-booted devices (`xcrun simctl boot` returns non-zero when
1258    /// the device is already booted; we swallow that).
1259    pub async fn boot_and_wait(&self, udid: &str, timeout: Duration) -> Result<(), SimctlError> {
1260        // Issue boot; ignore already-booted error (the only friendly path).
1261        let _ = simctl_run(&["boot", udid]).await;
1262        let start = std::time::Instant::now();
1263        loop {
1264            let devices = self.list_devices().await?;
1265            if devices
1266                .iter()
1267                .any(|d| d.udid == udid && d.state == "Booted")
1268            {
1269                return Ok(());
1270            }
1271            if start.elapsed() > timeout {
1272                return Err(SimctlError::Timeout {
1273                    subcommand: format!("boot {}", udid),
1274                    ms: timeout.as_millis() as u64,
1275                });
1276            }
1277            sleep(Duration::from_millis(500)).await;
1278        }
1279    }
1280
1281    /// `xcrun simctl erase <udid>` — wipe device contents.
1282    pub async fn erase(&self, udid: &str) -> Result<(), SimctlError> {
1283        simctl_run(&["erase", udid]).await?;
1284        Ok(())
1285    }
1286
1287    /// `xcrun simctl install <udid> <app-path>` — install a `.app` bundle.
1288    pub async fn install(&self, udid: &str, app_path: &str) -> Result<(), SimctlError> {
1289        simctl_run(&["install", udid, app_path]).await?;
1290        Ok(())
1291    }
1292
1293    /// `xcrun simctl uninstall <udid> <bundle-id>`.
1294    pub async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
1295        simctl_run(&["uninstall", udid, bundle_id]).await?;
1296        Ok(())
1297    }
1298
1299    /// `xcrun simctl terminate <udid> <bundle-id>` — kill a running app.
1300    pub async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
1301        simctl_run(&["terminate", udid, bundle_id]).await?;
1302        Ok(())
1303    }
1304
1305    /// `xcrun simctl launch <udid> <bundleId>` → parse `"<bundle>: <pid>"`.
1306    pub async fn launch(&self, udid: &str, bundle_id: &str) -> Result<LaunchResult, SimctlError> {
1307        self.launch_with_args(udid, bundle_id, &[]).await
1308    }
1309
1310    /// `xcrun simctl launch <udid> <bundleId> -- <arg>...` — launch with a
1311    /// process-level argument vector. Empty `args` is equivalent to
1312    /// [`Self::launch`]. Mirrors maestro yaml `launchApp.arguments`.
1313    pub async fn launch_with_args(
1314        &self,
1315        udid: &str,
1316        bundle_id: &str,
1317        args: &[String],
1318    ) -> Result<LaunchResult, SimctlError> {
1319        self.launch_with_args_and_env(udid, bundle_id, args, &[])
1320            .await
1321    }
1322
1323    /// Like [`Self::launch_with_args`] but also sets `SIMCTL_CHILD_*`
1324    /// envp on the simctl process so the launched app can read
1325    /// deploy-time vars via `ProcessInfo().environment["KEY"]`.
1326    /// `child_env` keys without the `SIMCTL_CHILD_` prefix get it added
1327    /// automatically (per [`compose_child_env`] semantics). Useful for
1328    /// prelaunching an app before any `openLink` so iOS treats the
1329    /// subsequent URL handoff as in-app routing instead of cross-app,
1330    /// side-stepping the SpringBoard "Open in '`<App>`'?" confirmation
1331    /// dialog.
1332    pub async fn launch_with_args_and_env(
1333        &self,
1334        udid: &str,
1335        bundle_id: &str,
1336        args: &[String],
1337        child_env: &[(&str, &str)],
1338    ) -> Result<LaunchResult, SimctlError> {
1339        let mut argv: Vec<&str> = vec!["launch", udid, bundle_id];
1340        if !args.is_empty() {
1341            argv.push("--");
1342            for a in args {
1343                argv.push(a.as_str());
1344            }
1345        }
1346        let composed = compose_child_env(child_env);
1347        let out = simctl_run_env(&argv, &composed).await?;
1348        // Output format: `com.example.app: 12345\n`
1349        let pid_str =
1350            out.rsplit(':')
1351                .next()
1352                .map(str::trim)
1353                .ok_or_else(|| SimctlError::Malformed {
1354                    subcommand: "launch".into(),
1355                    detail: format!("unexpected stdout shape: {}", out.trim()),
1356                })?;
1357        let pid: u32 = pid_str.parse().map_err(|_| SimctlError::Malformed {
1358            subcommand: "launch".into(),
1359            detail: format!("non-numeric pid in stdout: {}", out.trim()),
1360        })?;
1361        Ok(LaunchResult { pid })
1362    }
1363
1364    /// v1.0.4 §D12 — reset every privacy permission granted to
1365    /// `bundle_id` on the sim: `xcrun simctl privacy <udid> reset all
1366    /// <bundle-id>`. Companion to [`Self::clear_app_sandbox`] on the
1367    /// in-place `launchApp: clearState: true` path that replaces
1368    /// `simctl uninstall + install` (which triggers iOS 26.5 XCUITest
1369    /// binding loss + ReportCrash's "Insight quit unexpectedly" dialog).
1370    pub async fn privacy_reset_all(
1371        &self,
1372        udid: &str,
1373        bundle_id: &str,
1374    ) -> Result<(), SimctlError> {
1375        simctl_run(&["privacy", udid, "reset", "all", bundle_id]).await?;
1376        Ok(())
1377    }
1378
1379    /// v1.0.4 §D12 — wipe the app's sandbox on the sim: locate the
1380    /// Data container via `simctl get_app_container <udid> <bundle>
1381    /// data`, then `simctl spawn <udid> rm -rf <container>/Documents
1382    /// <container>/Library <container>/tmp`. The app remains installed
1383    /// (no `simctl uninstall`), so the XCUITest binding is preserved
1384    /// and macOS `ReportCrash` does not misinterpret a missing
1385    /// install-receipt as a crash.
1386    pub async fn clear_app_sandbox(
1387        &self,
1388        udid: &str,
1389        bundle_id: &str,
1390    ) -> Result<(), SimctlError> {
1391        let raw = simctl_run(&["get_app_container", udid, bundle_id, "data"]).await?;
1392        let container = raw.trim();
1393        if container.is_empty() {
1394            return Err(SimctlError::Malformed {
1395                subcommand: "clear_app_sandbox".into(),
1396                detail: format!("empty Data container path for bundle {bundle_id}"),
1397            });
1398        }
1399        let documents = format!("{container}/Documents");
1400        let library = format!("{container}/Library");
1401        let tmp = format!("{container}/tmp");
1402        // v1.0.7 §D1 — `xcrun simctl spawn <UDID> <cmd>` uses
1403        // `posix_spawn` inside the sim OS; `<cmd>` must be an absolute
1404        // path (no PATH resolution). Prior versions passed `"rm"` and
1405        // hit `NSPOSIXErrorDomain code 2: No such file or directory`
1406        // on iOS 17+ sims. `/bin/rm` is present on every stock sim
1407        // image.
1408        //
1409        // Best-effort: any missing subdir is fine (fresh app that never
1410        // wrote to that path). `rm -rf` treats absent targets as no-ops.
1411        simctl_run(&[
1412            "spawn", udid, "/bin/rm", "-rf", &documents, &library, &tmp,
1413        ])
1414        .await?;
1415        Ok(())
1416    }
1417
1418    /// `xcrun simctl openurl <udid> <url>` — open a URL on the device.
1419    ///
1420    /// **URL bytes are passed to `xcrun simctl` verbatim** — no
1421    /// parsing, no percent-encoding rewrite, no query-string
1422    /// stripping. Verified by [`openurl_argv`] (test-visible helper)
1423    /// and its unit test asserting query-params like
1424    /// `?url=http%3A%2F%2Flocalhost%3A8081` reach the argv byte-for-byte.
1425    /// Feedback §G verification — if the target app's URL router
1426    /// (e.g. expo-dev-client 57.0.5) shows a picker instead of
1427    /// auto-connecting, the URL made it through smix intact and the
1428    /// finding lives on the URL-router side.
1429    pub async fn open_url(&self, udid: &str, url: &str) -> Result<(), SimctlError> {
1430        let argv = openurl_argv(udid, url);
1431        let refs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1432        simctl_run(&refs).await?;
1433        Ok(())
1434    }
1435}
1436
1437/// v1.0.4 §G — argv construction for `xcrun simctl openurl`. Extracted
1438/// as a test-visible helper so the URL-preservation contract is
1439/// unit-testable without invoking `xcrun`.
1440#[doc(hidden)]
1441pub fn openurl_argv(udid: &str, url: &str) -> [String; 3] {
1442    ["openurl".to_string(), udid.to_string(), url.to_string()]
1443}
1444
1445impl SimctlClient {
1446
1447    /// `xcrun simctl push <udid> <bundle-id> <apns-json-path>`.
1448    /// Deliver an APNS payload to a sim-installed app. The payload file is
1449    /// a JSON document whose top-level dictionary mirrors what an APNS
1450    /// provider would send; `aps.alert.body` / `aps.alert.title` surface
1451    /// as banner content and reach the app's
1452    /// `UNUserNotificationCenterDelegate`.
1453    pub async fn send_push(
1454        &self,
1455        udid: &str,
1456        bundle_id: &str,
1457        apns_json_path: &str,
1458    ) -> Result<(), SimctlError> {
1459        simctl_run(&["push", udid, bundle_id, apns_json_path]).await?;
1460        Ok(())
1461    }
1462
1463    /// `xcrun simctl ui <udid> appearance <light|dark>` — set UI appearance.
1464    pub async fn set_appearance(&self, udid: &str, mode: Appearance) -> Result<(), SimctlError> {
1465        simctl_run(&["ui", udid, "appearance", mode.as_str()]).await?;
1466        Ok(())
1467    }
1468
1469    /// `xcrun simctl privacy <udid> grant <perm> <bundle-id>`.
1470    pub async fn grant_permission(
1471        &self,
1472        udid: &str,
1473        permission: SimctlPermission,
1474        bundle_id: &str,
1475    ) -> Result<(), SimctlError> {
1476        simctl_run(&["privacy", udid, "grant", permission.as_str(), bundle_id]).await?;
1477        Ok(())
1478    }
1479
1480    /// `xcrun simctl privacy <udid> revoke <perm> <bundle-id>` — explicitly
1481    /// deny the permission. Mirrors maestro yaml `permissions: { x: deny }`
1482    /// (the reverse of `grant`). Distinct from `reset`, which returns the
1483    /// permission to "not determined".
1484    pub async fn revoke_permission(
1485        &self,
1486        udid: &str,
1487        permission: SimctlPermission,
1488        bundle_id: &str,
1489    ) -> Result<(), SimctlError> {
1490        simctl_run(&["privacy", udid, "revoke", permission.as_str(), bundle_id]).await?;
1491        Ok(())
1492    }
1493
1494    /// `xcrun simctl location <udid> set <lat>,<lng>` — set sim location
1495    /// to a fixed point. Mirrors maestro `setLocation`.
1496    pub async fn location_set(
1497        &self,
1498        udid: &str,
1499        latitude: f64,
1500        longitude: f64,
1501    ) -> Result<(), SimctlError> {
1502        let coord = format!("{latitude},{longitude}");
1503        simctl_run(&["location", udid, "set", &coord]).await?;
1504        Ok(())
1505    }
1506
1507    /// `xcrun simctl location <udid> start [--speed=<m/s>] <waypoints>`
1508    /// — interpolate sim location along waypoints. Fire-and-return: simctl
1509    /// injects scenario and returns; sim continues interpolation in background.
1510    /// Mirrors maestro `travel`.
1511    pub async fn location_start(
1512        &self,
1513        udid: &str,
1514        points: &[(f64, f64)],
1515        speed_mps: Option<f64>,
1516    ) -> Result<(), SimctlError> {
1517        if points.len() < 2 {
1518            return Err(SimctlError::Malformed {
1519                subcommand: "location-start".into(),
1520                detail: format!("requires ≥2 waypoints, got {}", points.len()),
1521            });
1522        }
1523        let mut args: Vec<String> = vec!["location".into(), udid.into(), "start".into()];
1524        if let Some(s) = speed_mps {
1525            args.push(format!("--speed={s}"));
1526        }
1527        for (lat, lng) in points {
1528            args.push(format!("{lat},{lng}"));
1529        }
1530        let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
1531        simctl_run(&args_ref).await?;
1532        Ok(())
1533    }
1534
1535    /// `xcrun simctl location <udid> clear` — reset active location
1536    /// scenario.
1537    pub async fn location_clear(&self, udid: &str) -> Result<(), SimctlError> {
1538        simctl_run(&["location", udid, "clear"]).await?;
1539        Ok(())
1540    }
1541
1542    /// `xcrun simctl addmedia <udid> <path>...` — add photos / videos /
1543    /// contacts to sim library. Mirrors maestro `addMedia` (scalar or
1544    /// array form already flattened on adapter side).
1545    pub async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), SimctlError> {
1546        if paths.is_empty() {
1547            return Err(SimctlError::Malformed {
1548                subcommand: "addmedia".into(),
1549                detail: "no paths supplied".into(),
1550            });
1551        }
1552        let mut args: Vec<&str> = vec!["addmedia", udid];
1553        for p in paths {
1554            args.push(p.as_str());
1555        }
1556        simctl_run(&args).await?;
1557        Ok(())
1558    }
1559
1560    /// Start recording sim display to `path`. Spawns
1561    /// `xcrun simctl io <udid> recordVideo <path>` as a long-running child;
1562    /// returns handle immediately. Caller must pair with
1563    /// [`Self::record_video_stop`] for clean SIGINT-and-wait shutdown —
1564    /// dropping the handle would SIGKILL via tokio + lose mp4 trailer.
1565    pub async fn record_video_start(
1566        &self,
1567        udid: &str,
1568        path: &str,
1569    ) -> Result<RecordingHandle, SimctlError> {
1570        let child = tokio::process::Command::new("xcrun")
1571            .args(["simctl", "io", udid, "recordVideo", path])
1572            .stdin(std::process::Stdio::null())
1573            .stdout(std::process::Stdio::piped())
1574            .stderr(std::process::Stdio::piped())
1575            .spawn()?;
1576        // brief settle for simctl to initialize encoder + open output file.
1577        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1578        Ok(RecordingHandle {
1579            child,
1580            path: path.to_string(),
1581            started_at: std::time::Instant::now(),
1582        })
1583    }
1584
1585    /// Stop a recording via SIGINT + wait (≤10s). SIGINT lets simctl
1586    /// trap and flush the mp4 trailer; SIGKILL would corrupt output.
1587    /// Timeout escalates to SIGKILL with explicit error mentioning truncation.
1588    pub async fn record_video_stop(&self, mut handle: RecordingHandle) -> Result<(), SimctlError> {
1589        let pid = handle.child.id().ok_or_else(|| SimctlError::Malformed {
1590            subcommand: "recordVideo-stop".into(),
1591            detail: "child already reaped".into(),
1592        })?;
1593        // SAFETY: libc::kill is a thin POSIX syscall wrapper; pid is owned by
1594        // this Child instance (no race) and SIGINT is signal-safe.
1595        let rc = unsafe { libc::kill(pid as i32, libc::SIGINT) };
1596        if rc != 0 {
1597            return Err(SimctlError::Malformed {
1598                subcommand: "recordVideo-stop".into(),
1599                detail: format!(
1600                    "kill SIGINT failed: errno={}",
1601                    std::io::Error::last_os_error()
1602                ),
1603            });
1604        }
1605        let wait_result =
1606            tokio::time::timeout(std::time::Duration::from_secs(10), handle.child.wait()).await;
1607        match wait_result {
1608            Ok(Ok(_status)) => Ok(()),
1609            Ok(Err(e)) => Err(SimctlError::Malformed {
1610                subcommand: "recordVideo-stop".into(),
1611                detail: format!("wait failed: {e}"),
1612            }),
1613            Err(_timeout) => {
1614                let _ = handle.child.kill().await;
1615                Err(SimctlError::Malformed {
1616                    subcommand: "recordVideo-stop".into(),
1617                    detail: "SIGINT timeout (10s) — escalated SIGKILL; output mp4 likely truncated. Inspect simctl recordVideo stderr.".into(),
1618                })
1619            }
1620        }
1621    }
1622
1623    /// `xcrun simctl privacy <udid> reset <perm> <bundle-id>` — return the
1624    /// permission to "not determined" so the next request re-prompts.
1625    /// May terminate a running instance of the target app (Apple
1626    /// behavior) — call before launch, not mid-flow.
1627    pub async fn reset_permission(
1628        &self,
1629        udid: &str,
1630        permission: SimctlPermission,
1631        bundle_id: &str,
1632    ) -> Result<(), SimctlError> {
1633        simctl_run(&["privacy", udid, "reset", permission.as_str(), bundle_id]).await?;
1634        Ok(())
1635    }
1636
1637    /// `xcrun simctl keychain <udid> reset` — clear all keychain entries.
1638    pub async fn keychain_reset(&self, udid: &str) -> Result<(), SimctlError> {
1639        simctl_run(&["keychain", udid, "reset"]).await?;
1640        Ok(())
1641    }
1642
1643    /// `xcrun simctl pbpaste <udid>` — read clipboard contents.
1644    pub async fn pasteboard_get(&self, udid: &str) -> Result<String, SimctlError> {
1645        simctl_run(&["pbpaste", udid]).await
1646    }
1647
1648    /// `xcrun simctl pbcopy <udid>` — write clipboard contents (via piped stdin).
1649    pub async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), SimctlError> {
1650        // pbcopy reads stdin — we pipe via shell echo for simplicity.
1651        // Long-term: spawn with stdin pipe.
1652        use tokio::io::AsyncWriteExt;
1653        let mut cmd = Command::new("xcrun");
1654        cmd.arg("simctl").arg("pbcopy").arg(udid);
1655        cmd.stdin(std::process::Stdio::piped());
1656        let mut child = cmd.spawn()?;
1657        if let Some(mut stdin) = child.stdin.take() {
1658            stdin.write_all(text.as_bytes()).await?;
1659            drop(stdin); // close stdin so pbcopy returns
1660        }
1661        let status = child.wait().await?;
1662        if !status.success() {
1663            return Err(SimctlError::NonZeroExit {
1664                subcommand: "pbcopy".into(),
1665                argv: vec!["pbcopy".to_string()],
1666                code: status.code().unwrap_or(-1),
1667                stderr: String::new(),
1668                wall_ms: 0,
1669            });
1670        }
1671        Ok(())
1672    }
1673
1674    /// Toggle "Reduce Motion" accessibility setting via `defaults write`.
1675    pub async fn set_reduce_motion(&self, udid: &str, enabled: bool) -> Result<(), SimctlError> {
1676        let val = if enabled { "1" } else { "0" };
1677        // `defaults write` lives under spawn; routed via simctl spawn.
1678        simctl_run(&[
1679            "spawn",
1680            udid,
1681            "defaults",
1682            "write",
1683            "com.apple.UIKit",
1684            "UIAccessibilityReduceMotionEnabled",
1685            "-bool",
1686            val,
1687        ])
1688        .await?;
1689        Ok(())
1690    }
1691
1692    /// `xcrun simctl io <udid> screenshot <tmpfile>` → raw PNG bytes,
1693    /// with a byte-level sRGB metadata splice if the produced PNG lacks
1694    /// an `sRGB` chunk.
1695    ///
1696    /// Goes through a temp file: current Xcode's `screenshot -` does not
1697    /// treat `-` as stdout — it writes a literal file named `-` in cwd
1698    /// and emits nothing on stdout (observed on Xcode/iOS 26.5).
1699    ///
1700    /// **Pixel-preservation invariant**: the returned bytes are
1701    /// byte-identical to whatever `simctl io screenshot` wrote to disk
1702    /// EXCEPT for one narrow case — if the PNG does not carry an
1703    /// `sRGB` ancillary chunk (observed on iOS 26.5 sub-builds
1704    /// mid-2026), a 13-byte `sRGB` chunk is spliced in immediately
1705    /// before the first `IDAT`. Pixel data (IDAT bytes) is never
1706    /// decoded or modified. See [`ensure_srgb_chunk`] for the exact
1707    /// splice operation.
1708    pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, SimctlError> {
1709        // v1.0.4 — pace + circuit-check before invoking simctl.
1710        let wait = {
1711            let mut pacer = self
1712                .screenshot_pacer
1713                .lock()
1714                .expect("screenshot pacer mutex must not be poisoned");
1715            pacer
1716                .compute_wait()
1717                .map_err(|retry_after| SimctlError::CaptureBackpressure { retry_after })?
1718        };
1719        if !wait.is_zero() {
1720            sleep(wait).await;
1721        }
1722
1723        let call_start = std::time::Instant::now();
1724        let tmp =
1725            std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
1726        let tmp_str = tmp.display().to_string();
1727        let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
1728        let bytes = result.and_then(|_| {
1729            std::fs::read(&tmp).map_err(|e| SimctlError::Malformed {
1730                subcommand: "screenshot".into(),
1731                detail: format!("read {tmp_str}: {e}"),
1732            })
1733        });
1734        let _ = std::fs::remove_file(&tmp);
1735
1736        let wall = call_start.elapsed();
1737        let failed = bytes.is_err();
1738        {
1739            let mut pacer = self
1740                .screenshot_pacer
1741                .lock()
1742                .expect("screenshot pacer mutex must not be poisoned");
1743            pacer.record(wall, failed);
1744        }
1745
1746        let bytes = bytes?;
1747        if bytes.len() < 8 {
1748            return Err(SimctlError::Malformed {
1749                subcommand: "screenshot".into(),
1750                detail: format!("screenshot file too short: {} bytes", bytes.len()),
1751            });
1752        }
1753        Ok(ensure_srgb_chunk(bytes))
1754    }
1755
1756    /// `xcrun simctl create <name> <device-type-id> <runtime-id>` → udid.
1757    pub async fn create_device(
1758        &self,
1759        name: &str,
1760        device_type: &str,
1761        runtime_id: &str,
1762    ) -> Result<String, SimctlError> {
1763        let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
1764        Ok(out.trim().to_string())
1765    }
1766
1767    /// `xcrun simctl delete <udid>` — delete a simulator device.
1768    pub async fn delete_device(&self, udid: &str) -> Result<(), SimctlError> {
1769        simctl_run(&["delete", udid]).await?;
1770        Ok(())
1771    }
1772}
1773
1774// -------------------- PNG sRGB chunk normalization (v1.0.2) --------------------
1775//
1776// iOS 26.5 sub-builds (mid-2026) started omitting the `sRGB` ancillary
1777// chunk from `simctl io screenshot` output. macOS Preview.app and other
1778// viewers that fall back to Display P3 when no ICC profile is embedded
1779// then over-saturate the image (red gets pushed, text anti-alias picks
1780// up yellow fringing).
1781//
1782// This does NOT affect pixel-comparison (dhash decodes IDAT to RGBA and
1783// ignores ancillary chunks), but does affect any downstream tool that
1784// renders the PNG for human review. The normalizer runs on the raw byte
1785// stream — walks chunks, and if no `sRGB` chunk is seen before the first
1786// `IDAT`, splices in a synthesized 13-byte `sRGB` chunk (length=1,
1787// type="sRGB", data=[0 = perceptual intent], CRC over type+data).
1788//
1789// Pixel-preservation invariant: IDAT bytes are never decoded. Every
1790// existing chunk is copied verbatim. Only 13 bytes of new metadata are
1791// inserted.
1792
1793const PNG_MAGIC: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
1794
1795/// Ensure the PNG carries an `sRGB` ancillary chunk. Called on the raw
1796/// bytes returned by `xcrun simctl io <udid> screenshot`. If the PNG
1797/// already has an `sRGB` chunk, returns the input unchanged; otherwise
1798/// splices in a 13-byte `sRGB` chunk (rendering intent = 0, perceptual)
1799/// immediately before the first `IDAT`. Returns the input unchanged on
1800/// any structural anomaly (missing magic, malformed chunk) so a
1801/// corrupted PNG is passed through untouched for the caller to diagnose.
1802pub fn ensure_srgb_chunk(bytes: Vec<u8>) -> Vec<u8> {
1803    if bytes.len() < 8 || &bytes[..8] != PNG_MAGIC {
1804        return bytes;
1805    }
1806    let Some((idat_offset, has_srgb)) = scan_png_chunks(&bytes) else {
1807        return bytes;
1808    };
1809    if has_srgb {
1810        return bytes;
1811    }
1812    // Splice the synthesized sRGB chunk right before the first IDAT.
1813    let mut out = Vec::with_capacity(bytes.len() + 13);
1814    out.extend_from_slice(&bytes[..idat_offset]);
1815    out.extend_from_slice(&synthesized_srgb_chunk());
1816    out.extend_from_slice(&bytes[idat_offset..]);
1817    out
1818}
1819
1820/// Walk PNG chunks starting after the 8-byte magic. Returns
1821/// `(offset_of_first_IDAT, has_srgb_chunk_before_it)` when the walk
1822/// reaches an IDAT chunk. Returns `None` if the walk hits EOF or a
1823/// malformed chunk without seeing an IDAT.
1824fn scan_png_chunks(bytes: &[u8]) -> Option<(usize, bool)> {
1825    let mut i: usize = 8;
1826    let mut has_srgb = false;
1827    while i + 8 <= bytes.len() {
1828        let length =
1829            u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1830        let ctype = &bytes[i + 4..i + 8];
1831        if ctype == b"IDAT" {
1832            return Some((i, has_srgb));
1833        }
1834        if ctype == b"sRGB" {
1835            has_srgb = true;
1836        }
1837        // 4 (length) + 4 (type) + length (data) + 4 (crc)
1838        let end = i.checked_add(12)?.checked_add(length)?;
1839        if end > bytes.len() {
1840            return None;
1841        }
1842        i = end;
1843    }
1844    None
1845}
1846
1847/// Build the 13-byte `sRGB` chunk with rendering intent = 0 (perceptual).
1848/// Format: `[len:4][type:4][data:1][crc:4]` = 13 bytes total.
1849fn synthesized_srgb_chunk() -> [u8; 13] {
1850    // The CRC is computed over `type || data`.
1851    let mut crc_input = [0u8; 5];
1852    crc_input[0..4].copy_from_slice(b"sRGB");
1853    crc_input[4] = 0; // perceptual
1854    let crc = crc32_ieee(&crc_input);
1855    let mut chunk = [0u8; 13];
1856    chunk[0..4].copy_from_slice(&1u32.to_be_bytes()); // length = 1 (data byte)
1857    chunk[4..8].copy_from_slice(b"sRGB");
1858    chunk[8] = 0;
1859    chunk[9..13].copy_from_slice(&crc.to_be_bytes());
1860    chunk
1861}
1862
1863/// Table-less CRC-32 IEEE 802.3 (polynomial 0xEDB88320) as used by
1864/// PNG. Small enough for this crate's single call site — avoids
1865/// pulling in a `crc32fast` dependency.
1866fn crc32_ieee(bytes: &[u8]) -> u32 {
1867    let mut crc: u32 = 0xFFFF_FFFF;
1868    for &b in bytes {
1869        crc ^= u32::from(b);
1870        for _ in 0..8 {
1871            let mask = 0u32.wrapping_sub(crc & 1);
1872            crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
1873        }
1874    }
1875    !crc
1876}
1877
1878#[cfg(test)]
1879mod tests {
1880    use super::*;
1881
1882    #[test]
1883    fn compose_child_env_adds_prefix() {
1884        let composed = compose_child_env(&[
1885            ("SMIX_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
1886            ("LAUNCH_FORCE_PUSH", "true"),
1887        ]);
1888        assert_eq!(
1889            composed,
1890            vec![
1891                (
1892                    "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
1893                    "http://127.0.0.1:9999".to_string(),
1894                ),
1895                (
1896                    "SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
1897                    "true".to_string(),
1898                ),
1899            ]
1900        );
1901    }
1902
1903    #[test]
1904    fn compose_child_env_already_prefixed_passes_through() {
1905        // Defensive: caller may pre-prefix; we must not double-prefix.
1906        let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
1907        assert_eq!(
1908            composed,
1909            vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
1910        );
1911    }
1912
1913    #[test]
1914    fn compose_child_env_empty_input_is_empty_output() {
1915        assert!(compose_child_env(&[]).is_empty());
1916    }
1917
1918    // -- v1.0.4 §G openurl URL preservation -----------------------------
1919
1920    #[test]
1921    fn openurl_argv_preserves_url_verbatim() {
1922        let udid = "12345678-1234-5678-1234-567812345678";
1923        let url = "exp+focus-ai-app://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081";
1924        let argv = super::openurl_argv(udid, url);
1925        assert_eq!(argv[0], "openurl");
1926        assert_eq!(argv[1], udid);
1927        // Byte-identical URL — no percent-decoding, no query-strip.
1928        assert_eq!(argv[2], url);
1929        assert!(argv[2].contains("?url="));
1930        assert!(argv[2].contains("%3A"));
1931        assert!(argv[2].contains("%2F"));
1932    }
1933
1934    #[test]
1935    fn openurl_argv_preserves_ampersand_and_hash() {
1936        let udid = "12345678-1234-5678-1234-567812345678";
1937        let url = "insight://dev-mutate?action=env&value=staging#anchor";
1938        let argv = super::openurl_argv(udid, url);
1939        assert_eq!(argv[2], url);
1940        assert!(argv[2].contains('&'));
1941        assert!(argv[2].contains('#'));
1942    }
1943
1944    #[test]
1945    fn openurl_argv_preserves_unicode() {
1946        let udid = "12345678-1234-5678-1234-567812345678";
1947        let url = "insight://route?name=%E7%94%B0%E4%B8%AD";
1948        let argv = super::openurl_argv(udid, url);
1949        assert_eq!(argv[2], url);
1950    }
1951
1952    // -- sRGB chunk normalization ---------------------------------------
1953
1954    /// Build a minimal PNG: 1×1 8-bit RGBA, one IDAT (zlib-empty-safe),
1955    /// with or without an sRGB chunk. Returns synthetic bytes suitable
1956    /// for exercising the chunk-walking logic; no rendering intent.
1957    fn synth_png(with_srgb: bool) -> Vec<u8> {
1958        let mut out = Vec::new();
1959        out.extend_from_slice(super::PNG_MAGIC);
1960        // IHDR: 1x1, bit_depth=8, color_type=6 (RGBA), rest=0
1961        let ihdr_data: [u8; 13] = [
1962            0, 0, 0, 1, // width = 1
1963            0, 0, 0, 1, // height = 1
1964            8, // bit depth
1965            6, // color type = RGBA
1966            0, 0, 0,
1967        ];
1968        emit_chunk(&mut out, b"IHDR", &ihdr_data);
1969        if with_srgb {
1970            emit_chunk(&mut out, b"sRGB", &[0]);
1971        }
1972        // Placeholder IDAT — content doesn't matter for chunk-walking tests
1973        emit_chunk(&mut out, b"IDAT", &[0x78, 0x01, 0x00, 0x00]);
1974        emit_chunk(&mut out, b"IEND", &[]);
1975        out
1976    }
1977
1978    fn emit_chunk(out: &mut Vec<u8>, ctype: &[u8; 4], data: &[u8]) {
1979        out.extend_from_slice(&(data.len() as u32).to_be_bytes());
1980        out.extend_from_slice(ctype);
1981        out.extend_from_slice(data);
1982        let mut crc_in = Vec::with_capacity(4 + data.len());
1983        crc_in.extend_from_slice(ctype);
1984        crc_in.extend_from_slice(data);
1985        out.extend_from_slice(&super::crc32_ieee(&crc_in).to_be_bytes());
1986    }
1987
1988    #[test]
1989    fn ensure_srgb_passthrough_when_chunk_present() {
1990        let png = synth_png(true);
1991        let original_len = png.len();
1992        let out = super::ensure_srgb_chunk(png.clone());
1993        assert_eq!(out.len(), original_len);
1994        assert_eq!(out, png);
1995    }
1996
1997    #[test]
1998    fn ensure_srgb_inserts_chunk_when_absent() {
1999        let png = synth_png(false);
2000        let original_len = png.len();
2001        let out = super::ensure_srgb_chunk(png);
2002        assert_eq!(out.len(), original_len + 13);
2003        // First 8 bytes = PNG magic
2004        assert_eq!(&out[..8], super::PNG_MAGIC);
2005        // Search for the injected sRGB chunk
2006        let mut found = false;
2007        for w in out.windows(4) {
2008            if w == b"sRGB" {
2009                found = true;
2010                break;
2011            }
2012        }
2013        assert!(found, "sRGB chunk should have been spliced in");
2014    }
2015
2016    #[test]
2017    fn ensure_srgb_preserves_idat_bytes_verbatim() {
2018        // Any pixel corruption at the IDAT level would break the
2019        // pixel-preservation invariant. Extract IDAT payload from
2020        // input and output, assert byte-identical.
2021        let png = synth_png(false);
2022        let out = super::ensure_srgb_chunk(png.clone());
2023        assert_eq!(extract_idat_data(&png), extract_idat_data(&out));
2024    }
2025
2026    fn extract_idat_data(bytes: &[u8]) -> Vec<u8> {
2027        let mut i = 8;
2028        while i + 8 <= bytes.len() {
2029            let length =
2030                u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
2031            let ctype = &bytes[i + 4..i + 8];
2032            if ctype == b"IDAT" {
2033                return bytes[i + 8..i + 8 + length].to_vec();
2034            }
2035            i += 12 + length;
2036        }
2037        vec![]
2038    }
2039
2040    #[test]
2041    fn ensure_srgb_passthrough_on_bad_magic() {
2042        // Corrupted / non-PNG input must not be modified.
2043        let bytes = vec![0u8; 32];
2044        let out = super::ensure_srgb_chunk(bytes.clone());
2045        assert_eq!(out, bytes);
2046    }
2047
2048    #[test]
2049    fn crc32_matches_known_iend() {
2050        // The empty-data IEND CRC is a well-known constant.
2051        // CRC over "IEND" alone: 0xAE_42_60_82.
2052        assert_eq!(super::crc32_ieee(b"IEND"), 0xAE42_6082);
2053    }
2054}