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