Skip to main content

smix_simctl/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5//! smix-simctl — xcrun simctl child_process wrapper (outer crate).
6//!
7//! All operations shell out to `xcrun simctl <subcommand>`; JSON-formatted
8//! outputs (list runtimes, list devices, screenshot binary) are parsed with
9//! serde_json / raw bytes. Tokio's `process::Command` is the async spawn
10//! primitive.
11//!
12//! This is an outer crate — allowed to depend on the wider tokio ecosystem.
13//! Use it from cement (smix-cli / smix-mcp) or from a higher-level driver
14//! wrapper.
15
16#![doc(html_root_url = "https://docs.smix.dev/smix-simctl")]
17
18pub mod registry;
19/// v1.0.4 — adaptive `xcrun simctl io screenshot` pacer. See
20/// [`screenshot_pacer::ScreenshotPacer`] and
21/// `.claude/rfcs/1.0.4-sim-health-and-backpressure.md` §D3.
22pub mod screenshot_pacer;
23
24use screenshot_pacer::{ScreenshotPacer, ScreenshotPacerConfig};
25use serde::{Deserialize, Serialize};
26use std::io;
27use std::sync::Arc;
28use std::time::Duration;
29use thiserror::Error;
30use tokio::process::Command;
31use tokio::time::sleep;
32
33/// Failure variants for any `xcrun simctl` invocation.
34#[derive(Debug, Error)]
35#[non_exhaustive]
36pub enum SimctlError {
37    /// Failed to spawn the `xcrun` process itself (PATH lookup / fork failure).
38    #[error("spawn xcrun simctl failed: {0}")]
39    Spawn(#[from] io::Error),
40    /// `xcrun simctl <sub>` exited non-zero.
41    ///
42    /// v1.0.7 §D2 — carries the full `argv` and `wall_ms` so the
43    /// `Display` impl surfaces every argument the consumer needs to
44    /// reproduce or file a precise upstream bug. Prior versions
45    /// reported only the subcommand name (e.g., `"spawn"`) without
46    /// telling the caller which binary or paths simctl was asked to
47    /// touch. See `.claude/rfcs/1.0.7-observability-layer.md` §D2.
48    #[error("xcrun simctl {} exited {code} ({wall_ms}ms): {stderr}", .argv.join(" "))]
49    NonZeroExit {
50        /// Subcommand name (e.g. `"boot"`, `"launch"`).
51        subcommand: String,
52        /// Full argv passed to `xcrun simctl` (excludes the `xcrun
53        /// simctl` prefix itself; includes subcommand + all args).
54        ///
55        /// Since smix 1.0.7.
56        argv: Vec<String>,
57        /// Exit code from `xcrun simctl`.
58        code: i32,
59        /// Captured stderr (truncated for log-friendliness).
60        stderr: String,
61        /// Wall-clock milliseconds the invocation ran before failing.
62        ///
63        /// Since smix 1.0.7.
64        #[allow(dead_code)]
65        wall_ms: u64,
66    },
67    /// `xcrun simctl <sub>` exited 0 but stdout didn't match the expected shape.
68    #[error("xcrun simctl {subcommand} returned malformed output: {detail}")]
69    Malformed {
70        /// Subcommand name.
71        subcommand: String,
72        /// Parser-side detail.
73        detail: String,
74    },
75    /// `xcrun simctl <sub>` did not complete within the deadline.
76    #[error("xcrun simctl {subcommand} timed out after {ms}ms")]
77    Timeout {
78        /// Subcommand name.
79        subcommand: String,
80        /// Deadline that was exceeded (milliseconds).
81        ms: u64,
82    },
83    /// The screenshot pacer's circuit is open — a recent screenshot
84    /// wall time exceeded the circuit threshold, or a screenshot
85    /// failed. Callers should back off for `retry_after` and try
86    /// again. See [`screenshot_pacer::ScreenshotPacer`] and
87    /// `.claude/rfcs/1.0.4-sim-health-and-backpressure.md` §D3.
88    ///
89    /// Since smix 1.0.4.
90    #[error("screenshot pacer circuit open; retry after {retry_after:?}")]
91    CaptureBackpressure {
92        /// Suggested minimum delay before the next attempt.
93        retry_after: Duration,
94    },
95}
96
97impl SimctlError {
98    /// v1.0.7 §D2 — synthetic `NonZeroExit` for callers translating
99    /// a foreign subprocess error into `SimctlError` (e.g.
100    /// AndroidDeviceControl adapting adb failures). Fills
101    /// `argv = [subcommand]` + `wall_ms = 0`; when the caller has a
102    /// real argv, prefer the struct literal.
103    pub fn non_zero_exit(
104        subcommand: impl Into<String>,
105        code: i32,
106        stderr: impl Into<String>,
107    ) -> Self {
108        let sub = subcommand.into();
109        Self::NonZeroExit {
110            argv: vec![sub.clone()],
111            subcommand: sub,
112            code,
113            stderr: stderr.into(),
114            wall_ms: 0,
115        }
116    }
117}
118
119/// Handle to an active `xcrun simctl io recordVideo` child process. Pair
120/// with [`SimctlClient::record_video_stop`] for SIGINT-and-wait shutdown
121/// (so the mp4 trailer is flushed). Dropping the handle without `stop`
122/// would tokio-SIGKILL on Drop and truncate the output file.
123#[derive(Debug)]
124pub struct RecordingHandle {
125    pub(crate) child: tokio::process::Child,
126    /// Output mp4 path verbatim as passed to `record_video_start`.
127    pub path: String,
128    /// Wall-clock start time for "recording in progress for Xs" diagnostics.
129    pub started_at: std::time::Instant,
130}
131
132// -------------------- types ----------------------------------------------
133
134/// One iOS / watchOS / tvOS runtime installed on the host.
135#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
136pub struct SimctlRuntime {
137    /// Fully-qualified runtime identifier (e.g. `"com.apple.CoreSimulator.SimRuntime.iOS-17-0"`).
138    pub identifier: String,
139    /// Human-readable name (e.g. `"iOS 17.0"`).
140    pub name: String,
141    /// Version string (e.g. `"17.0"`).
142    pub version: String,
143    /// Whether the runtime is available for booting devices.
144    pub is_available: bool,
145}
146
147/// One simulator device known to `xcrun simctl`.
148#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
149pub struct SimctlDevice {
150    /// Device UDID (stable identifier).
151    pub udid: String,
152    /// Human-readable name.
153    pub name: String,
154    /// Current state (`"Booted"` / `"Shutdown"` / `"Creating"` / etc.).
155    pub state: String,
156    /// Whether the device is available for booting.
157    pub is_available: bool,
158    /// Device-type identifier (e.g. `"com.apple.CoreSimulator.SimDeviceType.iPhone-15"`).
159    #[serde(rename = "deviceTypeIdentifier", default)]
160    pub device_type_identifier: String,
161    /// Runtime identifier this device was created against.
162    #[serde(rename = "runtimeIdentifier", default)]
163    pub runtime_identifier: String,
164}
165
166/// Permission names accepted by `xcrun simctl privacy <udid> grant <name>`.
167#[derive(Clone, Copy, Debug, PartialEq, Eq)]
168pub enum SimctlPermission {
169    /// Camera access.
170    Camera,
171    /// Photos library access.
172    Photos,
173    /// Location access (while-in-use).
174    Location,
175    /// Background location access (always).
176    LocationAlways,
177    /// Notification posting permission.
178    Notifications,
179    /// Microphone access.
180    Microphone,
181    /// Contacts access.
182    Contacts,
183    /// Calendar events access.
184    Calendar,
185    /// Reminders access.
186    Reminders,
187    /// Media library (music / video) access.
188    Media,
189    /// Motion / fitness sensor access.
190    Motion,
191    /// HomeKit accessory access.
192    HomeKit,
193    /// HealthKit data access.
194    Health,
195    /// Bluetooth device discovery / connection.
196    Bluetooth,
197    /// FaceID / TouchID biometric prompt.
198    Faceid,
199    /// Address-book (deprecated alias for `Contacts`).
200    AddressBook,
201}
202
203impl SimctlPermission {
204    /// Wire string used by `xcrun simctl privacy <udid> grant <name>`.
205    pub fn as_str(self) -> &'static str {
206        match self {
207            SimctlPermission::Camera => "camera",
208            SimctlPermission::Photos => "photos",
209            SimctlPermission::Location => "location",
210            SimctlPermission::LocationAlways => "location-always",
211            SimctlPermission::Notifications => "notifications",
212            SimctlPermission::Microphone => "microphone",
213            SimctlPermission::Contacts => "contacts",
214            SimctlPermission::Calendar => "calendar",
215            SimctlPermission::Reminders => "reminders",
216            SimctlPermission::Media => "media-library",
217            SimctlPermission::Motion => "motion",
218            SimctlPermission::HomeKit => "homekit",
219            SimctlPermission::Health => "health",
220            SimctlPermission::Bluetooth => "bluetooth",
221            SimctlPermission::Faceid => "faceid",
222            SimctlPermission::AddressBook => "addressbook",
223        }
224    }
225}
226
227/// UI appearance mode for `xcrun simctl ui <udid> appearance`.
228#[derive(Clone, Copy, Debug, PartialEq, Eq)]
229pub enum Appearance {
230    /// Light mode.
231    Light,
232    /// Dark mode.
233    Dark,
234}
235
236impl Appearance {
237    /// Wire string used by `xcrun simctl ui <udid> appearance <mode>`.
238    pub fn as_str(self) -> &'static str {
239        match self {
240            Appearance::Light => "light",
241            Appearance::Dark => "dark",
242        }
243    }
244}
245
246/// Launched-app result.
247#[derive(Clone, Debug, PartialEq, Eq)]
248pub struct LaunchResult {
249    /// Process ID of the launched app.
250    pub pid: u32,
251}
252
253// -------------------- v1.0.7 §D3 subprocess ring buffer -----------------
254
255/// v1.0.7 §D3 — recorded snapshot of one `xcrun simctl` invocation.
256/// Exposed so callers can dump the ring buffer for post-mortem when
257/// something upstream fails.
258#[derive(Clone, Debug)]
259pub struct SubprocessRecord {
260    /// argv as passed to `xcrun simctl` (excludes the `xcrun simctl`
261    /// prefix; first entry is the subcommand).
262    pub argv: Vec<String>,
263    /// Exit code; `None` when the process failed to spawn or the
264    /// output-capture path failed before recording the exit.
265    pub exit_code: Option<i32>,
266    /// Wall-clock milliseconds.
267    pub wall_ms: u64,
268    /// First 256 bytes of stderr (truncated).
269    pub stderr_head: String,
270    /// Wall-clock timestamp the invocation completed.
271    pub timestamp: std::time::SystemTime,
272}
273
274mod subprocess_ring {
275    use super::SubprocessRecord;
276    use std::collections::VecDeque;
277    use std::io::Write;
278    use std::path::{Path, PathBuf};
279    use std::sync::{Mutex, OnceLock};
280    use std::time::{Duration, UNIX_EPOCH};
281
282    fn cell() -> &'static Mutex<VecDeque<SubprocessRecord>> {
283        static INSTANCE: OnceLock<Mutex<VecDeque<SubprocessRecord>>> = OnceLock::new();
284        INSTANCE.get_or_init(|| Mutex::new(VecDeque::with_capacity(128)))
285    }
286
287    /// v1.0.10 §D6 — persist path for the ring buffer. Nil = in-memory
288    /// only (legacy pre-v1.0.10 behavior). Set once at process startup
289    /// via [`set_persist_path`]; unchanged for the lifetime of the
290    /// process. Insight's v1.0.7 dogfood reported empty
291    /// `/diagnostic/dump` payloads because supervisor cycles killed the
292    /// CLI faster than a dump could snapshot the in-memory buffer.
293    /// Persisting side-steps that: the file survives cycles, so
294    /// post-mortem tools read the file rather than the (now-gone)
295    /// in-memory state.
296    fn persist_cell() -> &'static Mutex<Option<PathBuf>> {
297        static INSTANCE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
298        INSTANCE.get_or_init(|| Mutex::new(None))
299    }
300
301    /// v1.0.10 §D6 — install a persist path. Callers should also call
302    /// [`load_persisted`] once after this so the in-memory ring starts
303    /// pre-populated with the last-known state.
304    pub fn set_persist_path(path: PathBuf) {
305        let mut g = match persist_cell().lock() {
306            Ok(g) => g,
307            Err(p) => p.into_inner(),
308        };
309        *g = Some(path);
310    }
311
312    fn persist_path_copy() -> Option<PathBuf> {
313        let g = match persist_cell().lock() {
314            Ok(g) => g,
315            Err(p) => p.into_inner(),
316        };
317        g.clone()
318    }
319
320    /// v1.0.10 §D6 — record one invocation. Ring buffer capped at 128
321    /// entries; oldest evicted on push. When [`set_persist_path`] is
322    /// active, atomically writes the buffer to disk after the mutation
323    /// so a subsequent supervisor-cycle doesn't lose the observation.
324    pub(super) fn record(entry: SubprocessRecord) {
325        {
326            let mut g = match cell().lock() {
327                Ok(g) => g,
328                Err(p) => p.into_inner(),
329            };
330            if g.len() >= 128 {
331                g.pop_front();
332            }
333            g.push_back(entry);
334        }
335        if let Some(path) = persist_path_copy() {
336            let snapshot = snapshot();
337            // Best-effort. Failure to persist must never affect the
338            // caller of `xcrun simctl` — we swallow errors here and
339            // let the next `record()` retry.
340            let _ = write_json_atomic(&path, &snapshot);
341        }
342    }
343
344    /// Snapshot the current ring buffer. Ordered oldest → newest.
345    pub fn snapshot() -> Vec<SubprocessRecord> {
346        let g = match cell().lock() {
347            Ok(g) => g,
348            Err(p) => p.into_inner(),
349        };
350        g.iter().cloned().collect()
351    }
352
353    /// v1.0.10 §D6 — load a previously-persisted ring from disk. No-op
354    /// when the file does not exist. Called by CLI startup after
355    /// [`set_persist_path`] so the in-memory view starts with the
356    /// last-known state. Silently drops parse failures — corrupt files
357    /// are noise, not fatal.
358    pub fn load_persisted() {
359        let Some(path) = persist_path_copy() else {
360            return;
361        };
362        let Ok(bytes) = std::fs::read(&path) else {
363            return;
364        };
365        let Ok(records) = serde_json::from_slice::<Vec<PersistedRecord>>(&bytes) else {
366            return;
367        };
368        let mut g = match cell().lock() {
369            Ok(g) => g,
370            Err(p) => p.into_inner(),
371        };
372        for r in records.into_iter().rev().take(128).rev() {
373            g.push_back(r.into_record());
374        }
375    }
376
377    fn write_json_atomic(path: &Path, records: &[SubprocessRecord]) -> std::io::Result<()> {
378        if let Some(parent) = path.parent() {
379            std::fs::create_dir_all(parent)?;
380        }
381        let tmp = path.with_extension("json.tmp");
382        {
383            let payload: Vec<PersistedRecord> = records.iter().cloned().map(Into::into).collect();
384            let serialized = serde_json::to_vec(&payload)
385                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
386            let mut f = std::fs::File::create(&tmp)?;
387            f.write_all(&serialized)?;
388            f.sync_all()?;
389        }
390        std::fs::rename(&tmp, path)
391    }
392
393    /// On-disk representation. Kept separate from
394    /// [`SubprocessRecord`] because the wall-clock timestamp is a
395    /// `SystemTime` which does not serde-derive cleanly; we convert to
396    /// UNIX millis for a stable JSON shape.
397    #[derive(Clone, serde::Serialize, serde::Deserialize)]
398    struct PersistedRecord {
399        argv: Vec<String>,
400        exit_code: Option<i32>,
401        wall_ms: u64,
402        stderr_head: String,
403        timestamp_ms: u64,
404    }
405
406    impl From<SubprocessRecord> for PersistedRecord {
407        fn from(r: SubprocessRecord) -> Self {
408            let timestamp_ms = r
409                .timestamp
410                .duration_since(UNIX_EPOCH)
411                .map(|d| d.as_millis() as u64)
412                .unwrap_or(0);
413            Self {
414                argv: r.argv,
415                exit_code: r.exit_code,
416                wall_ms: r.wall_ms,
417                stderr_head: r.stderr_head,
418                timestamp_ms,
419            }
420        }
421    }
422
423    impl PersistedRecord {
424        fn into_record(self) -> SubprocessRecord {
425            let timestamp =
426                UNIX_EPOCH + Duration::from_millis(self.timestamp_ms);
427            SubprocessRecord {
428                argv: self.argv,
429                exit_code: self.exit_code,
430                wall_ms: self.wall_ms,
431                stderr_head: self.stderr_head,
432                timestamp,
433            }
434        }
435    }
436
437    #[cfg(test)]
438    mod tests {
439        use super::*;
440        use std::time::SystemTime;
441
442        #[test]
443        fn persist_roundtrip_after_supervisor_cycle_simulation() {
444            let dir = tempfile::tempdir().expect("tempdir");
445            let path = dir.path().join("ring.json");
446            set_persist_path(path.clone());
447
448            record(SubprocessRecord {
449                argv: vec!["shutdown".into(), "UDID-A".into()],
450                exit_code: Some(0),
451                wall_ms: 42,
452                stderr_head: String::new(),
453                timestamp: SystemTime::now(),
454            });
455            assert!(path.exists(), "persist file must exist after record");
456
457            // Simulate supervisor cycle: clear in-memory then load.
458            {
459                let mut g = cell().lock().unwrap();
460                g.clear();
461            }
462            assert!(snapshot().is_empty());
463
464            load_persisted();
465            let after = snapshot();
466            assert_eq!(after.len(), 1);
467            assert_eq!(after[0].argv, vec!["shutdown".to_string(), "UDID-A".into()]);
468            assert_eq!(after[0].exit_code, Some(0));
469            assert_eq!(after[0].wall_ms, 42);
470        }
471    }
472}
473
474/// v1.0.10 §D6 — enable subprocess-ring persistence at the given path.
475/// CLI startup wires this to `~/.local/share/smix/subprocess-ring.json`
476/// so `/diagnostic/dump` payloads survive supervisor cycles. Optional;
477/// missing this call keeps pre-v1.0.10 in-memory-only behavior.
478pub fn set_subprocess_ring_persist_path(path: std::path::PathBuf) {
479    subprocess_ring::set_persist_path(path);
480    subprocess_ring::load_persisted();
481}
482
483// v1.0.14 Cluster A — CLI-side resetAppData counter tracking.
484//
485// The `resetAppData` verb dispatches host-side (simctl openurl + metro
486// log tail, no runner HTTP endpoint) so counters can't come from the
487// runner's `/diagnostic/dump` payload. This module owns them,
488// persisting to `~/.local/share/smix/reset-app-data-counters.json`
489// so counter deltas across `smix run` invocations + `smix diagnostic
490// dump` (later, separate process) all see the same data.
491//
492// Public API mirrors [`subprocess_ring`] shape for consistency.
493mod reset_app_data_counters {
494    use std::path::{Path, PathBuf};
495    use std::sync::{Mutex, OnceLock};
496
497    fn cell() -> &'static Mutex<Counters> {
498        static INSTANCE: OnceLock<Mutex<Counters>> = OnceLock::new();
499        INSTANCE.get_or_init(|| Mutex::new(Counters::default()))
500    }
501    fn persist_cell() -> &'static Mutex<Option<PathBuf>> {
502        static INSTANCE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
503        INSTANCE.get_or_init(|| Mutex::new(None))
504    }
505
506    #[derive(Clone, Copy, Debug, Default, serde::Serialize, serde::Deserialize)]
507    pub struct Counters {
508        pub reset_app_data_total: u64,
509        pub reset_app_data_timed_out: u64,
510    }
511
512    pub fn set_persist_path(path: PathBuf) {
513        let mut g = match persist_cell().lock() {
514            Ok(g) => g,
515            Err(p) => p.into_inner(),
516        };
517        *g = Some(path);
518    }
519
520    fn persist_path_copy() -> Option<PathBuf> {
521        let g = match persist_cell().lock() {
522            Ok(g) => g,
523            Err(p) => p.into_inner(),
524        };
525        g.clone()
526    }
527
528    pub fn load_persisted() {
529        let Some(path) = persist_path_copy() else {
530            return;
531        };
532        let Ok(bytes) = std::fs::read(&path) else {
533            return;
534        };
535        let Ok(loaded) = serde_json::from_slice::<Counters>(&bytes) else {
536            return;
537        };
538        let mut g = match cell().lock() {
539            Ok(g) => g,
540            Err(p) => p.into_inner(),
541        };
542        *g = loaded;
543    }
544
545    pub fn increment_total() {
546        {
547            let mut g = match cell().lock() {
548                Ok(g) => g,
549                Err(p) => p.into_inner(),
550            };
551            g.reset_app_data_total = g.reset_app_data_total.saturating_add(1);
552        }
553        persist_best_effort();
554    }
555
556    pub fn increment_timed_out() {
557        {
558            let mut g = match cell().lock() {
559                Ok(g) => g,
560                Err(p) => p.into_inner(),
561            };
562            g.reset_app_data_timed_out = g.reset_app_data_timed_out.saturating_add(1);
563        }
564        persist_best_effort();
565    }
566
567    pub fn snapshot() -> Counters {
568        let g = match cell().lock() {
569            Ok(g) => g,
570            Err(p) => p.into_inner(),
571        };
572        *g
573    }
574
575    fn persist_best_effort() {
576        let Some(path) = persist_path_copy() else {
577            return;
578        };
579        let snapshot = snapshot();
580        let _ = write_json_atomic(&path, &snapshot);
581    }
582
583    fn write_json_atomic(path: &Path, counters: &Counters) -> std::io::Result<()> {
584        use std::io::Write;
585        if let Some(parent) = path.parent() {
586            std::fs::create_dir_all(parent)?;
587        }
588        let tmp = path.with_extension("json.tmp");
589        let bytes = serde_json::to_vec(counters)
590            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
591        {
592            let mut f = std::fs::File::create(&tmp)?;
593            f.write_all(&bytes)?;
594            f.sync_all()?;
595        }
596        std::fs::rename(&tmp, path)
597    }
598
599    #[cfg(test)]
600    mod tests {
601        use super::*;
602
603        #[test]
604        fn increment_and_persist_roundtrip() {
605            let dir = tempfile::tempdir().expect("tempdir");
606            let path = dir.path().join("counters.json");
607            set_persist_path(path.clone());
608            // Reset in-memory to avoid cross-test pollution.
609            {
610                let mut g = cell().lock().unwrap();
611                *g = Counters::default();
612            }
613            increment_total();
614            increment_total();
615            increment_timed_out();
616            assert!(path.exists());
617            let raw = std::fs::read_to_string(&path).unwrap();
618            let loaded: Counters = serde_json::from_str(&raw).unwrap();
619            assert_eq!(loaded.reset_app_data_total, 2);
620            assert_eq!(loaded.reset_app_data_timed_out, 1);
621        }
622    }
623}
624
625/// v1.0.14 Cluster A — public snapshot of CLI-side resetAppData
626/// counter state. Populated by [`increment_reset_app_data_total`] +
627/// [`increment_reset_app_data_timed_out`] as the CLI dispatches the
628/// verb; loaded from disk on CLI startup if
629/// [`set_reset_app_data_counters_persist_path`] was called.
630#[derive(Clone, Copy, Debug, Default)]
631pub struct ResetAppDataCounters {
632    /// Total resetAppData dispatches (any outcome).
633    pub reset_app_data_total: u64,
634    /// resetAppData dispatches where the completion signal did not
635    /// arrive inside the timeout window. `> 0` = the URL was fired
636    /// but the app did not emit the expected reset-complete log line.
637    pub reset_app_data_timed_out: u64,
638}
639
640/// v1.0.14 Cluster A — enable resetAppData counter persistence at
641/// the given path. Callers pass
642/// `~/.local/share/smix/reset-app-data-counters.json` at CLI startup
643/// so counter state survives across `smix run` → `smix diagnostic
644/// dump` invocations.
645pub fn set_reset_app_data_counters_persist_path(path: std::path::PathBuf) {
646    reset_app_data_counters::set_persist_path(path);
647    reset_app_data_counters::load_persisted();
648}
649
650/// v1.0.14 Cluster A — advance the resetAppData total counter.
651/// Called by the CLI runtime after each dispatch (success or timeout).
652pub fn increment_reset_app_data_total() {
653    reset_app_data_counters::increment_total();
654}
655
656/// v1.0.14 Cluster A — advance the resetAppData timed-out counter.
657/// Called by the CLI runtime when the completion signal (log-line
658/// pattern match) did not arrive inside the timeout window. Always
659/// paired with a preceding [`increment_reset_app_data_total`] on the
660/// same dispatch.
661pub fn increment_reset_app_data_timed_out() {
662    reset_app_data_counters::increment_timed_out();
663}
664
665/// v1.0.14 Cluster A — snapshot the current counter state for
666/// display / wire emission. Returns zero-valued counters when
667/// persistence was never wired.
668pub fn reset_app_data_counters_snapshot() -> ResetAppDataCounters {
669    let s = reset_app_data_counters::snapshot();
670    ResetAppDataCounters {
671        reset_app_data_total: s.reset_app_data_total,
672        reset_app_data_timed_out: s.reset_app_data_timed_out,
673    }
674}
675
676/// v1.0.7 §D3 — snapshot the process-wide ring buffer of recent
677/// `xcrun simctl` invocations. Ordered oldest → newest, capped at 128
678/// entries. Reset on process restart.
679pub fn recent_subprocesses() -> Vec<SubprocessRecord> {
680    subprocess_ring::snapshot()
681}
682
683// -------------------- raw spawn primitive --------------------------------
684
685/// Execute `xcrun simctl <args>` and capture stdout/stderr.
686async fn simctl_capture(args: &[&str]) -> Result<(Vec<u8>, String), SimctlError> {
687    simctl_capture_env(args, &[]).await
688}
689
690/// `simctl_capture` with extra envp pairs set on the spawned process.
691/// The `xcrun simctl launch` subcommand uses this to inject
692/// `SIMCTL_CHILD_<KEY>=<VAL>` vars that the launched app sees as
693/// `ProcessInfo().environment["KEY"]`. `env` entries here are passed
694/// verbatim — caller composes the `SIMCTL_CHILD_` prefix via
695/// [`compose_child_env`].
696async fn simctl_capture_env(
697    args: &[&str],
698    env: &[(String, String)],
699) -> Result<(Vec<u8>, String), SimctlError> {
700    let mut cmd = Command::new("xcrun");
701    cmd.arg("simctl");
702    for a in args {
703        cmd.arg(a);
704    }
705    for (k, v) in env {
706        cmd.env(k, v);
707    }
708    let started = std::time::Instant::now();
709    let output = cmd.output().await?;
710    let wall_ms = started.elapsed().as_millis() as u64;
711    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
712    // v1.0.7 §D3 — every simctl invocation records to the ring buffer
713    // regardless of exit status.
714    subprocess_ring::record(SubprocessRecord {
715        argv: args.iter().map(|s| s.to_string()).collect(),
716        exit_code: output.status.code(),
717        wall_ms,
718        stderr_head: {
719            let mut s = stderr.clone();
720            if s.len() > 256 {
721                s.truncate(256);
722            }
723            s
724        },
725        timestamp: std::time::SystemTime::now(),
726    });
727    if !output.status.success() {
728        return Err(SimctlError::NonZeroExit {
729            subcommand: args.first().map(|s| s.to_string()).unwrap_or_default(),
730            argv: args.iter().map(|s| s.to_string()).collect(),
731            code: output.status.code().unwrap_or(-1),
732            stderr,
733            wall_ms,
734        });
735    }
736    Ok((output.stdout, stderr))
737}
738
739async fn simctl_run(args: &[&str]) -> Result<String, SimctlError> {
740    let (stdout, _) = simctl_capture(args).await?;
741    Ok(String::from_utf8_lossy(&stdout).into_owned())
742}
743
744/// Like [`simctl_run`] but injects `child_env` envp on the spawned
745/// process. Used by the env-aware launch path so the launched app can
746/// read deploy-time secrets / endpoints via `ProcessInfo`.
747async fn simctl_run_env(args: &[&str], env: &[(String, String)]) -> Result<String, SimctlError> {
748    let (stdout, _) = simctl_capture_env(args, env).await?;
749    Ok(String::from_utf8_lossy(&stdout).into_owned())
750}
751
752/// Compose user-provided `(key, value)` pairs into the `SIMCTL_CHILD_*`
753/// envp that `xcrun simctl launch` strips and delivers to the launched
754/// app. Idempotent: a key that already starts with `SIMCTL_CHILD_` is
755/// passed through unchanged.
756///
757/// # Example
758///
759/// ```
760/// use smix_simctl::compose_child_env;
761/// let composed = compose_child_env(&[("SMIX_PERF_RECEIVER_URL", "http://h:9999")]);
762/// assert_eq!(
763///     composed,
764///     vec![(
765///         "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
766///         "http://h:9999".to_string(),
767///     )]
768/// );
769/// ```
770pub fn compose_child_env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
771    pairs
772        .iter()
773        .map(|(k, v)| {
774            let key = if k.starts_with("SIMCTL_CHILD_") {
775                (*k).to_string()
776            } else {
777                format!("SIMCTL_CHILD_{k}")
778            };
779            (key, (*v).to_string())
780        })
781        .collect()
782}
783
784// -------------------- client --------------------------------------------
785
786/// Stateless wrapper around xcrun simctl. Methods are free functions
787/// in spirit (no instance state beyond optionally-cached `xcrun` path);
788/// kept as a struct for API ergonomics + future caching.
789///
790/// Since v1.0.4, the client also holds a [`ScreenshotPacer`] that
791/// throttles `xcrun simctl io screenshot` under high-frequency
792/// load. Defaults are conservative (100 ms interval floor);
793/// consumers whose flows are already loose are unaffected.
794#[derive(Debug)]
795pub struct SimctlClient {
796    /// Screenshot pacer — enforces the RFC 1.0.4 §D3 interval floor,
797    /// slow-path lift, and circuit breaker. Shared via `Arc<Mutex<_>>`
798    /// so a cloned client (which callers occasionally do) still shares
799    /// pressure accounting.
800    screenshot_pacer: Arc<std::sync::Mutex<ScreenshotPacer>>,
801}
802
803impl Default for SimctlClient {
804    fn default() -> Self {
805        Self::new()
806    }
807}
808
809impl SimctlClient {
810    /// Construct a new client with default screenshot pacing (100 ms
811    /// interval floor, adaptive slow-path lift to 1500 ms, circuit
812    /// breaker on ≥ 1500 ms walls or failures).
813    pub fn new() -> Self {
814        SimctlClient {
815            screenshot_pacer: Arc::new(std::sync::Mutex::new(ScreenshotPacer::new(
816                ScreenshotPacerConfig::default(),
817            ))),
818        }
819    }
820
821    /// Override the screenshot pacer with a custom config.
822    ///
823    /// Since smix 1.0.4.
824    #[must_use]
825    pub fn with_screenshot_pacer(self, config: ScreenshotPacerConfig) -> Self {
826        {
827            let mut guard = self
828                .screenshot_pacer
829                .lock()
830                .expect("screenshot pacer mutex must not be poisoned");
831            *guard = ScreenshotPacer::new(config);
832        }
833        self
834    }
835
836    /// Attach a [`smix_sim_health::SimHealthMonitor`] to receive
837    /// screenshot wall-time observations from every `screenshot`
838    /// call. Composes with the pacer — the pacer still enforces its
839    /// interval / circuit locally, and the monitor sees the same
840    /// walls for global state classification.
841    ///
842    /// Since smix 1.0.4.
843    #[must_use]
844    pub fn with_sim_health(self, monitor: smix_sim_health::SimHealthMonitor) -> Self {
845        {
846            let mut guard = self
847                .screenshot_pacer
848                .lock()
849                .expect("screenshot pacer mutex must not be poisoned");
850            guard.set_monitor(monitor);
851        }
852        self
853    }
854
855    // ---- inventory ------------------------------------------------------
856
857    /// `xcrun simctl list runtimes -j` → `Vec<SimctlRuntime>`.
858    pub async fn list_runtimes(&self) -> Result<Vec<SimctlRuntime>, SimctlError> {
859        let raw = simctl_run(&["list", "runtimes", "-j"]).await?;
860        #[derive(Deserialize)]
861        struct Wrap {
862            runtimes: Vec<RawRuntime>,
863        }
864        #[derive(Deserialize)]
865        struct RawRuntime {
866            identifier: String,
867            name: String,
868            version: String,
869            #[serde(rename = "isAvailable", default)]
870            is_available: bool,
871        }
872        let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
873            subcommand: "list runtimes".into(),
874            detail: e.to_string(),
875        })?;
876        Ok(w.runtimes
877            .into_iter()
878            .map(|r| SimctlRuntime {
879                identifier: r.identifier,
880                name: r.name,
881                version: r.version,
882                is_available: r.is_available,
883            })
884            .collect())
885    }
886
887    /// `xcrun simctl list devices -j` → flattened `Vec<SimctlDevice>`.
888    pub async fn list_devices(&self) -> Result<Vec<SimctlDevice>, SimctlError> {
889        let raw = simctl_run(&["list", "devices", "-j"]).await?;
890        #[derive(Deserialize)]
891        struct Wrap {
892            devices: std::collections::BTreeMap<String, Vec<RawDevice>>,
893        }
894        #[derive(Deserialize)]
895        struct RawDevice {
896            udid: String,
897            name: String,
898            state: String,
899            #[serde(rename = "isAvailable", default)]
900            is_available: bool,
901            #[serde(rename = "deviceTypeIdentifier", default)]
902            device_type_identifier: String,
903        }
904        let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
905            subcommand: "list devices".into(),
906            detail: e.to_string(),
907        })?;
908        let mut out = Vec::new();
909        for (runtime_id, devices) in w.devices {
910            for d in devices {
911                out.push(SimctlDevice {
912                    udid: d.udid,
913                    name: d.name,
914                    state: d.state,
915                    is_available: d.is_available,
916                    device_type_identifier: d.device_type_identifier,
917                    runtime_identifier: runtime_id.clone(),
918                });
919            }
920        }
921        Ok(out)
922    }
923
924    // ---- lifecycle ------------------------------------------------------
925
926    /// `xcrun simctl boot <udid>` — fire-and-forget boot request.
927    pub async fn boot(&self, udid: &str) -> Result<(), SimctlError> {
928        simctl_run(&["boot", udid]).await?;
929        Ok(())
930    }
931
932    /// `xcrun simctl shutdown <udid>`.
933    pub async fn shutdown(&self, udid: &str) -> Result<(), SimctlError> {
934        simctl_run(&["shutdown", udid]).await?;
935        Ok(())
936    }
937
938    /// Read the sim's current BCP-47 locale (first entry of
939    /// `NSGlobalDomain AppleLanguages`). Returns `Ok(None)` when the
940    /// preference is unset (defaults read exits non-zero) or unparseable.
941    /// Wire format: `simctl spawn <udid> defaults read -g AppleLanguages`
942    /// stdout looks like `"(\n    \"en-US\"\n)\n"`; we extract the first
943    /// quoted token.
944    pub async fn current_locale(&self, udid: &str) -> Result<Option<String>, SimctlError> {
945        let out =
946            match simctl_run(&["spawn", udid, "/usr/bin/defaults", "read", "-g", "AppleLanguages"]).await {
947                Ok(s) => s,
948                // `defaults read` returns non-zero when the key is unset; that
949                // is a legitimate "no opinion" state, not an error.
950                Err(SimctlError::NonZeroExit { .. }) => return Ok(None),
951                Err(e) => return Err(e),
952            };
953        // First quoted substring.
954        if let Some(start) = out.find('"') {
955            let rest = &out[start + 1..];
956            if let Some(end) = rest.find('"') {
957                return Ok(Some(rest[..end].to_string()));
958            }
959        }
960        Ok(None)
961    }
962
963    /// Write `AppleLanguages` (array) + `AppleLocale` (scalar) to the
964    /// sim's NSGlobalDomain so SpringBoard + apps re-localize on next
965    /// launch. AppleLocale is BCP-47 with hyphen replaced by underscore
966    /// (`en_US`); AppleLanguages is the BCP-47 tag verbatim.
967    /// **The caller must shutdown + reboot the sim for the change to
968    /// take effect** — running apps cache the locale at process start.
969    pub async fn set_locale(&self, udid: &str, locale: &str) -> Result<(), SimctlError> {
970        simctl_run(&[
971            "spawn",
972            udid,
973            "defaults",
974            "write",
975            "-g",
976            "AppleLanguages",
977            "-array",
978            locale,
979        ])
980        .await?;
981        let locale_underscore = locale.replace('-', "_");
982        simctl_run(&[
983            "spawn",
984            udid,
985            "defaults",
986            "write",
987            "-g",
988            "AppleLocale",
989            &locale_underscore,
990        ])
991        .await?;
992        Ok(())
993    }
994
995    /// Boot + poll device state == "Booted" within timeout. Tries every
996    /// 500 ms until success or `timeout_ms` elapses. Idempotent on
997    /// already-booted devices (`xcrun simctl boot` returns non-zero when
998    /// the device is already booted; we swallow that).
999    pub async fn boot_and_wait(&self, udid: &str, timeout: Duration) -> Result<(), SimctlError> {
1000        // Issue boot; ignore already-booted error (the only friendly path).
1001        let _ = simctl_run(&["boot", udid]).await;
1002        let start = std::time::Instant::now();
1003        loop {
1004            let devices = self.list_devices().await?;
1005            if devices
1006                .iter()
1007                .any(|d| d.udid == udid && d.state == "Booted")
1008            {
1009                return Ok(());
1010            }
1011            if start.elapsed() > timeout {
1012                return Err(SimctlError::Timeout {
1013                    subcommand: format!("boot {}", udid),
1014                    ms: timeout.as_millis() as u64,
1015                });
1016            }
1017            sleep(Duration::from_millis(500)).await;
1018        }
1019    }
1020
1021    /// `xcrun simctl erase <udid>` — wipe device contents.
1022    pub async fn erase(&self, udid: &str) -> Result<(), SimctlError> {
1023        simctl_run(&["erase", udid]).await?;
1024        Ok(())
1025    }
1026
1027    /// `xcrun simctl install <udid> <app-path>` — install a `.app` bundle.
1028    pub async fn install(&self, udid: &str, app_path: &str) -> Result<(), SimctlError> {
1029        simctl_run(&["install", udid, app_path]).await?;
1030        Ok(())
1031    }
1032
1033    /// `xcrun simctl uninstall <udid> <bundle-id>`.
1034    pub async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
1035        simctl_run(&["uninstall", udid, bundle_id]).await?;
1036        Ok(())
1037    }
1038
1039    /// `xcrun simctl terminate <udid> <bundle-id>` — kill a running app.
1040    pub async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
1041        simctl_run(&["terminate", udid, bundle_id]).await?;
1042        Ok(())
1043    }
1044
1045    /// `xcrun simctl launch <udid> <bundleId>` → parse `"<bundle>: <pid>"`.
1046    pub async fn launch(&self, udid: &str, bundle_id: &str) -> Result<LaunchResult, SimctlError> {
1047        self.launch_with_args(udid, bundle_id, &[]).await
1048    }
1049
1050    /// `xcrun simctl launch <udid> <bundleId> -- <arg>...` — launch with a
1051    /// process-level argument vector. Empty `args` is equivalent to
1052    /// [`Self::launch`]. Mirrors maestro yaml `launchApp.arguments`.
1053    pub async fn launch_with_args(
1054        &self,
1055        udid: &str,
1056        bundle_id: &str,
1057        args: &[String],
1058    ) -> Result<LaunchResult, SimctlError> {
1059        self.launch_with_args_and_env(udid, bundle_id, args, &[])
1060            .await
1061    }
1062
1063    /// Like [`Self::launch_with_args`] but also sets `SIMCTL_CHILD_*`
1064    /// envp on the simctl process so the launched app can read
1065    /// deploy-time vars via `ProcessInfo().environment["KEY"]`.
1066    /// `child_env` keys without the `SIMCTL_CHILD_` prefix get it added
1067    /// automatically (per [`compose_child_env`] semantics). Useful for
1068    /// prelaunching an app before any `openLink` so iOS treats the
1069    /// subsequent URL handoff as in-app routing instead of cross-app,
1070    /// side-stepping the SpringBoard "Open in '`<App>`'?" confirmation
1071    /// dialog.
1072    pub async fn launch_with_args_and_env(
1073        &self,
1074        udid: &str,
1075        bundle_id: &str,
1076        args: &[String],
1077        child_env: &[(&str, &str)],
1078    ) -> Result<LaunchResult, SimctlError> {
1079        let mut argv: Vec<&str> = vec!["launch", udid, bundle_id];
1080        if !args.is_empty() {
1081            argv.push("--");
1082            for a in args {
1083                argv.push(a.as_str());
1084            }
1085        }
1086        let composed = compose_child_env(child_env);
1087        let out = simctl_run_env(&argv, &composed).await?;
1088        // Output format: `com.example.app: 12345\n`
1089        let pid_str =
1090            out.rsplit(':')
1091                .next()
1092                .map(str::trim)
1093                .ok_or_else(|| SimctlError::Malformed {
1094                    subcommand: "launch".into(),
1095                    detail: format!("unexpected stdout shape: {}", out.trim()),
1096                })?;
1097        let pid: u32 = pid_str.parse().map_err(|_| SimctlError::Malformed {
1098            subcommand: "launch".into(),
1099            detail: format!("non-numeric pid in stdout: {}", out.trim()),
1100        })?;
1101        Ok(LaunchResult { pid })
1102    }
1103
1104    /// v1.0.4 §D12 — reset every privacy permission granted to
1105    /// `bundle_id` on the sim: `xcrun simctl privacy <udid> reset all
1106    /// <bundle-id>`. Companion to [`Self::clear_app_sandbox`] on the
1107    /// in-place `launchApp: clearState: true` path that replaces
1108    /// `simctl uninstall + install` (which triggers iOS 26.5 XCUITest
1109    /// binding loss + ReportCrash's "Insight quit unexpectedly" dialog).
1110    pub async fn privacy_reset_all(
1111        &self,
1112        udid: &str,
1113        bundle_id: &str,
1114    ) -> Result<(), SimctlError> {
1115        simctl_run(&["privacy", udid, "reset", "all", bundle_id]).await?;
1116        Ok(())
1117    }
1118
1119    /// v1.0.4 §D12 — wipe the app's sandbox on the sim: locate the
1120    /// Data container via `simctl get_app_container <udid> <bundle>
1121    /// data`, then `simctl spawn <udid> rm -rf <container>/Documents
1122    /// <container>/Library <container>/tmp`. The app remains installed
1123    /// (no `simctl uninstall`), so the XCUITest binding is preserved
1124    /// and macOS `ReportCrash` does not misinterpret a missing
1125    /// install-receipt as a crash.
1126    pub async fn clear_app_sandbox(
1127        &self,
1128        udid: &str,
1129        bundle_id: &str,
1130    ) -> Result<(), SimctlError> {
1131        let raw = simctl_run(&["get_app_container", udid, bundle_id, "data"]).await?;
1132        let container = raw.trim();
1133        if container.is_empty() {
1134            return Err(SimctlError::Malformed {
1135                subcommand: "clear_app_sandbox".into(),
1136                detail: format!("empty Data container path for bundle {bundle_id}"),
1137            });
1138        }
1139        let documents = format!("{container}/Documents");
1140        let library = format!("{container}/Library");
1141        let tmp = format!("{container}/tmp");
1142        // v1.0.7 §D1 — `xcrun simctl spawn <UDID> <cmd>` uses
1143        // `posix_spawn` inside the sim OS; `<cmd>` must be an absolute
1144        // path (no PATH resolution). Prior versions passed `"rm"` and
1145        // hit `NSPOSIXErrorDomain code 2: No such file or directory`
1146        // on iOS 17+ sims. `/bin/rm` is present on every stock sim
1147        // image.
1148        //
1149        // Best-effort: any missing subdir is fine (fresh app that never
1150        // wrote to that path). `rm -rf` treats absent targets as no-ops.
1151        simctl_run(&[
1152            "spawn", udid, "/bin/rm", "-rf", &documents, &library, &tmp,
1153        ])
1154        .await?;
1155        Ok(())
1156    }
1157
1158    /// `xcrun simctl openurl <udid> <url>` — open a URL on the device.
1159    ///
1160    /// **URL bytes are passed to `xcrun simctl` verbatim** — no
1161    /// parsing, no percent-encoding rewrite, no query-string
1162    /// stripping. Verified by [`openurl_argv`] (test-visible helper)
1163    /// and its unit test asserting query-params like
1164    /// `?url=http%3A%2F%2Flocalhost%3A8081` reach the argv byte-for-byte.
1165    /// Feedback §G verification — if the target app's URL router
1166    /// (e.g. expo-dev-client 57.0.5) shows a picker instead of
1167    /// auto-connecting, the URL made it through smix intact and the
1168    /// finding lives on the URL-router side.
1169    pub async fn open_url(&self, udid: &str, url: &str) -> Result<(), SimctlError> {
1170        let argv = openurl_argv(udid, url);
1171        let refs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1172        simctl_run(&refs).await?;
1173        Ok(())
1174    }
1175}
1176
1177/// v1.0.4 §G — argv construction for `xcrun simctl openurl`. Extracted
1178/// as a test-visible helper so the URL-preservation contract is
1179/// unit-testable without invoking `xcrun`.
1180#[doc(hidden)]
1181pub fn openurl_argv(udid: &str, url: &str) -> [String; 3] {
1182    ["openurl".to_string(), udid.to_string(), url.to_string()]
1183}
1184
1185impl SimctlClient {
1186
1187    /// `xcrun simctl push <udid> <bundle-id> <apns-json-path>`.
1188    /// Deliver an APNS payload to a sim-installed app. The payload file is
1189    /// a JSON document whose top-level dictionary mirrors what an APNS
1190    /// provider would send; `aps.alert.body` / `aps.alert.title` surface
1191    /// as banner content and reach the app's
1192    /// `UNUserNotificationCenterDelegate`.
1193    pub async fn send_push(
1194        &self,
1195        udid: &str,
1196        bundle_id: &str,
1197        apns_json_path: &str,
1198    ) -> Result<(), SimctlError> {
1199        simctl_run(&["push", udid, bundle_id, apns_json_path]).await?;
1200        Ok(())
1201    }
1202
1203    /// `xcrun simctl ui <udid> appearance <light|dark>` — set UI appearance.
1204    pub async fn set_appearance(&self, udid: &str, mode: Appearance) -> Result<(), SimctlError> {
1205        simctl_run(&["ui", udid, "appearance", mode.as_str()]).await?;
1206        Ok(())
1207    }
1208
1209    /// `xcrun simctl privacy <udid> grant <perm> <bundle-id>`.
1210    pub async fn grant_permission(
1211        &self,
1212        udid: &str,
1213        permission: SimctlPermission,
1214        bundle_id: &str,
1215    ) -> Result<(), SimctlError> {
1216        simctl_run(&["privacy", udid, "grant", permission.as_str(), bundle_id]).await?;
1217        Ok(())
1218    }
1219
1220    /// `xcrun simctl privacy <udid> revoke <perm> <bundle-id>` — explicitly
1221    /// deny the permission. Mirrors maestro yaml `permissions: { x: deny }`
1222    /// (the reverse of `grant`). Distinct from `reset`, which returns the
1223    /// permission to "not determined".
1224    pub async fn revoke_permission(
1225        &self,
1226        udid: &str,
1227        permission: SimctlPermission,
1228        bundle_id: &str,
1229    ) -> Result<(), SimctlError> {
1230        simctl_run(&["privacy", udid, "revoke", permission.as_str(), bundle_id]).await?;
1231        Ok(())
1232    }
1233
1234    /// `xcrun simctl location <udid> set <lat>,<lng>` — set sim location
1235    /// to a fixed point. Mirrors maestro `setLocation`.
1236    pub async fn location_set(
1237        &self,
1238        udid: &str,
1239        latitude: f64,
1240        longitude: f64,
1241    ) -> Result<(), SimctlError> {
1242        let coord = format!("{latitude},{longitude}");
1243        simctl_run(&["location", udid, "set", &coord]).await?;
1244        Ok(())
1245    }
1246
1247    /// `xcrun simctl location <udid> start [--speed=<m/s>] <waypoints>`
1248    /// — interpolate sim location along waypoints. Fire-and-return: simctl
1249    /// injects scenario and returns; sim continues interpolation in background.
1250    /// Mirrors maestro `travel`.
1251    pub async fn location_start(
1252        &self,
1253        udid: &str,
1254        points: &[(f64, f64)],
1255        speed_mps: Option<f64>,
1256    ) -> Result<(), SimctlError> {
1257        if points.len() < 2 {
1258            return Err(SimctlError::Malformed {
1259                subcommand: "location-start".into(),
1260                detail: format!("requires ≥2 waypoints, got {}", points.len()),
1261            });
1262        }
1263        let mut args: Vec<String> = vec!["location".into(), udid.into(), "start".into()];
1264        if let Some(s) = speed_mps {
1265            args.push(format!("--speed={s}"));
1266        }
1267        for (lat, lng) in points {
1268            args.push(format!("{lat},{lng}"));
1269        }
1270        let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
1271        simctl_run(&args_ref).await?;
1272        Ok(())
1273    }
1274
1275    /// `xcrun simctl location <udid> clear` — reset active location
1276    /// scenario.
1277    pub async fn location_clear(&self, udid: &str) -> Result<(), SimctlError> {
1278        simctl_run(&["location", udid, "clear"]).await?;
1279        Ok(())
1280    }
1281
1282    /// `xcrun simctl addmedia <udid> <path>...` — add photos / videos /
1283    /// contacts to sim library. Mirrors maestro `addMedia` (scalar or
1284    /// array form already flattened on adapter side).
1285    pub async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), SimctlError> {
1286        if paths.is_empty() {
1287            return Err(SimctlError::Malformed {
1288                subcommand: "addmedia".into(),
1289                detail: "no paths supplied".into(),
1290            });
1291        }
1292        let mut args: Vec<&str> = vec!["addmedia", udid];
1293        for p in paths {
1294            args.push(p.as_str());
1295        }
1296        simctl_run(&args).await?;
1297        Ok(())
1298    }
1299
1300    /// Start recording sim display to `path`. Spawns
1301    /// `xcrun simctl io <udid> recordVideo <path>` as a long-running child;
1302    /// returns handle immediately. Caller must pair with
1303    /// [`Self::record_video_stop`] for clean SIGINT-and-wait shutdown —
1304    /// dropping the handle would SIGKILL via tokio + lose mp4 trailer.
1305    pub async fn record_video_start(
1306        &self,
1307        udid: &str,
1308        path: &str,
1309    ) -> Result<RecordingHandle, SimctlError> {
1310        let child = tokio::process::Command::new("xcrun")
1311            .args(["simctl", "io", udid, "recordVideo", path])
1312            .stdin(std::process::Stdio::null())
1313            .stdout(std::process::Stdio::piped())
1314            .stderr(std::process::Stdio::piped())
1315            .spawn()?;
1316        // brief settle for simctl to initialize encoder + open output file.
1317        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1318        Ok(RecordingHandle {
1319            child,
1320            path: path.to_string(),
1321            started_at: std::time::Instant::now(),
1322        })
1323    }
1324
1325    /// Stop a recording via SIGINT + wait (≤10s). SIGINT lets simctl
1326    /// trap and flush the mp4 trailer; SIGKILL would corrupt output.
1327    /// Timeout escalates to SIGKILL with explicit error mentioning truncation.
1328    pub async fn record_video_stop(&self, mut handle: RecordingHandle) -> Result<(), SimctlError> {
1329        let pid = handle.child.id().ok_or_else(|| SimctlError::Malformed {
1330            subcommand: "recordVideo-stop".into(),
1331            detail: "child already reaped".into(),
1332        })?;
1333        // SAFETY: libc::kill is a thin POSIX syscall wrapper; pid is owned by
1334        // this Child instance (no race) and SIGINT is signal-safe.
1335        let rc = unsafe { libc::kill(pid as i32, libc::SIGINT) };
1336        if rc != 0 {
1337            return Err(SimctlError::Malformed {
1338                subcommand: "recordVideo-stop".into(),
1339                detail: format!(
1340                    "kill SIGINT failed: errno={}",
1341                    std::io::Error::last_os_error()
1342                ),
1343            });
1344        }
1345        let wait_result =
1346            tokio::time::timeout(std::time::Duration::from_secs(10), handle.child.wait()).await;
1347        match wait_result {
1348            Ok(Ok(_status)) => Ok(()),
1349            Ok(Err(e)) => Err(SimctlError::Malformed {
1350                subcommand: "recordVideo-stop".into(),
1351                detail: format!("wait failed: {e}"),
1352            }),
1353            Err(_timeout) => {
1354                let _ = handle.child.kill().await;
1355                Err(SimctlError::Malformed {
1356                    subcommand: "recordVideo-stop".into(),
1357                    detail: "SIGINT timeout (10s) — escalated SIGKILL; output mp4 likely truncated. Inspect simctl recordVideo stderr.".into(),
1358                })
1359            }
1360        }
1361    }
1362
1363    /// `xcrun simctl privacy <udid> reset <perm> <bundle-id>` — return the
1364    /// permission to "not determined" so the next request re-prompts.
1365    /// May terminate a running instance of the target app (Apple
1366    /// behavior) — call before launch, not mid-flow.
1367    pub async fn reset_permission(
1368        &self,
1369        udid: &str,
1370        permission: SimctlPermission,
1371        bundle_id: &str,
1372    ) -> Result<(), SimctlError> {
1373        simctl_run(&["privacy", udid, "reset", permission.as_str(), bundle_id]).await?;
1374        Ok(())
1375    }
1376
1377    /// `xcrun simctl keychain <udid> reset` — clear all keychain entries.
1378    pub async fn keychain_reset(&self, udid: &str) -> Result<(), SimctlError> {
1379        simctl_run(&["keychain", udid, "reset"]).await?;
1380        Ok(())
1381    }
1382
1383    /// `xcrun simctl pbpaste <udid>` — read clipboard contents.
1384    pub async fn pasteboard_get(&self, udid: &str) -> Result<String, SimctlError> {
1385        simctl_run(&["pbpaste", udid]).await
1386    }
1387
1388    /// `xcrun simctl pbcopy <udid>` — write clipboard contents (via piped stdin).
1389    pub async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), SimctlError> {
1390        // pbcopy reads stdin — we pipe via shell echo for simplicity.
1391        // Long-term: spawn with stdin pipe.
1392        use tokio::io::AsyncWriteExt;
1393        let mut cmd = Command::new("xcrun");
1394        cmd.arg("simctl").arg("pbcopy").arg(udid);
1395        cmd.stdin(std::process::Stdio::piped());
1396        let mut child = cmd.spawn()?;
1397        if let Some(mut stdin) = child.stdin.take() {
1398            stdin.write_all(text.as_bytes()).await?;
1399            drop(stdin); // close stdin so pbcopy returns
1400        }
1401        let status = child.wait().await?;
1402        if !status.success() {
1403            return Err(SimctlError::NonZeroExit {
1404                subcommand: "pbcopy".into(),
1405                argv: vec!["pbcopy".to_string()],
1406                code: status.code().unwrap_or(-1),
1407                stderr: String::new(),
1408                wall_ms: 0,
1409            });
1410        }
1411        Ok(())
1412    }
1413
1414    /// Toggle "Reduce Motion" accessibility setting via `defaults write`.
1415    pub async fn set_reduce_motion(&self, udid: &str, enabled: bool) -> Result<(), SimctlError> {
1416        let val = if enabled { "1" } else { "0" };
1417        // `defaults write` lives under spawn; routed via simctl spawn.
1418        simctl_run(&[
1419            "spawn",
1420            udid,
1421            "defaults",
1422            "write",
1423            "com.apple.UIKit",
1424            "UIAccessibilityReduceMotionEnabled",
1425            "-bool",
1426            val,
1427        ])
1428        .await?;
1429        Ok(())
1430    }
1431
1432    /// `xcrun simctl io <udid> screenshot <tmpfile>` → raw PNG bytes,
1433    /// with a byte-level sRGB metadata splice if the produced PNG lacks
1434    /// an `sRGB` chunk.
1435    ///
1436    /// Goes through a temp file: current Xcode's `screenshot -` does not
1437    /// treat `-` as stdout — it writes a literal file named `-` in cwd
1438    /// and emits nothing on stdout (observed on Xcode/iOS 26.5).
1439    ///
1440    /// **Pixel-preservation invariant**: the returned bytes are
1441    /// byte-identical to whatever `simctl io screenshot` wrote to disk
1442    /// EXCEPT for one narrow case — if the PNG does not carry an
1443    /// `sRGB` ancillary chunk (observed on iOS 26.5 sub-builds
1444    /// mid-2026), a 13-byte `sRGB` chunk is spliced in immediately
1445    /// before the first `IDAT`. Pixel data (IDAT bytes) is never
1446    /// decoded or modified. See [`ensure_srgb_chunk`] for the exact
1447    /// splice operation.
1448    pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, SimctlError> {
1449        // v1.0.4 — pace + circuit-check before invoking simctl.
1450        let wait = {
1451            let mut pacer = self
1452                .screenshot_pacer
1453                .lock()
1454                .expect("screenshot pacer mutex must not be poisoned");
1455            pacer
1456                .compute_wait()
1457                .map_err(|retry_after| SimctlError::CaptureBackpressure { retry_after })?
1458        };
1459        if !wait.is_zero() {
1460            sleep(wait).await;
1461        }
1462
1463        let call_start = std::time::Instant::now();
1464        let tmp =
1465            std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
1466        let tmp_str = tmp.display().to_string();
1467        let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
1468        let bytes = result.and_then(|_| {
1469            std::fs::read(&tmp).map_err(|e| SimctlError::Malformed {
1470                subcommand: "screenshot".into(),
1471                detail: format!("read {tmp_str}: {e}"),
1472            })
1473        });
1474        let _ = std::fs::remove_file(&tmp);
1475
1476        let wall = call_start.elapsed();
1477        let failed = bytes.is_err();
1478        {
1479            let mut pacer = self
1480                .screenshot_pacer
1481                .lock()
1482                .expect("screenshot pacer mutex must not be poisoned");
1483            pacer.record(wall, failed);
1484        }
1485
1486        let bytes = bytes?;
1487        if bytes.len() < 8 {
1488            return Err(SimctlError::Malformed {
1489                subcommand: "screenshot".into(),
1490                detail: format!("screenshot file too short: {} bytes", bytes.len()),
1491            });
1492        }
1493        Ok(ensure_srgb_chunk(bytes))
1494    }
1495
1496    /// `xcrun simctl create <name> <device-type-id> <runtime-id>` → udid.
1497    pub async fn create_device(
1498        &self,
1499        name: &str,
1500        device_type: &str,
1501        runtime_id: &str,
1502    ) -> Result<String, SimctlError> {
1503        let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
1504        Ok(out.trim().to_string())
1505    }
1506
1507    /// `xcrun simctl delete <udid>` — delete a simulator device.
1508    pub async fn delete_device(&self, udid: &str) -> Result<(), SimctlError> {
1509        simctl_run(&["delete", udid]).await?;
1510        Ok(())
1511    }
1512}
1513
1514// -------------------- PNG sRGB chunk normalization (v1.0.2) --------------------
1515//
1516// iOS 26.5 sub-builds (mid-2026) started omitting the `sRGB` ancillary
1517// chunk from `simctl io screenshot` output. macOS Preview.app and other
1518// viewers that fall back to Display P3 when no ICC profile is embedded
1519// then over-saturate the image (red gets pushed, text anti-alias picks
1520// up yellow fringing).
1521//
1522// This does NOT affect pixel-comparison (dhash decodes IDAT to RGBA and
1523// ignores ancillary chunks), but does affect any downstream tool that
1524// renders the PNG for human review. The normalizer runs on the raw byte
1525// stream — walks chunks, and if no `sRGB` chunk is seen before the first
1526// `IDAT`, splices in a synthesized 13-byte `sRGB` chunk (length=1,
1527// type="sRGB", data=[0 = perceptual intent], CRC over type+data).
1528//
1529// Pixel-preservation invariant: IDAT bytes are never decoded. Every
1530// existing chunk is copied verbatim. Only 13 bytes of new metadata are
1531// inserted.
1532
1533const PNG_MAGIC: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
1534
1535/// Ensure the PNG carries an `sRGB` ancillary chunk. Called on the raw
1536/// bytes returned by `xcrun simctl io <udid> screenshot`. If the PNG
1537/// already has an `sRGB` chunk, returns the input unchanged; otherwise
1538/// splices in a 13-byte `sRGB` chunk (rendering intent = 0, perceptual)
1539/// immediately before the first `IDAT`. Returns the input unchanged on
1540/// any structural anomaly (missing magic, malformed chunk) so a
1541/// corrupted PNG is passed through untouched for the caller to diagnose.
1542pub fn ensure_srgb_chunk(bytes: Vec<u8>) -> Vec<u8> {
1543    if bytes.len() < 8 || &bytes[..8] != PNG_MAGIC {
1544        return bytes;
1545    }
1546    let Some((idat_offset, has_srgb)) = scan_png_chunks(&bytes) else {
1547        return bytes;
1548    };
1549    if has_srgb {
1550        return bytes;
1551    }
1552    // Splice the synthesized sRGB chunk right before the first IDAT.
1553    let mut out = Vec::with_capacity(bytes.len() + 13);
1554    out.extend_from_slice(&bytes[..idat_offset]);
1555    out.extend_from_slice(&synthesized_srgb_chunk());
1556    out.extend_from_slice(&bytes[idat_offset..]);
1557    out
1558}
1559
1560/// Walk PNG chunks starting after the 8-byte magic. Returns
1561/// `(offset_of_first_IDAT, has_srgb_chunk_before_it)` when the walk
1562/// reaches an IDAT chunk. Returns `None` if the walk hits EOF or a
1563/// malformed chunk without seeing an IDAT.
1564fn scan_png_chunks(bytes: &[u8]) -> Option<(usize, bool)> {
1565    let mut i: usize = 8;
1566    let mut has_srgb = false;
1567    while i + 8 <= bytes.len() {
1568        let length =
1569            u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1570        let ctype = &bytes[i + 4..i + 8];
1571        if ctype == b"IDAT" {
1572            return Some((i, has_srgb));
1573        }
1574        if ctype == b"sRGB" {
1575            has_srgb = true;
1576        }
1577        // 4 (length) + 4 (type) + length (data) + 4 (crc)
1578        let end = i.checked_add(12)?.checked_add(length)?;
1579        if end > bytes.len() {
1580            return None;
1581        }
1582        i = end;
1583    }
1584    None
1585}
1586
1587/// Build the 13-byte `sRGB` chunk with rendering intent = 0 (perceptual).
1588/// Format: `[len:4][type:4][data:1][crc:4]` = 13 bytes total.
1589fn synthesized_srgb_chunk() -> [u8; 13] {
1590    // The CRC is computed over `type || data`.
1591    let mut crc_input = [0u8; 5];
1592    crc_input[0..4].copy_from_slice(b"sRGB");
1593    crc_input[4] = 0; // perceptual
1594    let crc = crc32_ieee(&crc_input);
1595    let mut chunk = [0u8; 13];
1596    chunk[0..4].copy_from_slice(&1u32.to_be_bytes()); // length = 1 (data byte)
1597    chunk[4..8].copy_from_slice(b"sRGB");
1598    chunk[8] = 0;
1599    chunk[9..13].copy_from_slice(&crc.to_be_bytes());
1600    chunk
1601}
1602
1603/// Table-less CRC-32 IEEE 802.3 (polynomial 0xEDB88320) as used by
1604/// PNG. Small enough for this crate's single call site — avoids
1605/// pulling in a `crc32fast` dependency.
1606fn crc32_ieee(bytes: &[u8]) -> u32 {
1607    let mut crc: u32 = 0xFFFF_FFFF;
1608    for &b in bytes {
1609        crc ^= u32::from(b);
1610        for _ in 0..8 {
1611            let mask = 0u32.wrapping_sub(crc & 1);
1612            crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
1613        }
1614    }
1615    !crc
1616}
1617
1618#[cfg(test)]
1619mod tests {
1620    use super::*;
1621
1622    #[test]
1623    fn compose_child_env_adds_prefix() {
1624        let composed = compose_child_env(&[
1625            ("SMIX_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
1626            ("LAUNCH_FORCE_PUSH", "true"),
1627        ]);
1628        assert_eq!(
1629            composed,
1630            vec![
1631                (
1632                    "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
1633                    "http://127.0.0.1:9999".to_string(),
1634                ),
1635                (
1636                    "SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
1637                    "true".to_string(),
1638                ),
1639            ]
1640        );
1641    }
1642
1643    #[test]
1644    fn compose_child_env_already_prefixed_passes_through() {
1645        // Defensive: caller may pre-prefix; we must not double-prefix.
1646        let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
1647        assert_eq!(
1648            composed,
1649            vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
1650        );
1651    }
1652
1653    #[test]
1654    fn compose_child_env_empty_input_is_empty_output() {
1655        assert!(compose_child_env(&[]).is_empty());
1656    }
1657
1658    // -- v1.0.4 §G openurl URL preservation -----------------------------
1659
1660    #[test]
1661    fn openurl_argv_preserves_url_verbatim() {
1662        let udid = "12345678-1234-5678-1234-567812345678";
1663        let url = "exp+focus-ai-app://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081";
1664        let argv = super::openurl_argv(udid, url);
1665        assert_eq!(argv[0], "openurl");
1666        assert_eq!(argv[1], udid);
1667        // Byte-identical URL — no percent-decoding, no query-strip.
1668        assert_eq!(argv[2], url);
1669        assert!(argv[2].contains("?url="));
1670        assert!(argv[2].contains("%3A"));
1671        assert!(argv[2].contains("%2F"));
1672    }
1673
1674    #[test]
1675    fn openurl_argv_preserves_ampersand_and_hash() {
1676        let udid = "12345678-1234-5678-1234-567812345678";
1677        let url = "insight://dev-mutate?action=env&value=staging#anchor";
1678        let argv = super::openurl_argv(udid, url);
1679        assert_eq!(argv[2], url);
1680        assert!(argv[2].contains('&'));
1681        assert!(argv[2].contains('#'));
1682    }
1683
1684    #[test]
1685    fn openurl_argv_preserves_unicode() {
1686        let udid = "12345678-1234-5678-1234-567812345678";
1687        let url = "insight://route?name=%E7%94%B0%E4%B8%AD";
1688        let argv = super::openurl_argv(udid, url);
1689        assert_eq!(argv[2], url);
1690    }
1691
1692    // -- sRGB chunk normalization ---------------------------------------
1693
1694    /// Build a minimal PNG: 1×1 8-bit RGBA, one IDAT (zlib-empty-safe),
1695    /// with or without an sRGB chunk. Returns synthetic bytes suitable
1696    /// for exercising the chunk-walking logic; no rendering intent.
1697    fn synth_png(with_srgb: bool) -> Vec<u8> {
1698        let mut out = Vec::new();
1699        out.extend_from_slice(super::PNG_MAGIC);
1700        // IHDR: 1x1, bit_depth=8, color_type=6 (RGBA), rest=0
1701        let ihdr_data: [u8; 13] = [
1702            0, 0, 0, 1, // width = 1
1703            0, 0, 0, 1, // height = 1
1704            8, // bit depth
1705            6, // color type = RGBA
1706            0, 0, 0,
1707        ];
1708        emit_chunk(&mut out, b"IHDR", &ihdr_data);
1709        if with_srgb {
1710            emit_chunk(&mut out, b"sRGB", &[0]);
1711        }
1712        // Placeholder IDAT — content doesn't matter for chunk-walking tests
1713        emit_chunk(&mut out, b"IDAT", &[0x78, 0x01, 0x00, 0x00]);
1714        emit_chunk(&mut out, b"IEND", &[]);
1715        out
1716    }
1717
1718    fn emit_chunk(out: &mut Vec<u8>, ctype: &[u8; 4], data: &[u8]) {
1719        out.extend_from_slice(&(data.len() as u32).to_be_bytes());
1720        out.extend_from_slice(ctype);
1721        out.extend_from_slice(data);
1722        let mut crc_in = Vec::with_capacity(4 + data.len());
1723        crc_in.extend_from_slice(ctype);
1724        crc_in.extend_from_slice(data);
1725        out.extend_from_slice(&super::crc32_ieee(&crc_in).to_be_bytes());
1726    }
1727
1728    #[test]
1729    fn ensure_srgb_passthrough_when_chunk_present() {
1730        let png = synth_png(true);
1731        let original_len = png.len();
1732        let out = super::ensure_srgb_chunk(png.clone());
1733        assert_eq!(out.len(), original_len);
1734        assert_eq!(out, png);
1735    }
1736
1737    #[test]
1738    fn ensure_srgb_inserts_chunk_when_absent() {
1739        let png = synth_png(false);
1740        let original_len = png.len();
1741        let out = super::ensure_srgb_chunk(png);
1742        assert_eq!(out.len(), original_len + 13);
1743        // First 8 bytes = PNG magic
1744        assert_eq!(&out[..8], super::PNG_MAGIC);
1745        // Search for the injected sRGB chunk
1746        let mut found = false;
1747        for w in out.windows(4) {
1748            if w == b"sRGB" {
1749                found = true;
1750                break;
1751            }
1752        }
1753        assert!(found, "sRGB chunk should have been spliced in");
1754    }
1755
1756    #[test]
1757    fn ensure_srgb_preserves_idat_bytes_verbatim() {
1758        // Any pixel corruption at the IDAT level would break the
1759        // pixel-preservation invariant. Extract IDAT payload from
1760        // input and output, assert byte-identical.
1761        let png = synth_png(false);
1762        let out = super::ensure_srgb_chunk(png.clone());
1763        assert_eq!(extract_idat_data(&png), extract_idat_data(&out));
1764    }
1765
1766    fn extract_idat_data(bytes: &[u8]) -> Vec<u8> {
1767        let mut i = 8;
1768        while i + 8 <= bytes.len() {
1769            let length =
1770                u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1771            let ctype = &bytes[i + 4..i + 8];
1772            if ctype == b"IDAT" {
1773                return bytes[i + 8..i + 8 + length].to_vec();
1774            }
1775            i += 12 + length;
1776        }
1777        vec![]
1778    }
1779
1780    #[test]
1781    fn ensure_srgb_passthrough_on_bad_magic() {
1782        // Corrupted / non-PNG input must not be modified.
1783        let bytes = vec![0u8; 32];
1784        let out = super::ensure_srgb_chunk(bytes.clone());
1785        assert_eq!(out, bytes);
1786    }
1787
1788    #[test]
1789    fn crc32_matches_known_iend() {
1790        // The empty-data IEND CRC is a well-known constant.
1791        // CRC over "IEND" alone: 0xAE_42_60_82.
1792        assert_eq!(super::crc32_ieee(b"IEND"), 0xAE42_6082);
1793    }
1794}