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    ///
791    /// Goes through a temp file: current Xcode's `screenshot -` does not
792    /// treat `-` as stdout — it writes a literal file named `-` in cwd
793    /// and emits nothing on stdout (observed on Xcode/iOS 26.5).
794    pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, SimctlError> {
795        let tmp =
796            std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
797        let tmp_str = tmp.display().to_string();
798        let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
799        let bytes = result.and_then(|_| {
800            std::fs::read(&tmp).map_err(|e| SimctlError::Malformed {
801                subcommand: "screenshot".into(),
802                detail: format!("read {tmp_str}: {e}"),
803            })
804        });
805        let _ = std::fs::remove_file(&tmp);
806        let bytes = bytes?;
807        if bytes.len() < 8 {
808            return Err(SimctlError::Malformed {
809                subcommand: "screenshot".into(),
810                detail: format!("screenshot file too short: {} bytes", bytes.len()),
811            });
812        }
813        Ok(bytes)
814    }
815
816    /// `xcrun simctl create <name> <device-type-id> <runtime-id>` → udid.
817    pub async fn create_device(
818        &self,
819        name: &str,
820        device_type: &str,
821        runtime_id: &str,
822    ) -> Result<String, SimctlError> {
823        let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
824        Ok(out.trim().to_string())
825    }
826
827    /// `xcrun simctl delete <udid>` — delete a simulator device.
828    pub async fn delete_device(&self, udid: &str) -> Result<(), SimctlError> {
829        simctl_run(&["delete", udid]).await?;
830        Ok(())
831    }
832}
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837
838    #[test]
839    fn compose_child_env_adds_prefix() {
840        let composed = compose_child_env(&[
841            ("SMIX_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
842            ("LAUNCH_FORCE_PUSH", "true"),
843        ]);
844        assert_eq!(
845            composed,
846            vec![
847                (
848                    "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
849                    "http://127.0.0.1:9999".to_string(),
850                ),
851                (
852                    "SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
853                    "true".to_string(),
854                ),
855            ]
856        );
857    }
858
859    #[test]
860    fn compose_child_env_already_prefixed_passes_through() {
861        // Defensive: caller may pre-prefix; we must not double-prefix.
862        let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
863        assert_eq!(
864            composed,
865            vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
866        );
867    }
868
869    #[test]
870    fn compose_child_env_empty_input_is_empty_output() {
871        assert!(compose_child_env(&[]).is_empty());
872    }
873}