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.7 §D3 — snapshot the process-wide ring buffer of recent
484/// `xcrun simctl` invocations. Ordered oldest → newest, capped at 128
485/// entries. Reset on process restart.
486pub fn recent_subprocesses() -> Vec<SubprocessRecord> {
487    subprocess_ring::snapshot()
488}
489
490// -------------------- raw spawn primitive --------------------------------
491
492/// Execute `xcrun simctl <args>` and capture stdout/stderr.
493async fn simctl_capture(args: &[&str]) -> Result<(Vec<u8>, String), SimctlError> {
494    simctl_capture_env(args, &[]).await
495}
496
497/// `simctl_capture` with extra envp pairs set on the spawned process.
498/// The `xcrun simctl launch` subcommand uses this to inject
499/// `SIMCTL_CHILD_<KEY>=<VAL>` vars that the launched app sees as
500/// `ProcessInfo().environment["KEY"]`. `env` entries here are passed
501/// verbatim — caller composes the `SIMCTL_CHILD_` prefix via
502/// [`compose_child_env`].
503async fn simctl_capture_env(
504    args: &[&str],
505    env: &[(String, String)],
506) -> Result<(Vec<u8>, String), SimctlError> {
507    let mut cmd = Command::new("xcrun");
508    cmd.arg("simctl");
509    for a in args {
510        cmd.arg(a);
511    }
512    for (k, v) in env {
513        cmd.env(k, v);
514    }
515    let started = std::time::Instant::now();
516    let output = cmd.output().await?;
517    let wall_ms = started.elapsed().as_millis() as u64;
518    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
519    // v1.0.7 §D3 — every simctl invocation records to the ring buffer
520    // regardless of exit status.
521    subprocess_ring::record(SubprocessRecord {
522        argv: args.iter().map(|s| s.to_string()).collect(),
523        exit_code: output.status.code(),
524        wall_ms,
525        stderr_head: {
526            let mut s = stderr.clone();
527            if s.len() > 256 {
528                s.truncate(256);
529            }
530            s
531        },
532        timestamp: std::time::SystemTime::now(),
533    });
534    if !output.status.success() {
535        return Err(SimctlError::NonZeroExit {
536            subcommand: args.first().map(|s| s.to_string()).unwrap_or_default(),
537            argv: args.iter().map(|s| s.to_string()).collect(),
538            code: output.status.code().unwrap_or(-1),
539            stderr,
540            wall_ms,
541        });
542    }
543    Ok((output.stdout, stderr))
544}
545
546async fn simctl_run(args: &[&str]) -> Result<String, SimctlError> {
547    let (stdout, _) = simctl_capture(args).await?;
548    Ok(String::from_utf8_lossy(&stdout).into_owned())
549}
550
551/// Like [`simctl_run`] but injects `child_env` envp on the spawned
552/// process. Used by the env-aware launch path so the launched app can
553/// read deploy-time secrets / endpoints via `ProcessInfo`.
554async fn simctl_run_env(args: &[&str], env: &[(String, String)]) -> Result<String, SimctlError> {
555    let (stdout, _) = simctl_capture_env(args, env).await?;
556    Ok(String::from_utf8_lossy(&stdout).into_owned())
557}
558
559/// Compose user-provided `(key, value)` pairs into the `SIMCTL_CHILD_*`
560/// envp that `xcrun simctl launch` strips and delivers to the launched
561/// app. Idempotent: a key that already starts with `SIMCTL_CHILD_` is
562/// passed through unchanged.
563///
564/// # Example
565///
566/// ```
567/// use smix_simctl::compose_child_env;
568/// let composed = compose_child_env(&[("SMIX_PERF_RECEIVER_URL", "http://h:9999")]);
569/// assert_eq!(
570///     composed,
571///     vec![(
572///         "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
573///         "http://h:9999".to_string(),
574///     )]
575/// );
576/// ```
577pub fn compose_child_env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
578    pairs
579        .iter()
580        .map(|(k, v)| {
581            let key = if k.starts_with("SIMCTL_CHILD_") {
582                (*k).to_string()
583            } else {
584                format!("SIMCTL_CHILD_{k}")
585            };
586            (key, (*v).to_string())
587        })
588        .collect()
589}
590
591// -------------------- client --------------------------------------------
592
593/// Stateless wrapper around xcrun simctl. Methods are free functions
594/// in spirit (no instance state beyond optionally-cached `xcrun` path);
595/// kept as a struct for API ergonomics + future caching.
596///
597/// Since v1.0.4, the client also holds a [`ScreenshotPacer`] that
598/// throttles `xcrun simctl io screenshot` under high-frequency
599/// load. Defaults are conservative (100 ms interval floor);
600/// consumers whose flows are already loose are unaffected.
601#[derive(Debug)]
602pub struct SimctlClient {
603    /// Screenshot pacer — enforces the RFC 1.0.4 §D3 interval floor,
604    /// slow-path lift, and circuit breaker. Shared via `Arc<Mutex<_>>`
605    /// so a cloned client (which callers occasionally do) still shares
606    /// pressure accounting.
607    screenshot_pacer: Arc<std::sync::Mutex<ScreenshotPacer>>,
608}
609
610impl Default for SimctlClient {
611    fn default() -> Self {
612        Self::new()
613    }
614}
615
616impl SimctlClient {
617    /// Construct a new client with default screenshot pacing (100 ms
618    /// interval floor, adaptive slow-path lift to 1500 ms, circuit
619    /// breaker on ≥ 1500 ms walls or failures).
620    pub fn new() -> Self {
621        SimctlClient {
622            screenshot_pacer: Arc::new(std::sync::Mutex::new(ScreenshotPacer::new(
623                ScreenshotPacerConfig::default(),
624            ))),
625        }
626    }
627
628    /// Override the screenshot pacer with a custom config.
629    ///
630    /// Since smix 1.0.4.
631    #[must_use]
632    pub fn with_screenshot_pacer(self, config: ScreenshotPacerConfig) -> Self {
633        {
634            let mut guard = self
635                .screenshot_pacer
636                .lock()
637                .expect("screenshot pacer mutex must not be poisoned");
638            *guard = ScreenshotPacer::new(config);
639        }
640        self
641    }
642
643    /// Attach a [`smix_sim_health::SimHealthMonitor`] to receive
644    /// screenshot wall-time observations from every `screenshot`
645    /// call. Composes with the pacer — the pacer still enforces its
646    /// interval / circuit locally, and the monitor sees the same
647    /// walls for global state classification.
648    ///
649    /// Since smix 1.0.4.
650    #[must_use]
651    pub fn with_sim_health(self, monitor: smix_sim_health::SimHealthMonitor) -> Self {
652        {
653            let mut guard = self
654                .screenshot_pacer
655                .lock()
656                .expect("screenshot pacer mutex must not be poisoned");
657            guard.set_monitor(monitor);
658        }
659        self
660    }
661
662    // ---- inventory ------------------------------------------------------
663
664    /// `xcrun simctl list runtimes -j` → `Vec<SimctlRuntime>`.
665    pub async fn list_runtimes(&self) -> Result<Vec<SimctlRuntime>, SimctlError> {
666        let raw = simctl_run(&["list", "runtimes", "-j"]).await?;
667        #[derive(Deserialize)]
668        struct Wrap {
669            runtimes: Vec<RawRuntime>,
670        }
671        #[derive(Deserialize)]
672        struct RawRuntime {
673            identifier: String,
674            name: String,
675            version: String,
676            #[serde(rename = "isAvailable", default)]
677            is_available: bool,
678        }
679        let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
680            subcommand: "list runtimes".into(),
681            detail: e.to_string(),
682        })?;
683        Ok(w.runtimes
684            .into_iter()
685            .map(|r| SimctlRuntime {
686                identifier: r.identifier,
687                name: r.name,
688                version: r.version,
689                is_available: r.is_available,
690            })
691            .collect())
692    }
693
694    /// `xcrun simctl list devices -j` → flattened `Vec<SimctlDevice>`.
695    pub async fn list_devices(&self) -> Result<Vec<SimctlDevice>, SimctlError> {
696        let raw = simctl_run(&["list", "devices", "-j"]).await?;
697        #[derive(Deserialize)]
698        struct Wrap {
699            devices: std::collections::BTreeMap<String, Vec<RawDevice>>,
700        }
701        #[derive(Deserialize)]
702        struct RawDevice {
703            udid: String,
704            name: String,
705            state: String,
706            #[serde(rename = "isAvailable", default)]
707            is_available: bool,
708            #[serde(rename = "deviceTypeIdentifier", default)]
709            device_type_identifier: String,
710        }
711        let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
712            subcommand: "list devices".into(),
713            detail: e.to_string(),
714        })?;
715        let mut out = Vec::new();
716        for (runtime_id, devices) in w.devices {
717            for d in devices {
718                out.push(SimctlDevice {
719                    udid: d.udid,
720                    name: d.name,
721                    state: d.state,
722                    is_available: d.is_available,
723                    device_type_identifier: d.device_type_identifier,
724                    runtime_identifier: runtime_id.clone(),
725                });
726            }
727        }
728        Ok(out)
729    }
730
731    // ---- lifecycle ------------------------------------------------------
732
733    /// `xcrun simctl boot <udid>` — fire-and-forget boot request.
734    pub async fn boot(&self, udid: &str) -> Result<(), SimctlError> {
735        simctl_run(&["boot", udid]).await?;
736        Ok(())
737    }
738
739    /// `xcrun simctl shutdown <udid>`.
740    pub async fn shutdown(&self, udid: &str) -> Result<(), SimctlError> {
741        simctl_run(&["shutdown", udid]).await?;
742        Ok(())
743    }
744
745    /// Read the sim's current BCP-47 locale (first entry of
746    /// `NSGlobalDomain AppleLanguages`). Returns `Ok(None)` when the
747    /// preference is unset (defaults read exits non-zero) or unparseable.
748    /// Wire format: `simctl spawn <udid> defaults read -g AppleLanguages`
749    /// stdout looks like `"(\n    \"en-US\"\n)\n"`; we extract the first
750    /// quoted token.
751    pub async fn current_locale(&self, udid: &str) -> Result<Option<String>, SimctlError> {
752        let out =
753            match simctl_run(&["spawn", udid, "/usr/bin/defaults", "read", "-g", "AppleLanguages"]).await {
754                Ok(s) => s,
755                // `defaults read` returns non-zero when the key is unset; that
756                // is a legitimate "no opinion" state, not an error.
757                Err(SimctlError::NonZeroExit { .. }) => return Ok(None),
758                Err(e) => return Err(e),
759            };
760        // First quoted substring.
761        if let Some(start) = out.find('"') {
762            let rest = &out[start + 1..];
763            if let Some(end) = rest.find('"') {
764                return Ok(Some(rest[..end].to_string()));
765            }
766        }
767        Ok(None)
768    }
769
770    /// Write `AppleLanguages` (array) + `AppleLocale` (scalar) to the
771    /// sim's NSGlobalDomain so SpringBoard + apps re-localize on next
772    /// launch. AppleLocale is BCP-47 with hyphen replaced by underscore
773    /// (`en_US`); AppleLanguages is the BCP-47 tag verbatim.
774    /// **The caller must shutdown + reboot the sim for the change to
775    /// take effect** — running apps cache the locale at process start.
776    pub async fn set_locale(&self, udid: &str, locale: &str) -> Result<(), SimctlError> {
777        simctl_run(&[
778            "spawn",
779            udid,
780            "defaults",
781            "write",
782            "-g",
783            "AppleLanguages",
784            "-array",
785            locale,
786        ])
787        .await?;
788        let locale_underscore = locale.replace('-', "_");
789        simctl_run(&[
790            "spawn",
791            udid,
792            "defaults",
793            "write",
794            "-g",
795            "AppleLocale",
796            &locale_underscore,
797        ])
798        .await?;
799        Ok(())
800    }
801
802    /// Boot + poll device state == "Booted" within timeout. Tries every
803    /// 500 ms until success or `timeout_ms` elapses. Idempotent on
804    /// already-booted devices (`xcrun simctl boot` returns non-zero when
805    /// the device is already booted; we swallow that).
806    pub async fn boot_and_wait(&self, udid: &str, timeout: Duration) -> Result<(), SimctlError> {
807        // Issue boot; ignore already-booted error (the only friendly path).
808        let _ = simctl_run(&["boot", udid]).await;
809        let start = std::time::Instant::now();
810        loop {
811            let devices = self.list_devices().await?;
812            if devices
813                .iter()
814                .any(|d| d.udid == udid && d.state == "Booted")
815            {
816                return Ok(());
817            }
818            if start.elapsed() > timeout {
819                return Err(SimctlError::Timeout {
820                    subcommand: format!("boot {}", udid),
821                    ms: timeout.as_millis() as u64,
822                });
823            }
824            sleep(Duration::from_millis(500)).await;
825        }
826    }
827
828    /// `xcrun simctl erase <udid>` — wipe device contents.
829    pub async fn erase(&self, udid: &str) -> Result<(), SimctlError> {
830        simctl_run(&["erase", udid]).await?;
831        Ok(())
832    }
833
834    /// `xcrun simctl install <udid> <app-path>` — install a `.app` bundle.
835    pub async fn install(&self, udid: &str, app_path: &str) -> Result<(), SimctlError> {
836        simctl_run(&["install", udid, app_path]).await?;
837        Ok(())
838    }
839
840    /// `xcrun simctl uninstall <udid> <bundle-id>`.
841    pub async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
842        simctl_run(&["uninstall", udid, bundle_id]).await?;
843        Ok(())
844    }
845
846    /// `xcrun simctl terminate <udid> <bundle-id>` — kill a running app.
847    pub async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
848        simctl_run(&["terminate", udid, bundle_id]).await?;
849        Ok(())
850    }
851
852    /// `xcrun simctl launch <udid> <bundleId>` → parse `"<bundle>: <pid>"`.
853    pub async fn launch(&self, udid: &str, bundle_id: &str) -> Result<LaunchResult, SimctlError> {
854        self.launch_with_args(udid, bundle_id, &[]).await
855    }
856
857    /// `xcrun simctl launch <udid> <bundleId> -- <arg>...` — launch with a
858    /// process-level argument vector. Empty `args` is equivalent to
859    /// [`Self::launch`]. Mirrors maestro yaml `launchApp.arguments`.
860    pub async fn launch_with_args(
861        &self,
862        udid: &str,
863        bundle_id: &str,
864        args: &[String],
865    ) -> Result<LaunchResult, SimctlError> {
866        self.launch_with_args_and_env(udid, bundle_id, args, &[])
867            .await
868    }
869
870    /// Like [`Self::launch_with_args`] but also sets `SIMCTL_CHILD_*`
871    /// envp on the simctl process so the launched app can read
872    /// deploy-time vars via `ProcessInfo().environment["KEY"]`.
873    /// `child_env` keys without the `SIMCTL_CHILD_` prefix get it added
874    /// automatically (per [`compose_child_env`] semantics). Useful for
875    /// prelaunching an app before any `openLink` so iOS treats the
876    /// subsequent URL handoff as in-app routing instead of cross-app,
877    /// side-stepping the SpringBoard "Open in '`<App>`'?" confirmation
878    /// dialog.
879    pub async fn launch_with_args_and_env(
880        &self,
881        udid: &str,
882        bundle_id: &str,
883        args: &[String],
884        child_env: &[(&str, &str)],
885    ) -> Result<LaunchResult, SimctlError> {
886        let mut argv: Vec<&str> = vec!["launch", udid, bundle_id];
887        if !args.is_empty() {
888            argv.push("--");
889            for a in args {
890                argv.push(a.as_str());
891            }
892        }
893        let composed = compose_child_env(child_env);
894        let out = simctl_run_env(&argv, &composed).await?;
895        // Output format: `com.example.app: 12345\n`
896        let pid_str =
897            out.rsplit(':')
898                .next()
899                .map(str::trim)
900                .ok_or_else(|| SimctlError::Malformed {
901                    subcommand: "launch".into(),
902                    detail: format!("unexpected stdout shape: {}", out.trim()),
903                })?;
904        let pid: u32 = pid_str.parse().map_err(|_| SimctlError::Malformed {
905            subcommand: "launch".into(),
906            detail: format!("non-numeric pid in stdout: {}", out.trim()),
907        })?;
908        Ok(LaunchResult { pid })
909    }
910
911    /// v1.0.4 §D12 — reset every privacy permission granted to
912    /// `bundle_id` on the sim: `xcrun simctl privacy <udid> reset all
913    /// <bundle-id>`. Companion to [`Self::clear_app_sandbox`] on the
914    /// in-place `launchApp: clearState: true` path that replaces
915    /// `simctl uninstall + install` (which triggers iOS 26.5 XCUITest
916    /// binding loss + ReportCrash's "Insight quit unexpectedly" dialog).
917    pub async fn privacy_reset_all(
918        &self,
919        udid: &str,
920        bundle_id: &str,
921    ) -> Result<(), SimctlError> {
922        simctl_run(&["privacy", udid, "reset", "all", bundle_id]).await?;
923        Ok(())
924    }
925
926    /// v1.0.4 §D12 — wipe the app's sandbox on the sim: locate the
927    /// Data container via `simctl get_app_container <udid> <bundle>
928    /// data`, then `simctl spawn <udid> rm -rf <container>/Documents
929    /// <container>/Library <container>/tmp`. The app remains installed
930    /// (no `simctl uninstall`), so the XCUITest binding is preserved
931    /// and macOS `ReportCrash` does not misinterpret a missing
932    /// install-receipt as a crash.
933    pub async fn clear_app_sandbox(
934        &self,
935        udid: &str,
936        bundle_id: &str,
937    ) -> Result<(), SimctlError> {
938        let raw = simctl_run(&["get_app_container", udid, bundle_id, "data"]).await?;
939        let container = raw.trim();
940        if container.is_empty() {
941            return Err(SimctlError::Malformed {
942                subcommand: "clear_app_sandbox".into(),
943                detail: format!("empty Data container path for bundle {bundle_id}"),
944            });
945        }
946        let documents = format!("{container}/Documents");
947        let library = format!("{container}/Library");
948        let tmp = format!("{container}/tmp");
949        // v1.0.7 §D1 — `xcrun simctl spawn <UDID> <cmd>` uses
950        // `posix_spawn` inside the sim OS; `<cmd>` must be an absolute
951        // path (no PATH resolution). Prior versions passed `"rm"` and
952        // hit `NSPOSIXErrorDomain code 2: No such file or directory`
953        // on iOS 17+ sims. `/bin/rm` is present on every stock sim
954        // image.
955        //
956        // Best-effort: any missing subdir is fine (fresh app that never
957        // wrote to that path). `rm -rf` treats absent targets as no-ops.
958        simctl_run(&[
959            "spawn", udid, "/bin/rm", "-rf", &documents, &library, &tmp,
960        ])
961        .await?;
962        Ok(())
963    }
964
965    /// `xcrun simctl openurl <udid> <url>` — open a URL on the device.
966    ///
967    /// **URL bytes are passed to `xcrun simctl` verbatim** — no
968    /// parsing, no percent-encoding rewrite, no query-string
969    /// stripping. Verified by [`openurl_argv`] (test-visible helper)
970    /// and its unit test asserting query-params like
971    /// `?url=http%3A%2F%2Flocalhost%3A8081` reach the argv byte-for-byte.
972    /// Feedback §G verification — if the target app's URL router
973    /// (e.g. expo-dev-client 57.0.5) shows a picker instead of
974    /// auto-connecting, the URL made it through smix intact and the
975    /// finding lives on the URL-router side.
976    pub async fn open_url(&self, udid: &str, url: &str) -> Result<(), SimctlError> {
977        let argv = openurl_argv(udid, url);
978        let refs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
979        simctl_run(&refs).await?;
980        Ok(())
981    }
982}
983
984/// v1.0.4 §G — argv construction for `xcrun simctl openurl`. Extracted
985/// as a test-visible helper so the URL-preservation contract is
986/// unit-testable without invoking `xcrun`.
987#[doc(hidden)]
988pub fn openurl_argv(udid: &str, url: &str) -> [String; 3] {
989    ["openurl".to_string(), udid.to_string(), url.to_string()]
990}
991
992impl SimctlClient {
993
994    /// `xcrun simctl push <udid> <bundle-id> <apns-json-path>`.
995    /// Deliver an APNS payload to a sim-installed app. The payload file is
996    /// a JSON document whose top-level dictionary mirrors what an APNS
997    /// provider would send; `aps.alert.body` / `aps.alert.title` surface
998    /// as banner content and reach the app's
999    /// `UNUserNotificationCenterDelegate`.
1000    pub async fn send_push(
1001        &self,
1002        udid: &str,
1003        bundle_id: &str,
1004        apns_json_path: &str,
1005    ) -> Result<(), SimctlError> {
1006        simctl_run(&["push", udid, bundle_id, apns_json_path]).await?;
1007        Ok(())
1008    }
1009
1010    /// `xcrun simctl ui <udid> appearance <light|dark>` — set UI appearance.
1011    pub async fn set_appearance(&self, udid: &str, mode: Appearance) -> Result<(), SimctlError> {
1012        simctl_run(&["ui", udid, "appearance", mode.as_str()]).await?;
1013        Ok(())
1014    }
1015
1016    /// `xcrun simctl privacy <udid> grant <perm> <bundle-id>`.
1017    pub async fn grant_permission(
1018        &self,
1019        udid: &str,
1020        permission: SimctlPermission,
1021        bundle_id: &str,
1022    ) -> Result<(), SimctlError> {
1023        simctl_run(&["privacy", udid, "grant", permission.as_str(), bundle_id]).await?;
1024        Ok(())
1025    }
1026
1027    /// `xcrun simctl privacy <udid> revoke <perm> <bundle-id>` — explicitly
1028    /// deny the permission. Mirrors maestro yaml `permissions: { x: deny }`
1029    /// (the reverse of `grant`). Distinct from `reset`, which returns the
1030    /// permission to "not determined".
1031    pub async fn revoke_permission(
1032        &self,
1033        udid: &str,
1034        permission: SimctlPermission,
1035        bundle_id: &str,
1036    ) -> Result<(), SimctlError> {
1037        simctl_run(&["privacy", udid, "revoke", permission.as_str(), bundle_id]).await?;
1038        Ok(())
1039    }
1040
1041    /// `xcrun simctl location <udid> set <lat>,<lng>` — set sim location
1042    /// to a fixed point. Mirrors maestro `setLocation`.
1043    pub async fn location_set(
1044        &self,
1045        udid: &str,
1046        latitude: f64,
1047        longitude: f64,
1048    ) -> Result<(), SimctlError> {
1049        let coord = format!("{latitude},{longitude}");
1050        simctl_run(&["location", udid, "set", &coord]).await?;
1051        Ok(())
1052    }
1053
1054    /// `xcrun simctl location <udid> start [--speed=<m/s>] <waypoints>`
1055    /// — interpolate sim location along waypoints. Fire-and-return: simctl
1056    /// injects scenario and returns; sim continues interpolation in background.
1057    /// Mirrors maestro `travel`.
1058    pub async fn location_start(
1059        &self,
1060        udid: &str,
1061        points: &[(f64, f64)],
1062        speed_mps: Option<f64>,
1063    ) -> Result<(), SimctlError> {
1064        if points.len() < 2 {
1065            return Err(SimctlError::Malformed {
1066                subcommand: "location-start".into(),
1067                detail: format!("requires ≥2 waypoints, got {}", points.len()),
1068            });
1069        }
1070        let mut args: Vec<String> = vec!["location".into(), udid.into(), "start".into()];
1071        if let Some(s) = speed_mps {
1072            args.push(format!("--speed={s}"));
1073        }
1074        for (lat, lng) in points {
1075            args.push(format!("{lat},{lng}"));
1076        }
1077        let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
1078        simctl_run(&args_ref).await?;
1079        Ok(())
1080    }
1081
1082    /// `xcrun simctl location <udid> clear` — reset active location
1083    /// scenario.
1084    pub async fn location_clear(&self, udid: &str) -> Result<(), SimctlError> {
1085        simctl_run(&["location", udid, "clear"]).await?;
1086        Ok(())
1087    }
1088
1089    /// `xcrun simctl addmedia <udid> <path>...` — add photos / videos /
1090    /// contacts to sim library. Mirrors maestro `addMedia` (scalar or
1091    /// array form already flattened on adapter side).
1092    pub async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), SimctlError> {
1093        if paths.is_empty() {
1094            return Err(SimctlError::Malformed {
1095                subcommand: "addmedia".into(),
1096                detail: "no paths supplied".into(),
1097            });
1098        }
1099        let mut args: Vec<&str> = vec!["addmedia", udid];
1100        for p in paths {
1101            args.push(p.as_str());
1102        }
1103        simctl_run(&args).await?;
1104        Ok(())
1105    }
1106
1107    /// Start recording sim display to `path`. Spawns
1108    /// `xcrun simctl io <udid> recordVideo <path>` as a long-running child;
1109    /// returns handle immediately. Caller must pair with
1110    /// [`Self::record_video_stop`] for clean SIGINT-and-wait shutdown —
1111    /// dropping the handle would SIGKILL via tokio + lose mp4 trailer.
1112    pub async fn record_video_start(
1113        &self,
1114        udid: &str,
1115        path: &str,
1116    ) -> Result<RecordingHandle, SimctlError> {
1117        let child = tokio::process::Command::new("xcrun")
1118            .args(["simctl", "io", udid, "recordVideo", path])
1119            .stdin(std::process::Stdio::null())
1120            .stdout(std::process::Stdio::piped())
1121            .stderr(std::process::Stdio::piped())
1122            .spawn()?;
1123        // brief settle for simctl to initialize encoder + open output file.
1124        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1125        Ok(RecordingHandle {
1126            child,
1127            path: path.to_string(),
1128            started_at: std::time::Instant::now(),
1129        })
1130    }
1131
1132    /// Stop a recording via SIGINT + wait (≤10s). SIGINT lets simctl
1133    /// trap and flush the mp4 trailer; SIGKILL would corrupt output.
1134    /// Timeout escalates to SIGKILL with explicit error mentioning truncation.
1135    pub async fn record_video_stop(&self, mut handle: RecordingHandle) -> Result<(), SimctlError> {
1136        let pid = handle.child.id().ok_or_else(|| SimctlError::Malformed {
1137            subcommand: "recordVideo-stop".into(),
1138            detail: "child already reaped".into(),
1139        })?;
1140        // SAFETY: libc::kill is a thin POSIX syscall wrapper; pid is owned by
1141        // this Child instance (no race) and SIGINT is signal-safe.
1142        let rc = unsafe { libc::kill(pid as i32, libc::SIGINT) };
1143        if rc != 0 {
1144            return Err(SimctlError::Malformed {
1145                subcommand: "recordVideo-stop".into(),
1146                detail: format!(
1147                    "kill SIGINT failed: errno={}",
1148                    std::io::Error::last_os_error()
1149                ),
1150            });
1151        }
1152        let wait_result =
1153            tokio::time::timeout(std::time::Duration::from_secs(10), handle.child.wait()).await;
1154        match wait_result {
1155            Ok(Ok(_status)) => Ok(()),
1156            Ok(Err(e)) => Err(SimctlError::Malformed {
1157                subcommand: "recordVideo-stop".into(),
1158                detail: format!("wait failed: {e}"),
1159            }),
1160            Err(_timeout) => {
1161                let _ = handle.child.kill().await;
1162                Err(SimctlError::Malformed {
1163                    subcommand: "recordVideo-stop".into(),
1164                    detail: "SIGINT timeout (10s) — escalated SIGKILL; output mp4 likely truncated. Inspect simctl recordVideo stderr.".into(),
1165                })
1166            }
1167        }
1168    }
1169
1170    /// `xcrun simctl privacy <udid> reset <perm> <bundle-id>` — return the
1171    /// permission to "not determined" so the next request re-prompts.
1172    /// May terminate a running instance of the target app (Apple
1173    /// behavior) — call before launch, not mid-flow.
1174    pub async fn reset_permission(
1175        &self,
1176        udid: &str,
1177        permission: SimctlPermission,
1178        bundle_id: &str,
1179    ) -> Result<(), SimctlError> {
1180        simctl_run(&["privacy", udid, "reset", permission.as_str(), bundle_id]).await?;
1181        Ok(())
1182    }
1183
1184    /// `xcrun simctl keychain <udid> reset` — clear all keychain entries.
1185    pub async fn keychain_reset(&self, udid: &str) -> Result<(), SimctlError> {
1186        simctl_run(&["keychain", udid, "reset"]).await?;
1187        Ok(())
1188    }
1189
1190    /// `xcrun simctl pbpaste <udid>` — read clipboard contents.
1191    pub async fn pasteboard_get(&self, udid: &str) -> Result<String, SimctlError> {
1192        simctl_run(&["pbpaste", udid]).await
1193    }
1194
1195    /// `xcrun simctl pbcopy <udid>` — write clipboard contents (via piped stdin).
1196    pub async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), SimctlError> {
1197        // pbcopy reads stdin — we pipe via shell echo for simplicity.
1198        // Long-term: spawn with stdin pipe.
1199        use tokio::io::AsyncWriteExt;
1200        let mut cmd = Command::new("xcrun");
1201        cmd.arg("simctl").arg("pbcopy").arg(udid);
1202        cmd.stdin(std::process::Stdio::piped());
1203        let mut child = cmd.spawn()?;
1204        if let Some(mut stdin) = child.stdin.take() {
1205            stdin.write_all(text.as_bytes()).await?;
1206            drop(stdin); // close stdin so pbcopy returns
1207        }
1208        let status = child.wait().await?;
1209        if !status.success() {
1210            return Err(SimctlError::NonZeroExit {
1211                subcommand: "pbcopy".into(),
1212                argv: vec!["pbcopy".to_string()],
1213                code: status.code().unwrap_or(-1),
1214                stderr: String::new(),
1215                wall_ms: 0,
1216            });
1217        }
1218        Ok(())
1219    }
1220
1221    /// Toggle "Reduce Motion" accessibility setting via `defaults write`.
1222    pub async fn set_reduce_motion(&self, udid: &str, enabled: bool) -> Result<(), SimctlError> {
1223        let val = if enabled { "1" } else { "0" };
1224        // `defaults write` lives under spawn; routed via simctl spawn.
1225        simctl_run(&[
1226            "spawn",
1227            udid,
1228            "defaults",
1229            "write",
1230            "com.apple.UIKit",
1231            "UIAccessibilityReduceMotionEnabled",
1232            "-bool",
1233            val,
1234        ])
1235        .await?;
1236        Ok(())
1237    }
1238
1239    /// `xcrun simctl io <udid> screenshot <tmpfile>` → raw PNG bytes,
1240    /// with a byte-level sRGB metadata splice if the produced PNG lacks
1241    /// an `sRGB` chunk.
1242    ///
1243    /// Goes through a temp file: current Xcode's `screenshot -` does not
1244    /// treat `-` as stdout — it writes a literal file named `-` in cwd
1245    /// and emits nothing on stdout (observed on Xcode/iOS 26.5).
1246    ///
1247    /// **Pixel-preservation invariant**: the returned bytes are
1248    /// byte-identical to whatever `simctl io screenshot` wrote to disk
1249    /// EXCEPT for one narrow case — if the PNG does not carry an
1250    /// `sRGB` ancillary chunk (observed on iOS 26.5 sub-builds
1251    /// mid-2026), a 13-byte `sRGB` chunk is spliced in immediately
1252    /// before the first `IDAT`. Pixel data (IDAT bytes) is never
1253    /// decoded or modified. See [`ensure_srgb_chunk`] for the exact
1254    /// splice operation.
1255    pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, SimctlError> {
1256        // v1.0.4 — pace + circuit-check before invoking simctl.
1257        let wait = {
1258            let mut pacer = self
1259                .screenshot_pacer
1260                .lock()
1261                .expect("screenshot pacer mutex must not be poisoned");
1262            pacer
1263                .compute_wait()
1264                .map_err(|retry_after| SimctlError::CaptureBackpressure { retry_after })?
1265        };
1266        if !wait.is_zero() {
1267            sleep(wait).await;
1268        }
1269
1270        let call_start = std::time::Instant::now();
1271        let tmp =
1272            std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
1273        let tmp_str = tmp.display().to_string();
1274        let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
1275        let bytes = result.and_then(|_| {
1276            std::fs::read(&tmp).map_err(|e| SimctlError::Malformed {
1277                subcommand: "screenshot".into(),
1278                detail: format!("read {tmp_str}: {e}"),
1279            })
1280        });
1281        let _ = std::fs::remove_file(&tmp);
1282
1283        let wall = call_start.elapsed();
1284        let failed = bytes.is_err();
1285        {
1286            let mut pacer = self
1287                .screenshot_pacer
1288                .lock()
1289                .expect("screenshot pacer mutex must not be poisoned");
1290            pacer.record(wall, failed);
1291        }
1292
1293        let bytes = bytes?;
1294        if bytes.len() < 8 {
1295            return Err(SimctlError::Malformed {
1296                subcommand: "screenshot".into(),
1297                detail: format!("screenshot file too short: {} bytes", bytes.len()),
1298            });
1299        }
1300        Ok(ensure_srgb_chunk(bytes))
1301    }
1302
1303    /// `xcrun simctl create <name> <device-type-id> <runtime-id>` → udid.
1304    pub async fn create_device(
1305        &self,
1306        name: &str,
1307        device_type: &str,
1308        runtime_id: &str,
1309    ) -> Result<String, SimctlError> {
1310        let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
1311        Ok(out.trim().to_string())
1312    }
1313
1314    /// `xcrun simctl delete <udid>` — delete a simulator device.
1315    pub async fn delete_device(&self, udid: &str) -> Result<(), SimctlError> {
1316        simctl_run(&["delete", udid]).await?;
1317        Ok(())
1318    }
1319}
1320
1321// -------------------- PNG sRGB chunk normalization (v1.0.2) --------------------
1322//
1323// iOS 26.5 sub-builds (mid-2026) started omitting the `sRGB` ancillary
1324// chunk from `simctl io screenshot` output. macOS Preview.app and other
1325// viewers that fall back to Display P3 when no ICC profile is embedded
1326// then over-saturate the image (red gets pushed, text anti-alias picks
1327// up yellow fringing).
1328//
1329// This does NOT affect pixel-comparison (dhash decodes IDAT to RGBA and
1330// ignores ancillary chunks), but does affect any downstream tool that
1331// renders the PNG for human review. The normalizer runs on the raw byte
1332// stream — walks chunks, and if no `sRGB` chunk is seen before the first
1333// `IDAT`, splices in a synthesized 13-byte `sRGB` chunk (length=1,
1334// type="sRGB", data=[0 = perceptual intent], CRC over type+data).
1335//
1336// Pixel-preservation invariant: IDAT bytes are never decoded. Every
1337// existing chunk is copied verbatim. Only 13 bytes of new metadata are
1338// inserted.
1339
1340const PNG_MAGIC: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
1341
1342/// Ensure the PNG carries an `sRGB` ancillary chunk. Called on the raw
1343/// bytes returned by `xcrun simctl io <udid> screenshot`. If the PNG
1344/// already has an `sRGB` chunk, returns the input unchanged; otherwise
1345/// splices in a 13-byte `sRGB` chunk (rendering intent = 0, perceptual)
1346/// immediately before the first `IDAT`. Returns the input unchanged on
1347/// any structural anomaly (missing magic, malformed chunk) so a
1348/// corrupted PNG is passed through untouched for the caller to diagnose.
1349pub fn ensure_srgb_chunk(bytes: Vec<u8>) -> Vec<u8> {
1350    if bytes.len() < 8 || &bytes[..8] != PNG_MAGIC {
1351        return bytes;
1352    }
1353    let Some((idat_offset, has_srgb)) = scan_png_chunks(&bytes) else {
1354        return bytes;
1355    };
1356    if has_srgb {
1357        return bytes;
1358    }
1359    // Splice the synthesized sRGB chunk right before the first IDAT.
1360    let mut out = Vec::with_capacity(bytes.len() + 13);
1361    out.extend_from_slice(&bytes[..idat_offset]);
1362    out.extend_from_slice(&synthesized_srgb_chunk());
1363    out.extend_from_slice(&bytes[idat_offset..]);
1364    out
1365}
1366
1367/// Walk PNG chunks starting after the 8-byte magic. Returns
1368/// `(offset_of_first_IDAT, has_srgb_chunk_before_it)` when the walk
1369/// reaches an IDAT chunk. Returns `None` if the walk hits EOF or a
1370/// malformed chunk without seeing an IDAT.
1371fn scan_png_chunks(bytes: &[u8]) -> Option<(usize, bool)> {
1372    let mut i: usize = 8;
1373    let mut has_srgb = false;
1374    while i + 8 <= bytes.len() {
1375        let length =
1376            u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1377        let ctype = &bytes[i + 4..i + 8];
1378        if ctype == b"IDAT" {
1379            return Some((i, has_srgb));
1380        }
1381        if ctype == b"sRGB" {
1382            has_srgb = true;
1383        }
1384        // 4 (length) + 4 (type) + length (data) + 4 (crc)
1385        let end = i.checked_add(12)?.checked_add(length)?;
1386        if end > bytes.len() {
1387            return None;
1388        }
1389        i = end;
1390    }
1391    None
1392}
1393
1394/// Build the 13-byte `sRGB` chunk with rendering intent = 0 (perceptual).
1395/// Format: `[len:4][type:4][data:1][crc:4]` = 13 bytes total.
1396fn synthesized_srgb_chunk() -> [u8; 13] {
1397    // The CRC is computed over `type || data`.
1398    let mut crc_input = [0u8; 5];
1399    crc_input[0..4].copy_from_slice(b"sRGB");
1400    crc_input[4] = 0; // perceptual
1401    let crc = crc32_ieee(&crc_input);
1402    let mut chunk = [0u8; 13];
1403    chunk[0..4].copy_from_slice(&1u32.to_be_bytes()); // length = 1 (data byte)
1404    chunk[4..8].copy_from_slice(b"sRGB");
1405    chunk[8] = 0;
1406    chunk[9..13].copy_from_slice(&crc.to_be_bytes());
1407    chunk
1408}
1409
1410/// Table-less CRC-32 IEEE 802.3 (polynomial 0xEDB88320) as used by
1411/// PNG. Small enough for this crate's single call site — avoids
1412/// pulling in a `crc32fast` dependency.
1413fn crc32_ieee(bytes: &[u8]) -> u32 {
1414    let mut crc: u32 = 0xFFFF_FFFF;
1415    for &b in bytes {
1416        crc ^= u32::from(b);
1417        for _ in 0..8 {
1418            let mask = 0u32.wrapping_sub(crc & 1);
1419            crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
1420        }
1421    }
1422    !crc
1423}
1424
1425#[cfg(test)]
1426mod tests {
1427    use super::*;
1428
1429    #[test]
1430    fn compose_child_env_adds_prefix() {
1431        let composed = compose_child_env(&[
1432            ("SMIX_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
1433            ("LAUNCH_FORCE_PUSH", "true"),
1434        ]);
1435        assert_eq!(
1436            composed,
1437            vec![
1438                (
1439                    "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
1440                    "http://127.0.0.1:9999".to_string(),
1441                ),
1442                (
1443                    "SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
1444                    "true".to_string(),
1445                ),
1446            ]
1447        );
1448    }
1449
1450    #[test]
1451    fn compose_child_env_already_prefixed_passes_through() {
1452        // Defensive: caller may pre-prefix; we must not double-prefix.
1453        let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
1454        assert_eq!(
1455            composed,
1456            vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
1457        );
1458    }
1459
1460    #[test]
1461    fn compose_child_env_empty_input_is_empty_output() {
1462        assert!(compose_child_env(&[]).is_empty());
1463    }
1464
1465    // -- v1.0.4 §G openurl URL preservation -----------------------------
1466
1467    #[test]
1468    fn openurl_argv_preserves_url_verbatim() {
1469        let udid = "12345678-1234-5678-1234-567812345678";
1470        let url = "exp+focus-ai-app://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081";
1471        let argv = super::openurl_argv(udid, url);
1472        assert_eq!(argv[0], "openurl");
1473        assert_eq!(argv[1], udid);
1474        // Byte-identical URL — no percent-decoding, no query-strip.
1475        assert_eq!(argv[2], url);
1476        assert!(argv[2].contains("?url="));
1477        assert!(argv[2].contains("%3A"));
1478        assert!(argv[2].contains("%2F"));
1479    }
1480
1481    #[test]
1482    fn openurl_argv_preserves_ampersand_and_hash() {
1483        let udid = "12345678-1234-5678-1234-567812345678";
1484        let url = "insight://dev-mutate?action=env&value=staging#anchor";
1485        let argv = super::openurl_argv(udid, url);
1486        assert_eq!(argv[2], url);
1487        assert!(argv[2].contains('&'));
1488        assert!(argv[2].contains('#'));
1489    }
1490
1491    #[test]
1492    fn openurl_argv_preserves_unicode() {
1493        let udid = "12345678-1234-5678-1234-567812345678";
1494        let url = "insight://route?name=%E7%94%B0%E4%B8%AD";
1495        let argv = super::openurl_argv(udid, url);
1496        assert_eq!(argv[2], url);
1497    }
1498
1499    // -- sRGB chunk normalization ---------------------------------------
1500
1501    /// Build a minimal PNG: 1×1 8-bit RGBA, one IDAT (zlib-empty-safe),
1502    /// with or without an sRGB chunk. Returns synthetic bytes suitable
1503    /// for exercising the chunk-walking logic; no rendering intent.
1504    fn synth_png(with_srgb: bool) -> Vec<u8> {
1505        let mut out = Vec::new();
1506        out.extend_from_slice(super::PNG_MAGIC);
1507        // IHDR: 1x1, bit_depth=8, color_type=6 (RGBA), rest=0
1508        let ihdr_data: [u8; 13] = [
1509            0, 0, 0, 1, // width = 1
1510            0, 0, 0, 1, // height = 1
1511            8, // bit depth
1512            6, // color type = RGBA
1513            0, 0, 0,
1514        ];
1515        emit_chunk(&mut out, b"IHDR", &ihdr_data);
1516        if with_srgb {
1517            emit_chunk(&mut out, b"sRGB", &[0]);
1518        }
1519        // Placeholder IDAT — content doesn't matter for chunk-walking tests
1520        emit_chunk(&mut out, b"IDAT", &[0x78, 0x01, 0x00, 0x00]);
1521        emit_chunk(&mut out, b"IEND", &[]);
1522        out
1523    }
1524
1525    fn emit_chunk(out: &mut Vec<u8>, ctype: &[u8; 4], data: &[u8]) {
1526        out.extend_from_slice(&(data.len() as u32).to_be_bytes());
1527        out.extend_from_slice(ctype);
1528        out.extend_from_slice(data);
1529        let mut crc_in = Vec::with_capacity(4 + data.len());
1530        crc_in.extend_from_slice(ctype);
1531        crc_in.extend_from_slice(data);
1532        out.extend_from_slice(&super::crc32_ieee(&crc_in).to_be_bytes());
1533    }
1534
1535    #[test]
1536    fn ensure_srgb_passthrough_when_chunk_present() {
1537        let png = synth_png(true);
1538        let original_len = png.len();
1539        let out = super::ensure_srgb_chunk(png.clone());
1540        assert_eq!(out.len(), original_len);
1541        assert_eq!(out, png);
1542    }
1543
1544    #[test]
1545    fn ensure_srgb_inserts_chunk_when_absent() {
1546        let png = synth_png(false);
1547        let original_len = png.len();
1548        let out = super::ensure_srgb_chunk(png);
1549        assert_eq!(out.len(), original_len + 13);
1550        // First 8 bytes = PNG magic
1551        assert_eq!(&out[..8], super::PNG_MAGIC);
1552        // Search for the injected sRGB chunk
1553        let mut found = false;
1554        for w in out.windows(4) {
1555            if w == b"sRGB" {
1556                found = true;
1557                break;
1558            }
1559        }
1560        assert!(found, "sRGB chunk should have been spliced in");
1561    }
1562
1563    #[test]
1564    fn ensure_srgb_preserves_idat_bytes_verbatim() {
1565        // Any pixel corruption at the IDAT level would break the
1566        // pixel-preservation invariant. Extract IDAT payload from
1567        // input and output, assert byte-identical.
1568        let png = synth_png(false);
1569        let out = super::ensure_srgb_chunk(png.clone());
1570        assert_eq!(extract_idat_data(&png), extract_idat_data(&out));
1571    }
1572
1573    fn extract_idat_data(bytes: &[u8]) -> Vec<u8> {
1574        let mut i = 8;
1575        while i + 8 <= bytes.len() {
1576            let length =
1577                u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1578            let ctype = &bytes[i + 4..i + 8];
1579            if ctype == b"IDAT" {
1580                return bytes[i + 8..i + 8 + length].to_vec();
1581            }
1582            i += 12 + length;
1583        }
1584        vec![]
1585    }
1586
1587    #[test]
1588    fn ensure_srgb_passthrough_on_bad_magic() {
1589        // Corrupted / non-PNG input must not be modified.
1590        let bytes = vec![0u8; 32];
1591        let out = super::ensure_srgb_chunk(bytes.clone());
1592        assert_eq!(out, bytes);
1593    }
1594
1595    #[test]
1596    fn crc32_matches_known_iend() {
1597        // The empty-data IEND CRC is a well-known constant.
1598        // CRC over "IEND" alone: 0xAE_42_60_82.
1599        assert_eq!(super::crc32_ieee(b"IEND"), 0xAE42_6082);
1600    }
1601}