Expand description
§smix-simctl
Async Rust wrapper around Apple’s xcrun simctl command-line tool.
Boot / shutdown / launch / install / pasteboard / screenshot — every
simulator-management subcommand wrapped in a typed tokio::process-backed
client.
This crate exists because every Rust project doing iOS simulator
automation re-implements the same Command::new("xcrun").arg("simctl")...
boilerplate, parses the same JSON outputs, fights the same pbcopy
stdin-pipe edge case. smix-simctl does it once, cleanly typed.
§Quickstart
use smix_simctl::{Appearance, SimctlClient, SimctlPermission};
use std::time::Duration;
let simctl = SimctlClient::new();
// 1. Inventory
let runtimes = simctl.list_runtimes().await?;
let devices = simctl.list_devices().await?;
let booted: Vec<_> = devices.iter().filter(|d| d.state == "Booted").collect();
// 2. Lifecycle (idempotent on already-booted)
let udid = "4F0B35D2-03F0-4A8F-B729-09072153E8AE";
simctl.boot_and_wait(udid, Duration::from_secs(60)).await?;
let launched = simctl.launch(udid, "com.example.app").await?;
println!("launched pid={}", launched.pid);
// 3. Settings
simctl.set_appearance(udid, Appearance::Dark).await?;
simctl.grant_permission(udid, SimctlPermission::Camera, "com.example.app").await?;
simctl.set_reduce_motion(udid, true).await?;
// 4. IO
let png = simctl.screenshot(udid).await?;
std::fs::write("/tmp/screen.png", png).unwrap();
// 5. Pasteboard
simctl.pasteboard_set(udid, "hello clipboard").await?;
let got = simctl.pasteboard_get(udid).await?;
assert_eq!(got, "hello clipboard");§Methods
| Category | Methods |
|---|---|
| Inventory | list_runtimes, list_devices |
| Lifecycle | boot, boot_and_wait, shutdown, erase, install, uninstall, terminate, launch |
| Settings | set_appearance, grant_permission, keychain_reset, set_reduce_motion |
| Pasteboard | pasteboard_get, pasteboard_set |
| URL | open_url |
| IO | screenshot (returns Vec<u8> PNG bytes) |
| Provisioning | create_device, delete_device |
§When to reach for this vs. shelling out yourself
| Use case | Pick |
|---|---|
Need typed SimctlDevice / SimctlRuntime with is_available filter | smix-simctl |
Want boot_and_wait polling logic without re-implementing it | smix-simctl |
Need pasteboard_set (handles pbcopy stdin-pipe correctly) | smix-simctl |
One-off shell script wrapping a single xcrun simctl boot call | plain Command |
| Need real-device control (instruments, devicectl) | out of scope — this is sim-only |
§Scope
- ✅ All async, returns typed
SimctlErrorvariants - ✅ Uses
tokio::process::Commandfor spawn - ✅ Parses JSON outputs (
list devices -j, etc.) into typed structs - ✅
screenshotreturns raw PNG bytes — no PNG parsing dep needed - ❌ No real-device support — this is
xcrun simctlonly - ❌ No
instruments/devicectlintegration - ❌ No XCUITest runner spawning (see
smix-runner-clientfor that)
Originally extracted from smix as a standalone stone. The crate is intentionally smix-business-decoupled — any Rust project doing iOS-Simulator automation can adopt it without pulling in the rest of smix.
§License
Dual-licensed under either:
at your option. smix-simctl — xcrun simctl child_process wrapper (outer crate).
All operations shell out to xcrun simctl <subcommand>; JSON-formatted
outputs (list runtimes, list devices, screenshot binary) are parsed with
serde_json / raw bytes. Tokio’s process::Command is the async spawn
primitive.
This is an outer crate — allowed to depend on the wider tokio ecosystem. Use it from cement (smix-cli / smix-mcp) or from a higher-level driver wrapper.
Modules§
- registry
.smix/sims.jsondevice registry — deterministic device addressing.- screenshot_
pacer - v1.0.4 — adaptive
xcrun simctl io screenshotpacer. Seescreenshot_pacer::ScreenshotPacerand.claude/rfcs/1.0.4-sim-health-and-backpressure.md§D3.
Structs§
- Flow
Attempt Data - v1.0.15 §6 — public accessor with just the fields needed by callers.
Mirrors [
smix_runner_wire::FlowAttempt] shape. - Flow
Attempt Record Data - v1.0.15 §6 — recorded flow with its attempt list.
- Launch
Result - Launched-app result.
- Recording
Handle - Handle to an active
xcrun simctl io recordVideochild process. Pair withSimctlClient::record_video_stopfor SIGINT-and-wait shutdown (so the mp4 trailer is flushed). Dropping the handle withoutstopwould tokio-SIGKILL on Drop and truncate the output file. - Reset
AppData Counters - v1.0.14 Cluster A — public snapshot of CLI-side resetAppData
counter state. Populated by
increment_reset_app_data_total+increment_reset_app_data_timed_outas the CLI dispatches the verb; loaded from disk on CLI startup ifset_reset_app_data_counters_persist_pathwas called. - Simctl
Client - Stateless wrapper around xcrun simctl. Methods are free functions
in spirit (no instance state beyond optionally-cached
xcrunpath); kept as a struct for API ergonomics + future caching. - Simctl
Device - One simulator device known to
xcrun simctl. - Simctl
Runtime - One iOS / watchOS / tvOS runtime installed on the host.
- Subprocess
Record - v1.0.7 §D3 — recorded snapshot of one
xcrun simctlinvocation. Exposed so callers can dump the ring buffer for post-mortem when something upstream fails.
Enums§
- Appearance
- UI appearance mode for
xcrun simctl ui <udid> appearance. - Simctl
Error - Failure variants for any
xcrun simctlinvocation. - Simctl
Permission - Permission names accepted by
xcrun simctl privacy <udid> grant <name>.
Traits§
- Flow
Attempt Shape - v1.0.15 §6 — abstraction so callers pass either
[
smix_runner_wire::FlowAttempt] or a local struct with the same shape without a cross-crate dep on smix-runner-wire from smix-simctl.
Functions§
- compose_
child_ env - Compose user-provided
(key, value)pairs into theSIMCTL_CHILD_*envp thatxcrun simctl launchstrips and delivers to the launched app. Idempotent: a key that already starts withSIMCTL_CHILD_is passed through unchanged. - ensure_
srgb_ chunk - Ensure the PNG carries an
sRGBancillary chunk. Called on the raw bytes returned byxcrun simctl io <udid> screenshot. If the PNG already has ansRGBchunk, returns the input unchanged; otherwise splices in a 13-bytesRGBchunk (rendering intent = 0, perceptual) immediately before the firstIDAT. Returns the input unchanged on any structural anomaly (missing magic, malformed chunk) so a corrupted PNG is passed through untouched for the caller to diagnose. - increment_
reset_ app_ data_ timed_ out - v1.0.14 Cluster A — advance the resetAppData timed-out counter.
Called by the CLI runtime when the completion signal (log-line
pattern match) did not arrive inside the timeout window. Always
paired with a preceding
increment_reset_app_data_totalon the same dispatch. - increment_
reset_ app_ data_ total - v1.0.14 Cluster A — advance the resetAppData total counter. Called by the CLI runtime after each dispatch (success or timeout).
- recent_
flow_ attempts - v1.0.15 §6 — snapshot recent flow attempts for display / wire emission. Returns empty when persistence was never wired.
- recent_
subprocesses - v1.0.7 §D3 — snapshot the process-wide ring buffer of recent
xcrun simctlinvocations. Ordered oldest → newest, capped at 128 entries. Reset on process restart. - record_
flow_ attempts - v1.0.15 §6 — record the outcome of a flow’s attempts. Called from
smix runafter all retries for that flow have completed.smix diagnostic dumpreads viarecent_flow_attemptslater. - reset_
app_ data_ counters_ snapshot - v1.0.14 Cluster A — snapshot the current counter state for display / wire emission. Returns zero-valued counters when persistence was never wired.
- set_
flow_ attempts_ persist_ path - v1.0.15 §6 — enable flow-attempts persistence at the given path.
CLI startup wires this to
~/.local/share/smix/flow-attempts.jsonso retry attribution survives acrosssmix run→smix diagnostic dumpinvocations. - set_
reset_ app_ data_ counters_ persist_ path - v1.0.14 Cluster A — enable resetAppData counter persistence at
the given path. Callers pass
~/.local/share/smix/reset-app-data-counters.jsonat CLI startup so counter state survives acrosssmix run→smix diagnostic dumpinvocations. - set_
subprocess_ ring_ persist_ path - v1.0.10 §D6 — enable subprocess-ring persistence at the given path.
CLI startup wires this to
~/.local/share/smix/subprocess-ring.jsonso/diagnostic/dumppayloads survive supervisor cycles. Optional; missing this call keeps pre-v1.0.10 in-memory-only behavior.