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//! Ported from now-retired TS source: `src/sim/simctl.ts` (375 lines). All operations
8//! shell out to `xcrun simctl <subcommand>`; JSON-formatted outputs
9//! (list runtimes, list devices, screenshot binary) are parsed with
10//! serde_json / raw bytes. Tokio's `process::Command` is the async
11//! spawn primitive.
12//!
13//! This is an outer crate (per user 2026-05-25 brief — outer crates
14//! allowed to depend on the wider tokio ecosystem). Use it from
15//! cement (smix-cli / smix-mcp) or from a higher-level driver wrapper.
16
17#![doc(html_root_url = "https://docs.smix.dev/smix-simctl")]
18
19pub mod registry;
20
21use serde::{Deserialize, Serialize};
22use std::io;
23use std::time::Duration;
24use thiserror::Error;
25use tokio::process::Command;
26use tokio::time::sleep;
27
28/// Failure variants for any `xcrun simctl` invocation.
29#[derive(Debug, Error)]
30pub enum SimctlError {
31    /// Failed to spawn the `xcrun` process itself (PATH lookup / fork failure).
32    #[error("spawn xcrun simctl failed: {0}")]
33    Spawn(#[from] io::Error),
34    /// `xcrun simctl <sub>` exited non-zero.
35    #[error("xcrun simctl {subcommand} exited {code}: {stderr}")]
36    NonZeroExit {
37        /// Subcommand name (e.g. `"boot"`, `"launch"`).
38        subcommand: String,
39        /// Exit code from `xcrun simctl`.
40        code: i32,
41        /// Captured stderr (truncated for log-friendliness).
42        stderr: String,
43    },
44    /// `xcrun simctl <sub>` exited 0 but stdout didn't match the expected shape.
45    #[error("xcrun simctl {subcommand} returned malformed output: {detail}")]
46    Malformed {
47        /// Subcommand name.
48        subcommand: String,
49        /// Parser-side detail.
50        detail: String,
51    },
52    /// `xcrun simctl <sub>` did not complete within the deadline.
53    #[error("xcrun simctl {subcommand} timed out after {ms}ms")]
54    Timeout {
55        /// Subcommand name.
56        subcommand: String,
57        /// Deadline that was exceeded (milliseconds).
58        ms: u64,
59    },
60}
61
62/// v5.2 c5 — handle to an active `xcrun simctl io recordVideo` child
63/// process. Pair with [`SimctlClient::record_video_stop`] for SIGINT-and-wait
64/// shutdown (so the mp4 trailer is flushed). Dropping the handle without
65/// `stop` would tokio-SIGKILL on Drop and truncate the output file.
66#[derive(Debug)]
67pub struct RecordingHandle {
68    pub(crate) child: tokio::process::Child,
69    /// Output mp4 path verbatim as passed to `record_video_start`.
70    pub path: String,
71    /// Wall-clock start time for "recording in progress for Xs" diagnostics.
72    pub started_at: std::time::Instant,
73}
74
75// -------------------- types ----------------------------------------------
76
77/// One iOS / watchOS / tvOS runtime installed on the host.
78#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
79pub struct SimctlRuntime {
80    /// Fully-qualified runtime identifier (e.g. `"com.apple.CoreSimulator.SimRuntime.iOS-17-0"`).
81    pub identifier: String,
82    /// Human-readable name (e.g. `"iOS 17.0"`).
83    pub name: String,
84    /// Version string (e.g. `"17.0"`).
85    pub version: String,
86    /// Whether the runtime is available for booting devices.
87    pub is_available: bool,
88}
89
90/// One simulator device known to `xcrun simctl`.
91#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
92pub struct SimctlDevice {
93    /// Device UDID (stable identifier).
94    pub udid: String,
95    /// Human-readable name.
96    pub name: String,
97    /// Current state (`"Booted"` / `"Shutdown"` / `"Creating"` / etc.).
98    pub state: String,
99    /// Whether the device is available for booting.
100    pub is_available: bool,
101    /// Device-type identifier (e.g. `"com.apple.CoreSimulator.SimDeviceType.iPhone-15"`).
102    #[serde(rename = "deviceTypeIdentifier", default)]
103    pub device_type_identifier: String,
104    /// Runtime identifier this device was created against.
105    #[serde(rename = "runtimeIdentifier", default)]
106    pub runtime_identifier: String,
107}
108
109/// Permission names accepted by `xcrun simctl privacy <udid> grant <name>`.
110/// Mirrors TS `SimctlPermission` 1:1.
111#[derive(Clone, Copy, Debug, PartialEq, Eq)]
112pub enum SimctlPermission {
113    /// Camera access.
114    Camera,
115    /// Photos library access.
116    Photos,
117    /// Location access (while-in-use).
118    Location,
119    /// Background location access (always).
120    LocationAlways,
121    /// Notification posting permission.
122    Notifications,
123    /// Microphone access.
124    Microphone,
125    /// Contacts access.
126    Contacts,
127    /// Calendar events access.
128    Calendar,
129    /// Reminders access.
130    Reminders,
131    /// Media library (music / video) access.
132    Media,
133    /// Motion / fitness sensor access.
134    Motion,
135    /// HomeKit accessory access.
136    HomeKit,
137    /// HealthKit data access.
138    Health,
139    /// Bluetooth device discovery / connection.
140    Bluetooth,
141    /// FaceID / TouchID biometric prompt.
142    Faceid,
143    /// Address-book (deprecated alias for `Contacts`).
144    AddressBook,
145}
146
147impl SimctlPermission {
148    /// Wire string used by `xcrun simctl privacy <udid> grant <name>`.
149    pub fn as_str(self) -> &'static str {
150        match self {
151            SimctlPermission::Camera => "camera",
152            SimctlPermission::Photos => "photos",
153            SimctlPermission::Location => "location",
154            SimctlPermission::LocationAlways => "location-always",
155            SimctlPermission::Notifications => "notifications",
156            SimctlPermission::Microphone => "microphone",
157            SimctlPermission::Contacts => "contacts",
158            SimctlPermission::Calendar => "calendar",
159            SimctlPermission::Reminders => "reminders",
160            SimctlPermission::Media => "media-library",
161            SimctlPermission::Motion => "motion",
162            SimctlPermission::HomeKit => "homekit",
163            SimctlPermission::Health => "health",
164            SimctlPermission::Bluetooth => "bluetooth",
165            SimctlPermission::Faceid => "faceid",
166            SimctlPermission::AddressBook => "addressbook",
167        }
168    }
169}
170
171/// UI appearance mode for `xcrun simctl ui <udid> appearance`.
172#[derive(Clone, Copy, Debug, PartialEq, Eq)]
173pub enum Appearance {
174    /// Light mode.
175    Light,
176    /// Dark mode.
177    Dark,
178}
179
180impl Appearance {
181    /// Wire string used by `xcrun simctl ui <udid> appearance <mode>`.
182    pub fn as_str(self) -> &'static str {
183        match self {
184            Appearance::Light => "light",
185            Appearance::Dark => "dark",
186        }
187    }
188}
189
190/// Launched-app result. TS returns `{ pid: number }`; we follow suit.
191#[derive(Clone, Debug, PartialEq, Eq)]
192pub struct LaunchResult {
193    /// Process ID of the launched app.
194    pub pid: u32,
195}
196
197// -------------------- raw spawn primitive --------------------------------
198
199/// Execute `xcrun simctl <args>` and capture stdout/stderr.
200async fn simctl_capture(args: &[&str]) -> Result<(Vec<u8>, String), SimctlError> {
201    simctl_capture_env(args, &[]).await
202}
203
204/// v6.8 c2 — `simctl_capture` with extra envp pairs set on the spawned
205/// process. The `xcrun simctl launch` subcommand uses this to inject
206/// `SIMCTL_CHILD_<KEY>=<VAL>` vars that the launched app sees as
207/// `ProcessInfo().environment["KEY"]`. `env` entries here are passed
208/// verbatim — caller composes the `SIMCTL_CHILD_` prefix via
209/// [`compose_child_env`].
210async fn simctl_capture_env(
211    args: &[&str],
212    env: &[(String, String)],
213) -> Result<(Vec<u8>, String), SimctlError> {
214    let mut cmd = Command::new("xcrun");
215    cmd.arg("simctl");
216    for a in args {
217        cmd.arg(a);
218    }
219    for (k, v) in env {
220        cmd.env(k, v);
221    }
222    let output = cmd.output().await?;
223    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
224    if !output.status.success() {
225        return Err(SimctlError::NonZeroExit {
226            subcommand: args.first().map(|s| s.to_string()).unwrap_or_default(),
227            code: output.status.code().unwrap_or(-1),
228            stderr,
229        });
230    }
231    Ok((output.stdout, stderr))
232}
233
234async fn simctl_run(args: &[&str]) -> Result<String, SimctlError> {
235    let (stdout, _) = simctl_capture(args).await?;
236    Ok(String::from_utf8_lossy(&stdout).into_owned())
237}
238
239/// v6.8 c2 — like [`simctl_run`] but injects `child_env` envp on the
240/// spawned process. Used by the env-aware launch path so the launched
241/// app can read deploy-time secrets / endpoints via `ProcessInfo`.
242async fn simctl_run_env(args: &[&str], env: &[(String, String)]) -> Result<String, SimctlError> {
243    let (stdout, _) = simctl_capture_env(args, env).await?;
244    Ok(String::from_utf8_lossy(&stdout).into_owned())
245}
246
247/// v6.8 c2 — compose user-provided `(key, value)` pairs into the
248/// `SIMCTL_CHILD_*` envp that `xcrun simctl launch` strips and delivers
249/// to the launched app. Idempotent: a key that already starts with
250/// `SIMCTL_CHILD_` is passed through unchanged. Insight gol-611 §4
251/// reference: their `prelaunch-sim-app.ts` does the same composition.
252///
253/// # Example
254///
255/// ```
256/// use smix_simctl::compose_child_env;
257/// let composed = compose_child_env(&[("INSIGHT_PERF_RECEIVER_URL", "http://h:9999")]);
258/// assert_eq!(
259///     composed,
260///     vec![(
261///         "SIMCTL_CHILD_INSIGHT_PERF_RECEIVER_URL".to_string(),
262///         "http://h:9999".to_string(),
263///     )]
264/// );
265/// ```
266pub fn compose_child_env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
267    pairs
268        .iter()
269        .map(|(k, v)| {
270            let key = if k.starts_with("SIMCTL_CHILD_") {
271                (*k).to_string()
272            } else {
273                format!("SIMCTL_CHILD_{k}")
274            };
275            (key, (*v).to_string())
276        })
277        .collect()
278}
279
280// -------------------- client --------------------------------------------
281
282/// Stateless wrapper around xcrun simctl. Methods are free functions
283/// in spirit (no instance state beyond optionally-cached `xcrun` path);
284/// kept as a struct for API ergonomics + future caching.
285#[derive(Debug, Default)]
286pub struct SimctlClient {}
287
288impl SimctlClient {
289    /// Construct a new client (stateless — equivalent to `default()`).
290    pub fn new() -> Self {
291        SimctlClient {}
292    }
293
294    // ---- inventory ------------------------------------------------------
295
296    /// `xcrun simctl list runtimes -j` → `Vec<SimctlRuntime>`.
297    pub async fn list_runtimes(&self) -> Result<Vec<SimctlRuntime>, SimctlError> {
298        let raw = simctl_run(&["list", "runtimes", "-j"]).await?;
299        #[derive(Deserialize)]
300        struct Wrap {
301            runtimes: Vec<RawRuntime>,
302        }
303        #[derive(Deserialize)]
304        struct RawRuntime {
305            identifier: String,
306            name: String,
307            version: String,
308            #[serde(rename = "isAvailable", default)]
309            is_available: bool,
310        }
311        let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
312            subcommand: "list runtimes".into(),
313            detail: e.to_string(),
314        })?;
315        Ok(w.runtimes
316            .into_iter()
317            .map(|r| SimctlRuntime {
318                identifier: r.identifier,
319                name: r.name,
320                version: r.version,
321                is_available: r.is_available,
322            })
323            .collect())
324    }
325
326    /// `xcrun simctl list devices -j` → flattened `Vec<SimctlDevice>`.
327    pub async fn list_devices(&self) -> Result<Vec<SimctlDevice>, SimctlError> {
328        let raw = simctl_run(&["list", "devices", "-j"]).await?;
329        #[derive(Deserialize)]
330        struct Wrap {
331            devices: std::collections::BTreeMap<String, Vec<RawDevice>>,
332        }
333        #[derive(Deserialize)]
334        struct RawDevice {
335            udid: String,
336            name: String,
337            state: String,
338            #[serde(rename = "isAvailable", default)]
339            is_available: bool,
340            #[serde(rename = "deviceTypeIdentifier", default)]
341            device_type_identifier: String,
342        }
343        let w: Wrap = serde_json::from_str(&raw).map_err(|e| SimctlError::Malformed {
344            subcommand: "list devices".into(),
345            detail: e.to_string(),
346        })?;
347        let mut out = Vec::new();
348        for (runtime_id, devices) in w.devices {
349            for d in devices {
350                out.push(SimctlDevice {
351                    udid: d.udid,
352                    name: d.name,
353                    state: d.state,
354                    is_available: d.is_available,
355                    device_type_identifier: d.device_type_identifier,
356                    runtime_identifier: runtime_id.clone(),
357                });
358            }
359        }
360        Ok(out)
361    }
362
363    // ---- lifecycle ------------------------------------------------------
364
365    /// `xcrun simctl boot <udid>` — fire-and-forget boot request.
366    pub async fn boot(&self, udid: &str) -> Result<(), SimctlError> {
367        simctl_run(&["boot", udid]).await?;
368        Ok(())
369    }
370
371    /// `xcrun simctl shutdown <udid>`.
372    pub async fn shutdown(&self, udid: &str) -> Result<(), SimctlError> {
373        simctl_run(&["shutdown", udid]).await?;
374        Ok(())
375    }
376
377    /// v6.10 c2 — read the sim's current BCP-47 locale (first entry of
378    /// `NSGlobalDomain AppleLanguages`). Returns `Ok(None)` when the
379    /// preference is unset (defaults read exits non-zero) or unparseable.
380    /// Wire format: `simctl spawn <udid> defaults read -g AppleLanguages`
381    /// stdout looks like `"(\n    \"en-US\"\n)\n"`; we extract the first
382    /// quoted token.
383    pub async fn current_locale(&self, udid: &str) -> Result<Option<String>, SimctlError> {
384        let out =
385            match simctl_run(&["spawn", udid, "defaults", "read", "-g", "AppleLanguages"]).await {
386                Ok(s) => s,
387                // `defaults read` returns non-zero when the key is unset; that
388                // is a legitimate "no opinion" state, not an error.
389                Err(SimctlError::NonZeroExit { .. }) => return Ok(None),
390                Err(e) => return Err(e),
391            };
392        // First quoted substring.
393        if let Some(start) = out.find('"') {
394            let rest = &out[start + 1..];
395            if let Some(end) = rest.find('"') {
396                return Ok(Some(rest[..end].to_string()));
397            }
398        }
399        Ok(None)
400    }
401
402    /// v6.10 c2 — write `AppleLanguages` (array) + `AppleLocale` (scalar)
403    /// to the sim's NSGlobalDomain so SpringBoard + apps re-localize on
404    /// next launch. AppleLocale is BCP-47 with hyphen replaced by
405    /// underscore (`en_US`); AppleLanguages is the BCP-47 tag verbatim.
406    /// **The caller must shutdown + reboot the sim for the change to
407    /// take effect** — running apps cache the locale at process start.
408    /// Insight gol-611 §3 capability.
409    pub async fn set_locale(&self, udid: &str, locale: &str) -> Result<(), SimctlError> {
410        simctl_run(&[
411            "spawn",
412            udid,
413            "defaults",
414            "write",
415            "-g",
416            "AppleLanguages",
417            "-array",
418            locale,
419        ])
420        .await?;
421        let locale_underscore = locale.replace('-', "_");
422        simctl_run(&[
423            "spawn",
424            udid,
425            "defaults",
426            "write",
427            "-g",
428            "AppleLocale",
429            &locale_underscore,
430        ])
431        .await?;
432        Ok(())
433    }
434
435    /// Boot + poll device state == "Booted" within timeout. Tries every
436    /// 500 ms until success or `timeout_ms` elapses. Idempotent on
437    /// already-booted devices (xcrun simctl boot 已 booted 返非零, swallow).
438    pub async fn boot_and_wait(&self, udid: &str, timeout: Duration) -> Result<(), SimctlError> {
439        // Issue boot; ignore already-booted error (the only friendly path).
440        let _ = simctl_run(&["boot", udid]).await;
441        let start = std::time::Instant::now();
442        loop {
443            let devices = self.list_devices().await?;
444            if devices
445                .iter()
446                .any(|d| d.udid == udid && d.state == "Booted")
447            {
448                return Ok(());
449            }
450            if start.elapsed() > timeout {
451                return Err(SimctlError::Timeout {
452                    subcommand: format!("boot {}", udid),
453                    ms: timeout.as_millis() as u64,
454                });
455            }
456            sleep(Duration::from_millis(500)).await;
457        }
458    }
459
460    /// `xcrun simctl erase <udid>` — wipe device contents.
461    pub async fn erase(&self, udid: &str) -> Result<(), SimctlError> {
462        simctl_run(&["erase", udid]).await?;
463        Ok(())
464    }
465
466    /// `xcrun simctl install <udid> <app-path>` — install a `.app` bundle.
467    pub async fn install(&self, udid: &str, app_path: &str) -> Result<(), SimctlError> {
468        simctl_run(&["install", udid, app_path]).await?;
469        Ok(())
470    }
471
472    /// `xcrun simctl uninstall <udid> <bundle-id>`.
473    pub async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
474        simctl_run(&["uninstall", udid, bundle_id]).await?;
475        Ok(())
476    }
477
478    /// `xcrun simctl terminate <udid> <bundle-id>` — kill a running app.
479    pub async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), SimctlError> {
480        simctl_run(&["terminate", udid, bundle_id]).await?;
481        Ok(())
482    }
483
484    /// `xcrun simctl launch <udid> <bundleId>` → parse `"<bundle>: <pid>"`.
485    pub async fn launch(&self, udid: &str, bundle_id: &str) -> Result<LaunchResult, SimctlError> {
486        self.launch_with_args(udid, bundle_id, &[]).await
487    }
488
489    /// `xcrun simctl launch <udid> <bundleId> -- <arg>...` — launch with a
490    /// process-level argument vector. Empty `args` is equivalent to
491    /// [`Self::launch`]. v5.2 c2 — maestro yaml `launchApp.arguments` 同源.
492    pub async fn launch_with_args(
493        &self,
494        udid: &str,
495        bundle_id: &str,
496        args: &[String],
497    ) -> Result<LaunchResult, SimctlError> {
498        self.launch_with_args_and_env(udid, bundle_id, args, &[])
499            .await
500    }
501
502    /// v6.8 c2 — like [`Self::launch_with_args`] but also sets
503    /// `SIMCTL_CHILD_*` envp on the simctl process so the launched app
504    /// can read deploy-time vars via `ProcessInfo().environment["KEY"]`.
505    /// `child_env` keys without the `SIMCTL_CHILD_` prefix get it added
506    /// automatically (per [`compose_child_env`] semantics). Insight
507    /// gol-611 §4 — used to prelaunch an app before any `openLink` so
508    /// iOS treats the subsequent URL handoff as in-app routing instead
509    /// of cross-app, side-stepping the SpringBoard "Open in '<App>'?"
510    /// confirmation dialog.
511    pub async fn launch_with_args_and_env(
512        &self,
513        udid: &str,
514        bundle_id: &str,
515        args: &[String],
516        child_env: &[(&str, &str)],
517    ) -> Result<LaunchResult, SimctlError> {
518        let mut argv: Vec<&str> = vec!["launch", udid, bundle_id];
519        if !args.is_empty() {
520            argv.push("--");
521            for a in args {
522                argv.push(a.as_str());
523            }
524        }
525        let composed = compose_child_env(child_env);
526        let out = simctl_run_env(&argv, &composed).await?;
527        // Output format: `com.example.app: 12345\n`
528        let pid_str =
529            out.rsplit(':')
530                .next()
531                .map(str::trim)
532                .ok_or_else(|| SimctlError::Malformed {
533                    subcommand: "launch".into(),
534                    detail: format!("unexpected stdout shape: {}", out.trim()),
535                })?;
536        let pid: u32 = pid_str.parse().map_err(|_| SimctlError::Malformed {
537            subcommand: "launch".into(),
538            detail: format!("non-numeric pid in stdout: {}", out.trim()),
539        })?;
540        Ok(LaunchResult { pid })
541    }
542
543    /// `xcrun simctl openurl <udid> <url>` — open a URL on the device.
544    pub async fn open_url(&self, udid: &str, url: &str) -> Result<(), SimctlError> {
545        simctl_run(&["openurl", udid, url]).await?;
546        Ok(())
547    }
548
549    /// v5.7 c3 — `xcrun simctl push <udid> <bundle-id> <apns-json-path>`.
550    /// Deliver an APNS payload to a sim-installed app. The payload file is
551    /// a JSON document whose top-level dictionary mirrors what an APNS
552    /// provider would send; `aps.alert.body` / `aps.alert.title` surface
553    /// as banner content and reach the app's
554    /// `UNUserNotificationCenterDelegate`.
555    pub async fn send_push(
556        &self,
557        udid: &str,
558        bundle_id: &str,
559        apns_json_path: &str,
560    ) -> Result<(), SimctlError> {
561        simctl_run(&["push", udid, bundle_id, apns_json_path]).await?;
562        Ok(())
563    }
564
565    /// `xcrun simctl ui <udid> appearance <light|dark>` — set UI appearance.
566    pub async fn set_appearance(&self, udid: &str, mode: Appearance) -> Result<(), SimctlError> {
567        simctl_run(&["ui", udid, "appearance", mode.as_str()]).await?;
568        Ok(())
569    }
570
571    /// `xcrun simctl privacy <udid> grant <perm> <bundle-id>`.
572    pub async fn grant_permission(
573        &self,
574        udid: &str,
575        permission: SimctlPermission,
576        bundle_id: &str,
577    ) -> Result<(), SimctlError> {
578        simctl_run(&["privacy", udid, "grant", permission.as_str(), bundle_id]).await?;
579        Ok(())
580    }
581
582    /// `xcrun simctl privacy <udid> revoke <perm> <bundle-id>` — explicitly
583    /// deny the permission. v5.2 c2 — maestro yaml `permissions: { x: deny }`
584    /// 同源(grant 反向). 不同于 reset(那是回到 "not determined").
585    pub async fn revoke_permission(
586        &self,
587        udid: &str,
588        permission: SimctlPermission,
589        bundle_id: &str,
590    ) -> Result<(), SimctlError> {
591        simctl_run(&["privacy", udid, "revoke", permission.as_str(), bundle_id]).await?;
592        Ok(())
593    }
594
595    /// v5.2 c5 — `xcrun simctl location <udid> set <lat>,<lng>` — set sim
596    /// location to a fixed point. maestro `setLocation` 同源.
597    pub async fn location_set(
598        &self,
599        udid: &str,
600        latitude: f64,
601        longitude: f64,
602    ) -> Result<(), SimctlError> {
603        let coord = format!("{latitude},{longitude}");
604        simctl_run(&["location", udid, "set", &coord]).await?;
605        Ok(())
606    }
607
608    /// v5.2 c5 — `xcrun simctl location <udid> start [--speed=<m/s>] <waypoints>`
609    /// — interpolate sim location along waypoints. Fire-and-return: simctl
610    /// injects scenario and returns; sim continues interpolation in background.
611    /// maestro `travel` 同源.
612    pub async fn location_start(
613        &self,
614        udid: &str,
615        points: &[(f64, f64)],
616        speed_mps: Option<f64>,
617    ) -> Result<(), SimctlError> {
618        if points.len() < 2 {
619            return Err(SimctlError::Malformed {
620                subcommand: "location-start".into(),
621                detail: format!("requires ≥2 waypoints, got {}", points.len()),
622            });
623        }
624        let mut args: Vec<String> = vec!["location".into(), udid.into(), "start".into()];
625        if let Some(s) = speed_mps {
626            args.push(format!("--speed={s}"));
627        }
628        for (lat, lng) in points {
629            args.push(format!("{lat},{lng}"));
630        }
631        let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
632        simctl_run(&args_ref).await?;
633        Ok(())
634    }
635
636    /// v5.2 c5 — `xcrun simctl location <udid> clear` — reset active
637    /// location scenario.
638    pub async fn location_clear(&self, udid: &str) -> Result<(), SimctlError> {
639        simctl_run(&["location", udid, "clear"]).await?;
640        Ok(())
641    }
642
643    /// v5.2 c5 — `xcrun simctl addmedia <udid> <path>...` — add photos /
644    /// videos / contacts to sim library. maestro `addMedia` 同源 (scalar or
645    /// array form already flattened on adapter side).
646    pub async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), SimctlError> {
647        if paths.is_empty() {
648            return Err(SimctlError::Malformed {
649                subcommand: "addmedia".into(),
650                detail: "no paths supplied".into(),
651            });
652        }
653        let mut args: Vec<&str> = vec!["addmedia", udid];
654        for p in paths {
655            args.push(p.as_str());
656        }
657        simctl_run(&args).await?;
658        Ok(())
659    }
660
661    /// v5.2 c5 — start recording sim display to `path`. Spawns
662    /// `xcrun simctl io <udid> recordVideo <path>` as a long-running child;
663    /// returns handle immediately. Caller must pair with
664    /// [`Self::record_video_stop`] for clean SIGINT-and-wait shutdown —
665    /// dropping the handle would SIGKILL via tokio + lose mp4 trailer.
666    pub async fn record_video_start(
667        &self,
668        udid: &str,
669        path: &str,
670    ) -> Result<RecordingHandle, SimctlError> {
671        let child = tokio::process::Command::new("xcrun")
672            .args(["simctl", "io", udid, "recordVideo", path])
673            .stdin(std::process::Stdio::null())
674            .stdout(std::process::Stdio::piped())
675            .stderr(std::process::Stdio::piped())
676            .spawn()?;
677        // brief settle for simctl to initialize encoder + open output file.
678        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
679        Ok(RecordingHandle {
680            child,
681            path: path.to_string(),
682            started_at: std::time::Instant::now(),
683        })
684    }
685
686    /// v5.2 c5 — stop a recording via SIGINT + wait (≤10s). SIGINT lets
687    /// simctl trap and flush the mp4 trailer; SIGKILL would corrupt output.
688    /// Timeout escalates to SIGKILL with explicit error mentioning truncation.
689    pub async fn record_video_stop(&self, mut handle: RecordingHandle) -> Result<(), SimctlError> {
690        let pid = handle.child.id().ok_or_else(|| SimctlError::Malformed {
691            subcommand: "recordVideo-stop".into(),
692            detail: "child already reaped".into(),
693        })?;
694        // SAFETY: libc::kill is a thin POSIX syscall wrapper; pid is owned by
695        // this Child instance (no race) and SIGINT is signal-safe.
696        let rc = unsafe { libc::kill(pid as i32, libc::SIGINT) };
697        if rc != 0 {
698            return Err(SimctlError::Malformed {
699                subcommand: "recordVideo-stop".into(),
700                detail: format!(
701                    "kill SIGINT failed: errno={}",
702                    std::io::Error::last_os_error()
703                ),
704            });
705        }
706        let wait_result =
707            tokio::time::timeout(std::time::Duration::from_secs(10), handle.child.wait()).await;
708        match wait_result {
709            Ok(Ok(_status)) => Ok(()),
710            Ok(Err(e)) => Err(SimctlError::Malformed {
711                subcommand: "recordVideo-stop".into(),
712                detail: format!("wait failed: {e}"),
713            }),
714            Err(_timeout) => {
715                let _ = handle.child.kill().await;
716                Err(SimctlError::Malformed {
717                    subcommand: "recordVideo-stop".into(),
718                    detail: "SIGINT timeout (10s) — escalated SIGKILL; output mp4 likely truncated. Inspect simctl recordVideo stderr.".into(),
719                })
720            }
721        }
722    }
723
724    /// `xcrun simctl privacy <udid> reset <perm> <bundle-id>` — return the
725    /// permission to "not determined" so the next request re-prompts.
726    /// May terminate a running instance of the target app (Apple
727    /// behavior) — call before launch, not mid-flow.
728    pub async fn reset_permission(
729        &self,
730        udid: &str,
731        permission: SimctlPermission,
732        bundle_id: &str,
733    ) -> Result<(), SimctlError> {
734        simctl_run(&["privacy", udid, "reset", permission.as_str(), bundle_id]).await?;
735        Ok(())
736    }
737
738    /// `xcrun simctl keychain <udid> reset` — clear all keychain entries.
739    pub async fn keychain_reset(&self, udid: &str) -> Result<(), SimctlError> {
740        simctl_run(&["keychain", udid, "reset"]).await?;
741        Ok(())
742    }
743
744    /// `xcrun simctl pbpaste <udid>` — read clipboard contents.
745    pub async fn pasteboard_get(&self, udid: &str) -> Result<String, SimctlError> {
746        simctl_run(&["pbpaste", udid]).await
747    }
748
749    /// `xcrun simctl pbcopy <udid>` — write clipboard contents (via piped stdin).
750    pub async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), SimctlError> {
751        // pbcopy reads stdin — we pipe via shell echo for simplicity.
752        // Long-term: spawn with stdin pipe.
753        use tokio::io::AsyncWriteExt;
754        let mut cmd = Command::new("xcrun");
755        cmd.arg("simctl").arg("pbcopy").arg(udid);
756        cmd.stdin(std::process::Stdio::piped());
757        let mut child = cmd.spawn()?;
758        if let Some(mut stdin) = child.stdin.take() {
759            stdin.write_all(text.as_bytes()).await?;
760            drop(stdin); // close stdin so pbcopy returns
761        }
762        let status = child.wait().await?;
763        if !status.success() {
764            return Err(SimctlError::NonZeroExit {
765                subcommand: "pbcopy".into(),
766                code: status.code().unwrap_or(-1),
767                stderr: String::new(),
768            });
769        }
770        Ok(())
771    }
772
773    /// Toggle "Reduce Motion" accessibility setting via `defaults write`.
774    pub async fn set_reduce_motion(&self, udid: &str, enabled: bool) -> Result<(), SimctlError> {
775        let val = if enabled { "1" } else { "0" };
776        // `defaults write` lives under spawn; routed via simctl spawn.
777        simctl_run(&[
778            "spawn",
779            udid,
780            "defaults",
781            "write",
782            "com.apple.UIKit",
783            "UIAccessibilityReduceMotionEnabled",
784            "-bool",
785            val,
786        ])
787        .await?;
788        Ok(())
789    }
790
791    /// `xcrun simctl io <udid> screenshot <tmpfile>` → raw PNG bytes.
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    pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, SimctlError> {
797        let tmp =
798            std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
799        let tmp_str = tmp.display().to_string();
800        let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
801        let bytes = result.and_then(|_| {
802            std::fs::read(&tmp).map_err(|e| SimctlError::Malformed {
803                subcommand: "screenshot".into(),
804                detail: format!("read {tmp_str}: {e}"),
805            })
806        });
807        let _ = std::fs::remove_file(&tmp);
808        let bytes = bytes?;
809        if bytes.len() < 8 {
810            return Err(SimctlError::Malformed {
811                subcommand: "screenshot".into(),
812                detail: format!("screenshot file too short: {} bytes", bytes.len()),
813            });
814        }
815        Ok(bytes)
816    }
817
818    /// `xcrun simctl create <name> <device-type-id> <runtime-id>` → udid.
819    pub async fn create_device(
820        &self,
821        name: &str,
822        device_type: &str,
823        runtime_id: &str,
824    ) -> Result<String, SimctlError> {
825        let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
826        Ok(out.trim().to_string())
827    }
828
829    /// `xcrun simctl delete <udid>` — delete a simulator device.
830    pub async fn delete_device(&self, udid: &str) -> Result<(), SimctlError> {
831        simctl_run(&["delete", udid]).await?;
832        Ok(())
833    }
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839
840    #[test]
841    fn compose_child_env_adds_prefix() {
842        let composed = compose_child_env(&[
843            ("INSIGHT_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
844            ("LAUNCH_FORCE_PUSH", "true"),
845        ]);
846        assert_eq!(
847            composed,
848            vec![
849                (
850                    "SIMCTL_CHILD_INSIGHT_PERF_RECEIVER_URL".to_string(),
851                    "http://127.0.0.1:9999".to_string(),
852                ),
853                (
854                    "SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
855                    "true".to_string(),
856                ),
857            ]
858        );
859    }
860
861    #[test]
862    fn compose_child_env_already_prefixed_passes_through() {
863        // Defensive: caller may pre-prefix; we must not double-prefix.
864        let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
865        assert_eq!(
866            composed,
867            vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
868        );
869    }
870
871    #[test]
872    fn compose_child_env_empty_input_is_empty_output() {
873        assert!(compose_child_env(&[]).is_empty());
874    }
875}