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/// Adaptive `xcrun simctl io screenshot` pacer. See
20/// [`screenshot_pacer::ScreenshotPacer`].
21pub mod screenshot_pacer;
22/// Persistent CoreSimulator framebuffer capture via a resident
23/// `smix-capture-host`. See [`surface_capture::SurfaceCaptureHost`].
24pub mod surface_capture;
25
26use screenshot_pacer::{ScreenshotPacer, ScreenshotPacerConfig};
27use serde::{Deserialize, Serialize};
28use std::io;
29use std::sync::Arc;
30use std::time::Duration;
31use thiserror::Error;
32use tokio::process::Command;
33use tokio::time::sleep;
34
35/// Failure variants for a device-control invocation.
36///
37/// `DeviceControl` is one trait across iOS and Android, and this is its error
38/// type — `AndroidDeviceControl` raises it as much as the simctl path does.
39/// The messages name the command that actually ran rather than assuming
40/// simctl, so an Android failure does not send the reader to the wrong
41/// toolchain by claiming to come from `xcrun simctl`.
42#[derive(Debug, Error)]
43#[non_exhaustive]
44pub enum DeviceControlError {
45 /// Failed to spawn the device-control process (PATH lookup / fork failure).
46 #[error("spawn failed: {0}")]
47 Spawn(#[from] io::Error),
48 /// The bundle is not installed on the device. `simctl
49 /// get_app_container` exiting non-zero is the canonical signal —
50 /// surfaced as its own variant because "run a flow whose appId
51 /// names an app you have not installed yet" is the single most
52 /// common first-run mistake, and it used to read as a bare
53 /// subprocess error.
54 #[error(
55 "app {bundle_id} is not installed on {udid} — install it \
56 (`smix sim install <device> /path/to/YourApp.app`) or check the \
57 flow's `appId:` matches what is actually installed"
58 )]
59 AppNotInstalled {
60 /// The bundle the caller asked about.
61 bundle_id: String,
62 /// The device it is missing from.
63 udid: String,
64 },
65 /// The device-control command exited non-zero.
66 ///
67 /// Carries the full `argv` and `wall_ms` so the `Display` impl
68 /// surfaces every argument needed to reproduce the failure or file
69 /// a precise upstream bug — the subcommand name alone (e.g.
70 /// `"spawn"`) does not say which binary or paths were touched.
71 #[error("{} exited {code} ({wall_ms}ms): {stderr}", .argv.join(" "))]
72 NonZeroExit {
73 /// Subcommand name (e.g. `"boot"`, `"launch"`).
74 subcommand: String,
75 /// The full command as invoked, binary first — `["xcrun", "simctl",
76 /// "boot", …]` from simctl, `["adb", …]` from the Android side.
77 ///
78 /// The binary belongs here rather than in the `Display` format
79 /// string: this error type serves both platforms, and hard-coding
80 /// `xcrun simctl` made every Android failure name a tool that never
81 /// ran, sending the reader to the wrong toolchain.
82 ///
83 /// Since smix 1.0.7.
84 argv: Vec<String>,
85 /// Exit code from `xcrun simctl`.
86 code: i32,
87 /// Captured stderr (truncated for log-friendliness).
88 stderr: String,
89 /// Wall-clock milliseconds the invocation ran before failing.
90 ///
91 /// Since smix 1.0.7.
92 #[allow(dead_code)]
93 wall_ms: u64,
94 },
95 /// `xcrun simctl <sub>` exited 0 but stdout didn't match the expected shape.
96 #[error("{subcommand} returned malformed output: {detail}")]
97 Malformed {
98 /// Subcommand name.
99 subcommand: String,
100 /// Parser-side detail.
101 detail: String,
102 },
103 /// `xcrun simctl <sub>` did not complete within the deadline.
104 #[error("{subcommand} timed out after {ms}ms")]
105 Timeout {
106 /// Subcommand name.
107 subcommand: String,
108 /// Deadline that was exceeded (milliseconds).
109 ms: u64,
110 },
111 /// The screenshot pacer's circuit is open — a recent screenshot
112 /// wall time exceeded the circuit threshold, or a screenshot
113 /// failed. Callers should back off for `retry_after` and try
114 /// again. See [`screenshot_pacer::ScreenshotPacer`].
115 ///
116 /// Since smix 1.0.4.
117 #[error("screenshot pacer circuit open; retry after {retry_after:?}")]
118 CaptureBackpressure {
119 /// Suggested minimum delay before the next attempt.
120 retry_after: Duration,
121 },
122}
123
124impl DeviceControlError {
125 /// Synthetic `NonZeroExit` for callers translating a foreign
126 /// subprocess error into `DeviceControlError` (e.g.
127 /// AndroidDeviceControl adapting adb failures). Fills
128 /// `argv = [subcommand]` + `wall_ms = 0`; when the caller has a
129 /// real argv, prefer the struct literal.
130 pub fn non_zero_exit(
131 subcommand: impl Into<String>,
132 code: i32,
133 stderr: impl Into<String>,
134 ) -> Self {
135 let sub = subcommand.into();
136 Self::NonZeroExit {
137 argv: vec![sub.clone()],
138 subcommand: sub,
139 code,
140 stderr: stderr.into(),
141 wall_ms: 0,
142 }
143 }
144}
145
146/// Handle to an active `xcrun simctl io recordVideo` child process. Pair
147/// with [`SimctlClient::record_video_stop`] for SIGINT-and-wait shutdown
148/// (so the mp4 trailer is flushed). Dropping the handle without `stop`
149/// would tokio-SIGKILL on Drop and truncate the output file.
150#[derive(Debug)]
151pub struct RecordingHandle {
152 pub(crate) child: tokio::process::Child,
153 /// Output mp4 path verbatim as passed to `record_video_start`.
154 pub path: String,
155 /// Wall-clock start time for "recording in progress for Xs" diagnostics.
156 pub started_at: std::time::Instant,
157}
158
159impl RecordingHandle {
160 /// Pid of the `simctl io … recordVideo` child.
161 ///
162 /// Needed by anything that must record this process somewhere it will
163 /// outlive us: a recording whose only handle is this struct dies with
164 /// the process holding it, and the mp4 it was writing loses its
165 /// trailer with no one left who knows to send the SIGINT that would
166 /// have saved it.
167 ///
168 /// `None` once the child has been reaped.
169 #[must_use]
170 pub fn pid(&self) -> Option<u32> {
171 self.child.id()
172 }
173}
174
175// -------------------- types ----------------------------------------------
176
177/// One iOS / watchOS / tvOS runtime installed on the host.
178#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
179pub struct SimctlRuntime {
180 /// Fully-qualified runtime identifier (e.g. `"com.apple.CoreSimulator.SimRuntime.iOS-17-0"`).
181 pub identifier: String,
182 /// Human-readable name (e.g. `"iOS 17.0"`).
183 pub name: String,
184 /// Version string (e.g. `"17.0"`).
185 pub version: String,
186 /// Whether the runtime is available for booting devices.
187 pub is_available: bool,
188}
189
190/// One simulator device known to `xcrun simctl`.
191#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
192pub struct SimctlDevice {
193 /// Device UDID (stable identifier).
194 pub udid: String,
195 /// Human-readable name.
196 pub name: String,
197 /// Current state (`"Booted"` / `"Shutdown"` / `"Creating"` / etc.).
198 pub state: String,
199 /// Whether the device is available for booting.
200 pub is_available: bool,
201 /// Device-type identifier (e.g. `"com.apple.CoreSimulator.SimDeviceType.iPhone-15"`).
202 #[serde(rename = "deviceTypeIdentifier", default)]
203 pub device_type_identifier: String,
204 /// Runtime identifier this device was created against.
205 #[serde(rename = "runtimeIdentifier", default)]
206 pub runtime_identifier: String,
207}
208
209/// Permission names accepted by `xcrun simctl privacy <udid> grant <name>`.
210#[derive(Clone, Copy, Debug, PartialEq, Eq)]
211pub enum SimctlPermission {
212 /// Camera access.
213 Camera,
214 /// Photos library access.
215 Photos,
216 /// Location access (while-in-use).
217 Location,
218 /// Background location access (always).
219 LocationAlways,
220 /// Notification posting permission.
221 Notifications,
222 /// Microphone access.
223 Microphone,
224 /// Contacts access.
225 Contacts,
226 /// Calendar events access.
227 Calendar,
228 /// Reminders access.
229 Reminders,
230 /// Media library (music / video) access.
231 Media,
232 /// Motion / fitness sensor access.
233 Motion,
234 /// HomeKit accessory access.
235 HomeKit,
236 /// HealthKit data access.
237 Health,
238 /// Bluetooth device discovery / connection.
239 Bluetooth,
240 /// FaceID / TouchID biometric prompt.
241 Faceid,
242 /// Address-book (deprecated alias for `Contacts`).
243 AddressBook,
244}
245
246impl SimctlPermission {
247 /// Wire string used by `xcrun simctl privacy <udid> grant <name>`.
248 pub fn as_str(self) -> &'static str {
249 match self {
250 SimctlPermission::Camera => "camera",
251 SimctlPermission::Photos => "photos",
252 SimctlPermission::Location => "location",
253 SimctlPermission::LocationAlways => "location-always",
254 SimctlPermission::Notifications => "notifications",
255 SimctlPermission::Microphone => "microphone",
256 SimctlPermission::Contacts => "contacts",
257 SimctlPermission::Calendar => "calendar",
258 SimctlPermission::Reminders => "reminders",
259 SimctlPermission::Media => "media-library",
260 SimctlPermission::Motion => "motion",
261 SimctlPermission::HomeKit => "homekit",
262 SimctlPermission::Health => "health",
263 SimctlPermission::Bluetooth => "bluetooth",
264 SimctlPermission::Faceid => "faceid",
265 SimctlPermission::AddressBook => "addressbook",
266 }
267 }
268}
269
270/// UI appearance mode for `xcrun simctl ui <udid> appearance`.
271#[derive(Clone, Copy, Debug, PartialEq, Eq)]
272pub enum Appearance {
273 /// Light mode.
274 Light,
275 /// Dark mode.
276 Dark,
277}
278
279impl Appearance {
280 /// Wire string used by `xcrun simctl ui <udid> appearance <mode>`.
281 pub fn as_str(self) -> &'static str {
282 match self {
283 Appearance::Light => "light",
284 Appearance::Dark => "dark",
285 }
286 }
287}
288
289/// Launched-app result.
290#[derive(Clone, Debug, PartialEq, Eq)]
291pub struct LaunchResult {
292 /// Process ID of the launched app.
293 pub pid: u32,
294}
295
296// -------------------- subprocess ring buffer ----------------------------
297
298/// Recorded snapshot of one `xcrun simctl` invocation.
299/// Exposed so callers can dump the ring buffer for post-mortem when
300/// something upstream fails.
301#[derive(Clone, Debug)]
302pub struct SubprocessRecord {
303 /// argv as passed to `xcrun simctl` (excludes the `xcrun simctl`
304 /// prefix; first entry is the subcommand).
305 pub argv: Vec<String>,
306 /// Exit code; `None` when the process failed to spawn or the
307 /// output-capture path failed before recording the exit.
308 pub exit_code: Option<i32>,
309 /// Wall-clock milliseconds.
310 pub wall_ms: u64,
311 /// First 256 bytes of stderr (truncated).
312 pub stderr_head: String,
313 /// Wall-clock timestamp the invocation completed.
314 pub timestamp: std::time::SystemTime,
315}
316
317/// The three diagnostic records smix keeps between runs.
318///
319/// All three were the same shape and the same two bugs: a write of
320/// `let _ = write_json_atomic(...)`, which could not tell a full disk
321/// from a success, and a read of `let Ok(x) = .. else { return }`,
322/// which read a damaged file as an empty one and then overwrote it.
323///
324/// The swallowing had half a reason — persisting a diagnostic must not
325/// break the `xcrun simctl` call the user actually asked for. That
326/// reason survives here: failures are reported, never propagated. What
327/// does not survive is the silence.
328mod diag_store {
329 use std::path::{Path, PathBuf};
330 use std::sync::{Mutex, OnceLock};
331
332 /// Read a singleton from disk the first time someone needs it.
333 ///
334 /// The three diagnostic singletons used to load at startup: every
335 /// smix command called all three `set_*_persist_path` functions,
336 /// and each one read its value immediately. Measured with a
337 /// backtrace probe on `Store::open`, a plain `smix sim list` opened
338 /// the store four times — three eager loads plus the one write the
339 /// command actually needed. Two of those three were for state that
340 /// command never touches: it runs no flow and resets no app data.
341 ///
342 /// Each open replays the AOF and takes the store's *blocking*
343 /// advisory lock, so the cost is not only work — it is three extra
344 /// chances to queue behind another smix process for nothing.
345 ///
346 /// The flag is only latched once a path exists. A read that happens
347 /// before `set_persist_path` must leave it alone: latching there
348 /// would mean the path, once set, is never read at all — the load
349 /// would be permanently skipped rather than merely deferred.
350 pub(super) fn ensure_loaded(
351 flag: &'static OnceLock<Mutex<bool>>,
352 persist: &'static Mutex<Option<PathBuf>>,
353 load: fn(),
354 ) {
355 let mut done = match flag.get_or_init(|| Mutex::new(false)).lock() {
356 Ok(g) => g,
357 Err(poisoned) => poisoned.into_inner(),
358 };
359 if *done {
360 return;
361 }
362 let has_path = match persist.lock() {
363 Ok(g) => g.is_some(),
364 Err(poisoned) => poisoned.into_inner().is_some(),
365 };
366 if !has_path {
367 return;
368 }
369 load();
370 *done = true;
371 }
372
373 /// Resolve a caller-supplied path to the store root.
374 ///
375 /// Callers pass what used to be a JSON file path. Keeping their
376 /// signatures means the CLI wiring in `main.rs` does not move.
377 pub(super) fn root_of(path: &Path) -> std::path::PathBuf {
378 if path.extension().is_some_and(|e| e == "json") {
379 path.parent().unwrap_or(path).to_path_buf()
380 } else {
381 path.to_path_buf()
382 }
383 }
384
385 /// Read one diagnostic singleton.
386 ///
387 /// A value that will not parse is reported and treated as absent —
388 /// the caller has nothing better to do with it — but it is reported,
389 /// where before it vanished.
390 pub(super) fn load<T: serde::de::DeserializeOwned>(
391 path: &Path,
392 name: &'static str,
393 ) -> Option<T> {
394 let store = match smix_store::Store::open(&root_of(path)) {
395 Ok(s) => s,
396 Err(e) => {
397 eprintln!("smix: read {name}: {e}");
398 return None;
399 }
400 };
401 match store.singleton(name).get_json::<T>() {
402 Ok(v) => v,
403 Err(e) => {
404 eprintln!("smix: read {name}: {e}");
405 None
406 }
407 }
408 }
409
410 /// Write one diagnostic singleton, without making the caller wait.
411 ///
412 /// `try_open` rather than `open`: this runs after every simctl
413 /// invocation, and a diagnostic must never queue behind another
414 /// smix process. Busy means skip — the next call persists, which is
415 /// what the best-effort comment here always promised.
416 pub(super) fn store<T: serde::Serialize>(path: &Path, name: &'static str, value: &T) {
417 match smix_store::Store::try_open(&root_of(path)) {
418 Ok(None) => {}
419 Ok(Some(store)) => {
420 if let Err(e) = store.singleton(name).put_json(value) {
421 eprintln!("smix: persist {name}: {e}");
422 } else if let Err(e) = store.sync() {
423 eprintln!("smix: persist {name}: {e}");
424 }
425 }
426 Err(e) => eprintln!("smix: persist {name}: {e}"),
427 }
428 }
429}
430
431mod subprocess_ring {
432 use super::SubprocessRecord;
433 use std::collections::VecDeque;
434 use std::path::PathBuf;
435 use std::sync::{Mutex, OnceLock};
436 use std::time::{Duration, UNIX_EPOCH};
437
438 fn cell() -> &'static Mutex<VecDeque<SubprocessRecord>> {
439 static INSTANCE: OnceLock<Mutex<VecDeque<SubprocessRecord>>> = OnceLock::new();
440 INSTANCE.get_or_init(|| Mutex::new(VecDeque::with_capacity(128)))
441 }
442
443 /// Persist path for the ring buffer. `None` = in-memory only. Set
444 /// once at process startup via [`set_persist_path`]; unchanged for
445 /// the lifetime of the process.
446 ///
447 /// Persistence matters because supervisor cycles can kill the CLI
448 /// faster than a `/diagnostic/dump` can snapshot the in-memory
449 /// buffer, yielding empty payloads. The file survives cycles, so
450 /// post-mortem tools read it rather than the (now-gone) in-memory
451 /// state.
452 fn persist_cell() -> &'static Mutex<Option<PathBuf>> {
453 static INSTANCE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
454 INSTANCE.get_or_init(|| Mutex::new(None))
455 }
456
457 /// Install a persist path. The stored value is read on first use,
458 /// not here — see [`super::diag_store::ensure_loaded`] for why the
459 /// eager version cost every command three store opens.
460 pub fn set_persist_path(path: PathBuf) {
461 let mut g = match persist_cell().lock() {
462 Ok(g) => g,
463 Err(p) => p.into_inner(),
464 };
465 *g = Some(path);
466 }
467
468 fn loaded_flag() -> &'static OnceLock<Mutex<bool>> {
469 static INSTANCE: OnceLock<Mutex<bool>> = OnceLock::new();
470 &INSTANCE
471 }
472
473 fn ensure_loaded() {
474 super::diag_store::ensure_loaded(loaded_flag(), persist_cell(), load_persisted);
475 }
476
477 fn persist_path_copy() -> Option<PathBuf> {
478 let g = match persist_cell().lock() {
479 Ok(g) => g,
480 Err(p) => p.into_inner(),
481 };
482 g.clone()
483 }
484
485 /// Record one invocation. Ring buffer capped at 128
486 /// entries; oldest evicted on push. When [`set_persist_path`] is
487 /// active, atomically writes the buffer to disk after the mutation
488 /// so a subsequent supervisor-cycle doesn't lose the observation.
489 pub(super) fn record(entry: SubprocessRecord) {
490 ensure_loaded();
491 {
492 let mut g = match cell().lock() {
493 Ok(g) => g,
494 Err(p) => p.into_inner(),
495 };
496 if g.len() >= 128 {
497 g.pop_front();
498 }
499 g.push_back(entry);
500 }
501 if let Some(path) = persist_path_copy() {
502 let snapshot: Vec<PersistedRecord> = snapshot().into_iter().map(Into::into).collect();
503 // Best-effort in the sense that matters: failure never
504 // affects the caller of `xcrun simctl`. It is no longer
505 // best-effort in the sense of being invisible.
506 super::diag_store::store(&path, "subprocess-ring", &snapshot);
507 }
508 }
509
510 /// Snapshot the current ring buffer. Ordered oldest → newest.
511 pub fn snapshot() -> Vec<SubprocessRecord> {
512 ensure_loaded();
513 let g = match cell().lock() {
514 Ok(g) => g,
515 Err(p) => p.into_inner(),
516 };
517 g.iter().cloned().collect()
518 }
519
520 /// Load a previously-persisted ring from disk. No-op
521 /// when the file does not exist. Called by CLI startup after
522 /// [`set_persist_path`] so the in-memory view starts with the
523 /// last-known state. Silently drops parse failures — corrupt files
524 /// are noise, not fatal.
525 pub fn load_persisted() {
526 let Some(path) = persist_path_copy() else {
527 return;
528 };
529 let Some(records) =
530 super::diag_store::load::<Vec<PersistedRecord>>(&path, "subprocess-ring")
531 else {
532 return;
533 };
534 let mut g = match cell().lock() {
535 Ok(g) => g,
536 Err(p) => p.into_inner(),
537 };
538 for r in records.into_iter().rev().take(128).rev() {
539 g.push_back(r.into_record());
540 }
541 }
542
543 /// On-disk representation. Kept separate from
544 /// [`SubprocessRecord`] because the wall-clock timestamp is a
545 /// `SystemTime` which does not serde-derive cleanly; we convert to
546 /// UNIX millis for a stable JSON shape.
547 #[derive(Clone, serde::Serialize, serde::Deserialize)]
548 struct PersistedRecord {
549 argv: Vec<String>,
550 exit_code: Option<i32>,
551 wall_ms: u64,
552 stderr_head: String,
553 timestamp_ms: u64,
554 }
555
556 impl From<SubprocessRecord> for PersistedRecord {
557 fn from(r: SubprocessRecord) -> Self {
558 let timestamp_ms = r
559 .timestamp
560 .duration_since(UNIX_EPOCH)
561 .map(|d| d.as_millis() as u64)
562 .unwrap_or(0);
563 Self {
564 argv: r.argv,
565 exit_code: r.exit_code,
566 wall_ms: r.wall_ms,
567 stderr_head: r.stderr_head,
568 timestamp_ms,
569 }
570 }
571 }
572
573 impl PersistedRecord {
574 fn into_record(self) -> SubprocessRecord {
575 let timestamp = UNIX_EPOCH + Duration::from_millis(self.timestamp_ms);
576 SubprocessRecord {
577 argv: self.argv,
578 exit_code: self.exit_code,
579 wall_ms: self.wall_ms,
580 stderr_head: self.stderr_head,
581 timestamp,
582 }
583 }
584 }
585
586 #[cfg(test)]
587 mod tests {
588 use super::*;
589 use std::time::SystemTime;
590
591 #[test]
592 fn persist_roundtrip_after_supervisor_cycle_simulation() {
593 let dir = tempfile::tempdir().expect("tempdir");
594 let path = dir.path().join("ring.json");
595 set_persist_path(path.clone());
596
597 record(SubprocessRecord {
598 argv: vec!["shutdown".into(), "UDID-A".into()],
599 exit_code: Some(0),
600 wall_ms: 42,
601 stderr_head: String::new(),
602 timestamp: SystemTime::now(),
603 });
604 // The property that matters is survival across a restart,
605 // not which file holds it — asserting the filename made this
606 // test a check on the implementation the record moved out of.
607
608 // Simulate supervisor cycle: clear in-memory then load.
609 {
610 let mut g = cell().lock().unwrap();
611 g.clear();
612 }
613
614 // Asked about OUR record, not about the ring's length.
615 //
616 // The ring is process-global and every subprocess this
617 // binary runs writes to it, so `len() == 1` and
618 // `is_empty()` were claims about what every other test in
619 // the process happened to be doing. They held for as long
620 // as no sibling ran a subprocess; the day one did, this
621 // test failed twice in three runs and named nothing that
622 // had changed. What it is for -- a record survives a
623 // restart -- is true regardless of who else is recording.
624 let ours =
625 |r: &SubprocessRecord| r.argv == vec!["shutdown".to_string(), "UDID-A".to_string()];
626 assert!(
627 !snapshot().iter().any(ours),
628 "the clear did not take our record out of the ring"
629 );
630
631 load_persisted();
632 let after = snapshot();
633 let found = after
634 .iter()
635 .find(|r| ours(r))
636 .expect("our record did not come back from the persisted ring");
637 assert_eq!(found.exit_code, Some(0));
638 assert_eq!(found.wall_ms, 42);
639 }
640 }
641}
642
643/// Enable subprocess-ring persistence at the given path.
644/// CLI startup wires this to `~/.local/share/smix/subprocess-ring.json`
645/// so `/diagnostic/dump` payloads survive supervisor cycles. Optional;
646/// without this call the ring stays in-memory only.
647pub fn set_subprocess_ring_persist_path(path: std::path::PathBuf) {
648 subprocess_ring::set_persist_path(path);
649 // No eager read here: the value is loaded the first time
650 // something actually uses it. Loading all three at startup cost
651 // every command three store opens, each one an AOF replay and a
652 // blocking lock, for state most commands never touch.
653}
654
655// CLI-side resetAppData counter tracking.
656//
657// The `resetAppData` verb dispatches host-side (simctl openurl + metro
658// log tail, no runner HTTP endpoint) so counters can't come from the
659// runner's `/diagnostic/dump` payload. This module owns them,
660// persisting to `~/.local/share/smix/reset-app-data-counters.json`
661// so counter deltas across `smix run` invocations + `smix diagnostic
662// dump` (later, separate process) all see the same data.
663//
664// Public API mirrors [`subprocess_ring`] shape for consistency.
665mod reset_app_data_counters {
666 use std::path::PathBuf;
667 use std::sync::{Mutex, OnceLock};
668
669 fn cell() -> &'static Mutex<Counters> {
670 static INSTANCE: OnceLock<Mutex<Counters>> = OnceLock::new();
671 INSTANCE.get_or_init(|| Mutex::new(Counters::default()))
672 }
673 fn persist_cell() -> &'static Mutex<Option<PathBuf>> {
674 static INSTANCE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
675 INSTANCE.get_or_init(|| Mutex::new(None))
676 }
677
678 #[derive(Clone, Copy, Debug, Default, serde::Serialize, serde::Deserialize)]
679 pub struct Counters {
680 pub reset_app_data_total: u64,
681 pub reset_app_data_timed_out: u64,
682 }
683
684 pub fn set_persist_path(path: PathBuf) {
685 let mut g = match persist_cell().lock() {
686 Ok(g) => g,
687 Err(p) => p.into_inner(),
688 };
689 *g = Some(path);
690 }
691
692 fn loaded_flag() -> &'static OnceLock<Mutex<bool>> {
693 static INSTANCE: OnceLock<Mutex<bool>> = OnceLock::new();
694 &INSTANCE
695 }
696
697 fn ensure_loaded() {
698 super::diag_store::ensure_loaded(loaded_flag(), persist_cell(), load_persisted);
699 }
700
701 fn persist_path_copy() -> Option<PathBuf> {
702 let g = match persist_cell().lock() {
703 Ok(g) => g,
704 Err(p) => p.into_inner(),
705 };
706 g.clone()
707 }
708
709 pub fn load_persisted() {
710 let Some(path) = persist_path_copy() else {
711 return;
712 };
713 let Some(loaded) = super::diag_store::load::<Counters>(&path, "reset-app-data-counters")
714 else {
715 return;
716 };
717 let mut g = match cell().lock() {
718 Ok(g) => g,
719 Err(p) => p.into_inner(),
720 };
721 *g = loaded;
722 }
723
724 pub fn increment_total() {
725 ensure_loaded();
726 {
727 let mut g = match cell().lock() {
728 Ok(g) => g,
729 Err(p) => p.into_inner(),
730 };
731 g.reset_app_data_total = g.reset_app_data_total.saturating_add(1);
732 }
733 persist_best_effort();
734 }
735
736 pub fn increment_timed_out() {
737 ensure_loaded();
738 {
739 let mut g = match cell().lock() {
740 Ok(g) => g,
741 Err(p) => p.into_inner(),
742 };
743 g.reset_app_data_timed_out = g.reset_app_data_timed_out.saturating_add(1);
744 }
745 persist_best_effort();
746 }
747
748 pub fn snapshot() -> Counters {
749 ensure_loaded();
750 let g = match cell().lock() {
751 Ok(g) => g,
752 Err(p) => p.into_inner(),
753 };
754 *g
755 }
756
757 fn persist_best_effort() {
758 let Some(path) = persist_path_copy() else {
759 return;
760 };
761 let snapshot = snapshot();
762 super::diag_store::store(&path, "reset-app-data-counters", &snapshot);
763 }
764
765 #[cfg(test)]
766 mod tests {
767 use super::*;
768
769 #[test]
770 fn increment_and_persist_roundtrip() {
771 let dir = tempfile::tempdir().expect("tempdir");
772 let path = dir.path().join("counters.json");
773 set_persist_path(path.clone());
774 // Reset in-memory to avoid cross-test pollution.
775 {
776 let mut g = cell().lock().unwrap();
777 *g = Counters::default();
778 }
779 increment_total();
780 increment_total();
781 increment_timed_out();
782 // Read it back the way a restarted process would, rather
783 // than by opening a file whose path is no longer the
784 // contract.
785 {
786 let mut g = cell().lock().unwrap();
787 *g = Counters::default();
788 }
789 load_persisted();
790 let loaded = snapshot();
791 assert_eq!(loaded.reset_app_data_total, 2);
792 assert_eq!(loaded.reset_app_data_timed_out, 1);
793 }
794 }
795}
796
797/// Public snapshot of CLI-side resetAppData counter state. Populated by [`increment_reset_app_data_total`] +
798/// [`increment_reset_app_data_timed_out`] as the CLI dispatches the
799/// verb; loaded from disk on CLI startup if
800/// [`set_reset_app_data_counters_persist_path`] was called.
801#[derive(Clone, Copy, Debug, Default)]
802pub struct ResetAppDataCounters {
803 /// Total resetAppData dispatches (any outcome).
804 pub reset_app_data_total: u64,
805 /// resetAppData dispatches where the completion signal did not
806 /// arrive inside the timeout window. `> 0` = the URL was fired
807 /// but the app did not emit the expected reset-complete log line.
808 pub reset_app_data_timed_out: u64,
809}
810
811/// Enable resetAppData counter persistence at the given path. Callers pass
812/// `~/.local/share/smix/reset-app-data-counters.json` at CLI startup
813/// so counter state survives across `smix run` → `smix diagnostic
814/// dump` invocations.
815pub fn set_reset_app_data_counters_persist_path(path: std::path::PathBuf) {
816 reset_app_data_counters::set_persist_path(path);
817 // No eager read here: the value is loaded the first time
818 // something actually uses it. Loading all three at startup cost
819 // every command three store opens, each one an AOF replay and a
820 // blocking lock, for state most commands never touch.
821}
822
823/// Advance the resetAppData total counter.
824/// Called by the CLI runtime after each dispatch (success or timeout).
825pub fn increment_reset_app_data_total() {
826 reset_app_data_counters::increment_total();
827}
828
829/// Advance the resetAppData timed-out counter.
830/// Called by the CLI runtime when the completion signal (log-line
831/// pattern match) did not arrive inside the timeout window. Always
832/// paired with a preceding [`increment_reset_app_data_total`] on the
833/// same dispatch.
834pub fn increment_reset_app_data_timed_out() {
835 reset_app_data_counters::increment_timed_out();
836}
837
838/// Snapshot the current counter state for display / wire emission. Returns zero-valued counters when
839/// persistence was never wired.
840pub fn reset_app_data_counters_snapshot() -> ResetAppDataCounters {
841 let s = reset_app_data_counters::snapshot();
842 ResetAppDataCounters {
843 reset_app_data_total: s.reset_app_data_total,
844 reset_app_data_timed_out: s.reset_app_data_timed_out,
845 }
846}
847
848// Flow-attempt persistence for retry attribution. Called by `smix run`
849// after each flow completes (all its attempts done); read by
850// `smix diagnostic dump` to render the attribution table.
851// One record per flow, under `attempt:<flowName>`, written while this
852// process holds the store's own lock.
853//
854// It was a single machine-global blob rewritten whole, on a write that
855// skipped itself when another smix held the lock. `smix run` records
856// once and exits, so "the next attempt will persist" was never true:
857// a busy neighbour meant the record simply did not exist — and the gate
858// that reads these back cannot tell that from a flow that never ran.
859mod flow_attempts {
860 use serde::{Deserialize, Serialize};
861 use std::collections::BTreeMap;
862 use std::path::PathBuf;
863 use std::sync::{Mutex, OnceLock};
864 use std::time::{SystemTime, UNIX_EPOCH};
865
866 /// Enough history to diagnose a batch or two while keeping the dump
867 /// snapshot cheap to serialize.
868 const MAX_PERSISTED_FLOWS: usize = 32;
869
870 #[derive(Clone, Debug, Serialize, Deserialize)]
871 pub struct PersistedAttempt {
872 pub attempt_index: u32,
873 pub status: String,
874 pub error_class: Option<String>,
875 pub ips_generated: Option<String>,
876 pub wall_ms: u64,
877 }
878
879 #[derive(Clone, Debug, Serialize, Deserialize)]
880 pub struct PersistedFlow {
881 pub flow_name: String,
882 pub attempts: Vec<PersistedAttempt>,
883 /// `serde(default)` is load-bearing: the blob written before
884 /// this field existed has no such key, and without a default the
885 /// merge in [`snapshot`] would call that history corrupt —
886 /// losing it to the very change that exists to keep it.
887 #[serde(default)]
888 pub recorded_at_ms: u64,
889 }
890
891 fn persist_cell() -> &'static Mutex<Option<PathBuf>> {
892 static INSTANCE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
893 INSTANCE.get_or_init(|| Mutex::new(None))
894 }
895
896 pub fn set_persist_path(path: PathBuf) {
897 let mut g = match persist_cell().lock() {
898 Ok(g) => g,
899 Err(p) => p.into_inner(),
900 };
901 *g = Some(path);
902 }
903
904 fn persist_path_copy() -> Option<PathBuf> {
905 let g = match persist_cell().lock() {
906 Ok(g) => g,
907 Err(p) => p.into_inner(),
908 };
909 g.clone()
910 }
911
912 fn now_ms() -> u64 {
913 SystemTime::now()
914 .duration_since(UNIX_EPOCH)
915 .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
916 }
917
918 /// Blocking, not best-effort. Waiting a few milliseconds behind a
919 /// neighbour is the price of the record existing at all.
920 fn open() -> Option<smix_store::Store> {
921 let path = persist_path_copy()?;
922 match smix_store::Store::open(&super::diag_store::root_of(&path)) {
923 Ok(store) => Some(store),
924 Err(e) => {
925 eprintln!("smix: flow-attempts: {e}");
926 None
927 }
928 }
929 }
930
931 pub fn record(flow_name: &str, attempts: &[PersistedAttempt]) {
932 let Some(store) = open() else {
933 return;
934 };
935 let flow = PersistedFlow {
936 flow_name: flow_name.to_string(),
937 attempts: attempts.to_vec(),
938 recorded_at_ms: now_ms(),
939 };
940 if let Err(e) = store.attempts().put_json(flow_name, &flow) {
941 eprintln!("smix: persist flow-attempts: {e}");
942 return;
943 }
944 trim(&store);
945 if let Err(e) = store.sync() {
946 eprintln!("smix: persist flow-attempts: {e}");
947 }
948 }
949
950 /// Under the lock [`record`] already holds. Opening the store again
951 /// here would be a second read-modify-write window — the shape this
952 /// module exists to no longer have.
953 fn trim(store: &smix_store::Store) {
954 let ns = store.attempts();
955 let mut dated: Vec<(u64, String)> = Vec::new();
956 for id in ns.list() {
957 match ns.get_json::<PersistedFlow>(&id) {
958 Ok(Some(flow)) => dated.push((flow.recorded_at_ms, id)),
959 Ok(None) => {}
960 // Unreadable is not a candidate for eviction: deleting it
961 // erases the evidence of whatever wrote it.
962 Err(e) => eprintln!("smix: read flow-attempts {id}: {e}"),
963 }
964 }
965 let Some(excess) = dated.len().checked_sub(MAX_PERSISTED_FLOWS) else {
966 return;
967 };
968 if excess == 0 {
969 return;
970 }
971 dated.sort();
972 for (_, id) in dated.into_iter().take(excess) {
973 if let Err(e) = ns.delete(&id) {
974 eprintln!("smix: trim flow-attempts {id}: {e}");
975 }
976 }
977 }
978
979 pub fn snapshot() -> Vec<PersistedFlow> {
980 let Some(store) = open() else {
981 return Vec::new();
982 };
983 let mut by_name: BTreeMap<String, PersistedFlow> = BTreeMap::new();
984 // The blob this used to be: read, never rewritten. Migrating it
985 // would mean writing a whole blob again, which is the thing that
986 // lost records in the first place.
987 match store
988 .singleton("flow-attempts")
989 .get_json::<Vec<PersistedFlow>>()
990 {
991 Ok(Some(old)) => {
992 for flow in old {
993 by_name.insert(flow.flow_name.clone(), flow);
994 }
995 }
996 Ok(None) => {}
997 Err(e) => eprintln!("smix: read flow-attempts: {e}"),
998 }
999 let ns = store.attempts();
1000 for id in ns.list() {
1001 match ns.get_json::<PersistedFlow>(&id) {
1002 Ok(Some(flow)) => {
1003 by_name.insert(flow.flow_name.clone(), flow);
1004 }
1005 Ok(None) => {}
1006 Err(e) => eprintln!("smix: read flow-attempts {id}: {e}"),
1007 }
1008 }
1009 let mut flows: Vec<PersistedFlow> = by_name.into_values().collect();
1010 flows.sort_by(|a, b| {
1011 a.recorded_at_ms
1012 .cmp(&b.recorded_at_ms)
1013 .then_with(|| a.flow_name.cmp(&b.flow_name))
1014 });
1015 flows
1016 }
1017}
1018
1019/// Enable flow-attempts persistence at the given path.
1020/// CLI startup wires this to `~/.local/share/smix/flow-attempts.json`
1021/// so retry attribution survives across `smix run` → `smix diagnostic
1022/// dump` invocations.
1023pub fn set_flow_attempts_persist_path(path: std::path::PathBuf) {
1024 flow_attempts::set_persist_path(path);
1025 // No eager read here: the value is loaded the first time
1026 // something actually uses it. Loading all three at startup cost
1027 // every command three store opens, each one an AOF replay and a
1028 // blocking lock, for state most commands never touch.
1029}
1030
1031/// Public accessor with just the fields needed by callers.
1032/// Mirrors [`smix_runner_wire::FlowAttempt`] shape.
1033#[derive(Clone, Debug)]
1034pub struct FlowAttemptData {
1035 /// Zero-based retry index.
1036 pub attempt_index: u32,
1037 /// Overall outcome ("ok" / "timeout" / "error" / "crashed").
1038 pub status: String,
1039 /// Free-form error class code (`Some` on non-ok).
1040 pub error_class: Option<String>,
1041 /// `.ips` filename that appeared during this attempt, when detected.
1042 pub ips_generated: Option<String>,
1043 /// Wall-clock milliseconds.
1044 pub wall_ms: u64,
1045}
1046
1047/// Recorded flow with its attempt list.
1048#[derive(Clone, Debug)]
1049pub struct FlowAttemptRecordData {
1050 /// Flow name (yaml basename or explicit id).
1051 pub flow_name: String,
1052 /// Ordered attempts, first try first.
1053 pub attempts: Vec<FlowAttemptData>,
1054}
1055
1056/// Record the outcome of a flow's attempts. Called from
1057/// `smix run` after all retries for that flow have completed. `smix
1058/// diagnostic dump` reads via [`recent_flow_attempts`] later.
1059pub fn record_flow_attempts<A>(flow_name: &str, attempts: &[A])
1060where
1061 A: FlowAttemptShape,
1062{
1063 let converted: Vec<flow_attempts::PersistedAttempt> = attempts
1064 .iter()
1065 .map(|a| flow_attempts::PersistedAttempt {
1066 attempt_index: a.attempt_index(),
1067 status: a.status().to_string(),
1068 error_class: a.error_class().map(str::to_string),
1069 ips_generated: a.ips_generated().map(str::to_string),
1070 wall_ms: a.wall_ms(),
1071 })
1072 .collect();
1073 flow_attempts::record(flow_name, &converted);
1074}
1075
1076/// Abstraction so callers pass either
1077/// [`smix_runner_wire::FlowAttempt`] or a local struct with the same
1078/// shape without a cross-crate dep on smix-runner-wire from smix-simctl.
1079pub trait FlowAttemptShape {
1080 /// Zero-based retry index.
1081 fn attempt_index(&self) -> u32;
1082 /// "ok" / "timeout" / "error" / "crashed".
1083 fn status(&self) -> &str;
1084 /// Error class code, if any.
1085 fn error_class(&self) -> Option<&str>;
1086 /// `.ips` filename attributable to this attempt, if any.
1087 fn ips_generated(&self) -> Option<&str>;
1088 /// Wall-clock milliseconds.
1089 fn wall_ms(&self) -> u64;
1090}
1091
1092/// Snapshot recent flow attempts for display / wire
1093/// emission. Returns empty when persistence was never wired.
1094pub fn recent_flow_attempts() -> Vec<FlowAttemptRecordData> {
1095 flow_attempts::snapshot()
1096 .into_iter()
1097 .map(|f| FlowAttemptRecordData {
1098 flow_name: f.flow_name,
1099 attempts: f
1100 .attempts
1101 .into_iter()
1102 .map(|a| FlowAttemptData {
1103 attempt_index: a.attempt_index,
1104 status: a.status,
1105 error_class: a.error_class,
1106 ips_generated: a.ips_generated,
1107 wall_ms: a.wall_ms,
1108 })
1109 .collect(),
1110 })
1111 .collect()
1112}
1113
1114/// Snapshot the process-wide ring buffer of recent
1115/// `xcrun simctl` invocations. Ordered oldest → newest, capped at 128
1116/// entries. Reset on process restart.
1117pub fn recent_subprocesses() -> Vec<SubprocessRecord> {
1118 subprocess_ring::snapshot()
1119}
1120
1121// -------------------- raw spawn primitive --------------------------------
1122
1123/// Execute `xcrun simctl <args>` and capture stdout/stderr.
1124async fn simctl_capture(args: &[&str]) -> Result<(Vec<u8>, String), DeviceControlError> {
1125 simctl_capture_env(args, &[]).await
1126}
1127
1128/// `simctl_capture` with extra envp pairs set on the spawned process.
1129/// The `xcrun simctl launch` subcommand uses this to inject
1130/// `SIMCTL_CHILD_<KEY>=<VAL>` vars that the launched app sees as
1131/// `ProcessInfo().environment["KEY"]`. `env` entries here are passed
1132/// verbatim — caller composes the `SIMCTL_CHILD_` prefix via
1133/// [`compose_child_env`].
1134async fn simctl_capture_env(
1135 args: &[&str],
1136 env: &[(String, String)],
1137) -> Result<(Vec<u8>, String), DeviceControlError> {
1138 let mut cmd = Command::new("xcrun");
1139 cmd.arg("simctl");
1140 for a in args {
1141 cmd.arg(a);
1142 }
1143 for (k, v) in env {
1144 cmd.env(k, v);
1145 }
1146 let started = std::time::Instant::now();
1147 let output = cmd.output().await?;
1148 let wall_ms = started.elapsed().as_millis() as u64;
1149 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
1150 // Every simctl invocation records to the ring buffer regardless of
1151 // exit status.
1152 subprocess_ring::record(SubprocessRecord {
1153 argv: std::iter::once("xcrun".to_string())
1154 .chain(std::iter::once("simctl".to_string()))
1155 .chain(args.iter().map(|s| s.to_string()))
1156 .collect(),
1157 exit_code: output.status.code(),
1158 wall_ms,
1159 stderr_head: {
1160 let mut s = stderr.clone();
1161 if s.len() > 256 {
1162 s.truncate(256);
1163 }
1164 s
1165 },
1166 timestamp: std::time::SystemTime::now(),
1167 });
1168 if !output.status.success() {
1169 return Err(DeviceControlError::NonZeroExit {
1170 subcommand: args.first().map(|s| s.to_string()).unwrap_or_default(),
1171 argv: std::iter::once("xcrun".to_string())
1172 .chain(std::iter::once("simctl".to_string()))
1173 .chain(args.iter().map(|s| s.to_string()))
1174 .collect(),
1175 code: output.status.code().unwrap_or(-1),
1176 stderr,
1177 wall_ms,
1178 });
1179 }
1180 Ok((output.stdout, stderr))
1181}
1182
1183async fn simctl_run(args: &[&str]) -> Result<String, DeviceControlError> {
1184 let (stdout, _) = simctl_capture(args).await?;
1185 Ok(String::from_utf8_lossy(&stdout).into_owned())
1186}
1187
1188/// Like [`simctl_run`] but injects `child_env` envp on the spawned
1189/// process. Used by the env-aware launch path so the launched app can
1190/// read deploy-time secrets / endpoints via `ProcessInfo`.
1191async fn simctl_run_env(
1192 args: &[&str],
1193 env: &[(String, String)],
1194) -> Result<String, DeviceControlError> {
1195 let (stdout, _) = simctl_capture_env(args, env).await?;
1196 Ok(String::from_utf8_lossy(&stdout).into_owned())
1197}
1198
1199/// Compose user-provided `(key, value)` pairs into the `SIMCTL_CHILD_*`
1200/// envp that `xcrun simctl launch` strips and delivers to the launched
1201/// app. Idempotent: a key that already starts with `SIMCTL_CHILD_` is
1202/// passed through unchanged.
1203///
1204/// # Example
1205///
1206/// ```
1207/// use smix_simctl::compose_child_env;
1208/// let composed = compose_child_env(&[("SMIX_PERF_RECEIVER_URL", "http://h:9999")]);
1209/// assert_eq!(
1210/// composed,
1211/// vec![(
1212/// "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
1213/// "http://h:9999".to_string(),
1214/// )]
1215/// );
1216/// ```
1217pub fn compose_child_env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
1218 pairs
1219 .iter()
1220 .map(|(k, v)| {
1221 let key = if k.starts_with("SIMCTL_CHILD_") {
1222 (*k).to_string()
1223 } else {
1224 format!("SIMCTL_CHILD_{k}")
1225 };
1226 (key, (*v).to_string())
1227 })
1228 .collect()
1229}
1230
1231// -------------------- client --------------------------------------------
1232
1233/// Stateless wrapper around xcrun simctl. Methods are free functions
1234/// in spirit (no instance state beyond optionally-cached `xcrun` path);
1235/// kept as a struct for API ergonomics + future caching.
1236///
1237/// The client also holds a [`ScreenshotPacer`] that
1238/// throttles `xcrun simctl io screenshot` under high-frequency
1239/// load. Defaults are conservative (100 ms interval floor);
1240/// consumers whose flows are already loose are unaffected.
1241#[derive(Debug)]
1242pub struct SimctlClient {
1243 /// Screenshot pacer — enforces the interval floor, slow-path lift,
1244 /// and circuit breaker. Shared via `Arc<Mutex<_>>`
1245 /// so a cloned client (which callers occasionally do) still shares
1246 /// pressure accounting.
1247 screenshot_pacer: Arc<std::sync::Mutex<ScreenshotPacer>>,
1248 /// Resident per-UDID `smix-capture-host` processes for the direct
1249 /// IOSurface capture path. Shared so a cloned client reuses the same
1250 /// warm surfaces. See [`surface_capture`].
1251 capture_hosts: Arc<tokio::sync::Mutex<surface_capture::CaptureHostRegistry>>,
1252}
1253
1254impl Default for SimctlClient {
1255 fn default() -> Self {
1256 Self::new()
1257 }
1258}
1259
1260impl SimctlClient {
1261 /// Construct a new client with default screenshot pacing (100 ms
1262 /// interval floor, adaptive slow-path lift to 1500 ms, circuit
1263 /// breaker on ≥ 1500 ms walls or failures).
1264 pub fn new() -> Self {
1265 SimctlClient {
1266 screenshot_pacer: Arc::new(std::sync::Mutex::new(ScreenshotPacer::new(
1267 ScreenshotPacerConfig::default(),
1268 ))),
1269 capture_hosts: Arc::new(tokio::sync::Mutex::new(
1270 surface_capture::CaptureHostRegistry::default(),
1271 )),
1272 }
1273 }
1274
1275 /// Override the screenshot pacer with a custom config.
1276 ///
1277 /// Since smix 1.0.4.
1278 #[must_use]
1279 pub fn with_screenshot_pacer(self, config: ScreenshotPacerConfig) -> Self {
1280 {
1281 let mut guard = self
1282 .screenshot_pacer
1283 .lock()
1284 .expect("screenshot pacer mutex must not be poisoned");
1285 *guard = ScreenshotPacer::new(config);
1286 }
1287 self
1288 }
1289
1290 /// Attach a [`smix_sim_health::SimHealthMonitor`] to receive
1291 /// screenshot wall-time observations from every `screenshot`
1292 /// call. Composes with the pacer — the pacer still enforces its
1293 /// interval / circuit locally, and the monitor sees the same
1294 /// walls for global state classification.
1295 ///
1296 /// Since smix 1.0.4.
1297 #[must_use]
1298 pub fn with_sim_health(self, monitor: smix_sim_health::SimHealthMonitor) -> Self {
1299 {
1300 let mut guard = self
1301 .screenshot_pacer
1302 .lock()
1303 .expect("screenshot pacer mutex must not be poisoned");
1304 guard.set_monitor(monitor);
1305 }
1306 self
1307 }
1308
1309 // ---- inventory ------------------------------------------------------
1310
1311 /// `xcrun simctl list runtimes -j` → `Vec<SimctlRuntime>`.
1312 pub async fn list_runtimes(&self) -> Result<Vec<SimctlRuntime>, DeviceControlError> {
1313 let raw = simctl_run(&["list", "runtimes", "-j"]).await?;
1314 #[derive(Deserialize)]
1315 struct Wrap {
1316 runtimes: Vec<RawRuntime>,
1317 }
1318 #[derive(Deserialize)]
1319 struct RawRuntime {
1320 identifier: String,
1321 name: String,
1322 version: String,
1323 #[serde(rename = "isAvailable", default)]
1324 is_available: bool,
1325 }
1326 let w: Wrap = serde_json::from_str(&raw).map_err(|e| DeviceControlError::Malformed {
1327 subcommand: "list runtimes".into(),
1328 detail: e.to_string(),
1329 })?;
1330 Ok(w.runtimes
1331 .into_iter()
1332 .map(|r| SimctlRuntime {
1333 identifier: r.identifier,
1334 name: r.name,
1335 version: r.version,
1336 is_available: r.is_available,
1337 })
1338 .collect())
1339 }
1340
1341 /// `xcrun simctl list devices -j` → flattened `Vec<SimctlDevice>`.
1342 pub async fn list_devices(&self) -> Result<Vec<SimctlDevice>, DeviceControlError> {
1343 let raw = simctl_run(&["list", "devices", "-j"]).await?;
1344 #[derive(Deserialize)]
1345 struct Wrap {
1346 devices: std::collections::BTreeMap<String, Vec<RawDevice>>,
1347 }
1348 #[derive(Deserialize)]
1349 struct RawDevice {
1350 udid: String,
1351 name: String,
1352 state: String,
1353 #[serde(rename = "isAvailable", default)]
1354 is_available: bool,
1355 #[serde(rename = "deviceTypeIdentifier", default)]
1356 device_type_identifier: String,
1357 }
1358 let w: Wrap = serde_json::from_str(&raw).map_err(|e| DeviceControlError::Malformed {
1359 subcommand: "list devices".into(),
1360 detail: e.to_string(),
1361 })?;
1362 let mut out = Vec::new();
1363 for (runtime_id, devices) in w.devices {
1364 for d in devices {
1365 out.push(SimctlDevice {
1366 udid: d.udid,
1367 name: d.name,
1368 state: d.state,
1369 is_available: d.is_available,
1370 device_type_identifier: d.device_type_identifier,
1371 runtime_identifier: runtime_id.clone(),
1372 });
1373 }
1374 }
1375 Ok(out)
1376 }
1377
1378 // ---- lifecycle ------------------------------------------------------
1379
1380 /// `xcrun simctl boot <udid>` — fire-and-forget boot request.
1381 pub async fn boot(&self, udid: &str) -> Result<(), DeviceControlError> {
1382 simctl_run(&["boot", udid]).await?;
1383 Ok(())
1384 }
1385
1386 /// `xcrun simctl shutdown <udid>`.
1387 pub async fn shutdown(&self, udid: &str) -> Result<(), DeviceControlError> {
1388 simctl_run(&["shutdown", udid]).await?;
1389 Ok(())
1390 }
1391
1392 /// Read the sim's current BCP-47 locale (first entry of
1393 /// `NSGlobalDomain AppleLanguages`). Returns `Ok(None)` when the
1394 /// preference is unset (defaults read exits non-zero) or unparseable.
1395 /// Wire format: `simctl spawn <udid> defaults read -g AppleLanguages`
1396 /// stdout looks like `"(\n \"en-US\"\n)\n"`; we extract the first
1397 /// quoted token.
1398 pub async fn current_locale(&self, udid: &str) -> Result<Option<String>, DeviceControlError> {
1399 let out = match simctl_run(&[
1400 "spawn",
1401 udid,
1402 "/usr/bin/defaults",
1403 "read",
1404 "-g",
1405 "AppleLanguages",
1406 ])
1407 .await
1408 {
1409 Ok(s) => s,
1410 // `defaults read` returns non-zero when the key is unset; that
1411 // is a legitimate "no opinion" state, not an error.
1412 Err(DeviceControlError::NonZeroExit { .. }) => return Ok(None),
1413 Err(e) => return Err(e),
1414 };
1415 // First quoted substring.
1416 if let Some(start) = out.find('"') {
1417 let rest = &out[start + 1..];
1418 if let Some(end) = rest.find('"') {
1419 return Ok(Some(rest[..end].to_string()));
1420 }
1421 }
1422 Ok(None)
1423 }
1424
1425 /// Delete a single key from an app's NSUserDefaults domain via
1426 /// `simctl spawn <udid> defaults delete <bundleId> <key>`.
1427 /// Running `defaults` INSIDE the sim (spawn) goes through
1428 /// the sim's cfprefsd, so the deletion is coherent with what the
1429 /// app reads on next launch (editing the container plist from the
1430 /// host would race cfprefsd's cache).
1431 ///
1432 /// Returns `Ok(true)` when the key existed and was deleted,
1433 /// `Ok(false)` when the key (or the whole domain) was absent —
1434 /// the verb contract is "ensure key absent", so an already-absent
1435 /// key is success, not an error. Any other failure surfaces as
1436 /// the underlying [`DeviceControlError`].
1437 ///
1438 /// Motivating case: expo-dev-launcher
1439 /// persists the most recent deep link and re-delivers it after
1440 /// every JS bundle load; deleting its storage key between
1441 /// terminate and relaunch neutralizes the replay at the source.
1442 ///
1443 /// **Terminate the app first** — a running process has its
1444 /// defaults cached in-memory and may rewrite the key at exit.
1445 pub async fn user_defaults_delete(
1446 &self,
1447 udid: &str,
1448 bundle_id: &str,
1449 key: &str,
1450 ) -> Result<bool, DeviceControlError> {
1451 match simctl_run(&["spawn", udid, "/usr/bin/defaults", "delete", bundle_id, key]).await {
1452 Ok(_) => Ok(true),
1453 // `defaults delete` exits non-zero with "does not exist"
1454 // on stderr for both a missing key and a missing domain.
1455 // Both are the target state.
1456 Err(DeviceControlError::NonZeroExit { stderr, .. })
1457 if stderr.contains("does not exist") =>
1458 {
1459 Ok(false)
1460 }
1461 Err(e) => Err(e),
1462 }
1463 }
1464
1465 /// Write `AppleLanguages` (array) + `AppleLocale` (scalar) to the
1466 /// sim's NSGlobalDomain so SpringBoard + apps re-localize on next
1467 /// launch. AppleLocale is BCP-47 with hyphen replaced by underscore
1468 /// (`en_US`); AppleLanguages is the BCP-47 tag verbatim.
1469 /// **The caller must shutdown + reboot the sim for the change to
1470 /// take effect** — running apps cache the locale at process start.
1471 pub async fn set_locale(&self, udid: &str, locale: &str) -> Result<(), DeviceControlError> {
1472 simctl_run(&[
1473 "spawn",
1474 udid,
1475 "/usr/bin/defaults",
1476 "write",
1477 "-g",
1478 "AppleLanguages",
1479 "-array",
1480 locale,
1481 ])
1482 .await?;
1483 let locale_underscore = locale.replace('-', "_");
1484 simctl_run(&[
1485 "spawn",
1486 udid,
1487 "/usr/bin/defaults",
1488 "write",
1489 "-g",
1490 "AppleLocale",
1491 &locale_underscore,
1492 ])
1493 .await?;
1494 Ok(())
1495 }
1496
1497 /// Boot + poll device state == "Booted" within timeout. Tries every
1498 /// 500 ms until success or `timeout_ms` elapses. Idempotent on
1499 /// already-booted devices (`xcrun simctl boot` returns non-zero when
1500 /// the device is already booted; we swallow that).
1501 pub async fn boot_and_wait(
1502 &self,
1503 udid: &str,
1504 timeout: Duration,
1505 ) -> Result<(), DeviceControlError> {
1506 // Issue boot; ignore already-booted error (the only friendly path).
1507 let _ = simctl_run(&["boot", udid]).await;
1508 let start = std::time::Instant::now();
1509 loop {
1510 let devices = self.list_devices().await?;
1511 if devices
1512 .iter()
1513 .any(|d| d.udid == udid && d.state == "Booted")
1514 {
1515 return Ok(());
1516 }
1517 if start.elapsed() > timeout {
1518 return Err(DeviceControlError::Timeout {
1519 subcommand: format!("boot {}", udid),
1520 ms: timeout.as_millis() as u64,
1521 });
1522 }
1523 sleep(Duration::from_millis(500)).await;
1524 }
1525 }
1526
1527 /// `xcrun simctl erase <udid>` — wipe device contents.
1528 pub async fn erase(&self, udid: &str) -> Result<(), DeviceControlError> {
1529 simctl_run(&["erase", udid]).await?;
1530 Ok(())
1531 }
1532
1533 /// `xcrun simctl install <udid> <app-path>` — install a `.app` bundle.
1534 pub async fn install(&self, udid: &str, app_path: &str) -> Result<(), DeviceControlError> {
1535 simctl_run(&["install", udid, app_path]).await?;
1536 Ok(())
1537 }
1538
1539 /// `xcrun simctl uninstall <udid> <bundle-id>`.
1540 pub async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), DeviceControlError> {
1541 simctl_run(&["uninstall", udid, bundle_id]).await?;
1542 Ok(())
1543 }
1544
1545 /// `xcrun simctl terminate <udid> <bundle-id>` — kill a running app.
1546 pub async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), DeviceControlError> {
1547 simctl_run(&["terminate", udid, bundle_id]).await?;
1548 Ok(())
1549 }
1550
1551 /// `xcrun simctl launch <udid> <bundleId>` → parse `"<bundle>: <pid>"`.
1552 pub async fn launch(
1553 &self,
1554 udid: &str,
1555 bundle_id: &str,
1556 ) -> Result<LaunchResult, DeviceControlError> {
1557 self.launch_with_args(udid, bundle_id, &[]).await
1558 }
1559
1560 /// `xcrun simctl launch <udid> <bundleId> -- <arg>...` — launch with a
1561 /// process-level argument vector. Empty `args` is equivalent to
1562 /// [`Self::launch`]. Mirrors maestro yaml `launchApp.arguments`.
1563 pub async fn launch_with_args(
1564 &self,
1565 udid: &str,
1566 bundle_id: &str,
1567 args: &[String],
1568 ) -> Result<LaunchResult, DeviceControlError> {
1569 self.launch_with_args_and_env(udid, bundle_id, args, &[])
1570 .await
1571 }
1572
1573 /// Like [`Self::launch_with_args`] but also sets `SIMCTL_CHILD_*`
1574 /// envp on the simctl process so the launched app can read
1575 /// deploy-time vars via `ProcessInfo().environment["KEY"]`.
1576 /// `child_env` keys without the `SIMCTL_CHILD_` prefix get it added
1577 /// automatically (per [`compose_child_env`] semantics). Useful for
1578 /// prelaunching an app before any `openLink` so iOS treats the
1579 /// subsequent URL handoff as in-app routing instead of cross-app,
1580 /// side-stepping the SpringBoard "Open in '`<App>`'?" confirmation
1581 /// dialog.
1582 pub async fn launch_with_args_and_env(
1583 &self,
1584 udid: &str,
1585 bundle_id: &str,
1586 args: &[String],
1587 child_env: &[(&str, &str)],
1588 ) -> Result<LaunchResult, DeviceControlError> {
1589 let mut argv: Vec<&str> = vec!["launch", udid, bundle_id];
1590 if !args.is_empty() {
1591 argv.push("--");
1592 for a in args {
1593 argv.push(a.as_str());
1594 }
1595 }
1596 let composed = compose_child_env(child_env);
1597 let out = simctl_run_env(&argv, &composed).await?;
1598 // Output format: `com.example.app: 12345\n`
1599 let pid_str =
1600 out.rsplit(':')
1601 .next()
1602 .map(str::trim)
1603 .ok_or_else(|| DeviceControlError::Malformed {
1604 subcommand: "launch".into(),
1605 detail: format!("unexpected stdout shape: {}", out.trim()),
1606 })?;
1607 let pid: u32 = pid_str.parse().map_err(|_| DeviceControlError::Malformed {
1608 subcommand: "launch".into(),
1609 detail: format!("non-numeric pid in stdout: {}", out.trim()),
1610 })?;
1611 Ok(LaunchResult { pid })
1612 }
1613
1614 /// Reset every privacy permission granted to `bundle_id` on the
1615 /// sim: `xcrun simctl privacy <udid> reset all <bundle-id>`.
1616 /// Companion to [`Self::clear_app_sandbox`] on the in-place
1617 /// `launchApp: clearState: true` path, which replaces
1618 /// `simctl uninstall + install` — that pairing triggers iOS 26.5
1619 /// XCUITest binding loss plus a ReportCrash "<app> quit
1620 /// unexpectedly" dialog.
1621 pub async fn privacy_reset_all(
1622 &self,
1623 udid: &str,
1624 bundle_id: &str,
1625 ) -> Result<(), DeviceControlError> {
1626 simctl_run(&["privacy", udid, "reset", "all", bundle_id]).await?;
1627 Ok(())
1628 }
1629
1630 /// Whether the bundle is installed on the sim, without touching it.
1631 ///
1632 /// `get_app_container` is the canonical probe -- it exits non-zero
1633 /// for a bundle that is not there. `clear_app_sandbox` already knew
1634 /// that, but it wipes the sandbox on its way past, so nothing that
1635 /// only wanted to ask could use it.
1636 ///
1637 /// The caller that needs this is `foreground`. XCUITest's
1638 /// `.activate()` does not fail on a missing bundle -- it waits for
1639 /// an app that will never come to the front, and it waits on the
1640 /// main actor, so every later request that needs the app waits with
1641 /// it. On 2026-08-29 one flow naming an Android package did that to
1642 /// the release corpus: the runner wedged, XCTest's watchdog killed
1643 /// it, and the twenty-three flows after it reported `runner
1644 /// unreachable`. The runner cannot defend itself once `.activate()`
1645 /// is called, so the question has to be asked before it is.
1646 pub async fn app_is_installed(
1647 &self,
1648 udid: &str,
1649 bundle_id: &str,
1650 ) -> Result<bool, DeviceControlError> {
1651 match simctl_run(&["get_app_container", udid, bundle_id, "app"]).await {
1652 Ok(_) => Ok(true),
1653 // Only the message that means the app is absent. simctl exits
1654 // non-zero for a udid it does not know too, and answering
1655 // `false` there would say "that app is not installed" about a
1656 // device that does not exist -- a plausible sentence, and the
1657 // wrong one. Measured on 2026-08-29: a missing app gives
1658 // `NSPOSIXErrorDomain, code=2` / `No such file or directory`,
1659 // a missing device gives `Invalid device: <udid>`. Anything
1660 // else is an unknown and stays loud.
1661 Err(DeviceControlError::NonZeroExit { ref stderr, .. })
1662 if stderr.contains("No such file or directory") =>
1663 {
1664 Ok(false)
1665 }
1666 Err(other) => Err(other),
1667 }
1668 }
1669
1670 /// Wipe the app's sandbox on the sim: locate the
1671 /// Data container via `simctl get_app_container <udid> <bundle>
1672 /// data`, then `simctl spawn <udid> rm -rf <container>/Documents
1673 /// <container>/Library <container>/tmp`. The app remains installed
1674 /// (no `simctl uninstall`), so the XCUITest binding is preserved
1675 /// and macOS `ReportCrash` does not misinterpret a missing
1676 /// install-receipt as a crash.
1677 pub async fn clear_app_sandbox(
1678 &self,
1679 udid: &str,
1680 bundle_id: &str,
1681 ) -> Result<(), DeviceControlError> {
1682 let raw = simctl_run(&["get_app_container", udid, bundle_id, "data"])
1683 .await
1684 .map_err(|e| match e {
1685 // get_app_container failing IS "not installed" — the
1686 // subprocess text (`NSPOSIXErrorDomain code=2`) says
1687 // nothing a flow author can act on.
1688 DeviceControlError::NonZeroExit { .. } => DeviceControlError::AppNotInstalled {
1689 bundle_id: bundle_id.to_string(),
1690 udid: udid.to_string(),
1691 },
1692 other => other,
1693 })?;
1694 let container = raw.trim();
1695 if container.is_empty() {
1696 return Err(DeviceControlError::Malformed {
1697 subcommand: "clear_app_sandbox".into(),
1698 detail: format!("empty Data container path for bundle {bundle_id}"),
1699 });
1700 }
1701 let documents = format!("{container}/Documents");
1702 let library = format!("{container}/Library");
1703 let tmp = format!("{container}/tmp");
1704 // `xcrun simctl spawn <UDID> <cmd>` uses `posix_spawn` inside
1705 // the sim OS; `<cmd>` must be an absolute path (there is no
1706 // PATH resolution). A bare `"rm"` fails with
1707 // `NSPOSIXErrorDomain code 2: No such file or directory` on
1708 // iOS 17+ sims. `/bin/rm` is present on every stock sim image.
1709 //
1710 // Best-effort: any missing subdir is fine (fresh app that never
1711 // wrote to that path). `rm -rf` treats absent targets as no-ops.
1712 simctl_run(&["spawn", udid, "/bin/rm", "-rf", &documents, &library, &tmp]).await?;
1713 Ok(())
1714 }
1715
1716 /// `xcrun simctl openurl <udid> <url>` — open a URL on the device.
1717 ///
1718 /// **URL bytes are passed to `xcrun simctl` verbatim** — no
1719 /// parsing, no percent-encoding rewrite, no query-string
1720 /// stripping. Verified by [`openurl_argv`] (test-visible helper)
1721 /// and its unit test asserting query-params like
1722 /// `?url=http%3A%2F%2Flocalhost%3A8081` reach the argv byte-for-byte.
1723 /// Consequently, if the target app's URL router (e.g.
1724 /// expo-dev-client 57.0.5) shows a picker instead of
1725 /// auto-connecting, the URL reached it intact and the problem
1726 /// lives on the URL-router side.
1727 pub async fn open_url(&self, udid: &str, url: &str) -> Result<(), DeviceControlError> {
1728 let argv = openurl_argv(udid, url);
1729 let refs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1730 simctl_run(&refs).await?;
1731 Ok(())
1732 }
1733}
1734
1735/// Argv construction for `xcrun simctl openurl`. Extracted
1736/// as a test-visible helper so the URL-preservation contract is
1737/// unit-testable without invoking `xcrun`.
1738#[doc(hidden)]
1739pub fn openurl_argv(udid: &str, url: &str) -> [String; 3] {
1740 ["openurl".to_string(), udid.to_string(), url.to_string()]
1741}
1742
1743impl SimctlClient {
1744 /// `xcrun simctl push <udid> <bundle-id> <apns-json-path>`.
1745 /// Deliver an APNS payload to a sim-installed app. The payload file is
1746 /// a JSON document whose top-level dictionary mirrors what an APNS
1747 /// provider would send; `aps.alert.body` / `aps.alert.title` surface
1748 /// as banner content and reach the app's
1749 /// `UNUserNotificationCenterDelegate`.
1750 pub async fn send_push(
1751 &self,
1752 udid: &str,
1753 bundle_id: &str,
1754 apns_json_path: &str,
1755 ) -> Result<(), DeviceControlError> {
1756 simctl_run(&["push", udid, bundle_id, apns_json_path]).await?;
1757 Ok(())
1758 }
1759
1760 /// `xcrun simctl ui <udid> appearance <light|dark>` — set UI appearance.
1761 pub async fn set_appearance(
1762 &self,
1763 udid: &str,
1764 mode: Appearance,
1765 ) -> Result<(), DeviceControlError> {
1766 simctl_run(&["ui", udid, "appearance", mode.as_str()]).await?;
1767 Ok(())
1768 }
1769
1770 /// `xcrun simctl privacy <udid> grant <perm> <bundle-id>`.
1771 pub async fn grant_permission(
1772 &self,
1773 udid: &str,
1774 permission: SimctlPermission,
1775 bundle_id: &str,
1776 ) -> Result<(), DeviceControlError> {
1777 simctl_run(&["privacy", udid, "grant", permission.as_str(), bundle_id]).await?;
1778 Ok(())
1779 }
1780
1781 /// `xcrun simctl privacy <udid> revoke <perm> <bundle-id>` — explicitly
1782 /// deny the permission. Mirrors maestro yaml `permissions: { x: deny }`
1783 /// (the reverse of `grant`). Distinct from `reset`, which returns the
1784 /// permission to "not determined".
1785 pub async fn revoke_permission(
1786 &self,
1787 udid: &str,
1788 permission: SimctlPermission,
1789 bundle_id: &str,
1790 ) -> Result<(), DeviceControlError> {
1791 simctl_run(&["privacy", udid, "revoke", permission.as_str(), bundle_id]).await?;
1792 Ok(())
1793 }
1794
1795 /// `xcrun simctl location <udid> set <lat>,<lng>` — set sim location
1796 /// to a fixed point. Mirrors maestro `setLocation`.
1797 pub async fn location_set(
1798 &self,
1799 udid: &str,
1800 latitude: f64,
1801 longitude: f64,
1802 ) -> Result<(), DeviceControlError> {
1803 let coord = format!("{latitude},{longitude}");
1804 simctl_run(&["location", udid, "set", &coord]).await?;
1805 Ok(())
1806 }
1807
1808 /// `xcrun simctl location <udid> start [--speed=<m/s>] <waypoints>`
1809 /// — interpolate sim location along waypoints. Fire-and-return: simctl
1810 /// injects scenario and returns; sim continues interpolation in background.
1811 /// Mirrors maestro `travel`.
1812 pub async fn location_start(
1813 &self,
1814 udid: &str,
1815 points: &[(f64, f64)],
1816 speed_mps: Option<f64>,
1817 ) -> Result<(), DeviceControlError> {
1818 if points.len() < 2 {
1819 return Err(DeviceControlError::Malformed {
1820 subcommand: "location-start".into(),
1821 detail: format!("requires ≥2 waypoints, got {}", points.len()),
1822 });
1823 }
1824 let mut args: Vec<String> = vec!["location".into(), udid.into(), "start".into()];
1825 if let Some(s) = speed_mps {
1826 args.push(format!("--speed={s}"));
1827 }
1828 for (lat, lng) in points {
1829 args.push(format!("{lat},{lng}"));
1830 }
1831 let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
1832 simctl_run(&args_ref).await?;
1833 Ok(())
1834 }
1835
1836 /// `xcrun simctl location <udid> clear` — reset active location
1837 /// scenario.
1838 pub async fn location_clear(&self, udid: &str) -> Result<(), DeviceControlError> {
1839 simctl_run(&["location", udid, "clear"]).await?;
1840 Ok(())
1841 }
1842
1843 /// `xcrun simctl addmedia <udid> <path>...` — add photos / videos /
1844 /// contacts to sim library. Mirrors maestro `addMedia` (scalar or
1845 /// array form already flattened on adapter side).
1846 pub async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), DeviceControlError> {
1847 if paths.is_empty() {
1848 return Err(DeviceControlError::Malformed {
1849 subcommand: "addmedia".into(),
1850 detail: "no paths supplied".into(),
1851 });
1852 }
1853 let mut args: Vec<&str> = vec!["addmedia", udid];
1854 for p in paths {
1855 args.push(p.as_str());
1856 }
1857 simctl_run(&args).await?;
1858 Ok(())
1859 }
1860
1861 /// Start recording sim display to `path`. Spawns
1862 /// `xcrun simctl io <udid> recordVideo <path>` as a long-running child;
1863 /// returns handle immediately. Caller must pair with
1864 /// [`Self::record_video_stop`] for clean SIGINT-and-wait shutdown —
1865 /// dropping the handle would SIGKILL via tokio + lose mp4 trailer.
1866 pub async fn record_video_start(
1867 &self,
1868 udid: &str,
1869 path: &str,
1870 ) -> Result<RecordingHandle, DeviceControlError> {
1871 // Log to a file beside the video, not to pipes.
1872 //
1873 // Piped output with nobody reading it is a trap that only springs
1874 // once the recording has to outlive the process that started it:
1875 // when that process exits, the read ends close, and the next line
1876 // `simctl` writes kills it with SIGPIPE. The recording then stops
1877 // silently, seconds after being reported as started, leaving a
1878 // zero-byte file — which is exactly what `smix record start`
1879 // produced before this changed.
1880 //
1881 // A file also keeps `simctl`'s own diagnostics ("No display
1882 // specified…", "Recording started") somewhere a person can read
1883 // them, which a discarded pipe did not.
1884 let log_path = format!("{path}.log");
1885 let log = std::fs::File::create(&log_path)?;
1886 let log_err = log.try_clone()?;
1887 let child = tokio::process::Command::new("xcrun")
1888 .args(["simctl", "io", udid, "recordVideo", path])
1889 .stdin(std::process::Stdio::null())
1890 .stdout(std::process::Stdio::from(log))
1891 .stderr(std::process::Stdio::from(log_err))
1892 .spawn()?;
1893 // brief settle for simctl to initialize encoder + open output file.
1894 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1895 Ok(RecordingHandle {
1896 child,
1897 path: path.to_string(),
1898 started_at: std::time::Instant::now(),
1899 })
1900 }
1901
1902 /// Stop a recording via SIGINT + wait (≤10s). SIGINT lets simctl
1903 /// trap and flush the mp4 trailer; SIGKILL would corrupt output.
1904 /// Timeout escalates to SIGKILL with explicit error mentioning truncation.
1905 pub async fn record_video_stop(
1906 &self,
1907 mut handle: RecordingHandle,
1908 ) -> Result<(), DeviceControlError> {
1909 let pid = handle
1910 .child
1911 .id()
1912 .ok_or_else(|| DeviceControlError::Malformed {
1913 subcommand: "recordVideo-stop".into(),
1914 detail: "child already reaped".into(),
1915 })?;
1916 // SAFETY: libc::kill is a thin POSIX syscall wrapper; pid is owned by
1917 // this Child instance (no race) and SIGINT is signal-safe.
1918 let rc = unsafe { libc::kill(pid as i32, libc::SIGINT) };
1919 if rc != 0 {
1920 return Err(DeviceControlError::Malformed {
1921 subcommand: "recordVideo-stop".into(),
1922 detail: format!(
1923 "kill SIGINT failed: errno={}",
1924 std::io::Error::last_os_error()
1925 ),
1926 });
1927 }
1928 let wait_result =
1929 tokio::time::timeout(std::time::Duration::from_secs(10), handle.child.wait()).await;
1930 match wait_result {
1931 Ok(Ok(_status)) => Ok(()),
1932 Ok(Err(e)) => Err(DeviceControlError::Malformed {
1933 subcommand: "recordVideo-stop".into(),
1934 detail: format!("wait failed: {e}"),
1935 }),
1936 Err(_timeout) => {
1937 let _ = handle.child.kill().await;
1938 Err(DeviceControlError::Malformed {
1939 subcommand: "recordVideo-stop".into(),
1940 detail: "SIGINT timeout (10s) — escalated SIGKILL; output mp4 likely truncated. Inspect simctl recordVideo stderr.".into(),
1941 })
1942 }
1943 }
1944 }
1945
1946 /// `xcrun simctl privacy <udid> reset <perm> <bundle-id>` — return the
1947 /// permission to "not determined" so the next request re-prompts.
1948 /// May terminate a running instance of the target app (Apple
1949 /// behavior) — call before launch, not mid-flow.
1950 pub async fn reset_permission(
1951 &self,
1952 udid: &str,
1953 permission: SimctlPermission,
1954 bundle_id: &str,
1955 ) -> Result<(), DeviceControlError> {
1956 simctl_run(&["privacy", udid, "reset", permission.as_str(), bundle_id]).await?;
1957 Ok(())
1958 }
1959
1960 /// `xcrun simctl keychain <udid> reset` — clear all keychain entries.
1961 pub async fn keychain_reset(&self, udid: &str) -> Result<(), DeviceControlError> {
1962 simctl_run(&["keychain", udid, "reset"]).await?;
1963 Ok(())
1964 }
1965
1966 /// `xcrun simctl pbpaste <udid>` — read clipboard contents.
1967 pub async fn pasteboard_get(&self, udid: &str) -> Result<String, DeviceControlError> {
1968 simctl_run(&["pbpaste", udid]).await
1969 }
1970
1971 /// `xcrun simctl pbcopy <udid>` — write clipboard contents (via piped stdin).
1972 pub async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), DeviceControlError> {
1973 // pbcopy reads stdin — we pipe via shell echo for simplicity.
1974 // Long-term: spawn with stdin pipe.
1975 use tokio::io::AsyncWriteExt;
1976 let mut cmd = Command::new("xcrun");
1977 cmd.arg("simctl").arg("pbcopy").arg(udid);
1978 cmd.stdin(std::process::Stdio::piped());
1979 let mut child = cmd.spawn()?;
1980 if let Some(mut stdin) = child.stdin.take() {
1981 stdin.write_all(text.as_bytes()).await?;
1982 drop(stdin); // close stdin so pbcopy returns
1983 }
1984 let status = child.wait().await?;
1985 if !status.success() {
1986 return Err(DeviceControlError::NonZeroExit {
1987 subcommand: "pbcopy".into(),
1988 argv: vec!["pbcopy".to_string()],
1989 code: status.code().unwrap_or(-1),
1990 stderr: String::new(),
1991 wall_ms: 0,
1992 });
1993 }
1994 Ok(())
1995 }
1996
1997 /// Read back the Reduce Motion accessibility setting.
1998 ///
1999 /// `Ok(None)` when the key was never written, which `defaults read`
2000 /// reports by exiting non-zero. Absent is not off and not on — it
2001 /// is the device having no opinion, and a caller that wanted the
2002 /// setting established has to treat it as a failure to establish.
2003 pub async fn reduce_motion(&self, udid: &str) -> Result<Option<String>, DeviceControlError> {
2004 match simctl_run(&[
2005 "spawn",
2006 udid,
2007 "/usr/bin/defaults",
2008 "read",
2009 "com.apple.UIKit",
2010 "UIAccessibilityReduceMotionEnabled",
2011 ])
2012 .await
2013 {
2014 Ok(s) => Ok(Some(s.trim().to_string())),
2015 Err(DeviceControlError::NonZeroExit { .. }) => Ok(None),
2016 Err(e) => Err(e),
2017 }
2018 }
2019
2020 /// Toggle "Reduce Motion" accessibility setting via `defaults write`.
2021 pub async fn set_reduce_motion(
2022 &self,
2023 udid: &str,
2024 enabled: bool,
2025 ) -> Result<(), DeviceControlError> {
2026 // `true`/`false`, not `1`/`0`. `defaults` accepts
2027 // `-bool (true | false | yes | no)` and answers anything else
2028 // by printing its usage and exiting 255 — which is what this
2029 // did from the day it was written. It had no callers until the
2030 // animation switch, so nothing ever ran it.
2031 let val = if enabled { "true" } else { "false" };
2032 // Absolute path, not `defaults`. `simctl spawn` does not run a
2033 // login shell inside the simulator, so a bare name exits 255
2034 // with no stderr — which is exactly what it did the first time
2035 // an animation-quietening run met a device. The same lesson was
2036 // learned in v1.0.7 for `rm`; the reader below and
2037 // `current_locale` already spell it out.
2038 simctl_run(&[
2039 "spawn",
2040 udid,
2041 "/usr/bin/defaults",
2042 "write",
2043 "com.apple.UIKit",
2044 "UIAccessibilityReduceMotionEnabled",
2045 "-bool",
2046 val,
2047 ])
2048 .await?;
2049 Ok(())
2050 }
2051
2052 /// `xcrun simctl io <udid> screenshot <tmpfile>` → raw PNG bytes,
2053 /// with a byte-level sRGB metadata splice if the produced PNG lacks
2054 /// an `sRGB` chunk.
2055 ///
2056 /// Goes through a temp file: current Xcode's `screenshot -` does not
2057 /// treat `-` as stdout — it writes a literal file named `-` in cwd
2058 /// and emits nothing on stdout (observed on Xcode/iOS 26.5).
2059 ///
2060 /// **Pixel-preservation invariant**: the returned bytes are
2061 /// byte-identical to whatever `simctl io screenshot` wrote to disk
2062 /// EXCEPT for one narrow case — if the PNG does not carry an
2063 /// `sRGB` ancillary chunk (observed on iOS 26.5 sub-builds
2064 /// mid-2026), a 13-byte `sRGB` chunk is spliced in immediately
2065 /// before the first `IDAT`. Pixel data (IDAT bytes) is never
2066 /// decoded or modified. See [`ensure_srgb_chunk`] for the exact
2067 /// splice operation.
2068 pub async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, DeviceControlError> {
2069 match self.capture_frame(udid, true).await? {
2070 surface_capture::CapturedFrame::Png(bytes) => Ok(bytes),
2071 // want_png=true only ever produces a PNG (host ImageIO encode or
2072 // the simctl fallback). A raw frame here is a protocol violation.
2073 surface_capture::CapturedFrame::Bgra { .. } => Err(DeviceControlError::Malformed {
2074 subcommand: "screenshot".into(),
2075 detail: "capture returned raw BGRA for a PNG request".into(),
2076 }),
2077 }
2078 }
2079
2080 /// Capture a frame preferring the fast raw-BGRA path.
2081 ///
2082 /// When the resident IOSurface host is available this returns
2083 /// [`CapturedFrame::Bgra`](surface_capture::CapturedFrame::Bgra) —
2084 /// ~0.3 ms per frame, no PNG encode. When the surface can't be resolved
2085 /// (sim not booted, framework layout change) it falls back to
2086 /// `xcrun simctl io screenshot` and returns
2087 /// [`CapturedFrame::Png`](surface_capture::CapturedFrame::Png). The
2088 /// pixels are correct either way; consumers that only need grayscale
2089 /// samples (diff-loop / dhash) skip the PNG encode+decode round-trip.
2090 ///
2091 /// Since smix 2.0.0.
2092 pub async fn capture_bgra(
2093 &self,
2094 udid: &str,
2095 ) -> Result<surface_capture::CapturedFrame, DeviceControlError> {
2096 self.capture_frame(udid, false).await
2097 }
2098
2099 /// Core capture path: try the resident IOSurface host, fall back to
2100 /// `simctl`. `want_png` selects an in-host ImageIO PNG encode over a raw
2101 /// BGRA frame; the fallback is always a PNG.
2102 async fn capture_frame(
2103 &self,
2104 udid: &str,
2105 want_png: bool,
2106 ) -> Result<surface_capture::CapturedFrame, DeviceControlError> {
2107 // Direct path first. No pacer gate: the direct IOSurface read does not
2108 // touch `com.apple.display.captureservice`, so the crash-guard floor
2109 // the pacer enforces for `simctl io screenshot` does not apply here.
2110 // Surface unavailable, or the host transport failed — both fall
2111 // through to the correct-but-slow simctl path below.
2112 if let Ok(Some(frame)) = self.try_capture_direct(udid, want_png).await {
2113 return Ok(frame);
2114 }
2115 let png = self.screenshot_via_simctl(udid).await?;
2116 Ok(surface_capture::CapturedFrame::Png(png))
2117 }
2118
2119 /// Get-or-spawn the resident host for `udid` and grab one frame. Returns
2120 /// `Ok(None)` when the host reports the surface is gone, `Err` on a
2121 /// transport failure. In both non-`Some` cases the host is dropped (and
2122 /// killed) so the next call re-resolves from scratch.
2123 async fn try_capture_direct(
2124 &self,
2125 udid: &str,
2126 want_png: bool,
2127 ) -> Result<Option<surface_capture::CapturedFrame>, surface_capture::HostError> {
2128 // Take the host out from under the lock so a 12.6 MB grab (or a 5s
2129 // spawn) never serializes captures for other sims.
2130 let existing = { self.capture_hosts.lock().await.take(udid) };
2131 let mut host = match existing {
2132 Some(h) => h,
2133 None => surface_capture::SurfaceCaptureHost::spawn(udid).await?,
2134 };
2135 match host.grab(want_png).await {
2136 Ok(Some(frame)) => {
2137 self.capture_hosts.lock().await.put(udid, host);
2138 Ok(Some(frame))
2139 }
2140 // Host is exiting (surface gone) — drop it, fall back.
2141 Ok(None) => Ok(None),
2142 // Transport died — drop it, fall back.
2143 Err(e) => Err(e),
2144 }
2145 }
2146
2147 /// Drop the resident capture host for `udid`, if any. Call this whenever a
2148 /// lifecycle operation may have invalidated the framebuffer surface
2149 /// (shutdown / erase / reboot) so the next capture re-resolves cleanly.
2150 ///
2151 /// Since smix 2.0.0.
2152 pub async fn evict_capture_host(&self, udid: &str) {
2153 let host = { self.capture_hosts.lock().await.evict(udid) };
2154 if let Some(h) = host {
2155 h.shutdown().await;
2156 }
2157 }
2158
2159 /// `xcrun simctl io <udid> screenshot <tmpfile>` → raw PNG bytes, paced +
2160 /// circuit-guarded, with the sRGB metadata splice. The correct-but-slow
2161 /// fallback for [`capture_frame`](Self::capture_frame).
2162 async fn screenshot_via_simctl(&self, udid: &str) -> Result<Vec<u8>, DeviceControlError> {
2163 // Pace + circuit-check before invoking simctl.
2164 let wait = {
2165 let mut pacer = self
2166 .screenshot_pacer
2167 .lock()
2168 .expect("screenshot pacer mutex must not be poisoned");
2169 pacer
2170 .compute_wait()
2171 .map_err(|retry_after| DeviceControlError::CaptureBackpressure { retry_after })?
2172 };
2173 if !wait.is_zero() {
2174 sleep(wait).await;
2175 }
2176
2177 let call_start = std::time::Instant::now();
2178 let tmp =
2179 std::env::temp_dir().join(format!("smix-screenshot-{udid}-{}.png", std::process::id()));
2180 let tmp_str = tmp.display().to_string();
2181 let result = simctl_capture(&["io", udid, "screenshot", &tmp_str]).await;
2182 let bytes = result.and_then(|_| {
2183 std::fs::read(&tmp).map_err(|e| DeviceControlError::Malformed {
2184 subcommand: "screenshot".into(),
2185 detail: format!("read {tmp_str}: {e}"),
2186 })
2187 });
2188 let _ = std::fs::remove_file(&tmp);
2189
2190 let wall = call_start.elapsed();
2191 let failed = bytes.is_err();
2192 {
2193 let mut pacer = self
2194 .screenshot_pacer
2195 .lock()
2196 .expect("screenshot pacer mutex must not be poisoned");
2197 pacer.record(wall, failed);
2198 }
2199
2200 let bytes = bytes?;
2201 if bytes.len() < 8 {
2202 return Err(DeviceControlError::Malformed {
2203 subcommand: "screenshot".into(),
2204 detail: format!("screenshot file too short: {} bytes", bytes.len()),
2205 });
2206 }
2207 Ok(ensure_srgb_chunk(bytes))
2208 }
2209
2210 /// `xcrun simctl create <name> <device-type-id> <runtime-id>` → udid.
2211 pub async fn create_device(
2212 &self,
2213 name: &str,
2214 device_type: &str,
2215 runtime_id: &str,
2216 ) -> Result<String, DeviceControlError> {
2217 let out = simctl_run(&["create", name, device_type, runtime_id]).await?;
2218 Ok(out.trim().to_string())
2219 }
2220
2221 /// `xcrun simctl delete <udid>` — delete a simulator device.
2222 pub async fn delete_device(&self, udid: &str) -> Result<(), DeviceControlError> {
2223 simctl_run(&["delete", udid]).await?;
2224 Ok(())
2225 }
2226}
2227
2228// -------------------- PNG sRGB chunk normalization --------------------
2229//
2230// iOS 26.5 sub-builds (mid-2026) started omitting the `sRGB` ancillary
2231// chunk from `simctl io screenshot` output. macOS Preview.app and other
2232// viewers that fall back to Display P3 when no ICC profile is embedded
2233// then over-saturate the image (red gets pushed, text anti-alias picks
2234// up yellow fringing).
2235//
2236// This does NOT affect pixel-comparison (dhash decodes IDAT to RGBA and
2237// ignores ancillary chunks), but does affect any downstream tool that
2238// renders the PNG for human review. The normalizer runs on the raw byte
2239// stream — walks chunks, and if no `sRGB` chunk is seen before the first
2240// `IDAT`, splices in a synthesized 13-byte `sRGB` chunk (length=1,
2241// type="sRGB", data=[0 = perceptual intent], CRC over type+data).
2242//
2243// Pixel-preservation invariant: IDAT bytes are never decoded. Every
2244// existing chunk is copied verbatim. Only 13 bytes of new metadata are
2245// inserted.
2246
2247const PNG_MAGIC: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
2248
2249/// Ensure the PNG carries an `sRGB` ancillary chunk. Called on the raw
2250/// bytes returned by `xcrun simctl io <udid> screenshot`. If the PNG
2251/// already has an `sRGB` chunk, returns the input unchanged; otherwise
2252/// splices in a 13-byte `sRGB` chunk (rendering intent = 0, perceptual)
2253/// immediately before the first `IDAT`. Returns the input unchanged on
2254/// any structural anomaly (missing magic, malformed chunk) so a
2255/// corrupted PNG is passed through untouched for the caller to diagnose.
2256pub fn ensure_srgb_chunk(bytes: Vec<u8>) -> Vec<u8> {
2257 if bytes.len() < 8 || &bytes[..8] != PNG_MAGIC {
2258 return bytes;
2259 }
2260 let Some((idat_offset, has_srgb)) = scan_png_chunks(&bytes) else {
2261 return bytes;
2262 };
2263 if has_srgb {
2264 return bytes;
2265 }
2266 // Splice the synthesized sRGB chunk right before the first IDAT.
2267 let mut out = Vec::with_capacity(bytes.len() + 13);
2268 out.extend_from_slice(&bytes[..idat_offset]);
2269 out.extend_from_slice(&synthesized_srgb_chunk());
2270 out.extend_from_slice(&bytes[idat_offset..]);
2271 out
2272}
2273
2274/// Walk PNG chunks starting after the 8-byte magic. Returns
2275/// `(offset_of_first_IDAT, has_srgb_chunk_before_it)` when the walk
2276/// reaches an IDAT chunk. Returns `None` if the walk hits EOF or a
2277/// malformed chunk without seeing an IDAT.
2278fn scan_png_chunks(bytes: &[u8]) -> Option<(usize, bool)> {
2279 let mut i: usize = 8;
2280 let mut has_srgb = false;
2281 while i + 8 <= bytes.len() {
2282 let length =
2283 u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
2284 let ctype = &bytes[i + 4..i + 8];
2285 if ctype == b"IDAT" {
2286 return Some((i, has_srgb));
2287 }
2288 if ctype == b"sRGB" {
2289 has_srgb = true;
2290 }
2291 // 4 (length) + 4 (type) + length (data) + 4 (crc)
2292 let end = i.checked_add(12)?.checked_add(length)?;
2293 if end > bytes.len() {
2294 return None;
2295 }
2296 i = end;
2297 }
2298 None
2299}
2300
2301/// Build the 13-byte `sRGB` chunk with rendering intent = 0 (perceptual).
2302/// Format: `[len:4][type:4][data:1][crc:4]` = 13 bytes total.
2303fn synthesized_srgb_chunk() -> [u8; 13] {
2304 // The CRC is computed over `type || data`.
2305 let mut crc_input = [0u8; 5];
2306 crc_input[0..4].copy_from_slice(b"sRGB");
2307 crc_input[4] = 0; // perceptual
2308 let crc = crc32_ieee(&crc_input);
2309 let mut chunk = [0u8; 13];
2310 chunk[0..4].copy_from_slice(&1u32.to_be_bytes()); // length = 1 (data byte)
2311 chunk[4..8].copy_from_slice(b"sRGB");
2312 chunk[8] = 0;
2313 chunk[9..13].copy_from_slice(&crc.to_be_bytes());
2314 chunk
2315}
2316
2317/// Table-less CRC-32 IEEE 802.3 (polynomial 0xEDB88320) as used by
2318/// PNG. Small enough for this crate's single call site — avoids
2319/// pulling in a `crc32fast` dependency.
2320fn crc32_ieee(bytes: &[u8]) -> u32 {
2321 let mut crc: u32 = 0xFFFF_FFFF;
2322 for &b in bytes {
2323 crc ^= u32::from(b);
2324 for _ in 0..8 {
2325 let mask = 0u32.wrapping_sub(crc & 1);
2326 crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
2327 }
2328 }
2329 !crc
2330}
2331
2332#[cfg(test)]
2333mod tests {
2334 use super::*;
2335
2336 #[test]
2337 fn compose_child_env_adds_prefix() {
2338 let composed = compose_child_env(&[
2339 ("SMIX_PERF_RECEIVER_URL", "http://127.0.0.1:9999"),
2340 ("LAUNCH_FORCE_PUSH", "true"),
2341 ]);
2342 assert_eq!(
2343 composed,
2344 vec![
2345 (
2346 "SIMCTL_CHILD_SMIX_PERF_RECEIVER_URL".to_string(),
2347 "http://127.0.0.1:9999".to_string(),
2348 ),
2349 (
2350 "SIMCTL_CHILD_LAUNCH_FORCE_PUSH".to_string(),
2351 "true".to_string(),
2352 ),
2353 ]
2354 );
2355 }
2356
2357 #[test]
2358 fn compose_child_env_already_prefixed_passes_through() {
2359 // Defensive: caller may pre-prefix; we must not double-prefix.
2360 let composed = compose_child_env(&[("SIMCTL_CHILD_FOO", "bar")]);
2361 assert_eq!(
2362 composed,
2363 vec![("SIMCTL_CHILD_FOO".to_string(), "bar".to_string())]
2364 );
2365 }
2366
2367 #[test]
2368 fn compose_child_env_empty_input_is_empty_output() {
2369 assert!(compose_child_env(&[]).is_empty());
2370 }
2371
2372 // -- openurl URL preservation ---------------------------------------
2373
2374 #[test]
2375 fn openurl_argv_preserves_url_verbatim() {
2376 let udid = "12345678-1234-5678-1234-567812345678";
2377 let url = "exp+focus-ai-app://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081";
2378 let argv = super::openurl_argv(udid, url);
2379 assert_eq!(argv[0], "openurl");
2380 assert_eq!(argv[1], udid);
2381 // Byte-identical URL — no percent-decoding, no query-strip.
2382 assert_eq!(argv[2], url);
2383 assert!(argv[2].contains("?url="));
2384 assert!(argv[2].contains("%3A"));
2385 assert!(argv[2].contains("%2F"));
2386 }
2387
2388 #[test]
2389 fn openurl_argv_preserves_ampersand_and_hash() {
2390 let udid = "12345678-1234-5678-1234-567812345678";
2391 let url = "myapp://dev-mutate?action=env&value=staging#anchor";
2392 let argv = super::openurl_argv(udid, url);
2393 assert_eq!(argv[2], url);
2394 assert!(argv[2].contains('&'));
2395 assert!(argv[2].contains('#'));
2396 }
2397
2398 #[test]
2399 fn openurl_argv_preserves_unicode() {
2400 let udid = "12345678-1234-5678-1234-567812345678";
2401 let url = "myapp://route?name=%E7%94%B0%E4%B8%AD";
2402 let argv = super::openurl_argv(udid, url);
2403 assert_eq!(argv[2], url);
2404 }
2405
2406 // -- sRGB chunk normalization ---------------------------------------
2407
2408 /// Build a minimal PNG: 1×1 8-bit RGBA, one IDAT (zlib-empty-safe),
2409 /// with or without an sRGB chunk. Returns synthetic bytes suitable
2410 /// for exercising the chunk-walking logic; no rendering intent.
2411 fn synth_png(with_srgb: bool) -> Vec<u8> {
2412 let mut out = Vec::new();
2413 out.extend_from_slice(super::PNG_MAGIC);
2414 // IHDR: 1x1, bit_depth=8, color_type=6 (RGBA), rest=0
2415 let ihdr_data: [u8; 13] = [
2416 0, 0, 0, 1, // width = 1
2417 0, 0, 0, 1, // height = 1
2418 8, // bit depth
2419 6, // color type = RGBA
2420 0, 0, 0,
2421 ];
2422 emit_chunk(&mut out, b"IHDR", &ihdr_data);
2423 if with_srgb {
2424 emit_chunk(&mut out, b"sRGB", &[0]);
2425 }
2426 // Placeholder IDAT — content doesn't matter for chunk-walking tests
2427 emit_chunk(&mut out, b"IDAT", &[0x78, 0x01, 0x00, 0x00]);
2428 emit_chunk(&mut out, b"IEND", &[]);
2429 out
2430 }
2431
2432 fn emit_chunk(out: &mut Vec<u8>, ctype: &[u8; 4], data: &[u8]) {
2433 out.extend_from_slice(&(data.len() as u32).to_be_bytes());
2434 out.extend_from_slice(ctype);
2435 out.extend_from_slice(data);
2436 let mut crc_in = Vec::with_capacity(4 + data.len());
2437 crc_in.extend_from_slice(ctype);
2438 crc_in.extend_from_slice(data);
2439 out.extend_from_slice(&super::crc32_ieee(&crc_in).to_be_bytes());
2440 }
2441
2442 #[test]
2443 fn ensure_srgb_passthrough_when_chunk_present() {
2444 let png = synth_png(true);
2445 let original_len = png.len();
2446 let out = super::ensure_srgb_chunk(png.clone());
2447 assert_eq!(out.len(), original_len);
2448 assert_eq!(out, png);
2449 }
2450
2451 #[test]
2452 fn ensure_srgb_inserts_chunk_when_absent() {
2453 let png = synth_png(false);
2454 let original_len = png.len();
2455 let out = super::ensure_srgb_chunk(png);
2456 assert_eq!(out.len(), original_len + 13);
2457 // First 8 bytes = PNG magic
2458 assert_eq!(&out[..8], super::PNG_MAGIC);
2459 // Search for the injected sRGB chunk
2460 let mut found = false;
2461 for w in out.windows(4) {
2462 if w == b"sRGB" {
2463 found = true;
2464 break;
2465 }
2466 }
2467 assert!(found, "sRGB chunk should have been spliced in");
2468 }
2469
2470 #[test]
2471 fn ensure_srgb_preserves_idat_bytes_verbatim() {
2472 // Any pixel corruption at the IDAT level would break the
2473 // pixel-preservation invariant. Extract IDAT payload from
2474 // input and output, assert byte-identical.
2475 let png = synth_png(false);
2476 let out = super::ensure_srgb_chunk(png.clone());
2477 assert_eq!(extract_idat_data(&png), extract_idat_data(&out));
2478 }
2479
2480 fn extract_idat_data(bytes: &[u8]) -> Vec<u8> {
2481 let mut i = 8;
2482 while i + 8 <= bytes.len() {
2483 let length =
2484 u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
2485 let ctype = &bytes[i + 4..i + 8];
2486 if ctype == b"IDAT" {
2487 return bytes[i + 8..i + 8 + length].to_vec();
2488 }
2489 i += 12 + length;
2490 }
2491 vec![]
2492 }
2493
2494 #[test]
2495 fn ensure_srgb_passthrough_on_bad_magic() {
2496 // Corrupted / non-PNG input must not be modified.
2497 let bytes = vec![0u8; 32];
2498 let out = super::ensure_srgb_chunk(bytes.clone());
2499 assert_eq!(out, bytes);
2500 }
2501
2502 #[test]
2503 fn crc32_matches_known_iend() {
2504 // The empty-data IEND CRC is a well-known constant.
2505 // CRC over "IEND" alone: 0xAE_42_60_82.
2506 assert_eq!(super::crc32_ieee(b"IEND"), 0xAE42_6082);
2507 }
2508}