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            assert!(snapshot().is_empty());
614
615            load_persisted();
616            let after = snapshot();
617            assert_eq!(after.len(), 1);
618            assert_eq!(after[0].argv, vec!["shutdown".to_string(), "UDID-A".into()]);
619            assert_eq!(after[0].exit_code, Some(0));
620            assert_eq!(after[0].wall_ms, 42);
621        }
622    }
623}
624
625/// Enable subprocess-ring persistence at the given path.
626/// CLI startup wires this to `~/.local/share/smix/subprocess-ring.json`
627/// so `/diagnostic/dump` payloads survive supervisor cycles. Optional;
628/// without this call the ring stays in-memory only.
629pub fn set_subprocess_ring_persist_path(path: std::path::PathBuf) {
630    subprocess_ring::set_persist_path(path);
631    // No eager read here: the value is loaded the first time
632    // something actually uses it. Loading all three at startup cost
633    // every command three store opens, each one an AOF replay and a
634    // blocking lock, for state most commands never touch.
635}
636
637// CLI-side resetAppData counter tracking.
638//
639// The `resetAppData` verb dispatches host-side (simctl openurl + metro
640// log tail, no runner HTTP endpoint) so counters can't come from the
641// runner's `/diagnostic/dump` payload. This module owns them,
642// persisting to `~/.local/share/smix/reset-app-data-counters.json`
643// so counter deltas across `smix run` invocations + `smix diagnostic
644// dump` (later, separate process) all see the same data.
645//
646// Public API mirrors [`subprocess_ring`] shape for consistency.
647mod reset_app_data_counters {
648    use std::path::PathBuf;
649    use std::sync::{Mutex, OnceLock};
650
651    fn cell() -> &'static Mutex<Counters> {
652        static INSTANCE: OnceLock<Mutex<Counters>> = OnceLock::new();
653        INSTANCE.get_or_init(|| Mutex::new(Counters::default()))
654    }
655    fn persist_cell() -> &'static Mutex<Option<PathBuf>> {
656        static INSTANCE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
657        INSTANCE.get_or_init(|| Mutex::new(None))
658    }
659
660    #[derive(Clone, Copy, Debug, Default, serde::Serialize, serde::Deserialize)]
661    pub struct Counters {
662        pub reset_app_data_total: u64,
663        pub reset_app_data_timed_out: u64,
664    }
665
666    pub fn set_persist_path(path: PathBuf) {
667        let mut g = match persist_cell().lock() {
668            Ok(g) => g,
669            Err(p) => p.into_inner(),
670        };
671        *g = Some(path);
672    }
673
674    fn loaded_flag() -> &'static OnceLock<Mutex<bool>> {
675        static INSTANCE: OnceLock<Mutex<bool>> = OnceLock::new();
676        &INSTANCE
677    }
678
679    fn ensure_loaded() {
680        super::diag_store::ensure_loaded(loaded_flag(), persist_cell(), load_persisted);
681    }
682
683    fn persist_path_copy() -> Option<PathBuf> {
684        let g = match persist_cell().lock() {
685            Ok(g) => g,
686            Err(p) => p.into_inner(),
687        };
688        g.clone()
689    }
690
691    pub fn load_persisted() {
692        let Some(path) = persist_path_copy() else {
693            return;
694        };
695        let Some(loaded) = super::diag_store::load::<Counters>(&path, "reset-app-data-counters")
696        else {
697            return;
698        };
699        let mut g = match cell().lock() {
700            Ok(g) => g,
701            Err(p) => p.into_inner(),
702        };
703        *g = loaded;
704    }
705
706    pub fn increment_total() {
707        ensure_loaded();
708        {
709            let mut g = match cell().lock() {
710                Ok(g) => g,
711                Err(p) => p.into_inner(),
712            };
713            g.reset_app_data_total = g.reset_app_data_total.saturating_add(1);
714        }
715        persist_best_effort();
716    }
717
718    pub fn increment_timed_out() {
719        ensure_loaded();
720        {
721            let mut g = match cell().lock() {
722                Ok(g) => g,
723                Err(p) => p.into_inner(),
724            };
725            g.reset_app_data_timed_out = g.reset_app_data_timed_out.saturating_add(1);
726        }
727        persist_best_effort();
728    }
729
730    pub fn snapshot() -> Counters {
731        ensure_loaded();
732        let g = match cell().lock() {
733            Ok(g) => g,
734            Err(p) => p.into_inner(),
735        };
736        *g
737    }
738
739    fn persist_best_effort() {
740        let Some(path) = persist_path_copy() else {
741            return;
742        };
743        let snapshot = snapshot();
744        super::diag_store::store(&path, "reset-app-data-counters", &snapshot);
745    }
746
747    #[cfg(test)]
748    mod tests {
749        use super::*;
750
751        #[test]
752        fn increment_and_persist_roundtrip() {
753            let dir = tempfile::tempdir().expect("tempdir");
754            let path = dir.path().join("counters.json");
755            set_persist_path(path.clone());
756            // Reset in-memory to avoid cross-test pollution.
757            {
758                let mut g = cell().lock().unwrap();
759                *g = Counters::default();
760            }
761            increment_total();
762            increment_total();
763            increment_timed_out();
764            // Read it back the way a restarted process would, rather
765            // than by opening a file whose path is no longer the
766            // contract.
767            {
768                let mut g = cell().lock().unwrap();
769                *g = Counters::default();
770            }
771            load_persisted();
772            let loaded = snapshot();
773            assert_eq!(loaded.reset_app_data_total, 2);
774            assert_eq!(loaded.reset_app_data_timed_out, 1);
775        }
776    }
777}
778
779/// Public snapshot of CLI-side resetAppData counter state. Populated by [`increment_reset_app_data_total`] +
780/// [`increment_reset_app_data_timed_out`] as the CLI dispatches the
781/// verb; loaded from disk on CLI startup if
782/// [`set_reset_app_data_counters_persist_path`] was called.
783#[derive(Clone, Copy, Debug, Default)]
784pub struct ResetAppDataCounters {
785    /// Total resetAppData dispatches (any outcome).
786    pub reset_app_data_total: u64,
787    /// resetAppData dispatches where the completion signal did not
788    /// arrive inside the timeout window. `> 0` = the URL was fired
789    /// but the app did not emit the expected reset-complete log line.
790    pub reset_app_data_timed_out: u64,
791}
792
793/// Enable resetAppData counter persistence at the given path. Callers pass
794/// `~/.local/share/smix/reset-app-data-counters.json` at CLI startup
795/// so counter state survives across `smix run` → `smix diagnostic
796/// dump` invocations.
797pub fn set_reset_app_data_counters_persist_path(path: std::path::PathBuf) {
798    reset_app_data_counters::set_persist_path(path);
799    // No eager read here: the value is loaded the first time
800    // something actually uses it. Loading all three at startup cost
801    // every command three store opens, each one an AOF replay and a
802    // blocking lock, for state most commands never touch.
803}
804
805/// Advance the resetAppData total counter.
806/// Called by the CLI runtime after each dispatch (success or timeout).
807pub fn increment_reset_app_data_total() {
808    reset_app_data_counters::increment_total();
809}
810
811/// Advance the resetAppData timed-out counter.
812/// Called by the CLI runtime when the completion signal (log-line
813/// pattern match) did not arrive inside the timeout window. Always
814/// paired with a preceding [`increment_reset_app_data_total`] on the
815/// same dispatch.
816pub fn increment_reset_app_data_timed_out() {
817    reset_app_data_counters::increment_timed_out();
818}
819
820/// Snapshot the current counter state for display / wire emission. Returns zero-valued counters when
821/// persistence was never wired.
822pub fn reset_app_data_counters_snapshot() -> ResetAppDataCounters {
823    let s = reset_app_data_counters::snapshot();
824    ResetAppDataCounters {
825        reset_app_data_total: s.reset_app_data_total,
826        reset_app_data_timed_out: s.reset_app_data_timed_out,
827    }
828}
829
830// Flow-attempt persistence for retry attribution. Called by `smix run`
831// after each flow completes (all its attempts done); read by
832// `smix diagnostic dump` to render the attribution table.
833// Backed by `~/.local/share/smix/flow-attempts.json`; capped at the
834// last 32 flows — enough history to diagnose a batch or two while
835// keeping the dump snapshot cheap to serialize.
836mod flow_attempts {
837    use serde::{Deserialize, Serialize};
838    use std::path::PathBuf;
839    use std::sync::{Mutex, OnceLock};
840
841    #[derive(Clone, Debug, Serialize, Deserialize)]
842    pub struct PersistedAttempt {
843        pub attempt_index: u32,
844        pub status: String,
845        pub error_class: Option<String>,
846        pub ips_generated: Option<String>,
847        pub wall_ms: u64,
848    }
849
850    #[derive(Clone, Debug, Serialize, Deserialize)]
851    pub struct PersistedFlow {
852        pub flow_name: String,
853        pub attempts: Vec<PersistedAttempt>,
854    }
855
856    fn cell() -> &'static Mutex<Vec<PersistedFlow>> {
857        static INSTANCE: OnceLock<Mutex<Vec<PersistedFlow>>> = OnceLock::new();
858        INSTANCE.get_or_init(|| Mutex::new(Vec::new()))
859    }
860    fn persist_cell() -> &'static Mutex<Option<PathBuf>> {
861        static INSTANCE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
862        INSTANCE.get_or_init(|| Mutex::new(None))
863    }
864
865    pub fn set_persist_path(path: PathBuf) {
866        let mut g = match persist_cell().lock() {
867            Ok(g) => g,
868            Err(p) => p.into_inner(),
869        };
870        *g = Some(path);
871    }
872
873    fn persist_path_copy() -> Option<PathBuf> {
874        let g = match persist_cell().lock() {
875            Ok(g) => g,
876            Err(p) => p.into_inner(),
877        };
878        g.clone()
879    }
880
881    fn loaded_flag() -> &'static OnceLock<Mutex<bool>> {
882        static INSTANCE: OnceLock<Mutex<bool>> = OnceLock::new();
883        &INSTANCE
884    }
885
886    fn ensure_loaded() {
887        super::diag_store::ensure_loaded(loaded_flag(), persist_cell(), load_persisted);
888    }
889
890    pub fn load_persisted() {
891        let Some(path) = persist_path_copy() else {
892            return;
893        };
894        let Some(loaded) = super::diag_store::load::<Vec<PersistedFlow>>(&path, "flow-attempts")
895        else {
896            return;
897        };
898        let mut g = match cell().lock() {
899            Ok(g) => g,
900            Err(p) => p.into_inner(),
901        };
902        *g = loaded;
903    }
904
905    pub fn record(flow_name: &str, attempts: &[PersistedAttempt]) {
906        ensure_loaded();
907        {
908            let mut g = match cell().lock() {
909                Ok(g) => g,
910                Err(p) => p.into_inner(),
911            };
912            g.push(PersistedFlow {
913                flow_name: flow_name.to_string(),
914                attempts: attempts.to_vec(),
915            });
916            if g.len() > 32 {
917                let drop = g.len() - 32;
918                g.drain(0..drop);
919            }
920        }
921        persist_best_effort();
922    }
923
924    pub fn snapshot() -> Vec<PersistedFlow> {
925        ensure_loaded();
926        let g = match cell().lock() {
927            Ok(g) => g,
928            Err(p) => p.into_inner(),
929        };
930        g.clone()
931    }
932
933    fn persist_best_effort() {
934        let Some(path) = persist_path_copy() else {
935            return;
936        };
937        let flows = snapshot();
938        super::diag_store::store(&path, "flow-attempts", &flows);
939    }
940}
941
942/// Enable flow-attempts persistence at the given path.
943/// CLI startup wires this to `~/.local/share/smix/flow-attempts.json`
944/// so retry attribution survives across `smix run` → `smix diagnostic
945/// dump` invocations.
946pub fn set_flow_attempts_persist_path(path: std::path::PathBuf) {
947    flow_attempts::set_persist_path(path);
948    // No eager read here: the value is loaded the first time
949    // something actually uses it. Loading all three at startup cost
950    // every command three store opens, each one an AOF replay and a
951    // blocking lock, for state most commands never touch.
952}
953
954/// Public accessor with just the fields needed by callers.
955/// Mirrors [`smix_runner_wire::FlowAttempt`] shape.
956#[derive(Clone, Debug)]
957pub struct FlowAttemptData {
958    /// Zero-based retry index.
959    pub attempt_index: u32,
960    /// Overall outcome ("ok" / "timeout" / "error" / "crashed").
961    pub status: String,
962    /// Free-form error class code (`Some` on non-ok).
963    pub error_class: Option<String>,
964    /// `.ips` filename that appeared during this attempt, when detected.
965    pub ips_generated: Option<String>,
966    /// Wall-clock milliseconds.
967    pub wall_ms: u64,
968}
969
970/// Recorded flow with its attempt list.
971#[derive(Clone, Debug)]
972pub struct FlowAttemptRecordData {
973    /// Flow name (yaml basename or explicit id).
974    pub flow_name: String,
975    /// Ordered attempts, first try first.
976    pub attempts: Vec<FlowAttemptData>,
977}
978
979/// Record the outcome of a flow's attempts. Called from
980/// `smix run` after all retries for that flow have completed. `smix
981/// diagnostic dump` reads via [`recent_flow_attempts`] later.
982pub fn record_flow_attempts<A>(flow_name: &str, attempts: &[A])
983where
984    A: FlowAttemptShape,
985{
986    let converted: Vec<flow_attempts::PersistedAttempt> = attempts
987        .iter()
988        .map(|a| flow_attempts::PersistedAttempt {
989            attempt_index: a.attempt_index(),
990            status: a.status().to_string(),
991            error_class: a.error_class().map(str::to_string),
992            ips_generated: a.ips_generated().map(str::to_string),
993            wall_ms: a.wall_ms(),
994        })
995        .collect();
996    flow_attempts::record(flow_name, &converted);
997}
998
999/// Abstraction so callers pass either
1000/// [`smix_runner_wire::FlowAttempt`] or a local struct with the same
1001/// shape without a cross-crate dep on smix-runner-wire from smix-simctl.
1002pub trait FlowAttemptShape {
1003    /// Zero-based retry index.
1004    fn attempt_index(&self) -> u32;
1005    /// "ok" / "timeout" / "error" / "crashed".
1006    fn status(&self) -> &str;
1007    /// Error class code, if any.
1008    fn error_class(&self) -> Option<&str>;
1009    /// `.ips` filename attributable to this attempt, if any.
1010    fn ips_generated(&self) -> Option<&str>;
1011    /// Wall-clock milliseconds.
1012    fn wall_ms(&self) -> u64;
1013}
1014
1015/// Snapshot recent flow attempts for display / wire
1016/// emission. Returns empty when persistence was never wired.
1017pub fn recent_flow_attempts() -> Vec<FlowAttemptRecordData> {
1018    flow_attempts::snapshot()
1019        .into_iter()
1020        .map(|f| FlowAttemptRecordData {
1021            flow_name: f.flow_name,
1022            attempts: f
1023                .attempts
1024                .into_iter()
1025                .map(|a| FlowAttemptData {
1026                    attempt_index: a.attempt_index,
1027                    status: a.status,
1028                    error_class: a.error_class,
1029                    ips_generated: a.ips_generated,
1030                    wall_ms: a.wall_ms,
1031                })
1032                .collect(),
1033        })
1034        .collect()
1035}
1036
1037/// Snapshot the process-wide ring buffer of recent
1038/// `xcrun simctl` invocations. Ordered oldest → newest, capped at 128
1039/// entries. Reset on process restart.
1040pub fn recent_subprocesses() -> Vec<SubprocessRecord> {
1041    subprocess_ring::snapshot()
1042}
1043
1044// -------------------- raw spawn primitive --------------------------------
1045
1046/// Execute `xcrun simctl <args>` and capture stdout/stderr.
1047async fn simctl_capture(args: &[&str]) -> Result<(Vec<u8>, String), DeviceControlError> {
1048    simctl_capture_env(args, &[]).await
1049}
1050
1051/// `simctl_capture` with extra envp pairs set on the spawned process.
1052/// The `xcrun simctl launch` subcommand uses this to inject
1053/// `SIMCTL_CHILD_<KEY>=<VAL>` vars that the launched app sees as
1054/// `ProcessInfo().environment["KEY"]`. `env` entries here are passed
1055/// verbatim — caller composes the `SIMCTL_CHILD_` prefix via
1056/// [`compose_child_env`].
1057async fn simctl_capture_env(
1058    args: &[&str],
1059    env: &[(String, String)],
1060) -> Result<(Vec<u8>, String), DeviceControlError> {
1061    let mut cmd = Command::new("xcrun");
1062    cmd.arg("simctl");
1063    for a in args {
1064        cmd.arg(a);
1065    }
1066    for (k, v) in env {
1067        cmd.env(k, v);
1068    }
1069    let started = std::time::Instant::now();
1070    let output = cmd.output().await?;
1071    let wall_ms = started.elapsed().as_millis() as u64;
1072    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
1073    // Every simctl invocation records to the ring buffer regardless of
1074    // exit status.
1075    subprocess_ring::record(SubprocessRecord {
1076        argv: std::iter::once("xcrun".to_string())
1077            .chain(std::iter::once("simctl".to_string()))
1078            .chain(args.iter().map(|s| s.to_string()))
1079            .collect(),
1080        exit_code: output.status.code(),
1081        wall_ms,
1082        stderr_head: {
1083            let mut s = stderr.clone();
1084            if s.len() > 256 {
1085                s.truncate(256);
1086            }
1087            s
1088        },
1089        timestamp: std::time::SystemTime::now(),
1090    });
1091    if !output.status.success() {
1092        return Err(DeviceControlError::NonZeroExit {
1093            subcommand: args.first().map(|s| s.to_string()).unwrap_or_default(),
1094            argv: std::iter::once("xcrun".to_string())
1095                .chain(std::iter::once("simctl".to_string()))
1096                .chain(args.iter().map(|s| s.to_string()))
1097                .collect(),
1098            code: output.status.code().unwrap_or(-1),
1099            stderr,
1100            wall_ms,
1101        });
1102    }
1103    Ok((output.stdout, stderr))
1104}
1105
1106async fn simctl_run(args: &[&str]) -> Result<String, DeviceControlError> {
1107    let (stdout, _) = simctl_capture(args).await?;
1108    Ok(String::from_utf8_lossy(&stdout).into_owned())
1109}
1110
1111/// Like [`simctl_run`] but injects `child_env` envp on the spawned
1112/// process. Used by the env-aware launch path so the launched app can
1113/// read deploy-time secrets / endpoints via `ProcessInfo`.
1114async fn simctl_run_env(
1115    args: &[&str],
1116    env: &[(String, String)],
1117) -> Result<String, DeviceControlError> {
1118    let (stdout, _) = simctl_capture_env(args, env).await?;
1119    Ok(String::from_utf8_lossy(&stdout).into_owned())
1120}
1121
1122/// Compose user-provided `(key, value)` pairs into the `SIMCTL_CHILD_*`
1123/// envp that `xcrun simctl launch` strips and delivers to the launched
1124/// app. Idempotent: a key that already starts with `SIMCTL_CHILD_` is
1125/// passed through unchanged.
1126///
1127/// # Example
1128///
1129/// ```
1130/// use smix_simctl::compose_child_env;
1131/// let composed = compose_child_env(&[("SMIX_PERF_RECEIVER_URL", "http://h:9999")]);
1132/// assert_eq!(
1133///     composed,
1134///     vec![(
1135///         "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
1136///         "http://h:9999".to_string(),
1137///     )]
1138/// );
1139/// ```
1140pub fn compose_child_env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
1141    pairs
1142        .iter()
1143        .map(|(k, v)| {
1144            let key = if k.starts_with("SIMCTL_CHILD_") {
1145                (*k).to_string()
1146            } else {
1147                format!("SIMCTL_CHILD_{k}")
1148            };
1149            (key, (*v).to_string())
1150        })
1151        .collect()
1152}
1153
1154// -------------------- client --------------------------------------------
1155
1156/// Stateless wrapper around xcrun simctl. Methods are free functions
1157/// in spirit (no instance state beyond optionally-cached `xcrun` path);
1158/// kept as a struct for API ergonomics + future caching.
1159///
1160/// The client also holds a [`ScreenshotPacer`] that
1161/// throttles `xcrun simctl io screenshot` under high-frequency
1162/// load. Defaults are conservative (100 ms interval floor);
1163/// consumers whose flows are already loose are unaffected.
1164#[derive(Debug)]
1165pub struct SimctlClient {
1166    /// Screenshot pacer — enforces the interval floor, slow-path lift,
1167    /// and circuit breaker. Shared via `Arc<Mutex<_>>`
1168    /// so a cloned client (which callers occasionally do) still shares
1169    /// pressure accounting.
1170    screenshot_pacer: Arc<std::sync::Mutex<ScreenshotPacer>>,
1171    /// Resident per-UDID `smix-capture-host` processes for the direct
1172    /// IOSurface capture path. Shared so a cloned client reuses the same
1173    /// warm surfaces. See [`surface_capture`].
1174    capture_hosts: Arc<tokio::sync::Mutex<surface_capture::CaptureHostRegistry>>,
1175}
1176
1177impl Default for SimctlClient {
1178    fn default() -> Self {
1179        Self::new()
1180    }
1181}
1182
1183impl SimctlClient {
1184    /// Construct a new client with default screenshot pacing (100 ms
1185    /// interval floor, adaptive slow-path lift to 1500 ms, circuit
1186    /// breaker on ≥ 1500 ms walls or failures).
1187    pub fn new() -> Self {
1188        SimctlClient {
1189            screenshot_pacer: Arc::new(std::sync::Mutex::new(ScreenshotPacer::new(
1190                ScreenshotPacerConfig::default(),
1191            ))),
1192            capture_hosts: Arc::new(tokio::sync::Mutex::new(
1193                surface_capture::CaptureHostRegistry::default(),
1194            )),
1195        }
1196    }
1197
1198    /// Override the screenshot pacer with a custom config.
1199    ///
1200    /// Since smix 1.0.4.
1201    #[must_use]
1202    pub fn with_screenshot_pacer(self, config: ScreenshotPacerConfig) -> Self {
1203        {
1204            let mut guard = self
1205                .screenshot_pacer
1206                .lock()
1207                .expect("screenshot pacer mutex must not be poisoned");
1208            *guard = ScreenshotPacer::new(config);
1209        }
1210        self
1211    }
1212
1213    /// Attach a [`smix_sim_health::SimHealthMonitor`] to receive
1214    /// screenshot wall-time observations from every `screenshot`
1215    /// call. Composes with the pacer — the pacer still enforces its
1216    /// interval / circuit locally, and the monitor sees the same
1217    /// walls for global state classification.
1218    ///
1219    /// Since smix 1.0.4.
1220    #[must_use]
1221    pub fn with_sim_health(self, monitor: smix_sim_health::SimHealthMonitor) -> Self {
1222        {
1223            let mut guard = self
1224                .screenshot_pacer
1225                .lock()
1226                .expect("screenshot pacer mutex must not be poisoned");
1227            guard.set_monitor(monitor);
1228        }
1229        self
1230    }
1231
1232    // ---- inventory ------------------------------------------------------
1233
1234    /// `xcrun simctl list runtimes -j` → `Vec<SimctlRuntime>`.
1235    pub async fn list_runtimes(&self) -> Result<Vec<SimctlRuntime>, DeviceControlError> {
1236        let raw = simctl_run(&["list", "runtimes", "-j"]).await?;
1237        #[derive(Deserialize)]
1238        struct Wrap {
1239            runtimes: Vec<RawRuntime>,
1240        }
1241        #[derive(Deserialize)]
1242        struct RawRuntime {
1243            identifier: String,
1244            name: String,
1245            version: String,
1246            #[serde(rename = "isAvailable", default)]
1247            is_available: bool,
1248        }
1249        let w: Wrap = serde_json::from_str(&raw).map_err(|e| DeviceControlError::Malformed {
1250            subcommand: "list runtimes".into(),
1251            detail: e.to_string(),
1252        })?;
1253        Ok(w.runtimes
1254            .into_iter()
1255            .map(|r| SimctlRuntime {
1256                identifier: r.identifier,
1257                name: r.name,
1258                version: r.version,
1259                is_available: r.is_available,
1260            })
1261            .collect())
1262    }
1263
1264    /// `xcrun simctl list devices -j` → flattened `Vec<SimctlDevice>`.
1265    pub async fn list_devices(&self) -> Result<Vec<SimctlDevice>, DeviceControlError> {
1266        let raw = simctl_run(&["list", "devices", "-j"]).await?;
1267        #[derive(Deserialize)]
1268        struct Wrap {
1269            devices: std::collections::BTreeMap<String, Vec<RawDevice>>,
1270        }
1271        #[derive(Deserialize)]
1272        struct RawDevice {
1273            udid: String,
1274            name: String,
1275            state: String,
1276            #[serde(rename = "isAvailable", default)]
1277            is_available: bool,
1278            #[serde(rename = "deviceTypeIdentifier", default)]
1279            device_type_identifier: String,
1280        }
1281        let w: Wrap = serde_json::from_str(&raw).map_err(|e| DeviceControlError::Malformed {
1282            subcommand: "list devices".into(),
1283            detail: e.to_string(),
1284        })?;
1285        let mut out = Vec::new();
1286        for (runtime_id, devices) in w.devices {
1287            for d in devices {
1288                out.push(SimctlDevice {
1289                    udid: d.udid,
1290                    name: d.name,
1291                    state: d.state,
1292                    is_available: d.is_available,
1293                    device_type_identifier: d.device_type_identifier,
1294                    runtime_identifier: runtime_id.clone(),
1295                });
1296            }
1297        }
1298        Ok(out)
1299    }
1300
1301    // ---- lifecycle ------------------------------------------------------
1302
1303    /// `xcrun simctl boot <udid>` — fire-and-forget boot request.
1304    pub async fn boot(&self, udid: &str) -> Result<(), DeviceControlError> {
1305        simctl_run(&["boot", udid]).await?;
1306        Ok(())
1307    }
1308
1309    /// `xcrun simctl shutdown <udid>`.
1310    pub async fn shutdown(&self, udid: &str) -> Result<(), DeviceControlError> {
1311        simctl_run(&["shutdown", udid]).await?;
1312        Ok(())
1313    }
1314
1315    /// Read the sim's current BCP-47 locale (first entry of
1316    /// `NSGlobalDomain AppleLanguages`). Returns `Ok(None)` when the
1317    /// preference is unset (defaults read exits non-zero) or unparseable.
1318    /// Wire format: `simctl spawn <udid> defaults read -g AppleLanguages`
1319    /// stdout looks like `"(\n    \"en-US\"\n)\n"`; we extract the first
1320    /// quoted token.
1321    pub async fn current_locale(&self, udid: &str) -> Result<Option<String>, DeviceControlError> {
1322        let out = match simctl_run(&[
1323            "spawn",
1324            udid,
1325            "/usr/bin/defaults",
1326            "read",
1327            "-g",
1328            "AppleLanguages",
1329        ])
1330        .await
1331        {
1332            Ok(s) => s,
1333            // `defaults read` returns non-zero when the key is unset; that
1334            // is a legitimate "no opinion" state, not an error.
1335            Err(DeviceControlError::NonZeroExit { .. }) => return Ok(None),
1336            Err(e) => return Err(e),
1337        };
1338        // First quoted substring.
1339        if let Some(start) = out.find('"') {
1340            let rest = &out[start + 1..];
1341            if let Some(end) = rest.find('"') {
1342                return Ok(Some(rest[..end].to_string()));
1343            }
1344        }
1345        Ok(None)
1346    }
1347
1348    /// Delete a single key from an app's NSUserDefaults domain via
1349    /// `simctl spawn <udid> defaults delete <bundleId> <key>`.
1350    /// Running `defaults` INSIDE the sim (spawn) goes through
1351    /// the sim's cfprefsd, so the deletion is coherent with what the
1352    /// app reads on next launch (editing the container plist from the
1353    /// host would race cfprefsd's cache).
1354    ///
1355    /// Returns `Ok(true)` when the key existed and was deleted,
1356    /// `Ok(false)` when the key (or the whole domain) was absent —
1357    /// the verb contract is "ensure key absent", so an already-absent
1358    /// key is success, not an error. Any other failure surfaces as
1359    /// the underlying [`DeviceControlError`].
1360    ///
1361    /// Motivating case: expo-dev-launcher
1362    /// persists the most recent deep link and re-delivers it after
1363    /// every JS bundle load; deleting its storage key between
1364    /// terminate and relaunch neutralizes the replay at the source.
1365    ///
1366    /// **Terminate the app first** — a running process has its
1367    /// defaults cached in-memory and may rewrite the key at exit.
1368    pub async fn user_defaults_delete(
1369        &self,
1370        udid: &str,
1371        bundle_id: &str,
1372        key: &str,
1373    ) -> Result<bool, DeviceControlError> {
1374        match simctl_run(&["spawn", udid, "/usr/bin/defaults", "delete", bundle_id, key]).await {
1375            Ok(_) => Ok(true),
1376            // `defaults delete` exits non-zero with "does not exist"
1377            // on stderr for both a missing key and a missing domain.
1378            // Both are the target state.
1379            Err(DeviceControlError::NonZeroExit { stderr, .. })
1380                if stderr.contains("does not exist") =>
1381            {
1382                Ok(false)
1383            }
1384            Err(e) => Err(e),
1385        }
1386    }
1387
1388    /// Write `AppleLanguages` (array) + `AppleLocale` (scalar) to the
1389    /// sim's NSGlobalDomain so SpringBoard + apps re-localize on next
1390    /// launch. AppleLocale is BCP-47 with hyphen replaced by underscore
1391    /// (`en_US`); AppleLanguages is the BCP-47 tag verbatim.
1392    /// **The caller must shutdown + reboot the sim for the change to
1393    /// take effect** — running apps cache the locale at process start.
1394    pub async fn set_locale(&self, udid: &str, locale: &str) -> Result<(), DeviceControlError> {
1395        simctl_run(&[
1396            "spawn",
1397            udid,
1398            "/usr/bin/defaults",
1399            "write",
1400            "-g",
1401            "AppleLanguages",
1402            "-array",
1403            locale,
1404        ])
1405        .await?;
1406        let locale_underscore = locale.replace('-', "_");
1407        simctl_run(&[
1408            "spawn",
1409            udid,
1410            "/usr/bin/defaults",
1411            "write",
1412            "-g",
1413            "AppleLocale",
1414            &locale_underscore,
1415        ])
1416        .await?;
1417        Ok(())
1418    }
1419
1420    /// Boot + poll device state == "Booted" within timeout. Tries every
1421    /// 500 ms until success or `timeout_ms` elapses. Idempotent on
1422    /// already-booted devices (`xcrun simctl boot` returns non-zero when
1423    /// the device is already booted; we swallow that).
1424    pub async fn boot_and_wait(
1425        &self,
1426        udid: &str,
1427        timeout: Duration,
1428    ) -> Result<(), DeviceControlError> {
1429        // Issue boot; ignore already-booted error (the only friendly path).
1430        let _ = simctl_run(&["boot", udid]).await;
1431        let start = std::time::Instant::now();
1432        loop {
1433            let devices = self.list_devices().await?;
1434            if devices
1435                .iter()
1436                .any(|d| d.udid == udid && d.state == "Booted")
1437            {
1438                return Ok(());
1439            }
1440            if start.elapsed() > timeout {
1441                return Err(DeviceControlError::Timeout {
1442                    subcommand: format!("boot {}", udid),
1443                    ms: timeout.as_millis() as u64,
1444                });
1445            }
1446            sleep(Duration::from_millis(500)).await;
1447        }
1448    }
1449
1450    /// `xcrun simctl erase <udid>` — wipe device contents.
1451    pub async fn erase(&self, udid: &str) -> Result<(), DeviceControlError> {
1452        simctl_run(&["erase", udid]).await?;
1453        Ok(())
1454    }
1455
1456    /// `xcrun simctl install <udid> <app-path>` — install a `.app` bundle.
1457    pub async fn install(&self, udid: &str, app_path: &str) -> Result<(), DeviceControlError> {
1458        simctl_run(&["install", udid, app_path]).await?;
1459        Ok(())
1460    }
1461
1462    /// `xcrun simctl uninstall <udid> <bundle-id>`.
1463    pub async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), DeviceControlError> {
1464        simctl_run(&["uninstall", udid, bundle_id]).await?;
1465        Ok(())
1466    }
1467
1468    /// `xcrun simctl terminate <udid> <bundle-id>` — kill a running app.
1469    pub async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), DeviceControlError> {
1470        simctl_run(&["terminate", udid, bundle_id]).await?;
1471        Ok(())
1472    }
1473
1474    /// `xcrun simctl launch <udid> <bundleId>` → parse `"<bundle>: <pid>"`.
1475    pub async fn launch(
1476        &self,
1477        udid: &str,
1478        bundle_id: &str,
1479    ) -> Result<LaunchResult, DeviceControlError> {
1480        self.launch_with_args(udid, bundle_id, &[]).await
1481    }
1482
1483    /// `xcrun simctl launch <udid> <bundleId> -- <arg>...` — launch with a
1484    /// process-level argument vector. Empty `args` is equivalent to
1485    /// [`Self::launch`]. Mirrors maestro yaml `launchApp.arguments`.
1486    pub async fn launch_with_args(
1487        &self,
1488        udid: &str,
1489        bundle_id: &str,
1490        args: &[String],
1491    ) -> Result<LaunchResult, DeviceControlError> {
1492        self.launch_with_args_and_env(udid, bundle_id, args, &[])
1493            .await
1494    }
1495
1496    /// Like [`Self::launch_with_args`] but also sets `SIMCTL_CHILD_*`
1497    /// envp on the simctl process so the launched app can read
1498    /// deploy-time vars via `ProcessInfo().environment["KEY"]`.
1499    /// `child_env` keys without the `SIMCTL_CHILD_` prefix get it added
1500    /// automatically (per [`compose_child_env`] semantics). Useful for
1501    /// prelaunching an app before any `openLink` so iOS treats the
1502    /// subsequent URL handoff as in-app routing instead of cross-app,
1503    /// side-stepping the SpringBoard "Open in '`<App>`'?" confirmation
1504    /// dialog.
1505    pub async fn launch_with_args_and_env(
1506        &self,
1507        udid: &str,
1508        bundle_id: &str,
1509        args: &[String],
1510        child_env: &[(&str, &str)],
1511    ) -> Result<LaunchResult, DeviceControlError> {
1512        let mut argv: Vec<&str> = vec!["launch", udid, bundle_id];
1513        if !args.is_empty() {
1514            argv.push("--");
1515            for a in args {
1516                argv.push(a.as_str());
1517            }
1518        }
1519        let composed = compose_child_env(child_env);
1520        let out = simctl_run_env(&argv, &composed).await?;
1521        // Output format: `com.example.app: 12345\n`
1522        let pid_str =
1523            out.rsplit(':')
1524                .next()
1525                .map(str::trim)
1526                .ok_or_else(|| DeviceControlError::Malformed {
1527                    subcommand: "launch".into(),
1528                    detail: format!("unexpected stdout shape: {}", out.trim()),
1529                })?;
1530        let pid: u32 = pid_str.parse().map_err(|_| DeviceControlError::Malformed {
1531            subcommand: "launch".into(),
1532            detail: format!("non-numeric pid in stdout: {}", out.trim()),
1533        })?;
1534        Ok(LaunchResult { pid })
1535    }
1536
1537    /// Reset every privacy permission granted to `bundle_id` on the
1538    /// sim: `xcrun simctl privacy <udid> reset all <bundle-id>`.
1539    /// Companion to [`Self::clear_app_sandbox`] on the in-place
1540    /// `launchApp: clearState: true` path, which replaces
1541    /// `simctl uninstall + install` — that pairing triggers iOS 26.5
1542    /// XCUITest binding loss plus a ReportCrash "<app> quit
1543    /// unexpectedly" dialog.
1544    pub async fn privacy_reset_all(
1545        &self,
1546        udid: &str,
1547        bundle_id: &str,
1548    ) -> Result<(), DeviceControlError> {
1549        simctl_run(&["privacy", udid, "reset", "all", bundle_id]).await?;
1550        Ok(())
1551    }
1552
1553    /// Wipe the app's sandbox on the sim: locate the
1554    /// Data container via `simctl get_app_container <udid> <bundle>
1555    /// data`, then `simctl spawn <udid> rm -rf <container>/Documents
1556    /// <container>/Library <container>/tmp`. The app remains installed
1557    /// (no `simctl uninstall`), so the XCUITest binding is preserved
1558    /// and macOS `ReportCrash` does not misinterpret a missing
1559    /// install-receipt as a crash.
1560    pub async fn clear_app_sandbox(
1561        &self,
1562        udid: &str,
1563        bundle_id: &str,
1564    ) -> Result<(), DeviceControlError> {
1565        let raw = simctl_run(&["get_app_container", udid, bundle_id, "data"])
1566            .await
1567            .map_err(|e| match e {
1568                // get_app_container failing IS "not installed" — the
1569                // subprocess text (`NSPOSIXErrorDomain code=2`) says
1570                // nothing a flow author can act on.
1571                DeviceControlError::NonZeroExit { .. } => DeviceControlError::AppNotInstalled {
1572                    bundle_id: bundle_id.to_string(),
1573                    udid: udid.to_string(),
1574                },
1575                other => other,
1576            })?;
1577        let container = raw.trim();
1578        if container.is_empty() {
1579            return Err(DeviceControlError::Malformed {
1580                subcommand: "clear_app_sandbox".into(),
1581                detail: format!("empty Data container path for bundle {bundle_id}"),
1582            });
1583        }
1584        let documents = format!("{container}/Documents");
1585        let library = format!("{container}/Library");
1586        let tmp = format!("{container}/tmp");
1587        // `xcrun simctl spawn <UDID> <cmd>` uses `posix_spawn` inside
1588        // the sim OS; `<cmd>` must be an absolute path (there is no
1589        // PATH resolution). A bare `"rm"` fails with
1590        // `NSPOSIXErrorDomain code 2: No such file or directory` on
1591        // iOS 17+ sims. `/bin/rm` is present on every stock sim image.
1592        //
1593        // Best-effort: any missing subdir is fine (fresh app that never
1594        // wrote to that path). `rm -rf` treats absent targets as no-ops.
1595        simctl_run(&["spawn", udid, "/bin/rm", "-rf", &documents, &library, &tmp]).await?;
1596        Ok(())
1597    }
1598
1599    /// `xcrun simctl openurl <udid> <url>` — open a URL on the device.
1600    ///
1601    /// **URL bytes are passed to `xcrun simctl` verbatim** — no
1602    /// parsing, no percent-encoding rewrite, no query-string
1603    /// stripping. Verified by [`openurl_argv`] (test-visible helper)
1604    /// and its unit test asserting query-params like
1605    /// `?url=http%3A%2F%2Flocalhost%3A8081` reach the argv byte-for-byte.
1606    /// Consequently, if the target app's URL router (e.g.
1607    /// expo-dev-client 57.0.5) shows a picker instead of
1608    /// auto-connecting, the URL reached it intact and the problem
1609    /// lives on the URL-router side.
1610    pub async fn open_url(&self, udid: &str, url: &str) -> Result<(), DeviceControlError> {
1611        let argv = openurl_argv(udid, url);
1612        let refs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1613        simctl_run(&refs).await?;
1614        Ok(())
1615    }
1616}
1617
1618/// Argv construction for `xcrun simctl openurl`. Extracted
1619/// as a test-visible helper so the URL-preservation contract is
1620/// unit-testable without invoking `xcrun`.
1621#[doc(hidden)]
1622pub fn openurl_argv(udid: &str, url: &str) -> [String; 3] {
1623    ["openurl".to_string(), udid.to_string(), url.to_string()]
1624}
1625
1626impl SimctlClient {
1627    /// `xcrun simctl push <udid> <bundle-id> <apns-json-path>`.
1628    /// Deliver an APNS payload to a sim-installed app. The payload file is
1629    /// a JSON document whose top-level dictionary mirrors what an APNS
1630    /// provider would send; `aps.alert.body` / `aps.alert.title` surface
1631    /// as banner content and reach the app's
1632    /// `UNUserNotificationCenterDelegate`.
1633    pub async fn send_push(
1634        &self,
1635        udid: &str,
1636        bundle_id: &str,
1637        apns_json_path: &str,
1638    ) -> Result<(), DeviceControlError> {
1639        simctl_run(&["push", udid, bundle_id, apns_json_path]).await?;
1640        Ok(())
1641    }
1642
1643    /// `xcrun simctl ui <udid> appearance <light|dark>` — set UI appearance.
1644    pub async fn set_appearance(
1645        &self,
1646        udid: &str,
1647        mode: Appearance,
1648    ) -> Result<(), DeviceControlError> {
1649        simctl_run(&["ui", udid, "appearance", mode.as_str()]).await?;
1650        Ok(())
1651    }
1652
1653    /// `xcrun simctl privacy <udid> grant <perm> <bundle-id>`.
1654    pub async fn grant_permission(
1655        &self,
1656        udid: &str,
1657        permission: SimctlPermission,
1658        bundle_id: &str,
1659    ) -> Result<(), DeviceControlError> {
1660        simctl_run(&["privacy", udid, "grant", permission.as_str(), bundle_id]).await?;
1661        Ok(())
1662    }
1663
1664    /// `xcrun simctl privacy <udid> revoke <perm> <bundle-id>` — explicitly
1665    /// deny the permission. Mirrors maestro yaml `permissions: { x: deny }`
1666    /// (the reverse of `grant`). Distinct from `reset`, which returns the
1667    /// permission to "not determined".
1668    pub async fn revoke_permission(
1669        &self,
1670        udid: &str,
1671        permission: SimctlPermission,
1672        bundle_id: &str,
1673    ) -> Result<(), DeviceControlError> {
1674        simctl_run(&["privacy", udid, "revoke", permission.as_str(), bundle_id]).await?;
1675        Ok(())
1676    }
1677
1678    /// `xcrun simctl location <udid> set <lat>,<lng>` — set sim location
1679    /// to a fixed point. Mirrors maestro `setLocation`.
1680    pub async fn location_set(
1681        &self,
1682        udid: &str,
1683        latitude: f64,
1684        longitude: f64,
1685    ) -> Result<(), DeviceControlError> {
1686        let coord = format!("{latitude},{longitude}");
1687        simctl_run(&["location", udid, "set", &coord]).await?;
1688        Ok(())
1689    }
1690
1691    /// `xcrun simctl location <udid> start [--speed=<m/s>] <waypoints>`
1692    /// — interpolate sim location along waypoints. Fire-and-return: simctl
1693    /// injects scenario and returns; sim continues interpolation in background.
1694    /// Mirrors maestro `travel`.
1695    pub async fn location_start(
1696        &self,
1697        udid: &str,
1698        points: &[(f64, f64)],
1699        speed_mps: Option<f64>,
1700    ) -> Result<(), DeviceControlError> {
1701        if points.len() < 2 {
1702            return Err(DeviceControlError::Malformed {
1703                subcommand: "location-start".into(),
1704                detail: format!("requires ≥2 waypoints, got {}", points.len()),
1705            });
1706        }
1707        let mut args: Vec<String> = vec!["location".into(), udid.into(), "start".into()];
1708        if let Some(s) = speed_mps {
1709            args.push(format!("--speed={s}"));
1710        }
1711        for (lat, lng) in points {
1712            args.push(format!("{lat},{lng}"));
1713        }
1714        let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
1715        simctl_run(&args_ref).await?;
1716        Ok(())
1717    }
1718
1719    /// `xcrun simctl location <udid> clear` — reset active location
1720    /// scenario.
1721    pub async fn location_clear(&self, udid: &str) -> Result<(), DeviceControlError> {
1722        simctl_run(&["location", udid, "clear"]).await?;
1723        Ok(())
1724    }
1725
1726    /// `xcrun simctl addmedia <udid> <path>...` — add photos / videos /
1727    /// contacts to sim library. Mirrors maestro `addMedia` (scalar or
1728    /// array form already flattened on adapter side).
1729    pub async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), DeviceControlError> {
1730        if paths.is_empty() {
1731            return Err(DeviceControlError::Malformed {
1732                subcommand: "addmedia".into(),
1733                detail: "no paths supplied".into(),
1734            });
1735        }
1736        let mut args: Vec<&str> = vec!["addmedia", udid];
1737        for p in paths {
1738            args.push(p.as_str());
1739        }
1740        simctl_run(&args).await?;
1741        Ok(())
1742    }
1743
1744    /// Start recording sim display to `path`. Spawns
1745    /// `xcrun simctl io <udid> recordVideo <path>` as a long-running child;
1746    /// returns handle immediately. Caller must pair with
1747    /// [`Self::record_video_stop`] for clean SIGINT-and-wait shutdown —
1748    /// dropping the handle would SIGKILL via tokio + lose mp4 trailer.
1749    pub async fn record_video_start(
1750        &self,
1751        udid: &str,
1752        path: &str,
1753    ) -> Result<RecordingHandle, DeviceControlError> {
1754        // Log to a file beside the video, not to pipes.
1755        //
1756        // Piped output with nobody reading it is a trap that only springs
1757        // once the recording has to outlive the process that started it:
1758        // when that process exits, the read ends close, and the next line
1759        // `simctl` writes kills it with SIGPIPE. The recording then stops
1760        // silently, seconds after being reported as started, leaving a
1761        // zero-byte file — which is exactly what `smix record start`
1762        // produced before this changed.
1763        //
1764        // A file also keeps `simctl`'s own diagnostics ("No display
1765        // specified…", "Recording started") somewhere a person can read
1766        // them, which a discarded pipe did not.
1767        let log_path = format!("{path}.log");
1768        let log = std::fs::File::create(&log_path)?;
1769        let log_err = log.try_clone()?;
1770        let child = tokio::process::Command::new("xcrun")
1771            .args(["simctl", "io", udid, "recordVideo", path])
1772            .stdin(std::process::Stdio::null())
1773            .stdout(std::process::Stdio::from(log))
1774            .stderr(std::process::Stdio::from(log_err))
1775            .spawn()?;
1776        // brief settle for simctl to initialize encoder + open output file.
1777        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1778        Ok(RecordingHandle {
1779            child,
1780            path: path.to_string(),
1781            started_at: std::time::Instant::now(),
1782        })
1783    }
1784
1785    /// Stop a recording via SIGINT + wait (≤10s). SIGINT lets simctl
1786    /// trap and flush the mp4 trailer; SIGKILL would corrupt output.
1787    /// Timeout escalates to SIGKILL with explicit error mentioning truncation.
1788    pub async fn record_video_stop(
1789        &self,
1790        mut handle: RecordingHandle,
1791    ) -> Result<(), DeviceControlError> {
1792        let pid = handle
1793            .child
1794            .id()
1795            .ok_or_else(|| DeviceControlError::Malformed {
1796                subcommand: "recordVideo-stop".into(),
1797                detail: "child already reaped".into(),
1798            })?;
1799        // SAFETY: libc::kill is a thin POSIX syscall wrapper; pid is owned by
1800        // this Child instance (no race) and SIGINT is signal-safe.
1801        let rc = unsafe { libc::kill(pid as i32, libc::SIGINT) };
1802        if rc != 0 {
1803            return Err(DeviceControlError::Malformed {
1804                subcommand: "recordVideo-stop".into(),
1805                detail: format!(
1806                    "kill SIGINT failed: errno={}",
1807                    std::io::Error::last_os_error()
1808                ),
1809            });
1810        }
1811        let wait_result =
1812            tokio::time::timeout(std::time::Duration::from_secs(10), handle.child.wait()).await;
1813        match wait_result {
1814            Ok(Ok(_status)) => Ok(()),
1815            Ok(Err(e)) => Err(DeviceControlError::Malformed {
1816                subcommand: "recordVideo-stop".into(),
1817                detail: format!("wait failed: {e}"),
1818            }),
1819            Err(_timeout) => {
1820                let _ = handle.child.kill().await;
1821                Err(DeviceControlError::Malformed {
1822                    subcommand: "recordVideo-stop".into(),
1823                    detail: "SIGINT timeout (10s) — escalated SIGKILL; output mp4 likely truncated. Inspect simctl recordVideo stderr.".into(),
1824                })
1825            }
1826        }
1827    }
1828
1829    /// `xcrun simctl privacy <udid> reset <perm> <bundle-id>` — return the
1830    /// permission to "not determined" so the next request re-prompts.
1831    /// May terminate a running instance of the target app (Apple
1832    /// behavior) — call before launch, not mid-flow.
1833    pub async fn reset_permission(
1834        &self,
1835        udid: &str,
1836        permission: SimctlPermission,
1837        bundle_id: &str,
1838    ) -> Result<(), DeviceControlError> {
1839        simctl_run(&["privacy", udid, "reset", permission.as_str(), bundle_id]).await?;
1840        Ok(())
1841    }
1842
1843    /// `xcrun simctl keychain <udid> reset` — clear all keychain entries.
1844    pub async fn keychain_reset(&self, udid: &str) -> Result<(), DeviceControlError> {
1845        simctl_run(&["keychain", udid, "reset"]).await?;
1846        Ok(())
1847    }
1848
1849    /// `xcrun simctl pbpaste <udid>` — read clipboard contents.
1850    pub async fn pasteboard_get(&self, udid: &str) -> Result<String, DeviceControlError> {
1851        simctl_run(&["pbpaste", udid]).await
1852    }
1853
1854    /// `xcrun simctl pbcopy <udid>` — write clipboard contents (via piped stdin).
1855    pub async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), DeviceControlError> {
1856        // pbcopy reads stdin — we pipe via shell echo for simplicity.
1857        // Long-term: spawn with stdin pipe.
1858        use tokio::io::AsyncWriteExt;
1859        let mut cmd = Command::new("xcrun");
1860        cmd.arg("simctl").arg("pbcopy").arg(udid);
1861        cmd.stdin(std::process::Stdio::piped());
1862        let mut child = cmd.spawn()?;
1863        if let Some(mut stdin) = child.stdin.take() {
1864            stdin.write_all(text.as_bytes()).await?;
1865            drop(stdin); // close stdin so pbcopy returns
1866        }
1867        let status = child.wait().await?;
1868        if !status.success() {
1869            return Err(DeviceControlError::NonZeroExit {
1870                subcommand: "pbcopy".into(),
1871                argv: vec!["pbcopy".to_string()],
1872                code: status.code().unwrap_or(-1),
1873                stderr: String::new(),
1874                wall_ms: 0,
1875            });
1876        }
1877        Ok(())
1878    }
1879
1880    /// Read back the Reduce Motion accessibility setting.
1881    ///
1882    /// `Ok(None)` when the key was never written, which `defaults read`
1883    /// reports by exiting non-zero. Absent is not off and not on — it
1884    /// is the device having no opinion, and a caller that wanted the
1885    /// setting established has to treat it as a failure to establish.
1886    pub async fn reduce_motion(&self, udid: &str) -> Result<Option<String>, DeviceControlError> {
1887        match simctl_run(&[
1888            "spawn",
1889            udid,
1890            "/usr/bin/defaults",
1891            "read",
1892            "com.apple.UIKit",
1893            "UIAccessibilityReduceMotionEnabled",
1894        ])
1895        .await
1896        {
1897            Ok(s) => Ok(Some(s.trim().to_string())),
1898            Err(DeviceControlError::NonZeroExit { .. }) => Ok(None),
1899            Err(e) => Err(e),
1900        }
1901    }
1902
1903    /// Toggle "Reduce Motion" accessibility setting via `defaults write`.
1904    pub async fn set_reduce_motion(
1905        &self,
1906        udid: &str,
1907        enabled: bool,
1908    ) -> Result<(), DeviceControlError> {
1909        // `true`/`false`, not `1`/`0`. `defaults` accepts
1910        // `-bool (true | false | yes | no)` and answers anything else
1911        // by printing its usage and exiting 255 — which is what this
1912        // did from the day it was written. It had no callers until the
1913        // animation switch, so nothing ever ran it.
1914        let val = if enabled { "true" } else { "false" };
1915        // Absolute path, not `defaults`. `simctl spawn` does not run a
1916        // login shell inside the simulator, so a bare name exits 255
1917        // with no stderr — which is exactly what it did the first time
1918        // an animation-quietening run met a device. The same lesson was
1919        // learned in v1.0.7 for `rm`; the reader below and
1920        // `current_locale` already spell it out.
1921        simctl_run(&[
1922            "spawn",
1923            udid,
1924            "/usr/bin/defaults",
1925            "write",
1926            "com.apple.UIKit",
1927            "UIAccessibilityReduceMotionEnabled",
1928            "-bool",
1929            val,
1930        ])
1931        .await?;
1932        Ok(())
1933    }
1934
1935    /// `xcrun simctl io <udid> screenshot <tmpfile>` → raw PNG bytes,
1936    /// with a byte-level sRGB metadata splice if the produced PNG lacks
1937    /// an `sRGB` chunk.
1938    ///
1939    /// Goes through a temp file: current Xcode's `screenshot -` does not
1940    /// treat `-` as stdout — it writes a literal file named `-` in cwd
1941    /// and emits nothing on stdout (observed on Xcode/iOS 26.5).
1942    ///
1943    /// **Pixel-preservation invariant**: the returned bytes are
1944    /// byte-identical to whatever `simctl io screenshot` wrote to disk
1945    /// EXCEPT for one narrow case — if the PNG does not carry an
1946    /// `sRGB` ancillary chunk (observed on iOS 26.5 sub-builds
1947    /// mid-2026), a 13-byte `sRGB` chunk is spliced in immediately
1948    /// before the first `IDAT`. Pixel data (IDAT bytes) is never
1949    /// decoded or modified. See [`ensure_srgb_chunk`] for the exact
1950    /// splice operation.
1951    pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, DeviceControlError> {
1952        match self.capture_frame(udid, true).await? {
1953            surface_capture::CapturedFrame::Png(bytes) => Ok(bytes),
1954            // want_png=true only ever produces a PNG (host ImageIO encode or
1955            // the simctl fallback). A raw frame here is a protocol violation.
1956            surface_capture::CapturedFrame::Bgra { .. } => Err(DeviceControlError::Malformed {
1957                subcommand: "screenshot".into(),
1958                detail: "capture returned raw BGRA for a PNG request".into(),
1959            }),
1960        }
1961    }
1962
1963    /// Capture a frame preferring the fast raw-BGRA path.
1964    ///
1965    /// When the resident IOSurface host is available this returns
1966    /// [`CapturedFrame::Bgra`](surface_capture::CapturedFrame::Bgra) —
1967    /// ~0.3 ms per frame, no PNG encode. When the surface can't be resolved
1968    /// (sim not booted, framework layout change) it falls back to
1969    /// `xcrun simctl io screenshot` and returns
1970    /// [`CapturedFrame::Png`](surface_capture::CapturedFrame::Png). The
1971    /// pixels are correct either way; consumers that only need grayscale
1972    /// samples (diff-loop / dhash) skip the PNG encode+decode round-trip.
1973    ///
1974    /// Since smix 2.0.0.
1975    pub async fn capture_bgra(
1976        &self,
1977        udid: &str,
1978    ) -> Result<surface_capture::CapturedFrame, DeviceControlError> {
1979        self.capture_frame(udid, false).await
1980    }
1981
1982    /// Core capture path: try the resident IOSurface host, fall back to
1983    /// `simctl`. `want_png` selects an in-host ImageIO PNG encode over a raw
1984    /// BGRA frame; the fallback is always a PNG.
1985    async fn capture_frame(
1986        &self,
1987        udid: &str,
1988        want_png: bool,
1989    ) -> Result<surface_capture::CapturedFrame, DeviceControlError> {
1990        // Direct path first. No pacer gate: the direct IOSurface read does not
1991        // touch `com.apple.display.captureservice`, so the crash-guard floor
1992        // the pacer enforces for `simctl io screenshot` does not apply here.
1993        // Surface unavailable, or the host transport failed — both fall
1994        // through to the correct-but-slow simctl path below.
1995        if let Ok(Some(frame)) = self.try_capture_direct(udid, want_png).await {
1996            return Ok(frame);
1997        }
1998        let png = self.screenshot_via_simctl(udid).await?;
1999        Ok(surface_capture::CapturedFrame::Png(png))
2000    }
2001
2002    /// Get-or-spawn the resident host for `udid` and grab one frame. Returns
2003    /// `Ok(None)` when the host reports the surface is gone, `Err` on a
2004    /// transport failure. In both non-`Some` cases the host is dropped (and
2005    /// killed) so the next call re-resolves from scratch.
2006    async fn try_capture_direct(
2007        &self,
2008        udid: &str,
2009        want_png: bool,
2010    ) -> Result<Option<surface_capture::CapturedFrame>, surface_capture::HostError> {
2011        // Take the host out from under the lock so a 12.6 MB grab (or a 5s
2012        // spawn) never serializes captures for other sims.
2013        let existing = { self.capture_hosts.lock().await.take(udid) };
2014        let mut host = match existing {
2015            Some(h) => h,
2016            None => surface_capture::SurfaceCaptureHost::spawn(udid).await?,
2017        };
2018        match host.grab(want_png).await {
2019            Ok(Some(frame)) => {
2020                self.capture_hosts.lock().await.put(udid, host);
2021                Ok(Some(frame))
2022            }
2023            // Host is exiting (surface gone) — drop it, fall back.
2024            Ok(None) => Ok(None),
2025            // Transport died — drop it, fall back.
2026            Err(e) => Err(e),
2027        }
2028    }
2029
2030    /// Drop the resident capture host for `udid`, if any. Call this whenever a
2031    /// lifecycle operation may have invalidated the framebuffer surface
2032    /// (shutdown / erase / reboot) so the next capture re-resolves cleanly.
2033    ///
2034    /// Since smix 2.0.0.
2035    pub async fn evict_capture_host(&self, udid: &str) {
2036        let host = { self.capture_hosts.lock().await.evict(udid) };
2037        if let Some(h) = host {
2038            h.shutdown().await;
2039        }
2040    }
2041
2042    /// `xcrun simctl io <udid> screenshot <tmpfile>` → raw PNG bytes, paced +
2043    /// circuit-guarded, with the sRGB metadata splice. The correct-but-slow
2044    /// fallback for [`capture_frame`](Self::capture_frame).
2045    async fn screenshot_via_simctl(&self, udid: &str) -> Result<Vec<u8>, DeviceControlError> {
2046        // Pace + circuit-check before invoking simctl.
2047        let wait = {
2048            let mut pacer = self
2049                .screenshot_pacer
2050                .lock()
2051                .expect("screenshot pacer mutex must not be poisoned");
2052            pacer
2053                .compute_wait()
2054                .map_err(|retry_after| DeviceControlError::CaptureBackpressure { retry_after })?
2055        };
2056        if !wait.is_zero() {
2057            sleep(wait).await;
2058        }
2059
2060        let call_start = std::time::Instant::now();
2061        let tmp =
2062            std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
2063        let tmp_str = tmp.display().to_string();
2064        let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
2065        let bytes = result.and_then(|_| {
2066            std::fs::read(&tmp).map_err(|e| DeviceControlError::Malformed {
2067                subcommand: "screenshot".into(),
2068                detail: format!("read {tmp_str}: {e}"),
2069            })
2070        });
2071        let _ = std::fs::remove_file(&tmp);
2072
2073        let wall = call_start.elapsed();
2074        let failed = bytes.is_err();
2075        {
2076            let mut pacer = self
2077                .screenshot_pacer
2078                .lock()
2079                .expect("screenshot pacer mutex must not be poisoned");
2080            pacer.record(wall, failed);
2081        }
2082
2083        let bytes = bytes?;
2084        if bytes.len() < 8 {
2085            return Err(DeviceControlError::Malformed {
2086                subcommand: "screenshot".into(),
2087                detail: format!("screenshot file too short: {} bytes", bytes.len()),
2088            });
2089        }
2090        Ok(ensure_srgb_chunk(bytes))
2091    }
2092
2093    /// `xcrun simctl create <name> <device-type-id> <runtime-id>` → udid.
2094    pub async fn create_device(
2095        &self,
2096        name: &str,
2097        device_type: &str,
2098        runtime_id: &str,
2099    ) -> Result<String, DeviceControlError> {
2100        let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
2101        Ok(out.trim().to_string())
2102    }
2103
2104    /// `xcrun simctl delete <udid>` — delete a simulator device.
2105    pub async fn delete_device(&self, udid: &str) -> Result<(), DeviceControlError> {
2106        simctl_run(&["delete", udid]).await?;
2107        Ok(())
2108    }
2109}
2110
2111// -------------------- PNG sRGB chunk normalization --------------------
2112//
2113// iOS 26.5 sub-builds (mid-2026) started omitting the `sRGB` ancillary
2114// chunk from `simctl io screenshot` output. macOS Preview.app and other
2115// viewers that fall back to Display P3 when no ICC profile is embedded
2116// then over-saturate the image (red gets pushed, text anti-alias picks
2117// up yellow fringing).
2118//
2119// This does NOT affect pixel-comparison (dhash decodes IDAT to RGBA and
2120// ignores ancillary chunks), but does affect any downstream tool that
2121// renders the PNG for human review. The normalizer runs on the raw byte
2122// stream — walks chunks, and if no `sRGB` chunk is seen before the first
2123// `IDAT`, splices in a synthesized 13-byte `sRGB` chunk (length=1,
2124// type="sRGB", data=[0 = perceptual intent], CRC over type+data).
2125//
2126// Pixel-preservation invariant: IDAT bytes are never decoded. Every
2127// existing chunk is copied verbatim. Only 13 bytes of new metadata are
2128// inserted.
2129
2130const PNG_MAGIC: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
2131
2132/// Ensure the PNG carries an `sRGB` ancillary chunk. Called on the raw
2133/// bytes returned by `xcrun simctl io <udid> screenshot`. If the PNG
2134/// already has an `sRGB` chunk, returns the input unchanged; otherwise
2135/// splices in a 13-byte `sRGB` chunk (rendering intent = 0, perceptual)
2136/// immediately before the first `IDAT`. Returns the input unchanged on
2137/// any structural anomaly (missing magic, malformed chunk) so a
2138/// corrupted PNG is passed through untouched for the caller to diagnose.
2139pub fn ensure_srgb_chunk(bytes: Vec<u8>) -> Vec<u8> {
2140    if bytes.len() < 8 || &bytes[..8] != PNG_MAGIC {
2141        return bytes;
2142    }
2143    let Some((idat_offset, has_srgb)) = scan_png_chunks(&bytes) else {
2144        return bytes;
2145    };
2146    if has_srgb {
2147        return bytes;
2148    }
2149    // Splice the synthesized sRGB chunk right before the first IDAT.
2150    let mut out = Vec::with_capacity(bytes.len() + 13);
2151    out.extend_from_slice(&bytes[..idat_offset]);
2152    out.extend_from_slice(&synthesized_srgb_chunk());
2153    out.extend_from_slice(&bytes[idat_offset..]);
2154    out
2155}
2156
2157/// Walk PNG chunks starting after the 8-byte magic. Returns
2158/// `(offset_of_first_IDAT, has_srgb_chunk_before_it)` when the walk
2159/// reaches an IDAT chunk. Returns `None` if the walk hits EOF or a
2160/// malformed chunk without seeing an IDAT.
2161fn scan_png_chunks(bytes: &[u8]) -> Option<(usize, bool)> {
2162    let mut i: usize = 8;
2163    let mut has_srgb = false;
2164    while i + 8 <= bytes.len() {
2165        let length =
2166            u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
2167        let ctype = &bytes[i + 4..i + 8];
2168        if ctype == b"IDAT" {
2169            return Some((i, has_srgb));
2170        }
2171        if ctype == b"sRGB" {
2172            has_srgb = true;
2173        }
2174        // 4 (length) + 4 (type) + length (data) + 4 (crc)
2175        let end = i.checked_add(12)?.checked_add(length)?;
2176        if end > bytes.len() {
2177            return None;
2178        }
2179        i = end;
2180    }
2181    None
2182}
2183
2184/// Build the 13-byte `sRGB` chunk with rendering intent = 0 (perceptual).
2185/// Format: `[len:4][type:4][data:1][crc:4]` = 13 bytes total.
2186fn synthesized_srgb_chunk() -> [u8; 13] {
2187    // The CRC is computed over `type || data`.
2188    let mut crc_input = [0u8; 5];
2189    crc_input[0..4].copy_from_slice(b"sRGB");
2190    crc_input[4] = 0; // perceptual
2191    let crc = crc32_ieee(&crc_input);
2192    let mut chunk = [0u8; 13];
2193    chunk[0..4].copy_from_slice(&1u32.to_be_bytes()); // length = 1 (data byte)
2194    chunk[4..8].copy_from_slice(b"sRGB");
2195    chunk[8] = 0;
2196    chunk[9..13].copy_from_slice(&crc.to_be_bytes());
2197    chunk
2198}
2199
2200/// Table-less CRC-32 IEEE 802.3 (polynomial 0xEDB88320) as used by
2201/// PNG. Small enough for this crate's single call site — avoids
2202/// pulling in a `crc32fast` dependency.
2203fn crc32_ieee(bytes: &[u8]) -> u32 {
2204    let mut crc: u32 = 0xFFFF_FFFF;
2205    for &b in bytes {
2206        crc ^= u32::from(b);
2207        for _ in 0..8 {
2208            let mask = 0u32.wrapping_sub(crc & 1);
2209            crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
2210        }
2211    }
2212    !crc
2213}
2214
2215#[cfg(test)]
2216mod tests {
2217    use super::*;
2218
2219    #[test]
2220    fn compose_child_env_adds_prefix() {
2221        let composed = compose_child_env(&[
2222            ("SMIX_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
2223            ("LAUNCH_FORCE_PUSH", "true"),
2224        ]);
2225        assert_eq!(
2226            composed,
2227            vec![
2228                (
2229                    "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
2230                    "http://127.0.0.1:9999".to_string(),
2231                ),
2232                (
2233                    "SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
2234                    "true".to_string(),
2235                ),
2236            ]
2237        );
2238    }
2239
2240    #[test]
2241    fn compose_child_env_already_prefixed_passes_through() {
2242        // Defensive: caller may pre-prefix; we must not double-prefix.
2243        let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
2244        assert_eq!(
2245            composed,
2246            vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
2247        );
2248    }
2249
2250    #[test]
2251    fn compose_child_env_empty_input_is_empty_output() {
2252        assert!(compose_child_env(&[]).is_empty());
2253    }
2254
2255    // -- openurl URL preservation ---------------------------------------
2256
2257    #[test]
2258    fn openurl_argv_preserves_url_verbatim() {
2259        let udid = "12345678-1234-5678-1234-567812345678";
2260        let url = "exp+focus-ai-app://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081";
2261        let argv = super::openurl_argv(udid, url);
2262        assert_eq!(argv[0], "openurl");
2263        assert_eq!(argv[1], udid);
2264        // Byte-identical URL — no percent-decoding, no query-strip.
2265        assert_eq!(argv[2], url);
2266        assert!(argv[2].contains("?url="));
2267        assert!(argv[2].contains("%3A"));
2268        assert!(argv[2].contains("%2F"));
2269    }
2270
2271    #[test]
2272    fn openurl_argv_preserves_ampersand_and_hash() {
2273        let udid = "12345678-1234-5678-1234-567812345678";
2274        let url = "myapp://dev-mutate?action=env&value=staging#anchor";
2275        let argv = super::openurl_argv(udid, url);
2276        assert_eq!(argv[2], url);
2277        assert!(argv[2].contains('&'));
2278        assert!(argv[2].contains('#'));
2279    }
2280
2281    #[test]
2282    fn openurl_argv_preserves_unicode() {
2283        let udid = "12345678-1234-5678-1234-567812345678";
2284        let url = "myapp://route?name=%E7%94%B0%E4%B8%AD";
2285        let argv = super::openurl_argv(udid, url);
2286        assert_eq!(argv[2], url);
2287    }
2288
2289    // -- sRGB chunk normalization ---------------------------------------
2290
2291    /// Build a minimal PNG: 1×1 8-bit RGBA, one IDAT (zlib-empty-safe),
2292    /// with or without an sRGB chunk. Returns synthetic bytes suitable
2293    /// for exercising the chunk-walking logic; no rendering intent.
2294    fn synth_png(with_srgb: bool) -> Vec<u8> {
2295        let mut out = Vec::new();
2296        out.extend_from_slice(super::PNG_MAGIC);
2297        // IHDR: 1x1, bit_depth=8, color_type=6 (RGBA), rest=0
2298        let ihdr_data: [u8; 13] = [
2299            0, 0, 0, 1, // width = 1
2300            0, 0, 0, 1, // height = 1
2301            8, // bit depth
2302            6, // color type = RGBA
2303            0, 0, 0,
2304        ];
2305        emit_chunk(&mut out, b"IHDR", &ihdr_data);
2306        if with_srgb {
2307            emit_chunk(&mut out, b"sRGB", &[0]);
2308        }
2309        // Placeholder IDAT — content doesn't matter for chunk-walking tests
2310        emit_chunk(&mut out, b"IDAT", &[0x78, 0x01, 0x00, 0x00]);
2311        emit_chunk(&mut out, b"IEND", &[]);
2312        out
2313    }
2314
2315    fn emit_chunk(out: &mut Vec<u8>, ctype: &[u8; 4], data: &[u8]) {
2316        out.extend_from_slice(&(data.len() as u32).to_be_bytes());
2317        out.extend_from_slice(ctype);
2318        out.extend_from_slice(data);
2319        let mut crc_in = Vec::with_capacity(4 + data.len());
2320        crc_in.extend_from_slice(ctype);
2321        crc_in.extend_from_slice(data);
2322        out.extend_from_slice(&super::crc32_ieee(&crc_in).to_be_bytes());
2323    }
2324
2325    #[test]
2326    fn ensure_srgb_passthrough_when_chunk_present() {
2327        let png = synth_png(true);
2328        let original_len = png.len();
2329        let out = super::ensure_srgb_chunk(png.clone());
2330        assert_eq!(out.len(), original_len);
2331        assert_eq!(out, png);
2332    }
2333
2334    #[test]
2335    fn ensure_srgb_inserts_chunk_when_absent() {
2336        let png = synth_png(false);
2337        let original_len = png.len();
2338        let out = super::ensure_srgb_chunk(png);
2339        assert_eq!(out.len(), original_len + 13);
2340        // First 8 bytes = PNG magic
2341        assert_eq!(&out[..8], super::PNG_MAGIC);
2342        // Search for the injected sRGB chunk
2343        let mut found = false;
2344        for w in out.windows(4) {
2345            if w == b"sRGB" {
2346                found = true;
2347                break;
2348            }
2349        }
2350        assert!(found, "sRGB chunk should have been spliced in");
2351    }
2352
2353    #[test]
2354    fn ensure_srgb_preserves_idat_bytes_verbatim() {
2355        // Any pixel corruption at the IDAT level would break the
2356        // pixel-preservation invariant. Extract IDAT payload from
2357        // input and output, assert byte-identical.
2358        let png = synth_png(false);
2359        let out = super::ensure_srgb_chunk(png.clone());
2360        assert_eq!(extract_idat_data(&png), extract_idat_data(&out));
2361    }
2362
2363    fn extract_idat_data(bytes: &[u8]) -> Vec<u8> {
2364        let mut i = 8;
2365        while i + 8 <= bytes.len() {
2366            let length =
2367                u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
2368            let ctype = &bytes[i + 4..i + 8];
2369            if ctype == b"IDAT" {
2370                return bytes[i + 8..i + 8 + length].to_vec();
2371            }
2372            i += 12 + length;
2373        }
2374        vec![]
2375    }
2376
2377    #[test]
2378    fn ensure_srgb_passthrough_on_bad_magic() {
2379        // Corrupted / non-PNG input must not be modified.
2380        let bytes = vec![0u8; 32];
2381        let out = super::ensure_srgb_chunk(bytes.clone());
2382        assert_eq!(out, bytes);
2383    }
2384
2385    #[test]
2386    fn crc32_matches_known_iend() {
2387        // The empty-data IEND CRC is a well-known constant.
2388        // CRC over "IEND" alone: 0xAE_42_60_82.
2389        assert_eq!(super::crc32_ieee(b"IEND"), 0xAE42_6082);
2390    }
2391}