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    /// Write `AppleLanguages` (array) + `AppleLocale` (scalar) to the
1175    /// sim's NSGlobalDomain so SpringBoard + apps re-localize on next
1176    /// launch. AppleLocale is BCP-47 with hyphen replaced by underscore
1177    /// (`en_US`); AppleLanguages is the BCP-47 tag verbatim.
1178    /// **The caller must shutdown + reboot the sim for the change to
1179    /// take effect** — running apps cache the locale at process start.
1180    pub async fn set_locale(&self, udid: &str, locale: &str) -> Result<(), SimctlError> {
1181        simctl_run(&[
1182            "spawn",
1183            udid,
1184            "defaults",
1185            "write",
1186            "-g",
1187            "AppleLanguages",
1188            "-array",
1189            locale,
1190        ])
1191        .await?;
1192        let locale_underscore = locale.replace('-', "_");
1193        simctl_run(&[
1194            "spawn",
1195            udid,
1196            "defaults",
1197            "write",
1198            "-g",
1199            "AppleLocale",
1200            &locale_underscore,
1201        ])
1202        .await?;
1203        Ok(())
1204    }
1205
1206    /// Boot + poll device state == "Booted" within timeout. Tries every
1207    /// 500 ms until success or `timeout_ms` elapses. Idempotent on
1208    /// already-booted devices (`xcrun simctl boot` returns non-zero when
1209    /// the device is already booted; we swallow that).
1210    pub async fn boot_and_wait(&self, udid: &str, timeout: Duration) -> Result<(), SimctlError> {
1211        // Issue boot; ignore already-booted error (the only friendly path).
1212        let _ = simctl_run(&["boot", udid]).await;
1213        let start = std::time::Instant::now();
1214        loop {
1215            let devices = self.list_devices().await?;
1216            if devices
1217                .iter()
1218                .any(|d| d.udid == udid && d.state == "Booted")
1219            {
1220                return Ok(());
1221            }
1222            if start.elapsed() > timeout {
1223                return Err(SimctlError::Timeout {
1224                    subcommand: format!("boot {}", udid),
1225                    ms: timeout.as_millis() as u64,
1226                });
1227            }
1228            sleep(Duration::from_millis(500)).await;
1229        }
1230    }
1231
1232    /// `xcrun simctl erase <udid>` — wipe device contents.
1233    pub async fn erase(&self, udid: &str) -> Result<(), SimctlError> {
1234        simctl_run(&["erase", udid]).await?;
1235        Ok(())
1236    }
1237
1238    /// `xcrun simctl install <udid> <app-path>` — install a `.app` bundle.
1239    pub async fn install(&self, udid: &str, app_path: &str) -> Result<(), SimctlError> {
1240        simctl_run(&["install", udid, app_path]).await?;
1241        Ok(())
1242    }
1243
1244    /// `xcrun simctl uninstall <udid> <bundle-id>`.
1245    pub async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
1246        simctl_run(&["uninstall", udid, bundle_id]).await?;
1247        Ok(())
1248    }
1249
1250    /// `xcrun simctl terminate <udid> <bundle-id>` — kill a running app.
1251    pub async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
1252        simctl_run(&["terminate", udid, bundle_id]).await?;
1253        Ok(())
1254    }
1255
1256    /// `xcrun simctl launch <udid> <bundleId>` → parse `"<bundle>: <pid>"`.
1257    pub async fn launch(&self, udid: &str, bundle_id: &str) -> Result<LaunchResult, SimctlError> {
1258        self.launch_with_args(udid, bundle_id, &[]).await
1259    }
1260
1261    /// `xcrun simctl launch <udid> <bundleId> -- <arg>...` — launch with a
1262    /// process-level argument vector. Empty `args` is equivalent to
1263    /// [`Self::launch`]. Mirrors maestro yaml `launchApp.arguments`.
1264    pub async fn launch_with_args(
1265        &self,
1266        udid: &str,
1267        bundle_id: &str,
1268        args: &[String],
1269    ) -> Result<LaunchResult, SimctlError> {
1270        self.launch_with_args_and_env(udid, bundle_id, args, &[])
1271            .await
1272    }
1273
1274    /// Like [`Self::launch_with_args`] but also sets `SIMCTL_CHILD_*`
1275    /// envp on the simctl process so the launched app can read
1276    /// deploy-time vars via `ProcessInfo().environment["KEY"]`.
1277    /// `child_env` keys without the `SIMCTL_CHILD_` prefix get it added
1278    /// automatically (per [`compose_child_env`] semantics). Useful for
1279    /// prelaunching an app before any `openLink` so iOS treats the
1280    /// subsequent URL handoff as in-app routing instead of cross-app,
1281    /// side-stepping the SpringBoard "Open in '`<App>`'?" confirmation
1282    /// dialog.
1283    pub async fn launch_with_args_and_env(
1284        &self,
1285        udid: &str,
1286        bundle_id: &str,
1287        args: &[String],
1288        child_env: &[(&str, &str)],
1289    ) -> Result<LaunchResult, SimctlError> {
1290        let mut argv: Vec<&str> = vec!["launch", udid, bundle_id];
1291        if !args.is_empty() {
1292            argv.push("--");
1293            for a in args {
1294                argv.push(a.as_str());
1295            }
1296        }
1297        let composed = compose_child_env(child_env);
1298        let out = simctl_run_env(&argv, &composed).await?;
1299        // Output format: `com.example.app: 12345\n`
1300        let pid_str =
1301            out.rsplit(':')
1302                .next()
1303                .map(str::trim)
1304                .ok_or_else(|| SimctlError::Malformed {
1305                    subcommand: "launch".into(),
1306                    detail: format!("unexpected stdout shape: {}", out.trim()),
1307                })?;
1308        let pid: u32 = pid_str.parse().map_err(|_| SimctlError::Malformed {
1309            subcommand: "launch".into(),
1310            detail: format!("non-numeric pid in stdout: {}", out.trim()),
1311        })?;
1312        Ok(LaunchResult { pid })
1313    }
1314
1315    /// v1.0.4 §D12 — reset every privacy permission granted to
1316    /// `bundle_id` on the sim: `xcrun simctl privacy <udid> reset all
1317    /// <bundle-id>`. Companion to [`Self::clear_app_sandbox`] on the
1318    /// in-place `launchApp: clearState: true` path that replaces
1319    /// `simctl uninstall + install` (which triggers iOS 26.5 XCUITest
1320    /// binding loss + ReportCrash's "Insight quit unexpectedly" dialog).
1321    pub async fn privacy_reset_all(
1322        &self,
1323        udid: &str,
1324        bundle_id: &str,
1325    ) -> Result<(), SimctlError> {
1326        simctl_run(&["privacy", udid, "reset", "all", bundle_id]).await?;
1327        Ok(())
1328    }
1329
1330    /// v1.0.4 §D12 — wipe the app's sandbox on the sim: locate the
1331    /// Data container via `simctl get_app_container <udid> <bundle>
1332    /// data`, then `simctl spawn <udid> rm -rf <container>/Documents
1333    /// <container>/Library <container>/tmp`. The app remains installed
1334    /// (no `simctl uninstall`), so the XCUITest binding is preserved
1335    /// and macOS `ReportCrash` does not misinterpret a missing
1336    /// install-receipt as a crash.
1337    pub async fn clear_app_sandbox(
1338        &self,
1339        udid: &str,
1340        bundle_id: &str,
1341    ) -> Result<(), SimctlError> {
1342        let raw = simctl_run(&["get_app_container", udid, bundle_id, "data"]).await?;
1343        let container = raw.trim();
1344        if container.is_empty() {
1345            return Err(SimctlError::Malformed {
1346                subcommand: "clear_app_sandbox".into(),
1347                detail: format!("empty Data container path for bundle {bundle_id}"),
1348            });
1349        }
1350        let documents = format!("{container}/Documents");
1351        let library = format!("{container}/Library");
1352        let tmp = format!("{container}/tmp");
1353        // v1.0.7 §D1 — `xcrun simctl spawn <UDID> <cmd>` uses
1354        // `posix_spawn` inside the sim OS; `<cmd>` must be an absolute
1355        // path (no PATH resolution). Prior versions passed `"rm"` and
1356        // hit `NSPOSIXErrorDomain code 2: No such file or directory`
1357        // on iOS 17+ sims. `/bin/rm` is present on every stock sim
1358        // image.
1359        //
1360        // Best-effort: any missing subdir is fine (fresh app that never
1361        // wrote to that path). `rm -rf` treats absent targets as no-ops.
1362        simctl_run(&[
1363            "spawn", udid, "/bin/rm", "-rf", &documents, &library, &tmp,
1364        ])
1365        .await?;
1366        Ok(())
1367    }
1368
1369    /// `xcrun simctl openurl <udid> <url>` — open a URL on the device.
1370    ///
1371    /// **URL bytes are passed to `xcrun simctl` verbatim** — no
1372    /// parsing, no percent-encoding rewrite, no query-string
1373    /// stripping. Verified by [`openurl_argv`] (test-visible helper)
1374    /// and its unit test asserting query-params like
1375    /// `?url=http%3A%2F%2Flocalhost%3A8081` reach the argv byte-for-byte.
1376    /// Feedback §G verification — if the target app's URL router
1377    /// (e.g. expo-dev-client 57.0.5) shows a picker instead of
1378    /// auto-connecting, the URL made it through smix intact and the
1379    /// finding lives on the URL-router side.
1380    pub async fn open_url(&self, udid: &str, url: &str) -> Result<(), SimctlError> {
1381        let argv = openurl_argv(udid, url);
1382        let refs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1383        simctl_run(&refs).await?;
1384        Ok(())
1385    }
1386}
1387
1388/// v1.0.4 §G — argv construction for `xcrun simctl openurl`. Extracted
1389/// as a test-visible helper so the URL-preservation contract is
1390/// unit-testable without invoking `xcrun`.
1391#[doc(hidden)]
1392pub fn openurl_argv(udid: &str, url: &str) -> [String; 3] {
1393    ["openurl".to_string(), udid.to_string(), url.to_string()]
1394}
1395
1396impl SimctlClient {
1397
1398    /// `xcrun simctl push <udid> <bundle-id> <apns-json-path>`.
1399    /// Deliver an APNS payload to a sim-installed app. The payload file is
1400    /// a JSON document whose top-level dictionary mirrors what an APNS
1401    /// provider would send; `aps.alert.body` / `aps.alert.title` surface
1402    /// as banner content and reach the app's
1403    /// `UNUserNotificationCenterDelegate`.
1404    pub async fn send_push(
1405        &self,
1406        udid: &str,
1407        bundle_id: &str,
1408        apns_json_path: &str,
1409    ) -> Result<(), SimctlError> {
1410        simctl_run(&["push", udid, bundle_id, apns_json_path]).await?;
1411        Ok(())
1412    }
1413
1414    /// `xcrun simctl ui <udid> appearance <light|dark>` — set UI appearance.
1415    pub async fn set_appearance(&self, udid: &str, mode: Appearance) -> Result<(), SimctlError> {
1416        simctl_run(&["ui", udid, "appearance", mode.as_str()]).await?;
1417        Ok(())
1418    }
1419
1420    /// `xcrun simctl privacy <udid> grant <perm> <bundle-id>`.
1421    pub async fn grant_permission(
1422        &self,
1423        udid: &str,
1424        permission: SimctlPermission,
1425        bundle_id: &str,
1426    ) -> Result<(), SimctlError> {
1427        simctl_run(&["privacy", udid, "grant", permission.as_str(), bundle_id]).await?;
1428        Ok(())
1429    }
1430
1431    /// `xcrun simctl privacy <udid> revoke <perm> <bundle-id>` — explicitly
1432    /// deny the permission. Mirrors maestro yaml `permissions: { x: deny }`
1433    /// (the reverse of `grant`). Distinct from `reset`, which returns the
1434    /// permission to "not determined".
1435    pub async fn revoke_permission(
1436        &self,
1437        udid: &str,
1438        permission: SimctlPermission,
1439        bundle_id: &str,
1440    ) -> Result<(), SimctlError> {
1441        simctl_run(&["privacy", udid, "revoke", permission.as_str(), bundle_id]).await?;
1442        Ok(())
1443    }
1444
1445    /// `xcrun simctl location <udid> set <lat>,<lng>` — set sim location
1446    /// to a fixed point. Mirrors maestro `setLocation`.
1447    pub async fn location_set(
1448        &self,
1449        udid: &str,
1450        latitude: f64,
1451        longitude: f64,
1452    ) -> Result<(), SimctlError> {
1453        let coord = format!("{latitude},{longitude}");
1454        simctl_run(&["location", udid, "set", &coord]).await?;
1455        Ok(())
1456    }
1457
1458    /// `xcrun simctl location <udid> start [--speed=<m/s>] <waypoints>`
1459    /// — interpolate sim location along waypoints. Fire-and-return: simctl
1460    /// injects scenario and returns; sim continues interpolation in background.
1461    /// Mirrors maestro `travel`.
1462    pub async fn location_start(
1463        &self,
1464        udid: &str,
1465        points: &[(f64, f64)],
1466        speed_mps: Option<f64>,
1467    ) -> Result<(), SimctlError> {
1468        if points.len() < 2 {
1469            return Err(SimctlError::Malformed {
1470                subcommand: "location-start".into(),
1471                detail: format!("requires ≥2 waypoints, got {}", points.len()),
1472            });
1473        }
1474        let mut args: Vec<String> = vec!["location".into(), udid.into(), "start".into()];
1475        if let Some(s) = speed_mps {
1476            args.push(format!("--speed={s}"));
1477        }
1478        for (lat, lng) in points {
1479            args.push(format!("{lat},{lng}"));
1480        }
1481        let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
1482        simctl_run(&args_ref).await?;
1483        Ok(())
1484    }
1485
1486    /// `xcrun simctl location <udid> clear` — reset active location
1487    /// scenario.
1488    pub async fn location_clear(&self, udid: &str) -> Result<(), SimctlError> {
1489        simctl_run(&["location", udid, "clear"]).await?;
1490        Ok(())
1491    }
1492
1493    /// `xcrun simctl addmedia <udid> <path>...` — add photos / videos /
1494    /// contacts to sim library. Mirrors maestro `addMedia` (scalar or
1495    /// array form already flattened on adapter side).
1496    pub async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), SimctlError> {
1497        if paths.is_empty() {
1498            return Err(SimctlError::Malformed {
1499                subcommand: "addmedia".into(),
1500                detail: "no paths supplied".into(),
1501            });
1502        }
1503        let mut args: Vec<&str> = vec!["addmedia", udid];
1504        for p in paths {
1505            args.push(p.as_str());
1506        }
1507        simctl_run(&args).await?;
1508        Ok(())
1509    }
1510
1511    /// Start recording sim display to `path`. Spawns
1512    /// `xcrun simctl io <udid> recordVideo <path>` as a long-running child;
1513    /// returns handle immediately. Caller must pair with
1514    /// [`Self::record_video_stop`] for clean SIGINT-and-wait shutdown —
1515    /// dropping the handle would SIGKILL via tokio + lose mp4 trailer.
1516    pub async fn record_video_start(
1517        &self,
1518        udid: &str,
1519        path: &str,
1520    ) -> Result<RecordingHandle, SimctlError> {
1521        let child = tokio::process::Command::new("xcrun")
1522            .args(["simctl", "io", udid, "recordVideo", path])
1523            .stdin(std::process::Stdio::null())
1524            .stdout(std::process::Stdio::piped())
1525            .stderr(std::process::Stdio::piped())
1526            .spawn()?;
1527        // brief settle for simctl to initialize encoder + open output file.
1528        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1529        Ok(RecordingHandle {
1530            child,
1531            path: path.to_string(),
1532            started_at: std::time::Instant::now(),
1533        })
1534    }
1535
1536    /// Stop a recording via SIGINT + wait (≤10s). SIGINT lets simctl
1537    /// trap and flush the mp4 trailer; SIGKILL would corrupt output.
1538    /// Timeout escalates to SIGKILL with explicit error mentioning truncation.
1539    pub async fn record_video_stop(&self, mut handle: RecordingHandle) -> Result<(), SimctlError> {
1540        let pid = handle.child.id().ok_or_else(|| SimctlError::Malformed {
1541            subcommand: "recordVideo-stop".into(),
1542            detail: "child already reaped".into(),
1543        })?;
1544        // SAFETY: libc::kill is a thin POSIX syscall wrapper; pid is owned by
1545        // this Child instance (no race) and SIGINT is signal-safe.
1546        let rc = unsafe { libc::kill(pid as i32, libc::SIGINT) };
1547        if rc != 0 {
1548            return Err(SimctlError::Malformed {
1549                subcommand: "recordVideo-stop".into(),
1550                detail: format!(
1551                    "kill SIGINT failed: errno={}",
1552                    std::io::Error::last_os_error()
1553                ),
1554            });
1555        }
1556        let wait_result =
1557            tokio::time::timeout(std::time::Duration::from_secs(10), handle.child.wait()).await;
1558        match wait_result {
1559            Ok(Ok(_status)) => Ok(()),
1560            Ok(Err(e)) => Err(SimctlError::Malformed {
1561                subcommand: "recordVideo-stop".into(),
1562                detail: format!("wait failed: {e}"),
1563            }),
1564            Err(_timeout) => {
1565                let _ = handle.child.kill().await;
1566                Err(SimctlError::Malformed {
1567                    subcommand: "recordVideo-stop".into(),
1568                    detail: "SIGINT timeout (10s) — escalated SIGKILL; output mp4 likely truncated. Inspect simctl recordVideo stderr.".into(),
1569                })
1570            }
1571        }
1572    }
1573
1574    /// `xcrun simctl privacy <udid> reset <perm> <bundle-id>` — return the
1575    /// permission to "not determined" so the next request re-prompts.
1576    /// May terminate a running instance of the target app (Apple
1577    /// behavior) — call before launch, not mid-flow.
1578    pub async fn reset_permission(
1579        &self,
1580        udid: &str,
1581        permission: SimctlPermission,
1582        bundle_id: &str,
1583    ) -> Result<(), SimctlError> {
1584        simctl_run(&["privacy", udid, "reset", permission.as_str(), bundle_id]).await?;
1585        Ok(())
1586    }
1587
1588    /// `xcrun simctl keychain <udid> reset` — clear all keychain entries.
1589    pub async fn keychain_reset(&self, udid: &str) -> Result<(), SimctlError> {
1590        simctl_run(&["keychain", udid, "reset"]).await?;
1591        Ok(())
1592    }
1593
1594    /// `xcrun simctl pbpaste <udid>` — read clipboard contents.
1595    pub async fn pasteboard_get(&self, udid: &str) -> Result<String, SimctlError> {
1596        simctl_run(&["pbpaste", udid]).await
1597    }
1598
1599    /// `xcrun simctl pbcopy <udid>` — write clipboard contents (via piped stdin).
1600    pub async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), SimctlError> {
1601        // pbcopy reads stdin — we pipe via shell echo for simplicity.
1602        // Long-term: spawn with stdin pipe.
1603        use tokio::io::AsyncWriteExt;
1604        let mut cmd = Command::new("xcrun");
1605        cmd.arg("simctl").arg("pbcopy").arg(udid);
1606        cmd.stdin(std::process::Stdio::piped());
1607        let mut child = cmd.spawn()?;
1608        if let Some(mut stdin) = child.stdin.take() {
1609            stdin.write_all(text.as_bytes()).await?;
1610            drop(stdin); // close stdin so pbcopy returns
1611        }
1612        let status = child.wait().await?;
1613        if !status.success() {
1614            return Err(SimctlError::NonZeroExit {
1615                subcommand: "pbcopy".into(),
1616                argv: vec!["pbcopy".to_string()],
1617                code: status.code().unwrap_or(-1),
1618                stderr: String::new(),
1619                wall_ms: 0,
1620            });
1621        }
1622        Ok(())
1623    }
1624
1625    /// Toggle "Reduce Motion" accessibility setting via `defaults write`.
1626    pub async fn set_reduce_motion(&self, udid: &str, enabled: bool) -> Result<(), SimctlError> {
1627        let val = if enabled { "1" } else { "0" };
1628        // `defaults write` lives under spawn; routed via simctl spawn.
1629        simctl_run(&[
1630            "spawn",
1631            udid,
1632            "defaults",
1633            "write",
1634            "com.apple.UIKit",
1635            "UIAccessibilityReduceMotionEnabled",
1636            "-bool",
1637            val,
1638        ])
1639        .await?;
1640        Ok(())
1641    }
1642
1643    /// `xcrun simctl io <udid> screenshot <tmpfile>` → raw PNG bytes,
1644    /// with a byte-level sRGB metadata splice if the produced PNG lacks
1645    /// an `sRGB` chunk.
1646    ///
1647    /// Goes through a temp file: current Xcode's `screenshot -` does not
1648    /// treat `-` as stdout — it writes a literal file named `-` in cwd
1649    /// and emits nothing on stdout (observed on Xcode/iOS 26.5).
1650    ///
1651    /// **Pixel-preservation invariant**: the returned bytes are
1652    /// byte-identical to whatever `simctl io screenshot` wrote to disk
1653    /// EXCEPT for one narrow case — if the PNG does not carry an
1654    /// `sRGB` ancillary chunk (observed on iOS 26.5 sub-builds
1655    /// mid-2026), a 13-byte `sRGB` chunk is spliced in immediately
1656    /// before the first `IDAT`. Pixel data (IDAT bytes) is never
1657    /// decoded or modified. See [`ensure_srgb_chunk`] for the exact
1658    /// splice operation.
1659    pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, SimctlError> {
1660        // v1.0.4 — pace + circuit-check before invoking simctl.
1661        let wait = {
1662            let mut pacer = self
1663                .screenshot_pacer
1664                .lock()
1665                .expect("screenshot pacer mutex must not be poisoned");
1666            pacer
1667                .compute_wait()
1668                .map_err(|retry_after| SimctlError::CaptureBackpressure { retry_after })?
1669        };
1670        if !wait.is_zero() {
1671            sleep(wait).await;
1672        }
1673
1674        let call_start = std::time::Instant::now();
1675        let tmp =
1676            std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
1677        let tmp_str = tmp.display().to_string();
1678        let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
1679        let bytes = result.and_then(|_| {
1680            std::fs::read(&tmp).map_err(|e| SimctlError::Malformed {
1681                subcommand: "screenshot".into(),
1682                detail: format!("read {tmp_str}: {e}"),
1683            })
1684        });
1685        let _ = std::fs::remove_file(&tmp);
1686
1687        let wall = call_start.elapsed();
1688        let failed = bytes.is_err();
1689        {
1690            let mut pacer = self
1691                .screenshot_pacer
1692                .lock()
1693                .expect("screenshot pacer mutex must not be poisoned");
1694            pacer.record(wall, failed);
1695        }
1696
1697        let bytes = bytes?;
1698        if bytes.len() < 8 {
1699            return Err(SimctlError::Malformed {
1700                subcommand: "screenshot".into(),
1701                detail: format!("screenshot file too short: {} bytes", bytes.len()),
1702            });
1703        }
1704        Ok(ensure_srgb_chunk(bytes))
1705    }
1706
1707    /// `xcrun simctl create <name> <device-type-id> <runtime-id>` → udid.
1708    pub async fn create_device(
1709        &self,
1710        name: &str,
1711        device_type: &str,
1712        runtime_id: &str,
1713    ) -> Result<String, SimctlError> {
1714        let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
1715        Ok(out.trim().to_string())
1716    }
1717
1718    /// `xcrun simctl delete <udid>` — delete a simulator device.
1719    pub async fn delete_device(&self, udid: &str) -> Result<(), SimctlError> {
1720        simctl_run(&["delete", udid]).await?;
1721        Ok(())
1722    }
1723}
1724
1725// -------------------- PNG sRGB chunk normalization (v1.0.2) --------------------
1726//
1727// iOS 26.5 sub-builds (mid-2026) started omitting the `sRGB` ancillary
1728// chunk from `simctl io screenshot` output. macOS Preview.app and other
1729// viewers that fall back to Display P3 when no ICC profile is embedded
1730// then over-saturate the image (red gets pushed, text anti-alias picks
1731// up yellow fringing).
1732//
1733// This does NOT affect pixel-comparison (dhash decodes IDAT to RGBA and
1734// ignores ancillary chunks), but does affect any downstream tool that
1735// renders the PNG for human review. The normalizer runs on the raw byte
1736// stream — walks chunks, and if no `sRGB` chunk is seen before the first
1737// `IDAT`, splices in a synthesized 13-byte `sRGB` chunk (length=1,
1738// type="sRGB", data=[0 = perceptual intent], CRC over type+data).
1739//
1740// Pixel-preservation invariant: IDAT bytes are never decoded. Every
1741// existing chunk is copied verbatim. Only 13 bytes of new metadata are
1742// inserted.
1743
1744const PNG_MAGIC: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
1745
1746/// Ensure the PNG carries an `sRGB` ancillary chunk. Called on the raw
1747/// bytes returned by `xcrun simctl io <udid> screenshot`. If the PNG
1748/// already has an `sRGB` chunk, returns the input unchanged; otherwise
1749/// splices in a 13-byte `sRGB` chunk (rendering intent = 0, perceptual)
1750/// immediately before the first `IDAT`. Returns the input unchanged on
1751/// any structural anomaly (missing magic, malformed chunk) so a
1752/// corrupted PNG is passed through untouched for the caller to diagnose.
1753pub fn ensure_srgb_chunk(bytes: Vec<u8>) -> Vec<u8> {
1754    if bytes.len() < 8 || &bytes[..8] != PNG_MAGIC {
1755        return bytes;
1756    }
1757    let Some((idat_offset, has_srgb)) = scan_png_chunks(&bytes) else {
1758        return bytes;
1759    };
1760    if has_srgb {
1761        return bytes;
1762    }
1763    // Splice the synthesized sRGB chunk right before the first IDAT.
1764    let mut out = Vec::with_capacity(bytes.len() + 13);
1765    out.extend_from_slice(&bytes[..idat_offset]);
1766    out.extend_from_slice(&synthesized_srgb_chunk());
1767    out.extend_from_slice(&bytes[idat_offset..]);
1768    out
1769}
1770
1771/// Walk PNG chunks starting after the 8-byte magic. Returns
1772/// `(offset_of_first_IDAT, has_srgb_chunk_before_it)` when the walk
1773/// reaches an IDAT chunk. Returns `None` if the walk hits EOF or a
1774/// malformed chunk without seeing an IDAT.
1775fn scan_png_chunks(bytes: &[u8]) -> Option<(usize, bool)> {
1776    let mut i: usize = 8;
1777    let mut has_srgb = false;
1778    while i + 8 <= bytes.len() {
1779        let length =
1780            u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1781        let ctype = &bytes[i + 4..i + 8];
1782        if ctype == b"IDAT" {
1783            return Some((i, has_srgb));
1784        }
1785        if ctype == b"sRGB" {
1786            has_srgb = true;
1787        }
1788        // 4 (length) + 4 (type) + length (data) + 4 (crc)
1789        let end = i.checked_add(12)?.checked_add(length)?;
1790        if end > bytes.len() {
1791            return None;
1792        }
1793        i = end;
1794    }
1795    None
1796}
1797
1798/// Build the 13-byte `sRGB` chunk with rendering intent = 0 (perceptual).
1799/// Format: `[len:4][type:4][data:1][crc:4]` = 13 bytes total.
1800fn synthesized_srgb_chunk() -> [u8; 13] {
1801    // The CRC is computed over `type || data`.
1802    let mut crc_input = [0u8; 5];
1803    crc_input[0..4].copy_from_slice(b"sRGB");
1804    crc_input[4] = 0; // perceptual
1805    let crc = crc32_ieee(&crc_input);
1806    let mut chunk = [0u8; 13];
1807    chunk[0..4].copy_from_slice(&1u32.to_be_bytes()); // length = 1 (data byte)
1808    chunk[4..8].copy_from_slice(b"sRGB");
1809    chunk[8] = 0;
1810    chunk[9..13].copy_from_slice(&crc.to_be_bytes());
1811    chunk
1812}
1813
1814/// Table-less CRC-32 IEEE 802.3 (polynomial 0xEDB88320) as used by
1815/// PNG. Small enough for this crate's single call site — avoids
1816/// pulling in a `crc32fast` dependency.
1817fn crc32_ieee(bytes: &[u8]) -> u32 {
1818    let mut crc: u32 = 0xFFFF_FFFF;
1819    for &b in bytes {
1820        crc ^= u32::from(b);
1821        for _ in 0..8 {
1822            let mask = 0u32.wrapping_sub(crc & 1);
1823            crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
1824        }
1825    }
1826    !crc
1827}
1828
1829#[cfg(test)]
1830mod tests {
1831    use super::*;
1832
1833    #[test]
1834    fn compose_child_env_adds_prefix() {
1835        let composed = compose_child_env(&[
1836            ("SMIX_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
1837            ("LAUNCH_FORCE_PUSH", "true"),
1838        ]);
1839        assert_eq!(
1840            composed,
1841            vec![
1842                (
1843                    "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
1844                    "http://127.0.0.1:9999".to_string(),
1845                ),
1846                (
1847                    "SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
1848                    "true".to_string(),
1849                ),
1850            ]
1851        );
1852    }
1853
1854    #[test]
1855    fn compose_child_env_already_prefixed_passes_through() {
1856        // Defensive: caller may pre-prefix; we must not double-prefix.
1857        let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
1858        assert_eq!(
1859            composed,
1860            vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
1861        );
1862    }
1863
1864    #[test]
1865    fn compose_child_env_empty_input_is_empty_output() {
1866        assert!(compose_child_env(&[]).is_empty());
1867    }
1868
1869    // -- v1.0.4 §G openurl URL preservation -----------------------------
1870
1871    #[test]
1872    fn openurl_argv_preserves_url_verbatim() {
1873        let udid = "12345678-1234-5678-1234-567812345678";
1874        let url = "exp+focus-ai-app://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081";
1875        let argv = super::openurl_argv(udid, url);
1876        assert_eq!(argv[0], "openurl");
1877        assert_eq!(argv[1], udid);
1878        // Byte-identical URL — no percent-decoding, no query-strip.
1879        assert_eq!(argv[2], url);
1880        assert!(argv[2].contains("?url="));
1881        assert!(argv[2].contains("%3A"));
1882        assert!(argv[2].contains("%2F"));
1883    }
1884
1885    #[test]
1886    fn openurl_argv_preserves_ampersand_and_hash() {
1887        let udid = "12345678-1234-5678-1234-567812345678";
1888        let url = "insight://dev-mutate?action=env&value=staging#anchor";
1889        let argv = super::openurl_argv(udid, url);
1890        assert_eq!(argv[2], url);
1891        assert!(argv[2].contains('&'));
1892        assert!(argv[2].contains('#'));
1893    }
1894
1895    #[test]
1896    fn openurl_argv_preserves_unicode() {
1897        let udid = "12345678-1234-5678-1234-567812345678";
1898        let url = "insight://route?name=%E7%94%B0%E4%B8%AD";
1899        let argv = super::openurl_argv(udid, url);
1900        assert_eq!(argv[2], url);
1901    }
1902
1903    // -- sRGB chunk normalization ---------------------------------------
1904
1905    /// Build a minimal PNG: 1×1 8-bit RGBA, one IDAT (zlib-empty-safe),
1906    /// with or without an sRGB chunk. Returns synthetic bytes suitable
1907    /// for exercising the chunk-walking logic; no rendering intent.
1908    fn synth_png(with_srgb: bool) -> Vec<u8> {
1909        let mut out = Vec::new();
1910        out.extend_from_slice(super::PNG_MAGIC);
1911        // IHDR: 1x1, bit_depth=8, color_type=6 (RGBA), rest=0
1912        let ihdr_data: [u8; 13] = [
1913            0, 0, 0, 1, // width = 1
1914            0, 0, 0, 1, // height = 1
1915            8, // bit depth
1916            6, // color type = RGBA
1917            0, 0, 0,
1918        ];
1919        emit_chunk(&mut out, b"IHDR", &ihdr_data);
1920        if with_srgb {
1921            emit_chunk(&mut out, b"sRGB", &[0]);
1922        }
1923        // Placeholder IDAT — content doesn't matter for chunk-walking tests
1924        emit_chunk(&mut out, b"IDAT", &[0x78, 0x01, 0x00, 0x00]);
1925        emit_chunk(&mut out, b"IEND", &[]);
1926        out
1927    }
1928
1929    fn emit_chunk(out: &mut Vec<u8>, ctype: &[u8; 4], data: &[u8]) {
1930        out.extend_from_slice(&(data.len() as u32).to_be_bytes());
1931        out.extend_from_slice(ctype);
1932        out.extend_from_slice(data);
1933        let mut crc_in = Vec::with_capacity(4 + data.len());
1934        crc_in.extend_from_slice(ctype);
1935        crc_in.extend_from_slice(data);
1936        out.extend_from_slice(&super::crc32_ieee(&crc_in).to_be_bytes());
1937    }
1938
1939    #[test]
1940    fn ensure_srgb_passthrough_when_chunk_present() {
1941        let png = synth_png(true);
1942        let original_len = png.len();
1943        let out = super::ensure_srgb_chunk(png.clone());
1944        assert_eq!(out.len(), original_len);
1945        assert_eq!(out, png);
1946    }
1947
1948    #[test]
1949    fn ensure_srgb_inserts_chunk_when_absent() {
1950        let png = synth_png(false);
1951        let original_len = png.len();
1952        let out = super::ensure_srgb_chunk(png);
1953        assert_eq!(out.len(), original_len + 13);
1954        // First 8 bytes = PNG magic
1955        assert_eq!(&out[..8], super::PNG_MAGIC);
1956        // Search for the injected sRGB chunk
1957        let mut found = false;
1958        for w in out.windows(4) {
1959            if w == b"sRGB" {
1960                found = true;
1961                break;
1962            }
1963        }
1964        assert!(found, "sRGB chunk should have been spliced in");
1965    }
1966
1967    #[test]
1968    fn ensure_srgb_preserves_idat_bytes_verbatim() {
1969        // Any pixel corruption at the IDAT level would break the
1970        // pixel-preservation invariant. Extract IDAT payload from
1971        // input and output, assert byte-identical.
1972        let png = synth_png(false);
1973        let out = super::ensure_srgb_chunk(png.clone());
1974        assert_eq!(extract_idat_data(&png), extract_idat_data(&out));
1975    }
1976
1977    fn extract_idat_data(bytes: &[u8]) -> Vec<u8> {
1978        let mut i = 8;
1979        while i + 8 <= bytes.len() {
1980            let length =
1981                u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1982            let ctype = &bytes[i + 4..i + 8];
1983            if ctype == b"IDAT" {
1984                return bytes[i + 8..i + 8 + length].to_vec();
1985            }
1986            i += 12 + length;
1987        }
1988        vec![]
1989    }
1990
1991    #[test]
1992    fn ensure_srgb_passthrough_on_bad_magic() {
1993        // Corrupted / non-PNG input must not be modified.
1994        let bytes = vec![0u8; 32];
1995        let out = super::ensure_srgb_chunk(bytes.clone());
1996        assert_eq!(out, bytes);
1997    }
1998
1999    #[test]
2000    fn crc32_matches_known_iend() {
2001        // The empty-data IEND CRC is a well-known constant.
2002        // CRC over "IEND" alone: 0xAE_42_60_82.
2003        assert_eq!(super::crc32_ieee(b"IEND"), 0xAE42_6082);
2004    }
2005}