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