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/// Adaptive `xcrun simctl io screenshot` pacer. See
20/// [`screenshot_pacer::ScreenshotPacer`].
21pub mod screenshot_pacer;
22/// Persistent CoreSimulator framebuffer capture via a resident
23/// `smix-capture-host`. See [`surface_capture::SurfaceCaptureHost`].
24pub mod surface_capture;
25
26use screenshot_pacer::{ScreenshotPacer, ScreenshotPacerConfig};
27use serde::{Deserialize, Serialize};
28use std::io;
29use std::sync::Arc;
30use std::time::Duration;
31use thiserror::Error;
32use tokio::process::Command;
33use tokio::time::sleep;
34
35/// Failure variants for a device-control invocation.
36///
37/// `DeviceControl` is one trait across iOS and Android, and this is its error
38/// type — `AndroidDeviceControl` raises it as much as the simctl path does.
39/// The messages name the command that actually ran rather than assuming
40/// simctl, so an Android failure does not send the reader to the wrong
41/// toolchain by claiming to come from `xcrun simctl`.
42#[derive(Debug, Error)]
43#[non_exhaustive]
44pub enum DeviceControlError {
45    /// Failed to spawn the device-control process (PATH lookup / fork failure).
46    #[error("spawn failed: {0}")]
47    Spawn(#[from] io::Error),
48    /// The bundle is not installed on the device. `simctl
49    /// get_app_container` exiting non-zero is the canonical signal —
50    /// surfaced as its own variant because "run a flow whose appId
51    /// names an app you have not installed yet" is the single most
52    /// common first-run mistake, and it used to read as a bare
53    /// subprocess error.
54    #[error(
55        "app {bundle_id} is not installed on {udid} — install it \
56         (`smix sim install <device> /path/to/YourApp.app`) or check the \
57         flow's `appId:` matches what is actually installed"
58    )]
59    AppNotInstalled {
60        /// The bundle the caller asked about.
61        bundle_id: String,
62        /// The device it is missing from.
63        udid: String,
64    },
65    /// The device-control command exited non-zero.
66    ///
67    /// Carries the full `argv` and `wall_ms` so the `Display` impl
68    /// surfaces every argument needed to reproduce the failure or file
69    /// a precise upstream bug — the subcommand name alone (e.g.
70    /// `"spawn"`) does not say which binary or paths were touched.
71    #[error("{} exited {code} ({wall_ms}ms): {stderr}", .argv.join(" "))]
72    NonZeroExit {
73        /// Subcommand name (e.g. `"boot"`, `"launch"`).
74        subcommand: String,
75        /// The full command as invoked, binary first — `["xcrun", "simctl",
76        /// "boot", …]` from simctl, `["adb", …]` from the Android side.
77        ///
78        /// The binary belongs here rather than in the `Display` format
79        /// string: this error type serves both platforms, and hard-coding
80        /// `xcrun simctl` made every Android failure name a tool that never
81        /// ran, sending the reader to the wrong toolchain.
82        ///
83        /// Since smix 1.0.7.
84        argv: Vec<String>,
85        /// Exit code from `xcrun simctl`.
86        code: i32,
87        /// Captured stderr (truncated for log-friendliness).
88        stderr: String,
89        /// Wall-clock milliseconds the invocation ran before failing.
90        ///
91        /// Since smix 1.0.7.
92        #[allow(dead_code)]
93        wall_ms: u64,
94    },
95    /// `xcrun simctl <sub>` exited 0 but stdout didn't match the expected shape.
96    #[error("{subcommand} returned malformed output: {detail}")]
97    Malformed {
98        /// Subcommand name.
99        subcommand: String,
100        /// Parser-side detail.
101        detail: String,
102    },
103    /// `xcrun simctl <sub>` did not complete within the deadline.
104    #[error("{subcommand} timed out after {ms}ms")]
105    Timeout {
106        /// Subcommand name.
107        subcommand: String,
108        /// Deadline that was exceeded (milliseconds).
109        ms: u64,
110    },
111    /// The screenshot pacer's circuit is open — a recent screenshot
112    /// wall time exceeded the circuit threshold, or a screenshot
113    /// failed. Callers should back off for `retry_after` and try
114    /// again. See [`screenshot_pacer::ScreenshotPacer`].
115    ///
116    /// Since smix 1.0.4.
117    #[error("screenshot pacer circuit open; retry after {retry_after:?}")]
118    CaptureBackpressure {
119        /// Suggested minimum delay before the next attempt.
120        retry_after: Duration,
121    },
122}
123
124impl DeviceControlError {
125    /// Synthetic `NonZeroExit` for callers translating a foreign
126    /// subprocess error into `DeviceControlError` (e.g.
127    /// AndroidDeviceControl adapting adb failures). Fills
128    /// `argv = [subcommand]` + `wall_ms = 0`; when the caller has a
129    /// real argv, prefer the struct literal.
130    pub fn non_zero_exit(
131        subcommand: impl Into<String>,
132        code: i32,
133        stderr: impl Into<String>,
134    ) -> Self {
135        let sub = subcommand.into();
136        Self::NonZeroExit {
137            argv: vec![sub.clone()],
138            subcommand: sub,
139            code,
140            stderr: stderr.into(),
141            wall_ms: 0,
142        }
143    }
144}
145
146/// Handle to an active `xcrun simctl io recordVideo` child process. Pair
147/// with [`SimctlClient::record_video_stop`] for SIGINT-and-wait shutdown
148/// (so the mp4 trailer is flushed). Dropping the handle without `stop`
149/// would tokio-SIGKILL on Drop and truncate the output file.
150#[derive(Debug)]
151pub struct RecordingHandle {
152    pub(crate) child: tokio::process::Child,
153    /// Output mp4 path verbatim as passed to `record_video_start`.
154    pub path: String,
155    /// Wall-clock start time for "recording in progress for Xs" diagnostics.
156    pub started_at: std::time::Instant,
157}
158
159impl RecordingHandle {
160    /// Pid of the `simctl io … recordVideo` child.
161    ///
162    /// Needed by anything that must record this process somewhere it will
163    /// outlive us: a recording whose only handle is this struct dies with
164    /// the process holding it, and the mp4 it was writing loses its
165    /// trailer with no one left who knows to send the SIGINT that would
166    /// have saved it.
167    ///
168    /// `None` once the child has been reaped.
169    #[must_use]
170    pub fn pid(&self) -> Option<u32> {
171        self.child.id()
172    }
173}
174
175// -------------------- types ----------------------------------------------
176
177/// One iOS / watchOS / tvOS runtime installed on the host.
178#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
179pub struct SimctlRuntime {
180    /// Fully-qualified runtime identifier (e.g. `"com.apple.CoreSimulator.SimRuntime.iOS-17-0"`).
181    pub identifier: String,
182    /// Human-readable name (e.g. `"iOS 17.0"`).
183    pub name: String,
184    /// Version string (e.g. `"17.0"`).
185    pub version: String,
186    /// Whether the runtime is available for booting devices.
187    pub is_available: bool,
188}
189
190/// One simulator device known to `xcrun simctl`.
191#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
192pub struct SimctlDevice {
193    /// Device UDID (stable identifier).
194    pub udid: String,
195    /// Human-readable name.
196    pub name: String,
197    /// Current state (`"Booted"` / `"Shutdown"` / `"Creating"` / etc.).
198    pub state: String,
199    /// Whether the device is available for booting.
200    pub is_available: bool,
201    /// Device-type identifier (e.g. `"com.apple.CoreSimulator.SimDeviceType.iPhone-15"`).
202    #[serde(rename = "deviceTypeIdentifier", default)]
203    pub device_type_identifier: String,
204    /// Runtime identifier this device was created against.
205    #[serde(rename = "runtimeIdentifier", default)]
206    pub runtime_identifier: String,
207}
208
209/// Permission names accepted by `xcrun simctl privacy <udid> grant <name>`.
210#[derive(Clone, Copy, Debug, PartialEq, Eq)]
211pub enum SimctlPermission {
212    /// Camera access.
213    Camera,
214    /// Photos library access.
215    Photos,
216    /// Location access (while-in-use).
217    Location,
218    /// Background location access (always).
219    LocationAlways,
220    /// Notification posting permission.
221    Notifications,
222    /// Microphone access.
223    Microphone,
224    /// Contacts access.
225    Contacts,
226    /// Calendar events access.
227    Calendar,
228    /// Reminders access.
229    Reminders,
230    /// Media library (music / video) access.
231    Media,
232    /// Motion / fitness sensor access.
233    Motion,
234    /// HomeKit accessory access.
235    HomeKit,
236    /// HealthKit data access.
237    Health,
238    /// Bluetooth device discovery / connection.
239    Bluetooth,
240    /// FaceID / TouchID biometric prompt.
241    Faceid,
242    /// Address-book (deprecated alias for `Contacts`).
243    AddressBook,
244}
245
246impl SimctlPermission {
247    /// Wire string used by `xcrun simctl privacy <udid> grant <name>`.
248    pub fn as_str(self) -> &'static str {
249        match self {
250            SimctlPermission::Camera => "camera",
251            SimctlPermission::Photos => "photos",
252            SimctlPermission::Location => "location",
253            SimctlPermission::LocationAlways => "location-always",
254            SimctlPermission::Notifications => "notifications",
255            SimctlPermission::Microphone => "microphone",
256            SimctlPermission::Contacts => "contacts",
257            SimctlPermission::Calendar => "calendar",
258            SimctlPermission::Reminders => "reminders",
259            SimctlPermission::Media => "media-library",
260            SimctlPermission::Motion => "motion",
261            SimctlPermission::HomeKit => "homekit",
262            SimctlPermission::Health => "health",
263            SimctlPermission::Bluetooth => "bluetooth",
264            SimctlPermission::Faceid => "faceid",
265            SimctlPermission::AddressBook => "addressbook",
266        }
267    }
268}
269
270/// UI appearance mode for `xcrun simctl ui <udid> appearance`.
271#[derive(Clone, Copy, Debug, PartialEq, Eq)]
272pub enum Appearance {
273    /// Light mode.
274    Light,
275    /// Dark mode.
276    Dark,
277}
278
279impl Appearance {
280    /// Wire string used by `xcrun simctl ui <udid> appearance <mode>`.
281    pub fn as_str(self) -> &'static str {
282        match self {
283            Appearance::Light => "light",
284            Appearance::Dark => "dark",
285        }
286    }
287}
288
289/// Launched-app result.
290#[derive(Clone, Debug, PartialEq, Eq)]
291pub struct LaunchResult {
292    /// Process ID of the launched app.
293    pub pid: u32,
294}
295
296// -------------------- subprocess ring buffer ----------------------------
297
298/// Recorded snapshot of one `xcrun simctl` invocation.
299/// Exposed so callers can dump the ring buffer for post-mortem when
300/// something upstream fails.
301#[derive(Clone, Debug)]
302pub struct SubprocessRecord {
303    /// argv as passed to `xcrun simctl` (excludes the `xcrun simctl`
304    /// prefix; first entry is the subcommand).
305    pub argv: Vec<String>,
306    /// Exit code; `None` when the process failed to spawn or the
307    /// output-capture path failed before recording the exit.
308    pub exit_code: Option<i32>,
309    /// Wall-clock milliseconds.
310    pub wall_ms: u64,
311    /// First 256 bytes of stderr (truncated).
312    pub stderr_head: String,
313    /// Wall-clock timestamp the invocation completed.
314    pub timestamp: std::time::SystemTime,
315}
316
317/// The three diagnostic records smix keeps between runs.
318///
319/// All three were the same shape and the same two bugs: a write of
320/// `let _ = write_json_atomic(...)`, which could not tell a full disk
321/// from a success, and a read of `let Ok(x) = .. else { return }`,
322/// which read a damaged file as an empty one and then overwrote it.
323///
324/// The swallowing had half a reason — persisting a diagnostic must not
325/// break the `xcrun simctl` call the user actually asked for. That
326/// reason survives here: failures are reported, never propagated. What
327/// does not survive is the silence.
328mod diag_store {
329    use std::path::{Path, PathBuf};
330    use std::sync::{Mutex, OnceLock};
331
332    /// Read a singleton from disk the first time someone needs it.
333    ///
334    /// The three diagnostic singletons used to load at startup: every
335    /// smix command called all three `set_*_persist_path` functions,
336    /// and each one read its value immediately. Measured with a
337    /// backtrace probe on `Store::open`, a plain `smix sim list` opened
338    /// the store four times — three eager loads plus the one write the
339    /// command actually needed. Two of those three were for state that
340    /// command never touches: it runs no flow and resets no app data.
341    ///
342    /// Each open replays the AOF and takes the store's *blocking*
343    /// advisory lock, so the cost is not only work — it is three extra
344    /// chances to queue behind another smix process for nothing.
345    ///
346    /// The flag is only latched once a path exists. A read that happens
347    /// before `set_persist_path` must leave it alone: latching there
348    /// would mean the path, once set, is never read at all — the load
349    /// would be permanently skipped rather than merely deferred.
350    pub(super) fn ensure_loaded(
351        flag: &'static OnceLock<Mutex<bool>>,
352        persist: &'static Mutex<Option<PathBuf>>,
353        load: fn(),
354    ) {
355        let mut done = match flag.get_or_init(|| Mutex::new(false)).lock() {
356            Ok(g) => g,
357            Err(poisoned) => poisoned.into_inner(),
358        };
359        if *done {
360            return;
361        }
362        let has_path = match persist.lock() {
363            Ok(g) => g.is_some(),
364            Err(poisoned) => poisoned.into_inner().is_some(),
365        };
366        if !has_path {
367            return;
368        }
369        load();
370        *done = true;
371    }
372
373    /// Resolve a caller-supplied path to the store root.
374    ///
375    /// Callers pass what used to be a JSON file path. Keeping their
376    /// signatures means the CLI wiring in `main.rs` does not move.
377    pub(super) fn root_of(path: &Path) -> std::path::PathBuf {
378        if path.extension().is_some_and(|e| e == "json") {
379            path.parent().unwrap_or(path).to_path_buf()
380        } else {
381            path.to_path_buf()
382        }
383    }
384
385    /// Read one diagnostic singleton.
386    ///
387    /// A value that will not parse is reported and treated as absent —
388    /// the caller has nothing better to do with it — but it is reported,
389    /// where before it vanished.
390    pub(super) fn load<T: serde::de::DeserializeOwned>(
391        path: &Path,
392        name: &'static str,
393    ) -> Option<T> {
394        let store = match smix_store::Store::open(&root_of(path)) {
395            Ok(s) => s,
396            Err(e) => {
397                eprintln!("smix: read {name}: {e}");
398                return None;
399            }
400        };
401        match store.singleton(name).get_json::<T>() {
402            Ok(v) => v,
403            Err(e) => {
404                eprintln!("smix: read {name}: {e}");
405                None
406            }
407        }
408    }
409
410    /// Write one diagnostic singleton, without making the caller wait.
411    ///
412    /// `try_open` rather than `open`: this runs after every simctl
413    /// invocation, and a diagnostic must never queue behind another
414    /// smix process. Busy means skip — the next call persists, which is
415    /// what the best-effort comment here always promised.
416    pub(super) fn store<T: serde::Serialize>(path: &Path, name: &'static str, value: &T) {
417        match smix_store::Store::try_open(&root_of(path)) {
418            Ok(None) => {}
419            Ok(Some(store)) => {
420                if let Err(e) = store.singleton(name).put_json(value) {
421                    eprintln!("smix: persist {name}: {e}");
422                } else if let Err(e) = store.sync() {
423                    eprintln!("smix: persist {name}: {e}");
424                }
425            }
426            Err(e) => eprintln!("smix: persist {name}: {e}"),
427        }
428    }
429}
430
431mod subprocess_ring {
432    use super::SubprocessRecord;
433    use std::collections::VecDeque;
434    use std::path::PathBuf;
435    use std::sync::{Mutex, OnceLock};
436    use std::time::{Duration, UNIX_EPOCH};
437
438    fn cell() -> &'static Mutex<VecDeque<SubprocessRecord>> {
439        static INSTANCE: OnceLock<Mutex<VecDeque<SubprocessRecord>>> = OnceLock::new();
440        INSTANCE.get_or_init(|| Mutex::new(VecDeque::with_capacity(128)))
441    }
442
443    /// Persist path for the ring buffer. `None` = in-memory only. Set
444    /// once at process startup via [`set_persist_path`]; unchanged for
445    /// the lifetime of the process.
446    ///
447    /// Persistence matters because supervisor cycles can kill the CLI
448    /// faster than a `/diagnostic/dump` can snapshot the in-memory
449    /// buffer, yielding empty payloads. The file survives cycles, so
450    /// post-mortem tools read it rather than the (now-gone) in-memory
451    /// state.
452    fn persist_cell() -> &'static Mutex<Option<PathBuf>> {
453        static INSTANCE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
454        INSTANCE.get_or_init(|| Mutex::new(None))
455    }
456
457    /// Install a persist path. The stored value is read on first use,
458    /// not here — see [`super::diag_store::ensure_loaded`] for why the
459    /// eager version cost every command three store opens.
460    pub fn set_persist_path(path: PathBuf) {
461        let mut g = match persist_cell().lock() {
462            Ok(g) => g,
463            Err(p) => p.into_inner(),
464        };
465        *g = Some(path);
466    }
467
468    fn loaded_flag() -> &'static OnceLock<Mutex<bool>> {
469        static INSTANCE: OnceLock<Mutex<bool>> = OnceLock::new();
470        &INSTANCE
471    }
472
473    fn ensure_loaded() {
474        super::diag_store::ensure_loaded(loaded_flag(), persist_cell(), load_persisted);
475    }
476
477    fn persist_path_copy() -> Option<PathBuf> {
478        let g = match persist_cell().lock() {
479            Ok(g) => g,
480            Err(p) => p.into_inner(),
481        };
482        g.clone()
483    }
484
485    /// Record one invocation. Ring buffer capped at 128
486    /// entries; oldest evicted on push. When [`set_persist_path`] is
487    /// active, atomically writes the buffer to disk after the mutation
488    /// so a subsequent supervisor-cycle doesn't lose the observation.
489    pub(super) fn record(entry: SubprocessRecord) {
490        ensure_loaded();
491        {
492            let mut g = match cell().lock() {
493                Ok(g) => g,
494                Err(p) => p.into_inner(),
495            };
496            if g.len() >= 128 {
497                g.pop_front();
498            }
499            g.push_back(entry);
500        }
501        if let Some(path) = persist_path_copy() {
502            let snapshot: Vec<PersistedRecord> = snapshot().into_iter().map(Into::into).collect();
503            // Best-effort in the sense that matters: failure never
504            // affects the caller of `xcrun simctl`. It is no longer
505            // best-effort in the sense of being invisible.
506            super::diag_store::store(&path, "subprocess-ring", &snapshot);
507        }
508    }
509
510    /// Snapshot the current ring buffer. Ordered oldest → newest.
511    pub fn snapshot() -> Vec<SubprocessRecord> {
512        ensure_loaded();
513        let g = match cell().lock() {
514            Ok(g) => g,
515            Err(p) => p.into_inner(),
516        };
517        g.iter().cloned().collect()
518    }
519
520    /// Load a previously-persisted ring from disk. No-op
521    /// when the file does not exist. Called by CLI startup after
522    /// [`set_persist_path`] so the in-memory view starts with the
523    /// last-known state. Silently drops parse failures — corrupt files
524    /// are noise, not fatal.
525    pub fn load_persisted() {
526        let Some(path) = persist_path_copy() else {
527            return;
528        };
529        let Some(records) =
530            super::diag_store::load::<Vec<PersistedRecord>>(&path, "subprocess-ring")
531        else {
532            return;
533        };
534        let mut g = match cell().lock() {
535            Ok(g) => g,
536            Err(p) => p.into_inner(),
537        };
538        for r in records.into_iter().rev().take(128).rev() {
539            g.push_back(r.into_record());
540        }
541    }
542
543    /// On-disk representation. Kept separate from
544    /// [`SubprocessRecord`] because the wall-clock timestamp is a
545    /// `SystemTime` which does not serde-derive cleanly; we convert to
546    /// UNIX millis for a stable JSON shape.
547    #[derive(Clone, serde::Serialize, serde::Deserialize)]
548    struct PersistedRecord {
549        argv: Vec<String>,
550        exit_code: Option<i32>,
551        wall_ms: u64,
552        stderr_head: String,
553        timestamp_ms: u64,
554    }
555
556    impl From<SubprocessRecord> for PersistedRecord {
557        fn from(r: SubprocessRecord) -> Self {
558            let timestamp_ms = r
559                .timestamp
560                .duration_since(UNIX_EPOCH)
561                .map(|d| d.as_millis() as u64)
562                .unwrap_or(0);
563            Self {
564                argv: r.argv,
565                exit_code: r.exit_code,
566                wall_ms: r.wall_ms,
567                stderr_head: r.stderr_head,
568                timestamp_ms,
569            }
570        }
571    }
572
573    impl PersistedRecord {
574        fn into_record(self) -> SubprocessRecord {
575            let timestamp = UNIX_EPOCH + Duration::from_millis(self.timestamp_ms);
576            SubprocessRecord {
577                argv: self.argv,
578                exit_code: self.exit_code,
579                wall_ms: self.wall_ms,
580                stderr_head: self.stderr_head,
581                timestamp,
582            }
583        }
584    }
585
586    #[cfg(test)]
587    mod tests {
588        use super::*;
589        use std::time::SystemTime;
590
591        #[test]
592        fn persist_roundtrip_after_supervisor_cycle_simulation() {
593            let dir = tempfile::tempdir().expect("tempdir");
594            let path = dir.path().join("ring.json");
595            set_persist_path(path.clone());
596
597            record(SubprocessRecord {
598                argv: vec!["shutdown".into(), "UDID-A".into()],
599                exit_code: Some(0),
600                wall_ms: 42,
601                stderr_head: String::new(),
602                timestamp: SystemTime::now(),
603            });
604            // The property that matters is survival across a restart,
605            // not which file holds it — asserting the filename made this
606            // test a check on the implementation the record moved out of.
607
608            // Simulate supervisor cycle: clear in-memory then load.
609            {
610                let mut g = cell().lock().unwrap();
611                g.clear();
612            }
613
614            // Asked about OUR record, not about the ring's length.
615            //
616            // The ring is process-global and every subprocess this
617            // binary runs writes to it, so `len() == 1` and
618            // `is_empty()` were claims about what every other test in
619            // the process happened to be doing. They held for as long
620            // as no sibling ran a subprocess; the day one did, this
621            // test failed twice in three runs and named nothing that
622            // had changed. What it is for -- a record survives a
623            // restart -- is true regardless of who else is recording.
624            let ours =
625                |r: &SubprocessRecord| r.argv == vec!["shutdown".to_string(), "UDID-A".to_string()];
626            assert!(
627                !snapshot().iter().any(ours),
628                "the clear did not take our record out of the ring"
629            );
630
631            load_persisted();
632            let after = snapshot();
633            let found = after
634                .iter()
635                .find(|r| ours(r))
636                .expect("our record did not come back from the persisted ring");
637            assert_eq!(found.exit_code, Some(0));
638            assert_eq!(found.wall_ms, 42);
639        }
640    }
641}
642
643/// Enable subprocess-ring persistence at the given path.
644/// CLI startup wires this to `~/.local/share/smix/subprocess-ring.json`
645/// so `/diagnostic/dump` payloads survive supervisor cycles. Optional;
646/// without this call the ring stays in-memory only.
647pub fn set_subprocess_ring_persist_path(path: std::path::PathBuf) {
648    subprocess_ring::set_persist_path(path);
649    // No eager read here: the value is loaded the first time
650    // something actually uses it. Loading all three at startup cost
651    // every command three store opens, each one an AOF replay and a
652    // blocking lock, for state most commands never touch.
653}
654
655// CLI-side resetAppData counter tracking.
656//
657// The `resetAppData` verb dispatches host-side (simctl openurl + metro
658// log tail, no runner HTTP endpoint) so counters can't come from the
659// runner's `/diagnostic/dump` payload. This module owns them,
660// persisting to `~/.local/share/smix/reset-app-data-counters.json`
661// so counter deltas across `smix run` invocations + `smix diagnostic
662// dump` (later, separate process) all see the same data.
663//
664// Public API mirrors [`subprocess_ring`] shape for consistency.
665mod reset_app_data_counters {
666    use std::path::PathBuf;
667    use std::sync::{Mutex, OnceLock};
668
669    fn cell() -> &'static Mutex<Counters> {
670        static INSTANCE: OnceLock<Mutex<Counters>> = OnceLock::new();
671        INSTANCE.get_or_init(|| Mutex::new(Counters::default()))
672    }
673    fn persist_cell() -> &'static Mutex<Option<PathBuf>> {
674        static INSTANCE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
675        INSTANCE.get_or_init(|| Mutex::new(None))
676    }
677
678    #[derive(Clone, Copy, Debug, Default, serde::Serialize, serde::Deserialize)]
679    pub struct Counters {
680        pub reset_app_data_total: u64,
681        pub reset_app_data_timed_out: u64,
682    }
683
684    pub fn set_persist_path(path: PathBuf) {
685        let mut g = match persist_cell().lock() {
686            Ok(g) => g,
687            Err(p) => p.into_inner(),
688        };
689        *g = Some(path);
690    }
691
692    fn loaded_flag() -> &'static OnceLock<Mutex<bool>> {
693        static INSTANCE: OnceLock<Mutex<bool>> = OnceLock::new();
694        &INSTANCE
695    }
696
697    fn ensure_loaded() {
698        super::diag_store::ensure_loaded(loaded_flag(), persist_cell(), load_persisted);
699    }
700
701    fn persist_path_copy() -> Option<PathBuf> {
702        let g = match persist_cell().lock() {
703            Ok(g) => g,
704            Err(p) => p.into_inner(),
705        };
706        g.clone()
707    }
708
709    pub fn load_persisted() {
710        let Some(path) = persist_path_copy() else {
711            return;
712        };
713        let Some(loaded) = super::diag_store::load::<Counters>(&path, "reset-app-data-counters")
714        else {
715            return;
716        };
717        let mut g = match cell().lock() {
718            Ok(g) => g,
719            Err(p) => p.into_inner(),
720        };
721        *g = loaded;
722    }
723
724    pub fn increment_total() {
725        ensure_loaded();
726        {
727            let mut g = match cell().lock() {
728                Ok(g) => g,
729                Err(p) => p.into_inner(),
730            };
731            g.reset_app_data_total = g.reset_app_data_total.saturating_add(1);
732        }
733        persist_best_effort();
734    }
735
736    pub fn increment_timed_out() {
737        ensure_loaded();
738        {
739            let mut g = match cell().lock() {
740                Ok(g) => g,
741                Err(p) => p.into_inner(),
742            };
743            g.reset_app_data_timed_out = g.reset_app_data_timed_out.saturating_add(1);
744        }
745        persist_best_effort();
746    }
747
748    pub fn snapshot() -> Counters {
749        ensure_loaded();
750        let g = match cell().lock() {
751            Ok(g) => g,
752            Err(p) => p.into_inner(),
753        };
754        *g
755    }
756
757    fn persist_best_effort() {
758        let Some(path) = persist_path_copy() else {
759            return;
760        };
761        let snapshot = snapshot();
762        super::diag_store::store(&path, "reset-app-data-counters", &snapshot);
763    }
764
765    #[cfg(test)]
766    mod tests {
767        use super::*;
768
769        #[test]
770        fn increment_and_persist_roundtrip() {
771            let dir = tempfile::tempdir().expect("tempdir");
772            let path = dir.path().join("counters.json");
773            set_persist_path(path.clone());
774            // Reset in-memory to avoid cross-test pollution.
775            {
776                let mut g = cell().lock().unwrap();
777                *g = Counters::default();
778            }
779            increment_total();
780            increment_total();
781            increment_timed_out();
782            // Read it back the way a restarted process would, rather
783            // than by opening a file whose path is no longer the
784            // contract.
785            {
786                let mut g = cell().lock().unwrap();
787                *g = Counters::default();
788            }
789            load_persisted();
790            let loaded = snapshot();
791            assert_eq!(loaded.reset_app_data_total, 2);
792            assert_eq!(loaded.reset_app_data_timed_out, 1);
793        }
794    }
795}
796
797/// Public snapshot of CLI-side resetAppData counter state. Populated by [`increment_reset_app_data_total`] +
798/// [`increment_reset_app_data_timed_out`] as the CLI dispatches the
799/// verb; loaded from disk on CLI startup if
800/// [`set_reset_app_data_counters_persist_path`] was called.
801#[derive(Clone, Copy, Debug, Default)]
802pub struct ResetAppDataCounters {
803    /// Total resetAppData dispatches (any outcome).
804    pub reset_app_data_total: u64,
805    /// resetAppData dispatches where the completion signal did not
806    /// arrive inside the timeout window. `> 0` = the URL was fired
807    /// but the app did not emit the expected reset-complete log line.
808    pub reset_app_data_timed_out: u64,
809}
810
811/// Enable resetAppData counter persistence at the given path. Callers pass
812/// `~/.local/share/smix/reset-app-data-counters.json` at CLI startup
813/// so counter state survives across `smix run` → `smix diagnostic
814/// dump` invocations.
815pub fn set_reset_app_data_counters_persist_path(path: std::path::PathBuf) {
816    reset_app_data_counters::set_persist_path(path);
817    // No eager read here: the value is loaded the first time
818    // something actually uses it. Loading all three at startup cost
819    // every command three store opens, each one an AOF replay and a
820    // blocking lock, for state most commands never touch.
821}
822
823/// Advance the resetAppData total counter.
824/// Called by the CLI runtime after each dispatch (success or timeout).
825pub fn increment_reset_app_data_total() {
826    reset_app_data_counters::increment_total();
827}
828
829/// Advance the resetAppData timed-out counter.
830/// Called by the CLI runtime when the completion signal (log-line
831/// pattern match) did not arrive inside the timeout window. Always
832/// paired with a preceding [`increment_reset_app_data_total`] on the
833/// same dispatch.
834pub fn increment_reset_app_data_timed_out() {
835    reset_app_data_counters::increment_timed_out();
836}
837
838/// Snapshot the current counter state for display / wire emission. Returns zero-valued counters when
839/// persistence was never wired.
840pub fn reset_app_data_counters_snapshot() -> ResetAppDataCounters {
841    let s = reset_app_data_counters::snapshot();
842    ResetAppDataCounters {
843        reset_app_data_total: s.reset_app_data_total,
844        reset_app_data_timed_out: s.reset_app_data_timed_out,
845    }
846}
847
848// Flow-attempt persistence for retry attribution. Called by `smix run`
849// after each flow completes (all its attempts done); read by
850// `smix diagnostic dump` to render the attribution table.
851// One record per flow, under `attempt:<flowName>`, written while this
852// process holds the store's own lock.
853//
854// It was a single machine-global blob rewritten whole, on a write that
855// skipped itself when another smix held the lock. `smix run` records
856// once and exits, so "the next attempt will persist" was never true:
857// a busy neighbour meant the record simply did not exist — and the gate
858// that reads these back cannot tell that from a flow that never ran.
859mod flow_attempts {
860    use serde::{Deserialize, Serialize};
861    use std::collections::BTreeMap;
862    use std::path::PathBuf;
863    use std::sync::{Mutex, OnceLock};
864    use std::time::{SystemTime, UNIX_EPOCH};
865
866    /// Enough history to diagnose a batch or two while keeping the dump
867    /// snapshot cheap to serialize.
868    const MAX_PERSISTED_FLOWS: usize = 32;
869
870    #[derive(Clone, Debug, Serialize, Deserialize)]
871    pub struct PersistedAttempt {
872        pub attempt_index: u32,
873        pub status: String,
874        pub error_class: Option<String>,
875        pub ips_generated: Option<String>,
876        pub wall_ms: u64,
877    }
878
879    #[derive(Clone, Debug, Serialize, Deserialize)]
880    pub struct PersistedFlow {
881        pub flow_name: String,
882        pub attempts: Vec<PersistedAttempt>,
883        /// `serde(default)` is load-bearing: the blob written before
884        /// this field existed has no such key, and without a default the
885        /// merge in [`snapshot`] would call that history corrupt —
886        /// losing it to the very change that exists to keep it.
887        #[serde(default)]
888        pub recorded_at_ms: u64,
889    }
890
891    fn persist_cell() -> &'static Mutex<Option<PathBuf>> {
892        static INSTANCE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
893        INSTANCE.get_or_init(|| Mutex::new(None))
894    }
895
896    pub fn set_persist_path(path: PathBuf) {
897        let mut g = match persist_cell().lock() {
898            Ok(g) => g,
899            Err(p) => p.into_inner(),
900        };
901        *g = Some(path);
902    }
903
904    fn persist_path_copy() -> Option<PathBuf> {
905        let g = match persist_cell().lock() {
906            Ok(g) => g,
907            Err(p) => p.into_inner(),
908        };
909        g.clone()
910    }
911
912    fn now_ms() -> u64 {
913        SystemTime::now()
914            .duration_since(UNIX_EPOCH)
915            .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
916    }
917
918    /// Blocking, not best-effort. Waiting a few milliseconds behind a
919    /// neighbour is the price of the record existing at all.
920    fn open() -> Option<smix_store::Store> {
921        let path = persist_path_copy()?;
922        match smix_store::Store::open(&super::diag_store::root_of(&path)) {
923            Ok(store) => Some(store),
924            Err(e) => {
925                eprintln!("smix: flow-attempts: {e}");
926                None
927            }
928        }
929    }
930
931    pub fn record(flow_name: &str, attempts: &[PersistedAttempt]) {
932        let Some(store) = open() else {
933            return;
934        };
935        let flow = PersistedFlow {
936            flow_name: flow_name.to_string(),
937            attempts: attempts.to_vec(),
938            recorded_at_ms: now_ms(),
939        };
940        if let Err(e) = store.attempts().put_json(flow_name, &flow) {
941            eprintln!("smix: persist flow-attempts: {e}");
942            return;
943        }
944        trim(&store);
945        if let Err(e) = store.sync() {
946            eprintln!("smix: persist flow-attempts: {e}");
947        }
948    }
949
950    /// Under the lock [`record`] already holds. Opening the store again
951    /// here would be a second read-modify-write window — the shape this
952    /// module exists to no longer have.
953    fn trim(store: &smix_store::Store) {
954        let ns = store.attempts();
955        let mut dated: Vec<(u64, String)> = Vec::new();
956        for id in ns.list() {
957            match ns.get_json::<PersistedFlow>(&id) {
958                Ok(Some(flow)) => dated.push((flow.recorded_at_ms, id)),
959                Ok(None) => {}
960                // Unreadable is not a candidate for eviction: deleting it
961                // erases the evidence of whatever wrote it.
962                Err(e) => eprintln!("smix: read flow-attempts {id}: {e}"),
963            }
964        }
965        let Some(excess) = dated.len().checked_sub(MAX_PERSISTED_FLOWS) else {
966            return;
967        };
968        if excess == 0 {
969            return;
970        }
971        dated.sort();
972        for (_, id) in dated.into_iter().take(excess) {
973            if let Err(e) = ns.delete(&id) {
974                eprintln!("smix: trim flow-attempts {id}: {e}");
975            }
976        }
977    }
978
979    pub fn snapshot() -> Vec<PersistedFlow> {
980        let Some(store) = open() else {
981            return Vec::new();
982        };
983        let mut by_name: BTreeMap<String, PersistedFlow> = BTreeMap::new();
984        // The blob this used to be: read, never rewritten. Migrating it
985        // would mean writing a whole blob again, which is the thing that
986        // lost records in the first place.
987        match store
988            .singleton("flow-attempts")
989            .get_json::<Vec<PersistedFlow>>()
990        {
991            Ok(Some(old)) => {
992                for flow in old {
993                    by_name.insert(flow.flow_name.clone(), flow);
994                }
995            }
996            Ok(None) => {}
997            Err(e) => eprintln!("smix: read flow-attempts: {e}"),
998        }
999        let ns = store.attempts();
1000        for id in ns.list() {
1001            match ns.get_json::<PersistedFlow>(&id) {
1002                Ok(Some(flow)) => {
1003                    by_name.insert(flow.flow_name.clone(), flow);
1004                }
1005                Ok(None) => {}
1006                Err(e) => eprintln!("smix: read flow-attempts {id}: {e}"),
1007            }
1008        }
1009        let mut flows: Vec<PersistedFlow> = by_name.into_values().collect();
1010        flows.sort_by(|a, b| {
1011            a.recorded_at_ms
1012                .cmp(&b.recorded_at_ms)
1013                .then_with(|| a.flow_name.cmp(&b.flow_name))
1014        });
1015        flows
1016    }
1017}
1018
1019/// Enable flow-attempts persistence at the given path.
1020/// CLI startup wires this to `~/.local/share/smix/flow-attempts.json`
1021/// so retry attribution survives across `smix run` → `smix diagnostic
1022/// dump` invocations.
1023pub fn set_flow_attempts_persist_path(path: std::path::PathBuf) {
1024    flow_attempts::set_persist_path(path);
1025    // No eager read here: the value is loaded the first time
1026    // something actually uses it. Loading all three at startup cost
1027    // every command three store opens, each one an AOF replay and a
1028    // blocking lock, for state most commands never touch.
1029}
1030
1031/// Public accessor with just the fields needed by callers.
1032/// Mirrors the shape of `smix_runner_wire::FlowAttempt`.
1033#[derive(Clone, Debug)]
1034pub struct FlowAttemptData {
1035    /// Zero-based retry index.
1036    pub attempt_index: u32,
1037    /// Overall outcome ("ok" / "timeout" / "error" / "crashed").
1038    pub status: String,
1039    /// Free-form error class code (`Some` on non-ok).
1040    pub error_class: Option<String>,
1041    /// `.ips` filename that appeared during this attempt, when detected.
1042    pub ips_generated: Option<String>,
1043    /// Wall-clock milliseconds.
1044    pub wall_ms: u64,
1045}
1046
1047/// Recorded flow with its attempt list.
1048#[derive(Clone, Debug)]
1049pub struct FlowAttemptRecordData {
1050    /// Flow name (yaml basename or explicit id).
1051    pub flow_name: String,
1052    /// Ordered attempts, first try first.
1053    pub attempts: Vec<FlowAttemptData>,
1054}
1055
1056/// Record the outcome of a flow's attempts. Called from
1057/// `smix run` after all retries for that flow have completed. `smix
1058/// diagnostic dump` reads via [`recent_flow_attempts`] later.
1059pub fn record_flow_attempts<A>(flow_name: &str, attempts: &[A])
1060where
1061    A: FlowAttemptShape,
1062{
1063    let converted: Vec<flow_attempts::PersistedAttempt> = attempts
1064        .iter()
1065        .map(|a| flow_attempts::PersistedAttempt {
1066            attempt_index: a.attempt_index(),
1067            status: a.status().to_string(),
1068            error_class: a.error_class().map(str::to_string),
1069            ips_generated: a.ips_generated().map(str::to_string),
1070            wall_ms: a.wall_ms(),
1071        })
1072        .collect();
1073    flow_attempts::record(flow_name, &converted);
1074}
1075
1076/// Abstraction so callers pass either
1077/// `smix_runner_wire::FlowAttempt` or a local struct with the same
1078/// shape without a cross-crate dep on smix-runner-wire from smix-simctl.
1079pub trait FlowAttemptShape {
1080    /// Zero-based retry index.
1081    fn attempt_index(&self) -> u32;
1082    /// "ok" / "timeout" / "error" / "crashed".
1083    fn status(&self) -> &str;
1084    /// Error class code, if any.
1085    fn error_class(&self) -> Option<&str>;
1086    /// `.ips` filename attributable to this attempt, if any.
1087    fn ips_generated(&self) -> Option<&str>;
1088    /// Wall-clock milliseconds.
1089    fn wall_ms(&self) -> u64;
1090}
1091
1092/// Snapshot recent flow attempts for display / wire
1093/// emission. Returns empty when persistence was never wired.
1094pub fn recent_flow_attempts() -> Vec<FlowAttemptRecordData> {
1095    flow_attempts::snapshot()
1096        .into_iter()
1097        .map(|f| FlowAttemptRecordData {
1098            flow_name: f.flow_name,
1099            attempts: f
1100                .attempts
1101                .into_iter()
1102                .map(|a| FlowAttemptData {
1103                    attempt_index: a.attempt_index,
1104                    status: a.status,
1105                    error_class: a.error_class,
1106                    ips_generated: a.ips_generated,
1107                    wall_ms: a.wall_ms,
1108                })
1109                .collect(),
1110        })
1111        .collect()
1112}
1113
1114/// Snapshot the process-wide ring buffer of recent
1115/// `xcrun simctl` invocations. Ordered oldest → newest, capped at 128
1116/// entries. Reset on process restart.
1117pub fn recent_subprocesses() -> Vec<SubprocessRecord> {
1118    subprocess_ring::snapshot()
1119}
1120
1121// -------------------- raw spawn primitive --------------------------------
1122
1123/// Execute `xcrun simctl <args>` and capture stdout/stderr.
1124async fn simctl_capture(args: &[&str]) -> Result<(Vec<u8>, String), DeviceControlError> {
1125    simctl_capture_env(args, &[]).await
1126}
1127
1128/// `simctl_capture` with extra envp pairs set on the spawned process.
1129/// The `xcrun simctl launch` subcommand uses this to inject
1130/// `SIMCTL_CHILD_<KEY>=<VAL>` vars that the launched app sees as
1131/// `ProcessInfo().environment["KEY"]`. `env` entries here are passed
1132/// verbatim — caller composes the `SIMCTL_CHILD_` prefix via
1133/// [`compose_child_env`].
1134async fn simctl_capture_env(
1135    args: &[&str],
1136    env: &[(String, String)],
1137) -> Result<(Vec<u8>, String), DeviceControlError> {
1138    let mut cmd = Command::new("xcrun");
1139    cmd.arg("simctl");
1140    for a in args {
1141        cmd.arg(a);
1142    }
1143    for (k, v) in env {
1144        cmd.env(k, v);
1145    }
1146    let started = std::time::Instant::now();
1147    let output = cmd.output().await?;
1148    let wall_ms = started.elapsed().as_millis() as u64;
1149    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
1150    // Every simctl invocation records to the ring buffer regardless of
1151    // exit status.
1152    subprocess_ring::record(SubprocessRecord {
1153        argv: std::iter::once("xcrun".to_string())
1154            .chain(std::iter::once("simctl".to_string()))
1155            .chain(args.iter().map(|s| s.to_string()))
1156            .collect(),
1157        exit_code: output.status.code(),
1158        wall_ms,
1159        stderr_head: {
1160            let mut s = stderr.clone();
1161            if s.len() > 256 {
1162                s.truncate(256);
1163            }
1164            s
1165        },
1166        timestamp: std::time::SystemTime::now(),
1167    });
1168    if !output.status.success() {
1169        return Err(DeviceControlError::NonZeroExit {
1170            subcommand: args.first().map(|s| s.to_string()).unwrap_or_default(),
1171            argv: std::iter::once("xcrun".to_string())
1172                .chain(std::iter::once("simctl".to_string()))
1173                .chain(args.iter().map(|s| s.to_string()))
1174                .collect(),
1175            code: output.status.code().unwrap_or(-1),
1176            stderr,
1177            wall_ms,
1178        });
1179    }
1180    Ok((output.stdout, stderr))
1181}
1182
1183async fn simctl_run(args: &[&str]) -> Result<String, DeviceControlError> {
1184    let (stdout, _) = simctl_capture(args).await?;
1185    Ok(String::from_utf8_lossy(&stdout).into_owned())
1186}
1187
1188/// Like [`simctl_run`] but injects `child_env` envp on the spawned
1189/// process. Used by the env-aware launch path so the launched app can
1190/// read deploy-time secrets / endpoints via `ProcessInfo`.
1191async fn simctl_run_env(
1192    args: &[&str],
1193    env: &[(String, String)],
1194) -> Result<String, DeviceControlError> {
1195    let (stdout, _) = simctl_capture_env(args, env).await?;
1196    Ok(String::from_utf8_lossy(&stdout).into_owned())
1197}
1198
1199/// Compose user-provided `(key, value)` pairs into the `SIMCTL_CHILD_*`
1200/// envp that `xcrun simctl launch` strips and delivers to the launched
1201/// app. Idempotent: a key that already starts with `SIMCTL_CHILD_` is
1202/// passed through unchanged.
1203///
1204/// # Example
1205///
1206/// ```
1207/// use smix_simctl::compose_child_env;
1208/// let composed = compose_child_env(&[("SMIX_PERF_RECEIVER_URL", "http://h:9999")]);
1209/// assert_eq!(
1210///     composed,
1211///     vec![(
1212///         "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
1213///         "http://h:9999".to_string(),
1214///     )]
1215/// );
1216/// ```
1217pub fn compose_child_env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
1218    pairs
1219        .iter()
1220        .map(|(k, v)| {
1221            let key = if k.starts_with("SIMCTL_CHILD_") {
1222                (*k).to_string()
1223            } else {
1224                format!("SIMCTL_CHILD_{k}")
1225            };
1226            (key, (*v).to_string())
1227        })
1228        .collect()
1229}
1230
1231// -------------------- client --------------------------------------------
1232
1233/// Stateless wrapper around xcrun simctl. Methods are free functions
1234/// in spirit (no instance state beyond optionally-cached `xcrun` path);
1235/// kept as a struct for API ergonomics + future caching.
1236///
1237/// The client also holds a [`ScreenshotPacer`] that
1238/// throttles `xcrun simctl io screenshot` under high-frequency
1239/// load. Defaults are conservative (100 ms interval floor);
1240/// consumers whose flows are already loose are unaffected.
1241#[derive(Debug)]
1242pub struct SimctlClient {
1243    /// Screenshot pacer — enforces the interval floor, slow-path lift,
1244    /// and circuit breaker. Shared via `Arc<Mutex<_>>`
1245    /// so a cloned client (which callers occasionally do) still shares
1246    /// pressure accounting.
1247    screenshot_pacer: Arc<std::sync::Mutex<ScreenshotPacer>>,
1248    /// Resident per-UDID `smix-capture-host` processes for the direct
1249    /// IOSurface capture path. Shared so a cloned client reuses the same
1250    /// warm surfaces. See [`surface_capture`].
1251    capture_hosts: Arc<tokio::sync::Mutex<surface_capture::CaptureHostRegistry>>,
1252}
1253
1254impl Default for SimctlClient {
1255    fn default() -> Self {
1256        Self::new()
1257    }
1258}
1259
1260/// Whether a simulator in `state` can be recorded; `None` when simctl
1261/// does not list the UDID at all.
1262///
1263/// Asked before `recordVideo` is spawned because its own output cannot
1264/// answer this: on a shut-down simulator it prints "Recording started"
1265/// and "Wrote video to: …" and leaves a zero-byte file (measured
1266/// 2026-09-25). Only `Booted` has a screen; `Booting` and `Shutting Down`
1267/// are refused too rather than waited on — the caller booted or shut the
1268/// device and is the one who knows when it has finished.
1269///
1270/// # Errors
1271///
1272/// `NonZeroExit` naming the device and the state simctl reported.
1273pub fn recording_may_start(udid: &str, state: Option<&str>) -> Result<(), DeviceControlError> {
1274    match state {
1275        Some("Booted") => Ok(()),
1276        Some(other) => Err(DeviceControlError::non_zero_exit(
1277            "io recordVideo",
1278            -1,
1279            format!(
1280                "{udid} is {other}, not Booted: a simulator that is not on has no screen, and \
1281                 simctl would report \"Recording started\" and write an empty file. \
1282                 Boot it (`smix sim boot {udid}`) and start the recording again."
1283            ),
1284        )),
1285        None => Err(DeviceControlError::non_zero_exit(
1286            "io recordVideo",
1287            -1,
1288            format!(
1289                "simctl does not list {udid}, so there is no simulator screen to record. \
1290                 `smix sim list` shows the simulators this machine has."
1291            ),
1292        )),
1293    }
1294}
1295
1296impl SimctlClient {
1297    /// Construct a new client with default screenshot pacing (100 ms
1298    /// interval floor, adaptive slow-path lift to 1500 ms, circuit
1299    /// breaker on ≥ 1500 ms walls or failures).
1300    pub fn new() -> Self {
1301        SimctlClient {
1302            screenshot_pacer: Arc::new(std::sync::Mutex::new(ScreenshotPacer::new(
1303                ScreenshotPacerConfig::default(),
1304            ))),
1305            capture_hosts: Arc::new(tokio::sync::Mutex::new(
1306                surface_capture::CaptureHostRegistry::default(),
1307            )),
1308        }
1309    }
1310
1311    /// Override the screenshot pacer with a custom config.
1312    ///
1313    /// Since smix 1.0.4.
1314    #[must_use]
1315    pub fn with_screenshot_pacer(self, config: ScreenshotPacerConfig) -> Self {
1316        {
1317            let mut guard = self
1318                .screenshot_pacer
1319                .lock()
1320                .expect("screenshot pacer mutex must not be poisoned");
1321            *guard = ScreenshotPacer::new(config);
1322        }
1323        self
1324    }
1325
1326    /// Attach a [`smix_sim_health::SimHealthMonitor`] to receive
1327    /// screenshot wall-time observations from every `screenshot`
1328    /// call. Composes with the pacer — the pacer still enforces its
1329    /// interval / circuit locally, and the monitor sees the same
1330    /// walls for global state classification.
1331    ///
1332    /// Since smix 1.0.4.
1333    #[must_use]
1334    pub fn with_sim_health(self, monitor: smix_sim_health::SimHealthMonitor) -> Self {
1335        {
1336            let mut guard = self
1337                .screenshot_pacer
1338                .lock()
1339                .expect("screenshot pacer mutex must not be poisoned");
1340            guard.set_monitor(monitor);
1341        }
1342        self
1343    }
1344
1345    // ---- inventory ------------------------------------------------------
1346
1347    /// `xcrun simctl list runtimes -j` → `Vec<SimctlRuntime>`.
1348    pub async fn list_runtimes(&self) -> Result<Vec<SimctlRuntime>, DeviceControlError> {
1349        let raw = simctl_run(&["list", "runtimes", "-j"]).await?;
1350        #[derive(Deserialize)]
1351        struct Wrap {
1352            runtimes: Vec<RawRuntime>,
1353        }
1354        #[derive(Deserialize)]
1355        struct RawRuntime {
1356            identifier: String,
1357            name: String,
1358            version: String,
1359            #[serde(rename = "isAvailable", default)]
1360            is_available: bool,
1361        }
1362        let w: Wrap = serde_json::from_str(&raw).map_err(|e| DeviceControlError::Malformed {
1363            subcommand: "list runtimes".into(),
1364            detail: e.to_string(),
1365        })?;
1366        Ok(w.runtimes
1367            .into_iter()
1368            .map(|r| SimctlRuntime {
1369                identifier: r.identifier,
1370                name: r.name,
1371                version: r.version,
1372                is_available: r.is_available,
1373            })
1374            .collect())
1375    }
1376
1377    /// `xcrun simctl list devices -j` → flattened `Vec<SimctlDevice>`.
1378    pub async fn list_devices(&self) -> Result<Vec<SimctlDevice>, DeviceControlError> {
1379        let raw = simctl_run(&["list", "devices", "-j"]).await?;
1380        #[derive(Deserialize)]
1381        struct Wrap {
1382            devices: std::collections::BTreeMap<String, Vec<RawDevice>>,
1383        }
1384        #[derive(Deserialize)]
1385        struct RawDevice {
1386            udid: String,
1387            name: String,
1388            state: String,
1389            #[serde(rename = "isAvailable", default)]
1390            is_available: bool,
1391            #[serde(rename = "deviceTypeIdentifier", default)]
1392            device_type_identifier: String,
1393        }
1394        let w: Wrap = serde_json::from_str(&raw).map_err(|e| DeviceControlError::Malformed {
1395            subcommand: "list devices".into(),
1396            detail: e.to_string(),
1397        })?;
1398        let mut out = Vec::new();
1399        for (runtime_id, devices) in w.devices {
1400            for d in devices {
1401                out.push(SimctlDevice {
1402                    udid: d.udid,
1403                    name: d.name,
1404                    state: d.state,
1405                    is_available: d.is_available,
1406                    device_type_identifier: d.device_type_identifier,
1407                    runtime_identifier: runtime_id.clone(),
1408                });
1409            }
1410        }
1411        Ok(out)
1412    }
1413
1414    // ---- lifecycle ------------------------------------------------------
1415
1416    /// `xcrun simctl boot <udid>` — fire-and-forget boot request.
1417    pub async fn boot(&self, udid: &str) -> Result<(), DeviceControlError> {
1418        simctl_run(&["boot", udid]).await?;
1419        Ok(())
1420    }
1421
1422    /// `xcrun simctl shutdown <udid>`.
1423    pub async fn shutdown(&self, udid: &str) -> Result<(), DeviceControlError> {
1424        simctl_run(&["shutdown", udid]).await?;
1425        Ok(())
1426    }
1427
1428    /// `defaults <args…>` inside the simulator, with the first spelling in
1429    /// [`DEFAULTS_PROGRAMS`] that starts. Any answer from `defaults`
1430    /// itself — including a refusal — is returned as it came.
1431    async fn spawn_defaults(
1432        &self,
1433        udid: &str,
1434        args: &[&str],
1435    ) -> Result<String, DeviceControlError> {
1436        let mut did_not_start_err = None;
1437        for program in DEFAULTS_PROGRAMS {
1438            let argv: Vec<&str> = ["spawn", udid, program]
1439                .into_iter()
1440                .chain(args.iter().copied())
1441                .collect();
1442            match simctl_run(&argv).await {
1443                Err(e) if did_not_start(&e) => did_not_start_err = Some(e),
1444                other => return other,
1445            }
1446        }
1447        // Every spelling failed to start: the last one's own error, which
1448        // names the argv and the simulator's words.
1449        Err(did_not_start_err.expect("DEFAULTS_PROGRAMS is not empty"))
1450    }
1451
1452    /// Read the sim's current BCP-47 locale (first entry of
1453    /// `NSGlobalDomain AppleLanguages`). Returns `Ok(None)` when the
1454    /// preference is unset (defaults read exits non-zero) or unparseable.
1455    /// Wire format: `simctl spawn <udid> defaults read -g AppleLanguages`
1456    /// stdout looks like `"(\n    \"en-US\"\n)\n"`; we extract the first
1457    /// quoted token.
1458    pub async fn current_locale(&self, udid: &str) -> Result<Option<String>, DeviceControlError> {
1459        let out = match self
1460            .spawn_defaults(udid, &["read", "-g", "AppleLanguages"])
1461            .await
1462        {
1463            Ok(s) => s,
1464            // An unset key is a legitimate "no opinion" state, not an
1465            // error — when `defaults` says so, and only then.
1466            Err(DeviceControlError::NonZeroExit { ref stderr, .. })
1467                if defaults_key_is_absent(stderr) =>
1468            {
1469                return Ok(None);
1470            }
1471            Err(e) => return Err(e),
1472        };
1473        // First quoted substring.
1474        if let Some(start) = out.find('"') {
1475            let rest = &out[start + 1..];
1476            if let Some(end) = rest.find('"') {
1477                return Ok(Some(rest[..end].to_string()));
1478            }
1479        }
1480        Ok(None)
1481    }
1482
1483    /// Whether this simulator keeps its software keyboard minimized
1484    /// (`com.apple.keyboard.preferences` `AutomaticMinimizationEnabled`).
1485    ///
1486    /// With it on, a focused text field shows no keyboard, so a wait for
1487    /// `role: keyboard` can only time out — measured on 2026-09-25: the
1488    /// same flow red twice with the key set and green at once after
1489    /// `defaults delete`. `Ok(None)` when the key is unset or its value
1490    /// is not a boolean; neither is "off".
1491    ///
1492    /// Only read, never written: it is the owner's setting.
1493    pub async fn keyboard_minimization(
1494        &self,
1495        udid: &str,
1496    ) -> Result<Option<bool>, DeviceControlError> {
1497        match self
1498            .spawn_defaults(
1499                udid,
1500                &[
1501                    "read",
1502                    "com.apple.keyboard.preferences",
1503                    "AutomaticMinimizationEnabled",
1504                ],
1505            )
1506            .await
1507        {
1508            Ok(out) => Ok(parse_defaults_bool(&out)),
1509            Err(DeviceControlError::NonZeroExit { ref stderr, .. })
1510                if defaults_key_is_absent(stderr) =>
1511            {
1512                Ok(None)
1513            }
1514            Err(e) => Err(e),
1515        }
1516    }
1517
1518    /// Delete a single key from an app's NSUserDefaults domain via
1519    /// `simctl spawn <udid> defaults delete <bundleId> <key>`.
1520    /// Running `defaults` INSIDE the sim (spawn) goes through
1521    /// the sim's cfprefsd, so the deletion is coherent with what the
1522    /// app reads on next launch (editing the container plist from the
1523    /// host would race cfprefsd's cache).
1524    ///
1525    /// Returns `Ok(true)` when the key existed and was deleted,
1526    /// `Ok(false)` when the key (or the whole domain) was absent —
1527    /// the verb contract is "ensure key absent", so an already-absent
1528    /// key is success, not an error. Any other failure surfaces as
1529    /// the underlying [`DeviceControlError`].
1530    ///
1531    /// Motivating case: expo-dev-launcher
1532    /// persists the most recent deep link and re-delivers it after
1533    /// every JS bundle load; deleting its storage key between
1534    /// terminate and relaunch neutralizes the replay at the source.
1535    ///
1536    /// **Terminate the app first** — a running process has its
1537    /// defaults cached in-memory and may rewrite the key at exit.
1538    pub async fn user_defaults_delete(
1539        &self,
1540        udid: &str,
1541        bundle_id: &str,
1542        key: &str,
1543    ) -> Result<bool, DeviceControlError> {
1544        match self.spawn_defaults(udid, &["delete", bundle_id, key]).await {
1545            Ok(_) => Ok(true),
1546            // `defaults delete` exits non-zero with "does not exist"
1547            // on stderr for both a missing key and a missing domain.
1548            // Both are the target state.
1549            Err(DeviceControlError::NonZeroExit { stderr, .. })
1550                if defaults_key_is_absent(&stderr) =>
1551            {
1552                Ok(false)
1553            }
1554            Err(e) => Err(e),
1555        }
1556    }
1557
1558    /// Write `AppleLanguages` (array) + `AppleLocale` (scalar) to the
1559    /// sim's NSGlobalDomain so SpringBoard + apps re-localize on next
1560    /// launch. AppleLocale is BCP-47 with hyphen replaced by underscore
1561    /// (`en_US`); AppleLanguages is the BCP-47 tag verbatim.
1562    /// **The caller must shutdown + reboot the sim for the change to
1563    /// take effect** — running apps cache the locale at process start.
1564    pub async fn set_locale(&self, udid: &str, locale: &str) -> Result<(), DeviceControlError> {
1565        self.spawn_defaults(udid, &["write", "-g", "AppleLanguages", "-array", locale])
1566            .await?;
1567        let locale_underscore = locale.replace('-', "_");
1568        self.spawn_defaults(udid, &["write", "-g", "AppleLocale", &locale_underscore])
1569            .await?;
1570        Ok(())
1571    }
1572
1573    /// Boot the device and return once it has finished booting, within
1574    /// `timeout`. A device that is already booted and ready returns at
1575    /// once (about 0.1 s).
1576    ///
1577    /// "Booted" in the device list is not "finished": it is listed so
1578    /// while SpringBoard is still coming up — measured 8.9 s before
1579    /// `simctl bootstatus` said it was done — and an XCUITest runner
1580    /// started in that gap cannot launch its app and exits with status
1581    /// 65 having run no test. So the wait is `bootstatus`, which also
1582    /// covers a device someone else booted a moment ago.
1583    pub async fn boot_and_wait(
1584        &self,
1585        udid: &str,
1586        timeout: Duration,
1587    ) -> Result<(), DeviceControlError> {
1588        let started = std::time::Instant::now();
1589        let mut cmd = Command::new("xcrun");
1590        cmd.args(["simctl", "bootstatus", udid, "-b"])
1591            .stdout(std::process::Stdio::null())
1592            .stderr(std::process::Stdio::piped())
1593            .kill_on_drop(true);
1594        let output = match tokio::time::timeout(timeout, cmd.output()).await {
1595            Ok(output) => output?,
1596            Err(_) => {
1597                return Err(DeviceControlError::Timeout {
1598                    subcommand: format!("bootstatus {udid} -b"),
1599                    ms: timeout.as_millis() as u64,
1600                });
1601            }
1602        };
1603        let argv: Vec<String> = ["xcrun", "simctl", "bootstatus", udid, "-b"]
1604            .iter()
1605            .map(|s| s.to_string())
1606            .collect();
1607        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
1608        let wall_ms = started.elapsed().as_millis() as u64;
1609        subprocess_ring::record(SubprocessRecord {
1610            argv: argv.clone(),
1611            exit_code: output.status.code(),
1612            wall_ms,
1613            stderr_head: stderr.chars().take(256).collect(),
1614            timestamp: std::time::SystemTime::now(),
1615        });
1616        if !output.status.success() {
1617            return Err(DeviceControlError::NonZeroExit {
1618                subcommand: "bootstatus".to_string(),
1619                argv,
1620                code: output.status.code().unwrap_or(-1),
1621                stderr,
1622                wall_ms,
1623            });
1624        }
1625        Ok(())
1626    }
1627
1628    /// `xcrun simctl erase <udid>` — wipe device contents.
1629    pub async fn erase(&self, udid: &str) -> Result<(), DeviceControlError> {
1630        simctl_run(&["erase", udid]).await?;
1631        Ok(())
1632    }
1633
1634    /// `xcrun simctl install <udid> <app-path>` — install a `.app` bundle.
1635    pub async fn install(&self, udid: &str, app_path: &str) -> Result<(), DeviceControlError> {
1636        simctl_run(&["install", udid, app_path]).await?;
1637        Ok(())
1638    }
1639
1640    /// `xcrun simctl uninstall <udid> <bundle-id>`.
1641    pub async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), DeviceControlError> {
1642        simctl_run(&["uninstall", udid, bundle_id]).await?;
1643        Ok(())
1644    }
1645
1646    /// `xcrun simctl terminate <udid> <bundle-id>` — kill a running app.
1647    pub async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), DeviceControlError> {
1648        simctl_run(&["terminate", udid, bundle_id]).await?;
1649        Ok(())
1650    }
1651
1652    /// `xcrun simctl launch <udid> <bundleId>` → parse `"<bundle>: <pid>"`.
1653    pub async fn launch(
1654        &self,
1655        udid: &str,
1656        bundle_id: &str,
1657    ) -> Result<LaunchResult, DeviceControlError> {
1658        self.launch_with_args(udid, bundle_id, &[]).await
1659    }
1660
1661    /// `xcrun simctl launch <udid> <bundleId> -- <arg>...` — launch with a
1662    /// process-level argument vector. Empty `args` is equivalent to
1663    /// [`Self::launch`]. Mirrors maestro yaml `launchApp.arguments`.
1664    pub async fn launch_with_args(
1665        &self,
1666        udid: &str,
1667        bundle_id: &str,
1668        args: &[String],
1669    ) -> Result<LaunchResult, DeviceControlError> {
1670        self.launch_with_args_and_env(udid, bundle_id, args, &[])
1671            .await
1672    }
1673
1674    /// Like [`Self::launch_with_args`] but also sets `SIMCTL_CHILD_*`
1675    /// envp on the simctl process so the launched app can read
1676    /// deploy-time vars via `ProcessInfo().environment["KEY"]`.
1677    /// `child_env` keys without the `SIMCTL_CHILD_` prefix get it added
1678    /// automatically (per [`compose_child_env`] semantics). Useful for
1679    /// prelaunching an app before any `openLink` so iOS treats the
1680    /// subsequent URL handoff as in-app routing instead of cross-app,
1681    /// side-stepping the SpringBoard "Open in '`<App>`'?" confirmation
1682    /// dialog.
1683    pub async fn launch_with_args_and_env(
1684        &self,
1685        udid: &str,
1686        bundle_id: &str,
1687        args: &[String],
1688        child_env: &[(&str, &str)],
1689    ) -> Result<LaunchResult, DeviceControlError> {
1690        let mut argv: Vec<&str> = vec!["launch", udid, bundle_id];
1691        if !args.is_empty() {
1692            argv.push("--");
1693            for a in args {
1694                argv.push(a.as_str());
1695            }
1696        }
1697        let composed = compose_child_env(child_env);
1698        let out = simctl_run_env(&argv, &composed).await?;
1699        // Output format: `com.example.app: 12345\n`
1700        let pid_str =
1701            out.rsplit(':')
1702                .next()
1703                .map(str::trim)
1704                .ok_or_else(|| DeviceControlError::Malformed {
1705                    subcommand: "launch".into(),
1706                    detail: format!("unexpected stdout shape: {}", out.trim()),
1707                })?;
1708        let pid: u32 = pid_str.parse().map_err(|_| DeviceControlError::Malformed {
1709            subcommand: "launch".into(),
1710            detail: format!("non-numeric pid in stdout: {}", out.trim()),
1711        })?;
1712        Ok(LaunchResult { pid })
1713    }
1714
1715    /// Reset every privacy permission granted to `bundle_id` on the
1716    /// sim: `xcrun simctl privacy <udid> reset all <bundle-id>`.
1717    /// Companion to [`Self::clear_app_sandbox`] on the in-place
1718    /// `launchApp: clearState: true` path, which replaces
1719    /// `simctl uninstall + install` — that pairing triggers iOS 26.5
1720    /// XCUITest binding loss plus a ReportCrash "`<app>` quit
1721    /// unexpectedly" dialog.
1722    pub async fn privacy_reset_all(
1723        &self,
1724        udid: &str,
1725        bundle_id: &str,
1726    ) -> Result<(), DeviceControlError> {
1727        simctl_run(&["privacy", udid, "reset", "all", bundle_id]).await?;
1728        Ok(())
1729    }
1730
1731    /// Whether the bundle is installed on the sim, without touching it.
1732    ///
1733    /// `get_app_container` is the canonical probe -- it exits non-zero
1734    /// for a bundle that is not there. `clear_app_sandbox` already knew
1735    /// that, but it wipes the sandbox on its way past, so nothing that
1736    /// only wanted to ask could use it.
1737    ///
1738    /// The caller that needs this is `foreground`. XCUITest's
1739    /// `.activate()` does not fail on a missing bundle -- it waits for
1740    /// an app that will never come to the front, and it waits on the
1741    /// main actor, so every later request that needs the app waits with
1742    /// it. On 2026-08-29 one flow naming an Android package did that to
1743    /// the release corpus: the runner wedged, XCTest's watchdog killed
1744    /// it, and the twenty-three flows after it reported `runner
1745    /// unreachable`. The runner cannot defend itself once `.activate()`
1746    /// is called, so the question has to be asked before it is.
1747    pub async fn app_is_installed(
1748        &self,
1749        udid: &str,
1750        bundle_id: &str,
1751    ) -> Result<bool, DeviceControlError> {
1752        match simctl_run(&["get_app_container", udid, bundle_id, "app"]).await {
1753            Ok(_) => Ok(true),
1754            // Only the message that means the app is absent. simctl exits
1755            // non-zero for a udid it does not know too, and answering
1756            // `false` there would say "that app is not installed" about a
1757            // device that does not exist -- a plausible sentence, and the
1758            // wrong one. Measured on 2026-08-29: a missing app gives
1759            // `NSPOSIXErrorDomain, code=2` / `No such file or directory`,
1760            // a missing device gives `Invalid device: <udid>`. Anything
1761            // else is an unknown and stays loud.
1762            Err(DeviceControlError::NonZeroExit { ref stderr, .. })
1763                if stderr.contains("No such file or directory") =>
1764            {
1765                Ok(false)
1766            }
1767            Err(other) => Err(other),
1768        }
1769    }
1770
1771    /// Wipe the app's sandbox on the sim: locate the Data container via
1772    /// `simctl get_app_container <udid> <bundle> data`, then delete
1773    /// `Documents`, `Library` and `tmp` inside it. The app remains
1774    /// installed (no `simctl uninstall`), so the XCUITest binding is
1775    /// preserved and macOS `ReportCrash` does not misinterpret a missing
1776    /// install-receipt as a crash.
1777    ///
1778    /// The deletion happens here, on the host. A simulator's container
1779    /// IS a directory on this Mac — `get_app_container` answers with its
1780    /// host path — so the `simctl spawn <udid> /bin/rm` this used to run
1781    /// was starting a process inside the simulator to delete files the
1782    /// caller could already reach. On Xcode 27 it cannot: the runtime
1783    /// root ships `df` and `launchctl` and nothing else, so spawning
1784    /// `/bin/rm` exits 111 with `Invalid or missing Program`, and
1785    /// `launchApp: { clearState: true }` failed with it. (Measured
1786    /// 2026-09-23 against iOS 27.0 and 26.5; `/bin/echo` fails the same
1787    /// way, so it is the runtime's contents and not this call's shape.)
1788    pub async fn clear_app_sandbox(
1789        &self,
1790        udid: &str,
1791        bundle_id: &str,
1792    ) -> Result<(), DeviceControlError> {
1793        let raw = simctl_run(&["get_app_container", udid, bundle_id, "data"])
1794            .await
1795            .map_err(|e| match e {
1796                // get_app_container failing IS "not installed" — the
1797                // subprocess text (`NSPOSIXErrorDomain code=2`) says
1798                // nothing a flow author can act on.
1799                DeviceControlError::NonZeroExit { .. } => DeviceControlError::AppNotInstalled {
1800                    bundle_id: bundle_id.to_string(),
1801                    udid: udid.to_string(),
1802                },
1803                other => other,
1804            })?;
1805        let container = raw.trim();
1806        if container.is_empty() {
1807            return Err(DeviceControlError::Malformed {
1808                subcommand: "clear_app_sandbox".into(),
1809                detail: format!("empty Data container path for bundle {bundle_id}"),
1810            });
1811        }
1812        for path in sandbox_paths(container) {
1813            // A directory that was never written to is not there, and
1814            // that is the state this call exists to produce. `rm -rf`
1815            // read it the same way.
1816            match std::fs::remove_dir_all(&path) {
1817                Ok(()) => {}
1818                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1819                Err(e) => {
1820                    return Err(DeviceControlError::Malformed {
1821                        subcommand: "clear_app_sandbox".into(),
1822                        detail: format!("{path}: {e}"),
1823                    });
1824                }
1825            }
1826        }
1827        Ok(())
1828    }
1829
1830    /// `xcrun simctl openurl <udid> <url>` — open a URL on the device.
1831    ///
1832    /// **URL bytes are passed to `xcrun simctl` verbatim** — no
1833    /// parsing, no percent-encoding rewrite, no query-string
1834    /// stripping. Verified by [`openurl_argv`] (test-visible helper)
1835    /// and its unit test asserting query-params like
1836    /// `?url=http%3A%2F%2Flocalhost%3A8081` reach the argv byte-for-byte.
1837    /// Consequently, if the target app's URL router (e.g.
1838    /// expo-dev-client 57.0.5) shows a picker instead of
1839    /// auto-connecting, the URL reached it intact and the problem
1840    /// lives on the URL-router side.
1841    pub async fn open_url(&self, udid: &str, url: &str) -> Result<(), DeviceControlError> {
1842        let argv = openurl_argv(udid, url);
1843        let refs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1844        simctl_run(&refs).await?;
1845        Ok(())
1846    }
1847}
1848
1849/// How `defaults` is named inside a simulator, in the order tried.
1850///
1851/// Neither spelling works on every runtime. Earlier runtimes refused the
1852/// bare name — `simctl spawn` runs no login shell, and it exited 255 with
1853/// nothing on stderr — so every call site was written with the absolute
1854/// path. Under Xcode 27 the runtime root has no `/usr/bin` at all, and the
1855/// absolute path fails to start (111, "Invalid or missing Program";
1856/// measured 2026-09-25, where `/usr/bin/true` fails the same way), while
1857/// the bare name runs. Tried in order; only a spelling that did not start
1858/// moves on to the next.
1859pub const DEFAULTS_PROGRAMS: &[&str] = &["defaults", "/usr/bin/defaults"];
1860
1861/// Whether a `simctl spawn` failure means the program never started, as
1862/// opposed to the program running and answering with a failure. Only the
1863/// first is fixed by spelling the program another way.
1864#[must_use]
1865pub fn spawn_did_not_start(code: i32, stderr: &str) -> bool {
1866    stderr.contains("Invalid or missing Program") || (code == 255 && stderr.trim().is_empty())
1867}
1868
1869fn did_not_start(e: &DeviceControlError) -> bool {
1870    matches!(e, DeviceControlError::NonZeroExit { code, stderr, .. } if spawn_did_not_start(*code, stderr))
1871}
1872
1873/// Whether `defaults` itself said the key (or its domain) is not there —
1874/// its own words, not any non-zero exit. A spawn that never started also
1875/// exits non-zero, and reading that as "unset" turned a broken read into a
1876/// confident "no".
1877#[must_use]
1878pub fn defaults_key_is_absent(stderr: &str) -> bool {
1879    stderr.contains("does not exist")
1880}
1881
1882/// `defaults read` of a boolean: `1` or `0` on a line of its own.
1883/// Anything else is not an answer.
1884#[must_use]
1885pub fn parse_defaults_bool(out: &str) -> Option<bool> {
1886    match out.trim() {
1887        "1" => Some(true),
1888        "0" => Some(false),
1889        _ => None,
1890    }
1891}
1892
1893/// The three directories inside an app's Data container that
1894/// `clearState` empties, as paths on this host.
1895///
1896/// `Documents`, `Library` and `tmp` rather than the container itself:
1897/// the container holds the app's identity for the installed record, and
1898/// removing it is `uninstall` by another name — which is the pairing
1899/// that costs the XCUITest binding and raises a crash dialog.
1900#[doc(hidden)]
1901pub fn sandbox_paths(container: &str) -> [String; 3] {
1902    let base = container.trim_end_matches('/');
1903    [
1904        format!("{base}/Documents"),
1905        format!("{base}/Library"),
1906        format!("{base}/tmp"),
1907    ]
1908}
1909
1910/// Argv construction for `xcrun simctl openurl`. Extracted
1911/// as a test-visible helper so the URL-preservation contract is
1912/// unit-testable without invoking `xcrun`.
1913#[doc(hidden)]
1914pub fn openurl_argv(udid: &str, url: &str) -> [String; 3] {
1915    ["openurl".to_string(), udid.to_string(), url.to_string()]
1916}
1917
1918impl SimctlClient {
1919    /// `xcrun simctl push <udid> <bundle-id> <apns-json-path>`.
1920    /// Deliver an APNS payload to a sim-installed app. The payload file is
1921    /// a JSON document whose top-level dictionary mirrors what an APNS
1922    /// provider would send; `aps.alert.body` / `aps.alert.title` surface
1923    /// as banner content and reach the app's
1924    /// `UNUserNotificationCenterDelegate`.
1925    pub async fn send_push(
1926        &self,
1927        udid: &str,
1928        bundle_id: &str,
1929        apns_json_path: &str,
1930    ) -> Result<(), DeviceControlError> {
1931        simctl_run(&["push", udid, bundle_id, apns_json_path]).await?;
1932        Ok(())
1933    }
1934
1935    /// `xcrun simctl ui <udid> appearance <light|dark>` — set UI appearance.
1936    pub async fn set_appearance(
1937        &self,
1938        udid: &str,
1939        mode: Appearance,
1940    ) -> Result<(), DeviceControlError> {
1941        simctl_run(&["ui", udid, "appearance", mode.as_str()]).await?;
1942        Ok(())
1943    }
1944
1945    /// `xcrun simctl privacy <udid> grant <perm> <bundle-id>`.
1946    pub async fn grant_permission(
1947        &self,
1948        udid: &str,
1949        permission: SimctlPermission,
1950        bundle_id: &str,
1951    ) -> Result<(), DeviceControlError> {
1952        simctl_run(&["privacy", udid, "grant", permission.as_str(), bundle_id]).await?;
1953        Ok(())
1954    }
1955
1956    /// `xcrun simctl privacy <udid> revoke <perm> <bundle-id>` — explicitly
1957    /// deny the permission. Mirrors maestro yaml `permissions: { x: deny }`
1958    /// (the reverse of `grant`). Distinct from `reset`, which returns the
1959    /// permission to "not determined".
1960    pub async fn revoke_permission(
1961        &self,
1962        udid: &str,
1963        permission: SimctlPermission,
1964        bundle_id: &str,
1965    ) -> Result<(), DeviceControlError> {
1966        simctl_run(&["privacy", udid, "revoke", permission.as_str(), bundle_id]).await?;
1967        Ok(())
1968    }
1969
1970    /// `xcrun simctl location <udid> set <lat>,<lng>` — set sim location
1971    /// to a fixed point. Mirrors maestro `setLocation`.
1972    pub async fn location_set(
1973        &self,
1974        udid: &str,
1975        latitude: f64,
1976        longitude: f64,
1977    ) -> Result<(), DeviceControlError> {
1978        let coord = format!("{latitude},{longitude}");
1979        simctl_run(&["location", udid, "set", &coord]).await?;
1980        Ok(())
1981    }
1982
1983    /// `xcrun simctl location <udid> start [--speed=<m/s>] <waypoints>`
1984    /// — interpolate sim location along waypoints. Fire-and-return: simctl
1985    /// injects scenario and returns; sim continues interpolation in background.
1986    /// Mirrors maestro `travel`.
1987    pub async fn location_start(
1988        &self,
1989        udid: &str,
1990        points: &[(f64, f64)],
1991        speed_mps: Option<f64>,
1992    ) -> Result<(), DeviceControlError> {
1993        if points.len() < 2 {
1994            return Err(DeviceControlError::Malformed {
1995                subcommand: "location-start".into(),
1996                detail: format!("requires ≥2 waypoints, got {}", points.len()),
1997            });
1998        }
1999        let mut args: Vec<String> = vec!["location".into(), udid.into(), "start".into()];
2000        if let Some(s) = speed_mps {
2001            args.push(format!("--speed={s}"));
2002        }
2003        for (lat, lng) in points {
2004            args.push(format!("{lat},{lng}"));
2005        }
2006        let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
2007        simctl_run(&args_ref).await?;
2008        Ok(())
2009    }
2010
2011    /// `xcrun simctl location <udid> clear` — reset active location
2012    /// scenario.
2013    pub async fn location_clear(&self, udid: &str) -> Result<(), DeviceControlError> {
2014        simctl_run(&["location", udid, "clear"]).await?;
2015        Ok(())
2016    }
2017
2018    /// `xcrun simctl addmedia <udid> <path>...` — add photos / videos /
2019    /// contacts to sim library. Mirrors maestro `addMedia` (scalar or
2020    /// array form already flattened on adapter side).
2021    pub async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), DeviceControlError> {
2022        if paths.is_empty() {
2023            return Err(DeviceControlError::Malformed {
2024                subcommand: "addmedia".into(),
2025                detail: "no paths supplied".into(),
2026            });
2027        }
2028        let mut args: Vec<&str> = vec!["addmedia", udid];
2029        for p in paths {
2030            args.push(p.as_str());
2031        }
2032        simctl_run(&args).await?;
2033        Ok(())
2034    }
2035
2036    /// Start recording sim display to `path`. Spawns
2037    /// `xcrun simctl io <udid> recordVideo <path>` as a long-running child;
2038    /// returns handle immediately. Caller must pair with
2039    /// [`Self::record_video_stop`] for clean SIGINT-and-wait shutdown —
2040    /// dropping the handle would SIGKILL via tokio + lose mp4 trailer.
2041    pub async fn record_video_start(
2042        &self,
2043        udid: &str,
2044        path: &str,
2045    ) -> Result<RecordingHandle, DeviceControlError> {
2046        // Log to a file beside the video, not to pipes.
2047        //
2048        // Piped output with nobody reading it is a trap that only springs
2049        // once the recording has to outlive the process that started it:
2050        // when that process exits, the read ends close, and the next line
2051        // `simctl` writes kills it with SIGPIPE. The recording then stops
2052        // silently, seconds after being reported as started, leaving a
2053        // zero-byte file — which is exactly what `smix record start`
2054        // produced before this changed.
2055        //
2056        // A file also keeps `simctl`'s own diagnostics ("No display
2057        // specified…", "Recording started") somewhere a person can read
2058        // them, which a discarded pipe did not.
2059        let state = self
2060            .list_devices()
2061            .await?
2062            .into_iter()
2063            .find(|d| d.udid.eq_ignore_ascii_case(udid))
2064            .map(|d| d.state);
2065        recording_may_start(udid, state.as_deref())?;
2066        let log_path = format!("{path}.log");
2067        let log = std::fs::File::create(&log_path)?;
2068        let log_err = log.try_clone()?;
2069        let child = tokio::process::Command::new("xcrun")
2070            .args(["simctl", "io", udid, "recordVideo", path])
2071            .stdin(std::process::Stdio::null())
2072            .stdout(std::process::Stdio::from(log))
2073            .stderr(std::process::Stdio::from(log_err))
2074            .spawn()?;
2075        // brief settle for simctl to initialize encoder + open output file.
2076        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2077        Ok(RecordingHandle {
2078            child,
2079            path: path.to_string(),
2080            started_at: std::time::Instant::now(),
2081        })
2082    }
2083
2084    /// Stop a recording via SIGINT + wait (≤10s). SIGINT lets simctl
2085    /// trap and flush the mp4 trailer; SIGKILL would corrupt output.
2086    /// Timeout escalates to SIGKILL with explicit error mentioning truncation.
2087    pub async fn record_video_stop(
2088        &self,
2089        mut handle: RecordingHandle,
2090    ) -> Result<(), DeviceControlError> {
2091        let pid = handle
2092            .child
2093            .id()
2094            .ok_or_else(|| DeviceControlError::Malformed {
2095                subcommand: "recordVideo-stop".into(),
2096                detail: "child already reaped".into(),
2097            })?;
2098        // SAFETY: libc::kill is a thin POSIX syscall wrapper; pid is owned by
2099        // this Child instance (no race) and SIGINT is signal-safe.
2100        let rc = unsafe { libc::kill(pid as i32, libc::SIGINT) };
2101        if rc != 0 {
2102            return Err(DeviceControlError::Malformed {
2103                subcommand: "recordVideo-stop".into(),
2104                detail: format!(
2105                    "kill SIGINT failed: errno={}",
2106                    std::io::Error::last_os_error()
2107                ),
2108            });
2109        }
2110        let wait_result =
2111            tokio::time::timeout(std::time::Duration::from_secs(10), handle.child.wait()).await;
2112        match wait_result {
2113            Ok(Ok(_status)) => Ok(()),
2114            Ok(Err(e)) => Err(DeviceControlError::Malformed {
2115                subcommand: "recordVideo-stop".into(),
2116                detail: format!("wait failed: {e}"),
2117            }),
2118            Err(_timeout) => {
2119                let _ = handle.child.kill().await;
2120                Err(DeviceControlError::Malformed {
2121                    subcommand: "recordVideo-stop".into(),
2122                    detail: "SIGINT timeout (10s) — escalated SIGKILL; output mp4 likely truncated. Inspect simctl recordVideo stderr.".into(),
2123                })
2124            }
2125        }
2126    }
2127
2128    /// `xcrun simctl privacy <udid> reset <perm> <bundle-id>` — return the
2129    /// permission to "not determined" so the next request re-prompts.
2130    /// May terminate a running instance of the target app (Apple
2131    /// behavior) — call before launch, not mid-flow.
2132    pub async fn reset_permission(
2133        &self,
2134        udid: &str,
2135        permission: SimctlPermission,
2136        bundle_id: &str,
2137    ) -> Result<(), DeviceControlError> {
2138        simctl_run(&["privacy", udid, "reset", permission.as_str(), bundle_id]).await?;
2139        Ok(())
2140    }
2141
2142    /// `xcrun simctl keychain <udid> reset` — clear all keychain entries.
2143    pub async fn keychain_reset(&self, udid: &str) -> Result<(), DeviceControlError> {
2144        simctl_run(&["keychain", udid, "reset"]).await?;
2145        Ok(())
2146    }
2147
2148    /// `xcrun simctl pbpaste <udid>` — read clipboard contents.
2149    pub async fn pasteboard_get(&self, udid: &str) -> Result<String, DeviceControlError> {
2150        simctl_run(&["pbpaste", udid]).await
2151    }
2152
2153    /// `xcrun simctl pbcopy <udid>` — write clipboard contents (via piped stdin).
2154    pub async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), DeviceControlError> {
2155        // pbcopy reads stdin — we pipe via shell echo for simplicity.
2156        // Long-term: spawn with stdin pipe.
2157        use tokio::io::AsyncWriteExt;
2158        let mut cmd = Command::new("xcrun");
2159        cmd.arg("simctl").arg("pbcopy").arg(udid);
2160        cmd.stdin(std::process::Stdio::piped());
2161        let mut child = cmd.spawn()?;
2162        if let Some(mut stdin) = child.stdin.take() {
2163            stdin.write_all(text.as_bytes()).await?;
2164            drop(stdin); // close stdin so pbcopy returns
2165        }
2166        let status = child.wait().await?;
2167        if !status.success() {
2168            return Err(DeviceControlError::NonZeroExit {
2169                subcommand: "pbcopy".into(),
2170                argv: vec!["pbcopy".to_string()],
2171                code: status.code().unwrap_or(-1),
2172                stderr: String::new(),
2173                wall_ms: 0,
2174            });
2175        }
2176        Ok(())
2177    }
2178
2179    /// Read back the Reduce Motion accessibility setting.
2180    ///
2181    /// `Ok(None)` when the key was never written, which `defaults read`
2182    /// reports by exiting non-zero. Absent is not off and not on — it
2183    /// is the device having no opinion, and a caller that wanted the
2184    /// setting established has to treat it as a failure to establish.
2185    pub async fn reduce_motion(&self, udid: &str) -> Result<Option<String>, DeviceControlError> {
2186        match self
2187            .spawn_defaults(
2188                udid,
2189                &[
2190                    "read",
2191                    "com.apple.UIKit",
2192                    "UIAccessibilityReduceMotionEnabled",
2193                ],
2194            )
2195            .await
2196        {
2197            Ok(s) => Ok(Some(s.trim().to_string())),
2198            Err(DeviceControlError::NonZeroExit { ref stderr, .. })
2199                if defaults_key_is_absent(stderr) =>
2200            {
2201                Ok(None)
2202            }
2203            Err(e) => Err(e),
2204        }
2205    }
2206
2207    /// Toggle "Reduce Motion" accessibility setting via `defaults write`.
2208    pub async fn set_reduce_motion(
2209        &self,
2210        udid: &str,
2211        enabled: bool,
2212    ) -> Result<(), DeviceControlError> {
2213        // `true`/`false`, not `1`/`0`. `defaults` accepts
2214        // `-bool (true | false | yes | no)` and answers anything else
2215        // by printing its usage and exiting 255 — which is what this
2216        // did from the day it was written. It had no callers until the
2217        // animation switch, so nothing ever ran it.
2218        let val = if enabled { "true" } else { "false" };
2219        // Which spelling of `defaults` starts depends on the runtime; see
2220        // `DEFAULTS_PROGRAMS`.
2221        self.spawn_defaults(
2222            udid,
2223            &[
2224                "write",
2225                "com.apple.UIKit",
2226                "UIAccessibilityReduceMotionEnabled",
2227                "-bool",
2228                val,
2229            ],
2230        )
2231        .await?;
2232        Ok(())
2233    }
2234
2235    /// `xcrun simctl io <udid> screenshot <tmpfile>` → raw PNG bytes,
2236    /// with a byte-level sRGB metadata splice if the produced PNG lacks
2237    /// an `sRGB` chunk.
2238    ///
2239    /// Goes through a temp file: current Xcode's `screenshot -` does not
2240    /// treat `-` as stdout — it writes a literal file named `-` in cwd
2241    /// and emits nothing on stdout (observed on Xcode/iOS 26.5).
2242    ///
2243    /// **Pixel-preservation invariant**: the returned bytes are
2244    /// byte-identical to whatever `simctl io screenshot` wrote to disk
2245    /// EXCEPT for one narrow case — if the PNG does not carry an
2246    /// `sRGB` ancillary chunk (observed on iOS 26.5 sub-builds
2247    /// mid-2026), a 13-byte `sRGB` chunk is spliced in immediately
2248    /// before the first `IDAT`. Pixel data (IDAT bytes) is never
2249    /// decoded or modified. See [`ensure_srgb_chunk`] for the exact
2250    /// splice operation.
2251    pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, DeviceControlError> {
2252        match self.capture_frame(udid, true).await? {
2253            surface_capture::CapturedFrame::Png(bytes) => Ok(bytes),
2254            // want_png=true only ever produces a PNG (host ImageIO encode or
2255            // the simctl fallback). A raw frame here is a protocol violation.
2256            surface_capture::CapturedFrame::Bgra { .. } => Err(DeviceControlError::Malformed {
2257                subcommand: "screenshot".into(),
2258                detail: "capture returned raw BGRA for a PNG request".into(),
2259            }),
2260        }
2261    }
2262
2263    /// Capture a frame preferring the fast raw-BGRA path.
2264    ///
2265    /// When the resident IOSurface host is available this returns
2266    /// [`CapturedFrame::Bgra`](surface_capture::CapturedFrame::Bgra) —
2267    /// ~0.3 ms per frame, no PNG encode. When the surface can't be resolved
2268    /// (sim not booted, framework layout change) it falls back to
2269    /// `xcrun simctl io screenshot` and returns
2270    /// [`CapturedFrame::Png`](surface_capture::CapturedFrame::Png). The
2271    /// pixels are correct either way; consumers that only need grayscale
2272    /// samples (diff-loop / dhash) skip the PNG encode+decode round-trip.
2273    ///
2274    /// Since smix 2.0.0.
2275    pub async fn capture_bgra(
2276        &self,
2277        udid: &str,
2278    ) -> Result<surface_capture::CapturedFrame, DeviceControlError> {
2279        self.capture_frame(udid, false).await
2280    }
2281
2282    /// Core capture path: try the resident IOSurface host, fall back to
2283    /// `simctl`. `want_png` selects an in-host ImageIO PNG encode over a raw
2284    /// BGRA frame; the fallback is always a PNG.
2285    async fn capture_frame(
2286        &self,
2287        udid: &str,
2288        want_png: bool,
2289    ) -> Result<surface_capture::CapturedFrame, DeviceControlError> {
2290        // Direct path first. No pacer gate: the direct IOSurface read does not
2291        // touch `com.apple.display.captureservice`, so the crash-guard floor
2292        // the pacer enforces for `simctl io screenshot` does not apply here.
2293        // Surface unavailable, or the host transport failed — both fall
2294        // through to the correct-but-slow simctl path below.
2295        if let Ok(Some(frame)) = self.try_capture_direct(udid, want_png).await {
2296            return Ok(frame);
2297        }
2298        let png = self.screenshot_via_simctl(udid).await?;
2299        Ok(surface_capture::CapturedFrame::Png(png))
2300    }
2301
2302    /// Get-or-spawn the resident host for `udid` and grab one frame. Returns
2303    /// `Ok(None)` when the host reports the surface is gone, `Err` on a
2304    /// transport failure. In both non-`Some` cases the host is dropped (and
2305    /// killed) so the next call re-resolves from scratch.
2306    async fn try_capture_direct(
2307        &self,
2308        udid: &str,
2309        want_png: bool,
2310    ) -> Result<Option<surface_capture::CapturedFrame>, surface_capture::HostError> {
2311        // Take the host out from under the lock so a 12.6 MB grab (or a 5s
2312        // spawn) never serializes captures for other sims.
2313        let existing = { self.capture_hosts.lock().await.take(udid) };
2314        let mut host = match existing {
2315            Some(h) => h,
2316            None => surface_capture::SurfaceCaptureHost::spawn(udid).await?,
2317        };
2318        match host.grab(want_png).await {
2319            Ok(Some(frame)) => {
2320                self.capture_hosts.lock().await.put(udid, host);
2321                Ok(Some(frame))
2322            }
2323            // Host is exiting (surface gone) — drop it, fall back.
2324            Ok(None) => Ok(None),
2325            // Transport died — drop it, fall back.
2326            Err(e) => Err(e),
2327        }
2328    }
2329
2330    /// Drop the resident capture host for `udid`, if any. Call this whenever a
2331    /// lifecycle operation may have invalidated the framebuffer surface
2332    /// (shutdown / erase / reboot) so the next capture re-resolves cleanly.
2333    ///
2334    /// Since smix 2.0.0.
2335    pub async fn evict_capture_host(&self, udid: &str) {
2336        let host = { self.capture_hosts.lock().await.evict(udid) };
2337        if let Some(h) = host {
2338            h.shutdown().await;
2339        }
2340    }
2341
2342    /// `xcrun simctl io <udid> screenshot <tmpfile>` → raw PNG bytes, paced +
2343    /// circuit-guarded, with the sRGB metadata splice. The correct-but-slow
2344    /// fallback for [`capture_frame`](Self::capture_frame).
2345    async fn screenshot_via_simctl(&self, udid: &str) -> Result<Vec<u8>, DeviceControlError> {
2346        // Pace + circuit-check before invoking simctl.
2347        let wait = {
2348            let mut pacer = self
2349                .screenshot_pacer
2350                .lock()
2351                .expect("screenshot pacer mutex must not be poisoned");
2352            pacer
2353                .compute_wait()
2354                .map_err(|retry_after| DeviceControlError::CaptureBackpressure { retry_after })?
2355        };
2356        if !wait.is_zero() {
2357            sleep(wait).await;
2358        }
2359
2360        let call_start = std::time::Instant::now();
2361        let tmp =
2362            std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
2363        let tmp_str = tmp.display().to_string();
2364        let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
2365        let bytes = result.and_then(|_| {
2366            std::fs::read(&tmp).map_err(|e| DeviceControlError::Malformed {
2367                subcommand: "screenshot".into(),
2368                detail: format!("read {tmp_str}: {e}"),
2369            })
2370        });
2371        let _ = std::fs::remove_file(&tmp);
2372
2373        let wall = call_start.elapsed();
2374        let failed = bytes.is_err();
2375        {
2376            let mut pacer = self
2377                .screenshot_pacer
2378                .lock()
2379                .expect("screenshot pacer mutex must not be poisoned");
2380            pacer.record(wall, failed);
2381        }
2382
2383        let bytes = bytes?;
2384        if bytes.len() < 8 {
2385            return Err(DeviceControlError::Malformed {
2386                subcommand: "screenshot".into(),
2387                detail: format!("screenshot file too short: {} bytes", bytes.len()),
2388            });
2389        }
2390        Ok(ensure_srgb_chunk(bytes))
2391    }
2392
2393    /// `xcrun simctl create <name> <device-type-id> <runtime-id>` → udid.
2394    pub async fn create_device(
2395        &self,
2396        name: &str,
2397        device_type: &str,
2398        runtime_id: &str,
2399    ) -> Result<String, DeviceControlError> {
2400        let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
2401        Ok(out.trim().to_string())
2402    }
2403
2404    /// `xcrun simctl delete <udid>` — delete a simulator device.
2405    pub async fn delete_device(&self, udid: &str) -> Result<(), DeviceControlError> {
2406        simctl_run(&["delete", udid]).await?;
2407        Ok(())
2408    }
2409}
2410
2411// -------------------- PNG sRGB chunk normalization --------------------
2412//
2413// iOS 26.5 sub-builds (mid-2026) started omitting the `sRGB` ancillary
2414// chunk from `simctl io screenshot` output. macOS Preview.app and other
2415// viewers that fall back to Display P3 when no ICC profile is embedded
2416// then over-saturate the image (red gets pushed, text anti-alias picks
2417// up yellow fringing).
2418//
2419// This does NOT affect pixel-comparison (dhash decodes IDAT to RGBA and
2420// ignores ancillary chunks), but does affect any downstream tool that
2421// renders the PNG for human review. The normalizer runs on the raw byte
2422// stream — walks chunks, and if no `sRGB` chunk is seen before the first
2423// `IDAT`, splices in a synthesized 13-byte `sRGB` chunk (length=1,
2424// type="sRGB", data=[0 = perceptual intent], CRC over type+data).
2425//
2426// Pixel-preservation invariant: IDAT bytes are never decoded. Every
2427// existing chunk is copied verbatim. Only 13 bytes of new metadata are
2428// inserted.
2429
2430const PNG_MAGIC: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
2431
2432/// Ensure the PNG carries an `sRGB` ancillary chunk. Called on the raw
2433/// bytes returned by `xcrun simctl io <udid> screenshot`. If the PNG
2434/// already has an `sRGB` chunk, returns the input unchanged; otherwise
2435/// splices in a 13-byte `sRGB` chunk (rendering intent = 0, perceptual)
2436/// immediately before the first `IDAT`. Returns the input unchanged on
2437/// any structural anomaly (missing magic, malformed chunk) so a
2438/// corrupted PNG is passed through untouched for the caller to diagnose.
2439pub fn ensure_srgb_chunk(bytes: Vec<u8>) -> Vec<u8> {
2440    if bytes.len() < 8 || &bytes[..8] != PNG_MAGIC {
2441        return bytes;
2442    }
2443    let Some((idat_offset, has_srgb)) = scan_png_chunks(&bytes) else {
2444        return bytes;
2445    };
2446    if has_srgb {
2447        return bytes;
2448    }
2449    // Splice the synthesized sRGB chunk right before the first IDAT.
2450    let mut out = Vec::with_capacity(bytes.len() + 13);
2451    out.extend_from_slice(&bytes[..idat_offset]);
2452    out.extend_from_slice(&synthesized_srgb_chunk());
2453    out.extend_from_slice(&bytes[idat_offset..]);
2454    out
2455}
2456
2457/// Walk PNG chunks starting after the 8-byte magic. Returns
2458/// `(offset_of_first_IDAT, has_srgb_chunk_before_it)` when the walk
2459/// reaches an IDAT chunk. Returns `None` if the walk hits EOF or a
2460/// malformed chunk without seeing an IDAT.
2461fn scan_png_chunks(bytes: &[u8]) -> Option<(usize, bool)> {
2462    let mut i: usize = 8;
2463    let mut has_srgb = false;
2464    while i + 8 <= bytes.len() {
2465        let length =
2466            u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
2467        let ctype = &bytes[i + 4..i + 8];
2468        if ctype == b"IDAT" {
2469            return Some((i, has_srgb));
2470        }
2471        if ctype == b"sRGB" {
2472            has_srgb = true;
2473        }
2474        // 4 (length) + 4 (type) + length (data) + 4 (crc)
2475        let end = i.checked_add(12)?.checked_add(length)?;
2476        if end > bytes.len() {
2477            return None;
2478        }
2479        i = end;
2480    }
2481    None
2482}
2483
2484/// Build the 13-byte `sRGB` chunk with rendering intent = 0 (perceptual).
2485/// Format: `[len:4][type:4][data:1][crc:4]` = 13 bytes total.
2486fn synthesized_srgb_chunk() -> [u8; 13] {
2487    // The CRC is computed over `type || data`.
2488    let mut crc_input = [0u8; 5];
2489    crc_input[0..4].copy_from_slice(b"sRGB");
2490    crc_input[4] = 0; // perceptual
2491    let crc = crc32_ieee(&crc_input);
2492    let mut chunk = [0u8; 13];
2493    chunk[0..4].copy_from_slice(&1u32.to_be_bytes()); // length = 1 (data byte)
2494    chunk[4..8].copy_from_slice(b"sRGB");
2495    chunk[8] = 0;
2496    chunk[9..13].copy_from_slice(&crc.to_be_bytes());
2497    chunk
2498}
2499
2500/// Table-less CRC-32 IEEE 802.3 (polynomial 0xEDB88320) as used by
2501/// PNG. Small enough for this crate's single call site — avoids
2502/// pulling in a `crc32fast` dependency.
2503fn crc32_ieee(bytes: &[u8]) -> u32 {
2504    let mut crc: u32 = 0xFFFF_FFFF;
2505    for &b in bytes {
2506        crc ^= u32::from(b);
2507        for _ in 0..8 {
2508            let mask = 0u32.wrapping_sub(crc & 1);
2509            crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
2510        }
2511    }
2512    !crc
2513}
2514
2515#[cfg(test)]
2516mod tests {
2517    use super::*;
2518
2519    #[test]
2520    fn compose_child_env_adds_prefix() {
2521        let composed = compose_child_env(&[
2522            ("SMIX_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
2523            ("LAUNCH_FORCE_PUSH", "true"),
2524        ]);
2525        assert_eq!(
2526            composed,
2527            vec![
2528                (
2529                    "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
2530                    "http://127.0.0.1:9999".to_string(),
2531                ),
2532                (
2533                    "SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
2534                    "true".to_string(),
2535                ),
2536            ]
2537        );
2538    }
2539
2540    #[test]
2541    fn compose_child_env_already_prefixed_passes_through() {
2542        // Defensive: caller may pre-prefix; we must not double-prefix.
2543        let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
2544        assert_eq!(
2545            composed,
2546            vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
2547        );
2548    }
2549
2550    #[test]
2551    fn compose_child_env_empty_input_is_empty_output() {
2552        assert!(compose_child_env(&[]).is_empty());
2553    }
2554
2555    // -- clearing an app's sandbox --------------------------------------
2556
2557    /// The three directories `clearState` empties, as host paths.
2558    ///
2559    /// Host paths, because that is what they are: `get_app_container`
2560    /// answers with a directory on this Mac, and the only reason a
2561    /// process was ever started inside the simulator to delete them was
2562    /// that nobody looked. On Xcode 27 nothing can be started there —
2563    /// the runtime root carries `df` and `launchctl` and no `rm` — so
2564    /// `clearState: true` failed with `Invalid or missing Program` and
2565    /// took the whole `launchApp` with it.
2566    #[test]
2567    fn the_three_directories_cleared_are_under_the_container() {
2568        let c = "/Users/x/Library/Developer/CoreSimulator/Devices/UD-ID/data/\
2569                 Containers/Data/Application/GUID";
2570        let paths = sandbox_paths(c);
2571        assert_eq!(
2572            paths,
2573            [
2574                format!("{c}/Documents"),
2575                format!("{c}/Library"),
2576                format!("{c}/tmp"),
2577            ],
2578        );
2579    }
2580
2581    #[test]
2582    fn a_container_path_is_not_joined_with_a_stray_separator() {
2583        // `get_app_container` answers without a trailing slash and the
2584        // paths are joined by hand. A double slash still resolves, so
2585        // this would never fail on a device — it would only make the
2586        // failure message name a path no reader can find.
2587        let paths = sandbox_paths("/tmp/container/");
2588        assert_eq!(paths[0], "/tmp/container/Documents");
2589    }
2590
2591    // -- openurl URL preservation ---------------------------------------
2592
2593    #[test]
2594    fn openurl_argv_preserves_url_verbatim() {
2595        let udid = "12345678-1234-5678-1234-567812345678";
2596        let url = "exp+focus-ai-app://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081";
2597        let argv = super::openurl_argv(udid, url);
2598        assert_eq!(argv[0], "openurl");
2599        assert_eq!(argv[1], udid);
2600        // Byte-identical URL — no percent-decoding, no query-strip.
2601        assert_eq!(argv[2], url);
2602        assert!(argv[2].contains("?url="));
2603        assert!(argv[2].contains("%3A"));
2604        assert!(argv[2].contains("%2F"));
2605    }
2606
2607    #[test]
2608    fn openurl_argv_preserves_ampersand_and_hash() {
2609        let udid = "12345678-1234-5678-1234-567812345678";
2610        let url = "myapp://dev-mutate?action=env&value=staging#anchor";
2611        let argv = super::openurl_argv(udid, url);
2612        assert_eq!(argv[2], url);
2613        assert!(argv[2].contains('&'));
2614        assert!(argv[2].contains('#'));
2615    }
2616
2617    #[test]
2618    fn openurl_argv_preserves_unicode() {
2619        let udid = "12345678-1234-5678-1234-567812345678";
2620        let url = "myapp://route?name=%E7%94%B0%E4%B8%AD";
2621        let argv = super::openurl_argv(udid, url);
2622        assert_eq!(argv[2], url);
2623    }
2624
2625    // -- sRGB chunk normalization ---------------------------------------
2626
2627    /// Build a minimal PNG: 1×1 8-bit RGBA, one IDAT (zlib-empty-safe),
2628    /// with or without an sRGB chunk. Returns synthetic bytes suitable
2629    /// for exercising the chunk-walking logic; no rendering intent.
2630    fn synth_png(with_srgb: bool) -> Vec<u8> {
2631        let mut out = Vec::new();
2632        out.extend_from_slice(super::PNG_MAGIC);
2633        // IHDR: 1x1, bit_depth=8, color_type=6 (RGBA), rest=0
2634        let ihdr_data: [u8; 13] = [
2635            0, 0, 0, 1, // width = 1
2636            0, 0, 0, 1, // height = 1
2637            8, // bit depth
2638            6, // color type = RGBA
2639            0, 0, 0,
2640        ];
2641        emit_chunk(&mut out, b"IHDR", &ihdr_data);
2642        if with_srgb {
2643            emit_chunk(&mut out, b"sRGB", &[0]);
2644        }
2645        // Placeholder IDAT — content doesn't matter for chunk-walking tests
2646        emit_chunk(&mut out, b"IDAT", &[0x78, 0x01, 0x00, 0x00]);
2647        emit_chunk(&mut out, b"IEND", &[]);
2648        out
2649    }
2650
2651    fn emit_chunk(out: &mut Vec<u8>, ctype: &[u8; 4], data: &[u8]) {
2652        out.extend_from_slice(&(data.len() as u32).to_be_bytes());
2653        out.extend_from_slice(ctype);
2654        out.extend_from_slice(data);
2655        let mut crc_in = Vec::with_capacity(4 + data.len());
2656        crc_in.extend_from_slice(ctype);
2657        crc_in.extend_from_slice(data);
2658        out.extend_from_slice(&super::crc32_ieee(&crc_in).to_be_bytes());
2659    }
2660
2661    #[test]
2662    fn ensure_srgb_passthrough_when_chunk_present() {
2663        let png = synth_png(true);
2664        let original_len = png.len();
2665        let out = super::ensure_srgb_chunk(png.clone());
2666        assert_eq!(out.len(), original_len);
2667        assert_eq!(out, png);
2668    }
2669
2670    #[test]
2671    fn ensure_srgb_inserts_chunk_when_absent() {
2672        let png = synth_png(false);
2673        let original_len = png.len();
2674        let out = super::ensure_srgb_chunk(png);
2675        assert_eq!(out.len(), original_len + 13);
2676        // First 8 bytes = PNG magic
2677        assert_eq!(&out[..8], super::PNG_MAGIC);
2678        // Search for the injected sRGB chunk
2679        let mut found = false;
2680        for w in out.windows(4) {
2681            if w == b"sRGB" {
2682                found = true;
2683                break;
2684            }
2685        }
2686        assert!(found, "sRGB chunk should have been spliced in");
2687    }
2688
2689    #[test]
2690    fn ensure_srgb_preserves_idat_bytes_verbatim() {
2691        // Any pixel corruption at the IDAT level would break the
2692        // pixel-preservation invariant. Extract IDAT payload from
2693        // input and output, assert byte-identical.
2694        let png = synth_png(false);
2695        let out = super::ensure_srgb_chunk(png.clone());
2696        assert_eq!(extract_idat_data(&png), extract_idat_data(&out));
2697    }
2698
2699    fn extract_idat_data(bytes: &[u8]) -> Vec<u8> {
2700        let mut i = 8;
2701        while i + 8 <= bytes.len() {
2702            let length =
2703                u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
2704            let ctype = &bytes[i + 4..i + 8];
2705            if ctype == b"IDAT" {
2706                return bytes[i + 8..i + 8 + length].to_vec();
2707            }
2708            i += 12 + length;
2709        }
2710        vec![]
2711    }
2712
2713    #[test]
2714    fn ensure_srgb_passthrough_on_bad_magic() {
2715        // Corrupted / non-PNG input must not be modified.
2716        let bytes = vec![0u8; 32];
2717        let out = super::ensure_srgb_chunk(bytes.clone());
2718        assert_eq!(out, bytes);
2719    }
2720
2721    #[test]
2722    fn crc32_matches_known_iend() {
2723        // The empty-data IEND CRC is a well-known constant.
2724        // CRC over "IEND" alone: 0xAE_42_60_82.
2725        assert_eq!(super::crc32_ieee(b"IEND"), 0xAE42_6082);
2726    }
2727}