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
20use serde::{Deserialize, Serialize};
21use std::io;
22use std::time::Duration;
23use thiserror::Error;
24use tokio::process::Command;
25use tokio::time::sleep;
26
27/// Failure variants for any `xcrun simctl` invocation.
28#[derive(Debug, Error)]
29pub enum SimctlError {
30    /// Failed to spawn the `xcrun` process itself (PATH lookup / fork failure).
31    #[error("spawn xcrun simctl failed: {0}")]
32    Spawn(#[from] io::Error),
33    /// `xcrun simctl <sub>` exited non-zero.
34    #[error("xcrun simctl {subcommand} exited {code}: {stderr}")]
35    NonZeroExit {
36        /// Subcommand name (e.g. `"boot"`, `"launch"`).
37        subcommand: String,
38        /// Exit code from `xcrun simctl`.
39        code: i32,
40        /// Captured stderr (truncated for log-friendliness).
41        stderr: String,
42    },
43    /// `xcrun simctl <sub>` exited 0 but stdout didn't match the expected shape.
44    #[error("xcrun simctl {subcommand} returned malformed output: {detail}")]
45    Malformed {
46        /// Subcommand name.
47        subcommand: String,
48        /// Parser-side detail.
49        detail: String,
50    },
51    /// `xcrun simctl <sub>` did not complete within the deadline.
52    #[error("xcrun simctl {subcommand} timed out after {ms}ms")]
53    Timeout {
54        /// Subcommand name.
55        subcommand: String,
56        /// Deadline that was exceeded (milliseconds).
57        ms: u64,
58    },
59}
60
61/// Handle to an active `xcrun simctl io recordVideo` child process. Pair
62/// with [`SimctlClient::record_video_stop`] for SIGINT-and-wait shutdown
63/// (so the mp4 trailer is flushed). Dropping the handle without `stop`
64/// would tokio-SIGKILL on Drop and truncate the output file.
65#[derive(Debug)]
66pub struct RecordingHandle {
67    pub(crate) child: tokio::process::Child,
68    /// Output mp4 path verbatim as passed to `record_video_start`.
69    pub path: String,
70    /// Wall-clock start time for "recording in progress for Xs" diagnostics.
71    pub started_at: std::time::Instant,
72}
73
74// -------------------- types ----------------------------------------------
75
76/// One iOS / watchOS / tvOS runtime installed on the host.
77#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
78pub struct SimctlRuntime {
79    /// Fully-qualified runtime identifier (e.g. `"com.apple.CoreSimulator.SimRuntime.iOS-17-0"`).
80    pub identifier: String,
81    /// Human-readable name (e.g. `"iOS 17.0"`).
82    pub name: String,
83    /// Version string (e.g. `"17.0"`).
84    pub version: String,
85    /// Whether the runtime is available for booting devices.
86    pub is_available: bool,
87}
88
89/// One simulator device known to `xcrun simctl`.
90#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
91pub struct SimctlDevice {
92    /// Device UDID (stable identifier).
93    pub udid: String,
94    /// Human-readable name.
95    pub name: String,
96    /// Current state (`"Booted"` / `"Shutdown"` / `"Creating"` / etc.).
97    pub state: String,
98    /// Whether the device is available for booting.
99    pub is_available: bool,
100    /// Device-type identifier (e.g. `"com.apple.CoreSimulator.SimDeviceType.iPhone-15"`).
101    #[serde(rename = "deviceTypeIdentifier", default)]
102    pub device_type_identifier: String,
103    /// Runtime identifier this device was created against.
104    #[serde(rename = "runtimeIdentifier", default)]
105    pub runtime_identifier: String,
106}
107
108/// Permission names accepted by `xcrun simctl privacy <udid> grant <name>`.
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110pub enum SimctlPermission {
111    /// Camera access.
112    Camera,
113    /// Photos library access.
114    Photos,
115    /// Location access (while-in-use).
116    Location,
117    /// Background location access (always).
118    LocationAlways,
119    /// Notification posting permission.
120    Notifications,
121    /// Microphone access.
122    Microphone,
123    /// Contacts access.
124    Contacts,
125    /// Calendar events access.
126    Calendar,
127    /// Reminders access.
128    Reminders,
129    /// Media library (music / video) access.
130    Media,
131    /// Motion / fitness sensor access.
132    Motion,
133    /// HomeKit accessory access.
134    HomeKit,
135    /// HealthKit data access.
136    Health,
137    /// Bluetooth device discovery / connection.
138    Bluetooth,
139    /// FaceID / TouchID biometric prompt.
140    Faceid,
141    /// Address-book (deprecated alias for `Contacts`).
142    AddressBook,
143}
144
145impl SimctlPermission {
146    /// Wire string used by `xcrun simctl privacy <udid> grant <name>`.
147    pub fn as_str(self) -> &'static str {
148        match self {
149            SimctlPermission::Camera => "camera",
150            SimctlPermission::Photos => "photos",
151            SimctlPermission::Location => "location",
152            SimctlPermission::LocationAlways => "location-always",
153            SimctlPermission::Notifications => "notifications",
154            SimctlPermission::Microphone => "microphone",
155            SimctlPermission::Contacts => "contacts",
156            SimctlPermission::Calendar => "calendar",
157            SimctlPermission::Reminders => "reminders",
158            SimctlPermission::Media => "media-library",
159            SimctlPermission::Motion => "motion",
160            SimctlPermission::HomeKit => "homekit",
161            SimctlPermission::Health => "health",
162            SimctlPermission::Bluetooth => "bluetooth",
163            SimctlPermission::Faceid => "faceid",
164            SimctlPermission::AddressBook => "addressbook",
165        }
166    }
167}
168
169/// UI appearance mode for `xcrun simctl ui <udid> appearance`.
170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
171pub enum Appearance {
172    /// Light mode.
173    Light,
174    /// Dark mode.
175    Dark,
176}
177
178impl Appearance {
179    /// Wire string used by `xcrun simctl ui <udid> appearance <mode>`.
180    pub fn as_str(self) -> &'static str {
181        match self {
182            Appearance::Light => "light",
183            Appearance::Dark => "dark",
184        }
185    }
186}
187
188/// Launched-app result.
189#[derive(Clone, Debug, PartialEq, Eq)]
190pub struct LaunchResult {
191    /// Process ID of the launched app.
192    pub pid: u32,
193}
194
195// -------------------- raw spawn primitive --------------------------------
196
197/// Execute `xcrun simctl <args>` and capture stdout/stderr.
198async fn simctl_capture(args: &[&str]) -> Result<(Vec<u8>, String), SimctlError> {
199    simctl_capture_env(args, &[]).await
200}
201
202/// `simctl_capture` with extra envp pairs set on the spawned process.
203/// The `xcrun simctl launch` subcommand uses this to inject
204/// `SIMCTL_CHILD_<KEY>=<VAL>` vars that the launched app sees as
205/// `ProcessInfo().environment["KEY"]`. `env` entries here are passed
206/// verbatim — caller composes the `SIMCTL_CHILD_` prefix via
207/// [`compose_child_env`].
208async fn simctl_capture_env(
209    args: &[&str],
210    env: &[(String, String)],
211) -> Result<(Vec<u8>, String), SimctlError> {
212    let mut cmd = Command::new("xcrun");
213    cmd.arg("simctl");
214    for a in args {
215        cmd.arg(a);
216    }
217    for (k, v) in env {
218        cmd.env(k, v);
219    }
220    let output = cmd.output().await?;
221    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
222    if !output.status.success() {
223        return Err(SimctlError::NonZeroExit {
224            subcommand: args.first().map(|s| s.to_string()).unwrap_or_default(),
225            code: output.status.code().unwrap_or(-1),
226            stderr,
227        });
228    }
229    Ok((output.stdout, stderr))
230}
231
232async fn simctl_run(args: &[&str]) -> Result<String, SimctlError> {
233    let (stdout, _) = simctl_capture(args).await?;
234    Ok(String::from_utf8_lossy(&stdout).into_owned())
235}
236
237/// Like [`simctl_run`] but injects `child_env` envp on the spawned
238/// process. Used by the env-aware launch path so the launched app can
239/// read deploy-time secrets / endpoints via `ProcessInfo`.
240async fn simctl_run_env(args: &[&str], env: &[(String, String)]) -> Result<String, SimctlError> {
241    let (stdout, _) = simctl_capture_env(args, env).await?;
242    Ok(String::from_utf8_lossy(&stdout).into_owned())
243}
244
245/// Compose user-provided `(key, value)` pairs into the `SIMCTL_CHILD_*`
246/// envp that `xcrun simctl launch` strips and delivers to the launched
247/// app. Idempotent: a key that already starts with `SIMCTL_CHILD_` is
248/// passed through unchanged.
249///
250/// # Example
251///
252/// ```
253/// use smix_simctl::compose_child_env;
254/// let composed = compose_child_env(&[("SMIX_PERF_RECEIVER_URL", "http://h:9999")]);
255/// assert_eq!(
256///     composed,
257///     vec![(
258///         "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
259///         "http://h:9999".to_string(),
260///     )]
261/// );
262/// ```
263pub fn compose_child_env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
264    pairs
265        .iter()
266        .map(|(k, v)| {
267            let key = if k.starts_with("SIMCTL_CHILD_") {
268                (*k).to_string()
269            } else {
270                format!("SIMCTL_CHILD_{k}")
271            };
272            (key, (*v).to_string())
273        })
274        .collect()
275}
276
277// -------------------- client --------------------------------------------
278
279/// Stateless wrapper around xcrun simctl. Methods are free functions
280/// in spirit (no instance state beyond optionally-cached `xcrun` path);
281/// kept as a struct for API ergonomics + future caching.
282#[derive(Debug, Default)]
283pub struct SimctlClient {}
284
285impl SimctlClient {
286    /// Construct a new client (stateless — equivalent to `default()`).
287    pub fn new() -> Self {
288        SimctlClient {}
289    }
290
291    // ---- inventory ------------------------------------------------------
292
293    /// `xcrun simctl list runtimes -j` → `Vec<SimctlRuntime>`.
294    pub async fn list_runtimes(&self) -> Result<Vec<SimctlRuntime>, SimctlError> {
295        let raw = simctl_run(&["list", "runtimes", "-j"]).await?;
296        #[derive(Deserialize)]
297        struct Wrap {
298            runtimes: Vec<RawRuntime>,
299        }
300        #[derive(Deserialize)]
301        struct RawRuntime {
302            identifier: String,
303            name: String,
304            version: String,
305            #[serde(rename = "isAvailable", default)]
306            is_available: bool,
307        }
308        let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
309            subcommand: "list runtimes".into(),
310            detail: e.to_string(),
311        })?;
312        Ok(w.runtimes
313            .into_iter()
314            .map(|r| SimctlRuntime {
315                identifier: r.identifier,
316                name: r.name,
317                version: r.version,
318                is_available: r.is_available,
319            })
320            .collect())
321    }
322
323    /// `xcrun simctl list devices -j` → flattened `Vec<SimctlDevice>`.
324    pub async fn list_devices(&self) -> Result<Vec<SimctlDevice>, SimctlError> {
325        let raw = simctl_run(&["list", "devices", "-j"]).await?;
326        #[derive(Deserialize)]
327        struct Wrap {
328            devices: std::collections::BTreeMap<String, Vec<RawDevice>>,
329        }
330        #[derive(Deserialize)]
331        struct RawDevice {
332            udid: String,
333            name: String,
334            state: String,
335            #[serde(rename = "isAvailable", default)]
336            is_available: bool,
337            #[serde(rename = "deviceTypeIdentifier", default)]
338            device_type_identifier: String,
339        }
340        let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
341            subcommand: "list devices".into(),
342            detail: e.to_string(),
343        })?;
344        let mut out = Vec::new();
345        for (runtime_id, devices) in w.devices {
346            for d in devices {
347                out.push(SimctlDevice {
348                    udid: d.udid,
349                    name: d.name,
350                    state: d.state,
351                    is_available: d.is_available,
352                    device_type_identifier: d.device_type_identifier,
353                    runtime_identifier: runtime_id.clone(),
354                });
355            }
356        }
357        Ok(out)
358    }
359
360    // ---- lifecycle ------------------------------------------------------
361
362    /// `xcrun simctl boot <udid>` — fire-and-forget boot request.
363    pub async fn boot(&self, udid: &str) -> Result<(), SimctlError> {
364        simctl_run(&["boot", udid]).await?;
365        Ok(())
366    }
367
368    /// `xcrun simctl shutdown <udid>`.
369    pub async fn shutdown(&self, udid: &str) -> Result<(), SimctlError> {
370        simctl_run(&["shutdown", udid]).await?;
371        Ok(())
372    }
373
374    /// Read the sim's current BCP-47 locale (first entry of
375    /// `NSGlobalDomain AppleLanguages`). Returns `Ok(None)` when the
376    /// preference is unset (defaults read exits non-zero) or unparseable.
377    /// Wire format: `simctl spawn <udid> defaults read -g AppleLanguages`
378    /// stdout looks like `"(\n    \"en-US\"\n)\n"`; we extract the first
379    /// quoted token.
380    pub async fn current_locale(&self, udid: &str) -> Result<Option<String>, SimctlError> {
381        let out =
382            match simctl_run(&["spawn", udid, "defaults", "read", "-g", "AppleLanguages"]).await {
383                Ok(s) => s,
384                // `defaults read` returns non-zero when the key is unset; that
385                // is a legitimate "no opinion" state, not an error.
386                Err(SimctlError::NonZeroExit { .. }) => return Ok(None),
387                Err(e) => return Err(e),
388            };
389        // First quoted substring.
390        if let Some(start) = out.find('"') {
391            let rest = &out[start + 1..];
392            if let Some(end) = rest.find('"') {
393                return Ok(Some(rest[..end].to_string()));
394            }
395        }
396        Ok(None)
397    }
398
399    /// Write `AppleLanguages` (array) + `AppleLocale` (scalar) to the
400    /// sim's NSGlobalDomain so SpringBoard + apps re-localize on next
401    /// launch. AppleLocale is BCP-47 with hyphen replaced by underscore
402    /// (`en_US`); AppleLanguages is the BCP-47 tag verbatim.
403    /// **The caller must shutdown + reboot the sim for the change to
404    /// take effect** — running apps cache the locale at process start.
405    pub async fn set_locale(&self, udid: &str, locale: &str) -> Result<(), SimctlError> {
406        simctl_run(&[
407            "spawn",
408            udid,
409            "defaults",
410            "write",
411            "-g",
412            "AppleLanguages",
413            "-array",
414            locale,
415        ])
416        .await?;
417        let locale_underscore = locale.replace('-', "_");
418        simctl_run(&[
419            "spawn",
420            udid,
421            "defaults",
422            "write",
423            "-g",
424            "AppleLocale",
425            &locale_underscore,
426        ])
427        .await?;
428        Ok(())
429    }
430
431    /// Boot + poll device state == "Booted" within timeout. Tries every
432    /// 500 ms until success or `timeout_ms` elapses. Idempotent on
433    /// already-booted devices (`xcrun simctl boot` returns non-zero when
434    /// the device is already booted; we swallow that).
435    pub async fn boot_and_wait(&self, udid: &str, timeout: Duration) -> Result<(), SimctlError> {
436        // Issue boot; ignore already-booted error (the only friendly path).
437        let _ = simctl_run(&["boot", udid]).await;
438        let start = std::time::Instant::now();
439        loop {
440            let devices = self.list_devices().await?;
441            if devices
442                .iter()
443                .any(|d| d.udid == udid && d.state == "Booted")
444            {
445                return Ok(());
446            }
447            if start.elapsed() > timeout {
448                return Err(SimctlError::Timeout {
449                    subcommand: format!("boot {}", udid),
450                    ms: timeout.as_millis() as u64,
451                });
452            }
453            sleep(Duration::from_millis(500)).await;
454        }
455    }
456
457    /// `xcrun simctl erase <udid>` — wipe device contents.
458    pub async fn erase(&self, udid: &str) -> Result<(), SimctlError> {
459        simctl_run(&["erase", udid]).await?;
460        Ok(())
461    }
462
463    /// `xcrun simctl install <udid> <app-path>` — install a `.app` bundle.
464    pub async fn install(&self, udid: &str, app_path: &str) -> Result<(), SimctlError> {
465        simctl_run(&["install", udid, app_path]).await?;
466        Ok(())
467    }
468
469    /// `xcrun simctl uninstall <udid> <bundle-id>`.
470    pub async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
471        simctl_run(&["uninstall", udid, bundle_id]).await?;
472        Ok(())
473    }
474
475    /// `xcrun simctl terminate <udid> <bundle-id>` — kill a running app.
476    pub async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
477        simctl_run(&["terminate", udid, bundle_id]).await?;
478        Ok(())
479    }
480
481    /// `xcrun simctl launch <udid> <bundleId>` → parse `"<bundle>: <pid>"`.
482    pub async fn launch(&self, udid: &str, bundle_id: &str) -> Result<LaunchResult, SimctlError> {
483        self.launch_with_args(udid, bundle_id, &[]).await
484    }
485
486    /// `xcrun simctl launch <udid> <bundleId> -- <arg>...` — launch with a
487    /// process-level argument vector. Empty `args` is equivalent to
488    /// [`Self::launch`]. Mirrors maestro yaml `launchApp.arguments`.
489    pub async fn launch_with_args(
490        &self,
491        udid: &str,
492        bundle_id: &str,
493        args: &[String],
494    ) -> Result<LaunchResult, SimctlError> {
495        self.launch_with_args_and_env(udid, bundle_id, args, &[])
496            .await
497    }
498
499    /// Like [`Self::launch_with_args`] but also sets `SIMCTL_CHILD_*`
500    /// envp on the simctl process so the launched app can read
501    /// deploy-time vars via `ProcessInfo().environment["KEY"]`.
502    /// `child_env` keys without the `SIMCTL_CHILD_` prefix get it added
503    /// automatically (per [`compose_child_env`] semantics). Useful for
504    /// prelaunching an app before any `openLink` so iOS treats the
505    /// subsequent URL handoff as in-app routing instead of cross-app,
506    /// side-stepping the SpringBoard "Open in '`<App>`'?" confirmation
507    /// dialog.
508    pub async fn launch_with_args_and_env(
509        &self,
510        udid: &str,
511        bundle_id: &str,
512        args: &[String],
513        child_env: &[(&str, &str)],
514    ) -> Result<LaunchResult, SimctlError> {
515        let mut argv: Vec<&str> = vec!["launch", udid, bundle_id];
516        if !args.is_empty() {
517            argv.push("--");
518            for a in args {
519                argv.push(a.as_str());
520            }
521        }
522        let composed = compose_child_env(child_env);
523        let out = simctl_run_env(&argv, &composed).await?;
524        // Output format: `com.example.app: 12345\n`
525        let pid_str =
526            out.rsplit(':')
527                .next()
528                .map(str::trim)
529                .ok_or_else(|| SimctlError::Malformed {
530                    subcommand: "launch".into(),
531                    detail: format!("unexpected stdout shape: {}", out.trim()),
532                })?;
533        let pid: u32 = pid_str.parse().map_err(|_| SimctlError::Malformed {
534            subcommand: "launch".into(),
535            detail: format!("non-numeric pid in stdout: {}", out.trim()),
536        })?;
537        Ok(LaunchResult { pid })
538    }
539
540    /// `xcrun simctl openurl <udid> <url>` — open a URL on the device.
541    pub async fn open_url(&self, udid: &str, url: &str) -> Result<(), SimctlError> {
542        simctl_run(&["openurl", udid, url]).await?;
543        Ok(())
544    }
545
546    /// `xcrun simctl push <udid> <bundle-id> <apns-json-path>`.
547    /// Deliver an APNS payload to a sim-installed app. The payload file is
548    /// a JSON document whose top-level dictionary mirrors what an APNS
549    /// provider would send; `aps.alert.body` / `aps.alert.title` surface
550    /// as banner content and reach the app's
551    /// `UNUserNotificationCenterDelegate`.
552    pub async fn send_push(
553        &self,
554        udid: &str,
555        bundle_id: &str,
556        apns_json_path: &str,
557    ) -> Result<(), SimctlError> {
558        simctl_run(&["push", udid, bundle_id, apns_json_path]).await?;
559        Ok(())
560    }
561
562    /// `xcrun simctl ui <udid> appearance <light|dark>` — set UI appearance.
563    pub async fn set_appearance(&self, udid: &str, mode: Appearance) -> Result<(), SimctlError> {
564        simctl_run(&["ui", udid, "appearance", mode.as_str()]).await?;
565        Ok(())
566    }
567
568    /// `xcrun simctl privacy <udid> grant <perm> <bundle-id>`.
569    pub async fn grant_permission(
570        &self,
571        udid: &str,
572        permission: SimctlPermission,
573        bundle_id: &str,
574    ) -> Result<(), SimctlError> {
575        simctl_run(&["privacy", udid, "grant", permission.as_str(), bundle_id]).await?;
576        Ok(())
577    }
578
579    /// `xcrun simctl privacy <udid> revoke <perm> <bundle-id>` — explicitly
580    /// deny the permission. Mirrors maestro yaml `permissions: { x: deny }`
581    /// (the reverse of `grant`). Distinct from `reset`, which returns the
582    /// permission to "not determined".
583    pub async fn revoke_permission(
584        &self,
585        udid: &str,
586        permission: SimctlPermission,
587        bundle_id: &str,
588    ) -> Result<(), SimctlError> {
589        simctl_run(&["privacy", udid, "revoke", permission.as_str(), bundle_id]).await?;
590        Ok(())
591    }
592
593    /// `xcrun simctl location <udid> set <lat>,<lng>` — set sim location
594    /// to a fixed point. Mirrors maestro `setLocation`.
595    pub async fn location_set(
596        &self,
597        udid: &str,
598        latitude: f64,
599        longitude: f64,
600    ) -> Result<(), SimctlError> {
601        let coord = format!("{latitude},{longitude}");
602        simctl_run(&["location", udid, "set", &coord]).await?;
603        Ok(())
604    }
605
606    /// `xcrun simctl location <udid> start [--speed=<m/s>] <waypoints>`
607    /// — interpolate sim location along waypoints. Fire-and-return: simctl
608    /// injects scenario and returns; sim continues interpolation in background.
609    /// Mirrors maestro `travel`.
610    pub async fn location_start(
611        &self,
612        udid: &str,
613        points: &[(f64, f64)],
614        speed_mps: Option<f64>,
615    ) -> Result<(), SimctlError> {
616        if points.len() < 2 {
617            return Err(SimctlError::Malformed {
618                subcommand: "location-start".into(),
619                detail: format!("requires ≥2 waypoints, got {}", points.len()),
620            });
621        }
622        let mut args: Vec<String> = vec!["location".into(), udid.into(), "start".into()];
623        if let Some(s) = speed_mps {
624            args.push(format!("--speed={s}"));
625        }
626        for (lat, lng) in points {
627            args.push(format!("{lat},{lng}"));
628        }
629        let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
630        simctl_run(&args_ref).await?;
631        Ok(())
632    }
633
634    /// `xcrun simctl location <udid> clear` — reset active location
635    /// scenario.
636    pub async fn location_clear(&self, udid: &str) -> Result<(), SimctlError> {
637        simctl_run(&["location", udid, "clear"]).await?;
638        Ok(())
639    }
640
641    /// `xcrun simctl addmedia <udid> <path>...` — add photos / videos /
642    /// contacts to sim library. Mirrors maestro `addMedia` (scalar or
643    /// array form already flattened on adapter side).
644    pub async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), SimctlError> {
645        if paths.is_empty() {
646            return Err(SimctlError::Malformed {
647                subcommand: "addmedia".into(),
648                detail: "no paths supplied".into(),
649            });
650        }
651        let mut args: Vec<&str> = vec!["addmedia", udid];
652        for p in paths {
653            args.push(p.as_str());
654        }
655        simctl_run(&args).await?;
656        Ok(())
657    }
658
659    /// Start recording sim display to `path`. Spawns
660    /// `xcrun simctl io <udid> recordVideo <path>` as a long-running child;
661    /// returns handle immediately. Caller must pair with
662    /// [`Self::record_video_stop`] for clean SIGINT-and-wait shutdown —
663    /// dropping the handle would SIGKILL via tokio + lose mp4 trailer.
664    pub async fn record_video_start(
665        &self,
666        udid: &str,
667        path: &str,
668    ) -> Result<RecordingHandle, SimctlError> {
669        let child = tokio::process::Command::new("xcrun")
670            .args(["simctl", "io", udid, "recordVideo", path])
671            .stdin(std::process::Stdio::null())
672            .stdout(std::process::Stdio::piped())
673            .stderr(std::process::Stdio::piped())
674            .spawn()?;
675        // brief settle for simctl to initialize encoder + open output file.
676        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
677        Ok(RecordingHandle {
678            child,
679            path: path.to_string(),
680            started_at: std::time::Instant::now(),
681        })
682    }
683
684    /// Stop a recording via SIGINT + wait (≤10s). SIGINT lets simctl
685    /// trap and flush the mp4 trailer; SIGKILL would corrupt output.
686    /// Timeout escalates to SIGKILL with explicit error mentioning truncation.
687    pub async fn record_video_stop(&self, mut handle: RecordingHandle) -> Result<(), SimctlError> {
688        let pid = handle.child.id().ok_or_else(|| SimctlError::Malformed {
689            subcommand: "recordVideo-stop".into(),
690            detail: "child already reaped".into(),
691        })?;
692        // SAFETY: libc::kill is a thin POSIX syscall wrapper; pid is owned by
693        // this Child instance (no race) and SIGINT is signal-safe.
694        let rc = unsafe { libc::kill(pid as i32, libc::SIGINT) };
695        if rc != 0 {
696            return Err(SimctlError::Malformed {
697                subcommand: "recordVideo-stop".into(),
698                detail: format!(
699                    "kill SIGINT failed: errno={}",
700                    std::io::Error::last_os_error()
701                ),
702            });
703        }
704        let wait_result =
705            tokio::time::timeout(std::time::Duration::from_secs(10), handle.child.wait()).await;
706        match wait_result {
707            Ok(Ok(_status)) => Ok(()),
708            Ok(Err(e)) => Err(SimctlError::Malformed {
709                subcommand: "recordVideo-stop".into(),
710                detail: format!("wait failed: {e}"),
711            }),
712            Err(_timeout) => {
713                let _ = handle.child.kill().await;
714                Err(SimctlError::Malformed {
715                    subcommand: "recordVideo-stop".into(),
716                    detail: "SIGINT timeout (10s) — escalated SIGKILL; output mp4 likely truncated. Inspect simctl recordVideo stderr.".into(),
717                })
718            }
719        }
720    }
721
722    /// `xcrun simctl privacy <udid> reset <perm> <bundle-id>` — return the
723    /// permission to "not determined" so the next request re-prompts.
724    /// May terminate a running instance of the target app (Apple
725    /// behavior) — call before launch, not mid-flow.
726    pub async fn reset_permission(
727        &self,
728        udid: &str,
729        permission: SimctlPermission,
730        bundle_id: &str,
731    ) -> Result<(), SimctlError> {
732        simctl_run(&["privacy", udid, "reset", permission.as_str(), bundle_id]).await?;
733        Ok(())
734    }
735
736    /// `xcrun simctl keychain <udid> reset` — clear all keychain entries.
737    pub async fn keychain_reset(&self, udid: &str) -> Result<(), SimctlError> {
738        simctl_run(&["keychain", udid, "reset"]).await?;
739        Ok(())
740    }
741
742    /// `xcrun simctl pbpaste <udid>` — read clipboard contents.
743    pub async fn pasteboard_get(&self, udid: &str) -> Result<String, SimctlError> {
744        simctl_run(&["pbpaste", udid]).await
745    }
746
747    /// `xcrun simctl pbcopy <udid>` — write clipboard contents (via piped stdin).
748    pub async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), SimctlError> {
749        // pbcopy reads stdin — we pipe via shell echo for simplicity.
750        // Long-term: spawn with stdin pipe.
751        use tokio::io::AsyncWriteExt;
752        let mut cmd = Command::new("xcrun");
753        cmd.arg("simctl").arg("pbcopy").arg(udid);
754        cmd.stdin(std::process::Stdio::piped());
755        let mut child = cmd.spawn()?;
756        if let Some(mut stdin) = child.stdin.take() {
757            stdin.write_all(text.as_bytes()).await?;
758            drop(stdin); // close stdin so pbcopy returns
759        }
760        let status = child.wait().await?;
761        if !status.success() {
762            return Err(SimctlError::NonZeroExit {
763                subcommand: "pbcopy".into(),
764                code: status.code().unwrap_or(-1),
765                stderr: String::new(),
766            });
767        }
768        Ok(())
769    }
770
771    /// Toggle "Reduce Motion" accessibility setting via `defaults write`.
772    pub async fn set_reduce_motion(&self, udid: &str, enabled: bool) -> Result<(), SimctlError> {
773        let val = if enabled { "1" } else { "0" };
774        // `defaults write` lives under spawn; routed via simctl spawn.
775        simctl_run(&[
776            "spawn",
777            udid,
778            "defaults",
779            "write",
780            "com.apple.UIKit",
781            "UIAccessibilityReduceMotionEnabled",
782            "-bool",
783            val,
784        ])
785        .await?;
786        Ok(())
787    }
788
789    /// `xcrun simctl io <udid> screenshot <tmpfile>` → raw PNG bytes,
790    /// with a byte-level sRGB metadata splice if the produced PNG lacks
791    /// an `sRGB` chunk.
792    ///
793    /// Goes through a temp file: current Xcode's `screenshot -` does not
794    /// treat `-` as stdout — it writes a literal file named `-` in cwd
795    /// and emits nothing on stdout (observed on Xcode/iOS 26.5).
796    ///
797    /// **Pixel-preservation invariant**: the returned bytes are
798    /// byte-identical to whatever `simctl io screenshot` wrote to disk
799    /// EXCEPT for one narrow case — if the PNG does not carry an
800    /// `sRGB` ancillary chunk (observed on iOS 26.5 sub-builds
801    /// mid-2026), a 13-byte `sRGB` chunk is spliced in immediately
802    /// before the first `IDAT`. Pixel data (IDAT bytes) is never
803    /// decoded or modified. See [`ensure_srgb_chunk`] for the exact
804    /// splice operation.
805    pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, SimctlError> {
806        let tmp =
807            std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
808        let tmp_str = tmp.display().to_string();
809        let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
810        let bytes = result.and_then(|_| {
811            std::fs::read(&tmp).map_err(|e| SimctlError::Malformed {
812                subcommand: "screenshot".into(),
813                detail: format!("read {tmp_str}: {e}"),
814            })
815        });
816        let _ = std::fs::remove_file(&tmp);
817        let bytes = bytes?;
818        if bytes.len() < 8 {
819            return Err(SimctlError::Malformed {
820                subcommand: "screenshot".into(),
821                detail: format!("screenshot file too short: {} bytes", bytes.len()),
822            });
823        }
824        Ok(ensure_srgb_chunk(bytes))
825    }
826
827    /// `xcrun simctl create <name> <device-type-id> <runtime-id>` → udid.
828    pub async fn create_device(
829        &self,
830        name: &str,
831        device_type: &str,
832        runtime_id: &str,
833    ) -> Result<String, SimctlError> {
834        let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
835        Ok(out.trim().to_string())
836    }
837
838    /// `xcrun simctl delete <udid>` — delete a simulator device.
839    pub async fn delete_device(&self, udid: &str) -> Result<(), SimctlError> {
840        simctl_run(&["delete", udid]).await?;
841        Ok(())
842    }
843}
844
845// -------------------- PNG sRGB chunk normalization (v1.0.2) --------------------
846//
847// iOS 26.5 sub-builds (mid-2026) started omitting the `sRGB` ancillary
848// chunk from `simctl io screenshot` output. macOS Preview.app and other
849// viewers that fall back to Display P3 when no ICC profile is embedded
850// then over-saturate the image (red gets pushed, text anti-alias picks
851// up yellow fringing).
852//
853// This does NOT affect pixel-comparison (dhash decodes IDAT to RGBA and
854// ignores ancillary chunks), but does affect any downstream tool that
855// renders the PNG for human review. The normalizer runs on the raw byte
856// stream — walks chunks, and if no `sRGB` chunk is seen before the first
857// `IDAT`, splices in a synthesized 13-byte `sRGB` chunk (length=1,
858// type="sRGB", data=[0 = perceptual intent], CRC over type+data).
859//
860// Pixel-preservation invariant: IDAT bytes are never decoded. Every
861// existing chunk is copied verbatim. Only 13 bytes of new metadata are
862// inserted.
863
864const PNG_MAGIC: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
865
866/// Ensure the PNG carries an `sRGB` ancillary chunk. Called on the raw
867/// bytes returned by `xcrun simctl io <udid> screenshot`. If the PNG
868/// already has an `sRGB` chunk, returns the input unchanged; otherwise
869/// splices in a 13-byte `sRGB` chunk (rendering intent = 0, perceptual)
870/// immediately before the first `IDAT`. Returns the input unchanged on
871/// any structural anomaly (missing magic, malformed chunk) so a
872/// corrupted PNG is passed through untouched for the caller to diagnose.
873pub fn ensure_srgb_chunk(bytes: Vec<u8>) -> Vec<u8> {
874    if bytes.len() < 8 || &bytes[..8] != PNG_MAGIC {
875        return bytes;
876    }
877    let Some((idat_offset, has_srgb)) = scan_png_chunks(&bytes) else {
878        return bytes;
879    };
880    if has_srgb {
881        return bytes;
882    }
883    // Splice the synthesized sRGB chunk right before the first IDAT.
884    let mut out = Vec::with_capacity(bytes.len() + 13);
885    out.extend_from_slice(&bytes[..idat_offset]);
886    out.extend_from_slice(&synthesized_srgb_chunk());
887    out.extend_from_slice(&bytes[idat_offset..]);
888    out
889}
890
891/// Walk PNG chunks starting after the 8-byte magic. Returns
892/// `(offset_of_first_IDAT, has_srgb_chunk_before_it)` when the walk
893/// reaches an IDAT chunk. Returns `None` if the walk hits EOF or a
894/// malformed chunk without seeing an IDAT.
895fn scan_png_chunks(bytes: &[u8]) -> Option<(usize, bool)> {
896    let mut i: usize = 8;
897    let mut has_srgb = false;
898    while i + 8 <= bytes.len() {
899        let length =
900            u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
901        let ctype = &bytes[i + 4..i + 8];
902        if ctype == b"IDAT" {
903            return Some((i, has_srgb));
904        }
905        if ctype == b"sRGB" {
906            has_srgb = true;
907        }
908        // 4 (length) + 4 (type) + length (data) + 4 (crc)
909        let end = i.checked_add(12)?.checked_add(length)?;
910        if end > bytes.len() {
911            return None;
912        }
913        i = end;
914    }
915    None
916}
917
918/// Build the 13-byte `sRGB` chunk with rendering intent = 0 (perceptual).
919/// Format: `[len:4][type:4][data:1][crc:4]` = 13 bytes total.
920fn synthesized_srgb_chunk() -> [u8; 13] {
921    // The CRC is computed over `type || data`.
922    let mut crc_input = [0u8; 5];
923    crc_input[0..4].copy_from_slice(b"sRGB");
924    crc_input[4] = 0; // perceptual
925    let crc = crc32_ieee(&crc_input);
926    let mut chunk = [0u8; 13];
927    chunk[0..4].copy_from_slice(&1u32.to_be_bytes()); // length = 1 (data byte)
928    chunk[4..8].copy_from_slice(b"sRGB");
929    chunk[8] = 0;
930    chunk[9..13].copy_from_slice(&crc.to_be_bytes());
931    chunk
932}
933
934/// Table-less CRC-32 IEEE 802.3 (polynomial 0xEDB88320) as used by
935/// PNG. Small enough for this crate's single call site — avoids
936/// pulling in a `crc32fast` dependency.
937fn crc32_ieee(bytes: &[u8]) -> u32 {
938    let mut crc: u32 = 0xFFFF_FFFF;
939    for &b in bytes {
940        crc ^= u32::from(b);
941        for _ in 0..8 {
942            let mask = 0u32.wrapping_sub(crc & 1);
943            crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
944        }
945    }
946    !crc
947}
948
949#[cfg(test)]
950mod tests {
951    use super::*;
952
953    #[test]
954    fn compose_child_env_adds_prefix() {
955        let composed = compose_child_env(&[
956            ("SMIX_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
957            ("LAUNCH_FORCE_PUSH", "true"),
958        ]);
959        assert_eq!(
960            composed,
961            vec![
962                (
963                    "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
964                    "http://127.0.0.1:9999".to_string(),
965                ),
966                (
967                    "SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
968                    "true".to_string(),
969                ),
970            ]
971        );
972    }
973
974    #[test]
975    fn compose_child_env_already_prefixed_passes_through() {
976        // Defensive: caller may pre-prefix; we must not double-prefix.
977        let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
978        assert_eq!(
979            composed,
980            vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
981        );
982    }
983
984    #[test]
985    fn compose_child_env_empty_input_is_empty_output() {
986        assert!(compose_child_env(&[]).is_empty());
987    }
988
989    // -- sRGB chunk normalization ---------------------------------------
990
991    /// Build a minimal PNG: 1×1 8-bit RGBA, one IDAT (zlib-empty-safe),
992    /// with or without an sRGB chunk. Returns synthetic bytes suitable
993    /// for exercising the chunk-walking logic; no rendering intent.
994    fn synth_png(with_srgb: bool) -> Vec<u8> {
995        let mut out = Vec::new();
996        out.extend_from_slice(super::PNG_MAGIC);
997        // IHDR: 1x1, bit_depth=8, color_type=6 (RGBA), rest=0
998        let ihdr_data: [u8; 13] = [
999            0, 0, 0, 1, // width = 1
1000            0, 0, 0, 1, // height = 1
1001            8, // bit depth
1002            6, // color type = RGBA
1003            0, 0, 0,
1004        ];
1005        emit_chunk(&mut out, b"IHDR", &ihdr_data);
1006        if with_srgb {
1007            emit_chunk(&mut out, b"sRGB", &[0]);
1008        }
1009        // Placeholder IDAT — content doesn't matter for chunk-walking tests
1010        emit_chunk(&mut out, b"IDAT", &[0x78, 0x01, 0x00, 0x00]);
1011        emit_chunk(&mut out, b"IEND", &[]);
1012        out
1013    }
1014
1015    fn emit_chunk(out: &mut Vec<u8>, ctype: &[u8; 4], data: &[u8]) {
1016        out.extend_from_slice(&(data.len() as u32).to_be_bytes());
1017        out.extend_from_slice(ctype);
1018        out.extend_from_slice(data);
1019        let mut crc_in = Vec::with_capacity(4 + data.len());
1020        crc_in.extend_from_slice(ctype);
1021        crc_in.extend_from_slice(data);
1022        out.extend_from_slice(&super::crc32_ieee(&crc_in).to_be_bytes());
1023    }
1024
1025    #[test]
1026    fn ensure_srgb_passthrough_when_chunk_present() {
1027        let png = synth_png(true);
1028        let original_len = png.len();
1029        let out = super::ensure_srgb_chunk(png.clone());
1030        assert_eq!(out.len(), original_len);
1031        assert_eq!(out, png);
1032    }
1033
1034    #[test]
1035    fn ensure_srgb_inserts_chunk_when_absent() {
1036        let png = synth_png(false);
1037        let original_len = png.len();
1038        let out = super::ensure_srgb_chunk(png);
1039        assert_eq!(out.len(), original_len + 13);
1040        // First 8 bytes = PNG magic
1041        assert_eq!(&out[..8], super::PNG_MAGIC);
1042        // Search for the injected sRGB chunk
1043        let mut found = false;
1044        for w in out.windows(4) {
1045            if w == b"sRGB" {
1046                found = true;
1047                break;
1048            }
1049        }
1050        assert!(found, "sRGB chunk should have been spliced in");
1051    }
1052
1053    #[test]
1054    fn ensure_srgb_preserves_idat_bytes_verbatim() {
1055        // Any pixel corruption at the IDAT level would break the
1056        // pixel-preservation invariant. Extract IDAT payload from
1057        // input and output, assert byte-identical.
1058        let png = synth_png(false);
1059        let out = super::ensure_srgb_chunk(png.clone());
1060        assert_eq!(extract_idat_data(&png), extract_idat_data(&out));
1061    }
1062
1063    fn extract_idat_data(bytes: &[u8]) -> Vec<u8> {
1064        let mut i = 8;
1065        while i + 8 <= bytes.len() {
1066            let length =
1067                u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
1068            let ctype = &bytes[i + 4..i + 8];
1069            if ctype == b"IDAT" {
1070                return bytes[i + 8..i + 8 + length].to_vec();
1071            }
1072            i += 12 + length;
1073        }
1074        vec![]
1075    }
1076
1077    #[test]
1078    fn ensure_srgb_passthrough_on_bad_magic() {
1079        // Corrupted / non-PNG input must not be modified.
1080        let bytes = vec![0u8; 32];
1081        let out = super::ensure_srgb_chunk(bytes.clone());
1082        assert_eq!(out, bytes);
1083    }
1084
1085    #[test]
1086    fn crc32_matches_known_iend() {
1087        // The empty-data IEND CRC is a well-known constant.
1088        // CRC over "IEND" alone: 0xAE_42_60_82.
1089        assert_eq!(super::crc32_ieee(b"IEND"), 0xAE42_6082);
1090    }
1091}