simctl Integration¶
β Implemented π§ͺ Tested π± Simulator Only
Current state:
simctlintegration is fully implemented for simulator lifecycle, app management, device discovery, and demo mode. macOS only. See the Status Glossary for chip definitions.
AutoMobile uses simctl for iOS simulator lifecycle and app management. This layer is
responsible for booting simulators, installing apps, launching processes, and controlling
system-level simulator behaviors.
Responsibilities¶
- Simulator lifecycle: boot, shutdown, erase.
- App lifecycle: install, uninstall, launch, terminate.
- Device discovery and capability reporting.
- Status bar configuration (demo mode) when supported.
- Live locale and localization changes (see below).
- Biometric (Touch ID / Face ID) simulation for
biometricAuth(see below). - Simulated push delivery for
postNotification(see below). - Deep-link / URL-scheme discovery from the installed
.appbundle (see below). - Per-app notification authorization read via BulletinBoard (see below).
- Device settings capture/restore for
deviceSnapshot(see below).
App lifecycle: simulators vs physical devices¶
β Implemented
launchApp, terminateApp, installApp, and uninstallApp pick their transport
from the target’s UDID form (isIosSimulatorUdid): the 8-4-4-4-12 UUID means a
simulator (simctl); anything else (e.g. the 00008XXX-β¦ form) means a
physical device (devicectl, macOS + iOS 17+).
For launchApp specifically:
- Simulator β
xcrun simctl launch/terminate(unchanged). On cold boot the app is terminated first becausesimctl launchdoes not terminate an already-running instance. - Physical device β
xcrun devicectl device process launch --device <udid> --terminate-existing --json-output <file> --quiet <bundle-id>; the launched PID is read fromresult.process.processIdentifierin the JSON output.--terminate-existingis the authoritative cold-boot relaunch: it terminates any already-running instance and starts a fresh process (which foregrounds).devicectlhas no foreground-if-running verb, so a warm launch also relaunches this way. The device path therefore issues no separate pre-terminate β that would be a redundant round-trip. - No standalone devicectl terminate within launch.
launchApprelies on--terminate-existingfor its cold-boot relaunch rather than a separate pre-terminate round-trip. The standaloneterminateApptool does implement a device terminate (see below). clearAppDataon a device β wipes the sandbox viadevicectluninstall+reinstall (clearAppDataViaReinstall) before relaunching; a failed clear aborts the launch rather than launching with stale data.
For terminateApp specifically:
- Simulator β
xcrun simctl terminate <udid> <bundle-id>; a “found nothing to terminate” error is treated aswasRunning: falserather than a failure. - Physical device β
devicectlhas no terminate-by-bundle-id verb, so termination is a three-step operation (DeviceAppManager.terminateApp): resolve the bundle path, find the running PID, then kill it β - Resolve the on-device bundle path via
xcrun devicectl device info apps --device <udid> --bundle-id <id> --json-output <file> --quiet. No matching bundle β{ wasInstalled: false, wasRunning: false }(no terminate issued). - Enumerate running processes via
xcrun devicectl device info processes --device <udid> --json-output <file> --quietand match the process whose executable path lives inside the resolved bundle directory (the app’s own main binary, a direct child of<bundle>.app/β nestedPlugIns/*.appex/*extensions are excluded). No match β{ wasInstalled: true, wasRunning: false }. - Force-kill the matched PID with the dedicated verb:
xcrun devicectl device process terminate --device <udid> --pid <pid> --kill --quietβ{ wasInstalled: true, wasRunning: true }.--killsends SIGKILL (uncatchable), matching Androidam force-stopsemantics; the bare verb would send a catchable SIGTERM. - Already-exited race (#3054). The resolved PID can exit on its own between step 2 and the kill; devicectl then returns non-zero (ESRCH / “No such process”) and the promisified
execrejects.isDevicectlProcessGoneErrorrecognizes that text and treats it as{ wasInstalled: true, wasRunning: true }(the app was running and is now gone) instead of a falsesuccess: false, mirroring the simulator’s “found nothing to terminate” tolerance. Any other terminate failure (device locked, not connected) still propagates. - JSON field-name caveat. The
device info processesenvelope is not formally documented by Apple;findRunningProcessPiddeep-walks it and accepts several spellings (executable as a string or{ url | path }; PID asprocessIdentifier/pid/processID). A basename fallback matches when the process path root differs from theinfo appsbundle URL (e.g./varvs/private/varsymlink). Pin the real field names against captured device output when possible. - iOS β€16 / non-macOS β
devicectlprocess management requires iOS 17+ on a macOS host; unsupported combinations surface as a clear, non-crashingsuccess: falseerror (thrown byDeviceAppManager.terminateApp, mapped to a result byTerminateApp) rather than a silentsuccess: trueno-op.
Live locale changes¶
β Implemented
The changeLocalization MCP tool supports live locale changes on iOS simulators using
simctl plus defaults. Physical iOS device system configuration is not supported.
How it works¶
- Write simulator settings via
xcrun simctl spawn <udid> defaults write: AppleLocaleβ the ICU locale identifier (underscored form, e.g.ja_JP).AppleLanguagesβ an ordered array of BCP 47 tags built from the requested locale (e.g.["ja-JP", "ja"]), so UI strings resolve correctly.AppleTimeZone,AppleICUForce24HourTime,AppleCalendarβ for the remaining localization axes.- Read-back verification β every write is read back and compared to the expected value; a mismatch is reported as an error.
- SpringBoard restart β
launchctl stop com.apple.SpringBoardinside the simulator causes SpringBoard to re-launch and adopt the new preferences. The server pollslaunchctl list com.apple.SpringBoard(up to 10 retries, 500 ms apart) to confirm it is back. - Darwin notification β
notifyutil -p com.apple.language.changedtells in-process observers that the locale has changed. - Optional app restart β the
restartAppparameter accepts a bundle ID. When provided, the server terminates the app withxcrun simctl terminateand relaunches it withxcrun simctl launch, so the app picks up the new locale (running apps cache locale at launch). The bundle ID is validated against a strict reverse-DNS pattern to prevent shell injection.
MCP tool parameters (iOS-specific)¶
| Parameter | Description |
|---|---|
locale |
Locale tag, e.g. ar-SA, ja-JP |
timeZone |
IANA zone ID, e.g. Asia/Tokyo |
timeFormat |
"12" or "24" |
calendarSystem |
e.g. gregory, japanese, buddhist |
restartApp |
Bundle ID of an app to terminate and relaunch after the change (iOS only) |
Limitations¶
- Physical iOS device system configuration is not supported.
- Text direction (
textDirectionparameter) is not applicable on iOS β RTL layout is driven by the app’s language; set an RTL locale instead. - Running apps cache locale at launch. Use the
restartAppparameter or manually relaunch the app for it to pick up new settings.
Biometric simulation¶
β Implemented π§ͺ Tested π± Simulator Only
The biometricAuth MCP tool simulates Touch ID / Face ID on simulators by posting
BiometricKit Darwin notifications inside the simulator via
xcrun simctl spawn <udid> notifyutil. This reaches parity with the Android emulator
adb emu finger path (see Android biometrics) for
match / fail.
How it works¶
- Enroll β a registered
notifyutilset/read/post oncom.apple.BiometricKit.enrollmentChangedensures a biometry is enrolled. Applenotifyutil(1)documents that state values require a current registration; the helper self-registers so enrollment does not depend on BiometricKit already owning the key. Empirical iOS 18.6 simulator checks showed this specific BiometricKit key also persisted without explicit-1, but the shared helper keeps the registration for keys without an external owner. - Match / non-match β post the key the app’s
LAContextis waiting on: - Touch ID:
com.apple.BiometricKit_Sim.fingerTouch.match/β¦nomatch - Face ID:
com.apple.BiometricKit_Sim.pearl.match/β¦nomatch
modality: "any" posts both pairs; the simulator’s non-enrolled biometry is a no-op,
so the action works regardless of the device’s biometry type.
Action mapping¶
MCP action |
iOS result |
|---|---|
match |
post *.match |
fail |
post *.nomatch |
cancel / error |
supported: "partial" β no simctl equivalent |
Limitations¶
- Simulator only. Physical iOS devices have no public biometric-injection API and return
supported: false. cancel/errorcannot be injected β the notifications carry only match vs non-match. On Android these use theAutoMobileBiometricsSDK override, which has no iOS counterpart.
Push notifications¶
β Implemented π§ͺ Tested π± Simulator Only
The postNotification MCP tool delivers a simulated remote push to a target app on a
booted simulator via xcrun simctl push <udid> <bundleId> <payload.apns>. Unlike Android
(a local notification posted by the in-app SDK; see
Android notifications), this is an OS-routed simulated push
that needs no AutoMobile iOS SDK β but it does require an explicit appId (bundle id).
How it works¶
- Build an APNs payload:
title/bodyβaps.alert,channelIdβaps.category, plus a top-level"Simulator Target Bundle": <appId>. - Reject payloads larger than the 4096-byte
simctl pushlimit. - Write the payload to a temp
.apnsfile and runsimctl push(the simctl command layer has no stdin, so a file is used rather than-).
Limitations¶
- Simulator only.
simctl pushcannot target physical devices (no APNs token/server); physical iOS devices returnsupported: false. appIdis required on iOS β there is no frontmost-bundle resolution like the Android dumpsys path.- Rich media is limited:
bigPicture/imagePath(needs a Notification Service Extension) andactions(need a pre-registeredUNNotificationCategory) are ignored with awarningrather than failing.
Deep-link discovery¶
β Implemented π§ͺ Tested π± Simulator Only
The getDeepLinks MCP tool returns an iOS app’s declared deep links. iOS apps declare
deep links statically in bundle metadata rather than via a runtime resolver, so AutoMobile
reads them off the installed .app bundle on disk. Custom URL schemes and universal-link
hosts come from two different sources.
How it works¶
- Resolve the bundle path β
xcrun simctl get_app_container <udid> <bundleId> appreturns the.app’s host filesystem path. A missing app exits non-zero and is reported as a clean “not installed” result. - URL schemes β read
<app>/Info.plistwith hostplutil -convert jsonand collectCFBundleURLTypes[].CFBundleURLSchemes[](deduplicated). These become theschemesfield. - Universal-link hosts β
codesign -d --entitlements :- <app> | plutil -convert jsonsurfaces the code-signing entitlements; AutoMobile keepscom.apple.developer.associated-domainsentries prefixed withapplinks:and strips the prefix. These become thehostsfield. Unsigned bundles or bundles without associated domains yield[], not an error. - Document types β best-effort
supportedMimeTypesfromCFBundleDocumentTypes[].LSItemContentTypes[]. - Cross-platform shape β synthesized
intentFilters(one VIEW filter listing each scheme/host) keep theDeepLinkResultshape identical to Android so platform-agnostic callers work unchanged.
get_app_container routes through simctl; plutil/codesign are host tools (not
simctl subcommands) and run directly on the host filesystem.
Limitations¶
- Simulator only. Physical-device discovery (copying the device-signed bundle off-device
via
devicectl) returns an explicit “not yet implemented” error. - iOS has no runtime intent resolver, so only declared schemes/domains are reported.
supportedMimeTypesis best-effort document-type metadata, not a routing guarantee.
Notification authorization read¶
β Implemented π§ͺ Tested π± Simulator Only
The getNotificationPolicy MCP tool reports per-app notification authorization
(UNAuthorizationStatus) on iOS simulators by reading the BulletinBoard daemon’s on-disk
section info. There is no host-side runtime API for this, so AutoMobile decodes the
persisted plist.
How it works¶
- Locate the plist β
~/Library/Developer/CoreSimulator/Devices/<udid>/data/Library/BulletinBoard/VersionedSectionInfo.plist. - Decode the outer plist β convert it with
plutil -convert xml1(notjson: the file embeds<data>blobs JSON cannot represent) and extract the base64<data>blob registered undersectionInfo[<bundleId>]. - Decode the nested blob β each section value is a separate
bplist00NSKeyedArchiver archive. Because it containsCFKeyedArchiverUIDrefs thatplutil -convert jsonrejects, AutoMobile writes the blob to a temp file and converts it withplutil -convert xml1, then reads the settings dict scalars (authorizationStatus,alertType,lockScreenSetting,notificationCenterSetting,pushSettings). - Map the status β
authorizationStatusmaps tonotDetermined/denied/authorized/provisional/ephemeral;allowedis true for any delivery-capable status βauthorized(2),provisional(3, quiet delivery) andephemeral(4, App Clips). Callers needing strict full authorization checkauthorizationStatus === "authorized". The result usesmethod: "ios_bulletinboard_plist".
An app with no registered section returns allowed: null plus a warning (the app likely
never requested authorization), not an error. A missing/unreadable plist returns a warning
rather than throwing.
Limitations¶
- Simulator only. Physical devices return
supported: false(no host-side read API; notification settings are only available to the owning app at runtime viaUNUserNotificationCenter). - Read-only.
setNotificationPolicystays unsupported on iOS: there is no public API to write per-app notification authorization, and editing the BulletinBoard plist on disk would not take effect without restarting the daemon. - This is per-app authorization status, a different concept from Android’s DND policy access β see Notifications.
Device settings snapshot¶
β Implemented π§ͺ Tested π± Simulator Only
When deviceSnapshot is invoked with includeSettings: true on an iOS simulator,
AutoMobile captures and restores a curated allowlist of simulator settings alongside app
data. (Previously iOS silently dropped settings and hard-coded the manifest to
includeSettings: false.)
How it works¶
- Capture per-key defaults β for each
(domain, key)in the allowlist, runxcrun simctl spawn <udid> defaults read <domain> <key>and record the value. The allowlist is scalar-only for now ({.GlobalPreferences, AppleLocale}); array-typed keys such asAppleLanguagesare a follow-up because a plain per-keydefaults writecannot round-trip them. - Capture UI state β
xcrun simctl ui <udid> appearanceand... content_sizeread the device-level light/dark appearance and Dynamic Type size. - Persist to the manifest β captured values go into a distinct optional
iosSettingsmanifest field (separate from Android’sglobal/secure/systemtriplet, which does not fit(domain, key)exports). - Restore surgically β on restore (gated on
manifest.includeSettings && manifest.iosSettings, identical to Android), each value is re-applied with a per-keydefaults write(never a whole-domaindefaults import, so system-managed keys are not clobbered), thensimctl ui appearance/content_sizere-apply UI state so it takes effect without a respawn.
Individual key read/write failures are logged and skipped (non-fatal), matching the per-package resilience of iOS app-data restore.
Limitations¶
- Simulator only; physical-device settings are not reachable via
simctl. - Scalar keys only in the first cut; array-typed and per-app domains are future work.
simctl spawn ... defaultshas no stdin, so whole-domaindefaults export/importis not used β the per-key allowlist is the deliberate, auditable design.
Do Not Disturb (setDeviceState)¶
β Implemented π± Simulator (iOS β€17) Only
The setDeviceState / getDeviceState MCP tools control Do Not Disturb. On iOS,
DND is simulator-only and binary (on/off) when the legacy notification path
can prove it works β this is a hard platform limitation, not a missing wiring
detail. Known iOS 18+ simulators skip the legacy write probe and report
capability: "unsupported" (see below): DND moved to the private
donotdisturbd Focus daemon. Unknown or older simulator runtimes attempt the
legacy path, but write success is behavior-based and requires an independent
fresh-process readback.
How it works (iOS β€17 simulators)¶
- Binary toggle β the only lever the simulator exposes is the
com.apple.donotdisturb.enabledDarwin notification, driven viaxcrun simctl spawn <udid> notifyutil. All four flags are passed in a single combined invocation βnotifyutil -1 <key> -s <key> <0|1> -g <key> -p <key>(iosNotifyutilRegisteredSetReadPostCommandinsrc/utils/ios-cmdline-tools/notifyutil.ts), not four separate shell calls: notifyutil -1 com.apple.donotdisturb.enabled ...creates a temporary registration so the state variable exists even if no other process already owns the key.notifyutil -s com.apple.donotdisturb.enabled <0|1>sets the value while that registration is alive.notifyutil -g com.apple.donotdisturb.enabledreads the state in the same invocation.notifyutil -p com.apple.donotdisturb.enabledposts it so observers react.- After a short settle, a separate fresh-process
notifyutil -g com.apple.donotdisturb.enabledverifies that the value persisted. Only this independent readback can make the writeverified: true. - Honest capability reporting β every result carries a machine-readable
capabilityfield so callers can branch instead of string-matching warnings: binaryβ simulator legacy path verified: on/off only.unsupportedβ physical device, known iOS 18+ simulator, or a legacy write whose independent readback reverted instead of persisting.- (
fullis reserved for Android, where all fourzen_modetiers are distinct, persisted, and verified.) - No silent downgrade β a
priorityoralarmsrequest applies plain DND and reports it honestly:requestedMode: "priority"|"alarms",appliedMode: "none", a structuredwarning, andverified: false(sosuccessisfalse). The tool never claims a tier it cannot deliver. - Best effort with behavior verification β results are
bestEffort: true. The same-invocation registered set/read/post proves only that the command could set state while its registration existed; it is not authoritative by itself. The setter only reports success when the independent readback observes the requested binary state.
Limitations¶
- Known iOS 18+ simulators: unsupported fast-path. iOS 18 moved Do Not Disturb / Focus to the
private
donotdisturbddaemon (a Core Data store + Focus-assertion model). That daemon owns the legacycom.apple.donotdisturb.enablednotification and immediately resets it to its authoritative value β verified empirically, anotifyutil -swrite reads back0from a fresh process even sub-second later (an unmanaged notify key set the same way persists). So the legacy key neither reflects nor controls the real Focus state: writes cannot verify and reads are a confident falsehood. When the simulator’s iOS major version is known from device metadata orsimctl list devices <udid> --json, iOS 18+ is treated as an unsupported fast-path to avoid a known-dead probe. When the version is unknown or expected to support the legacy path,setDeviceStatestill probes behavior: if the fresh-process readback reverts, the result iscapability: "unsupported"instead of a false success. On unknown runtimes, an off request reading back disabled is not enough to prove capability, because reclaimed runtimes also settle to0; positive enable persistence is the useful proof. Apple exposes no public API to read or set Focus/DND. Use an iOS 17 or earlier simulator with verified legacy behavior, or set it manually / via a Shortcuts automation. - Simulator only. Physical iOS devices return
supported: false,capability: "unsupported", and a specific error: iOS exposes no public API to enable/disable Focus or Do Not Disturb (only the read-only Focus Filter API), and Apple’s device tooling (devicectl, XCUITest) ships no DND/Focus setter. - Binary, not per-mode. Since iOS 15, DND lives inside the private Focus
framework. There is no per-mode (priority vs. alarms-only) Darwin notification
analogous to Android’s
zen_modeinteger, so iOS cannot be mapped to the Android four-mode model. - No cosmetic fallback.
simctl status_bar overrideexposestime/dataNetwork/wifi*/cellular*/battery*/operatorNameβ no DND flag β so even a status-bar-only indicator is unavailable.
Usage patterns¶
- Prefer deterministic simulator selection by device identifier.
- Keep simulator state consistent between runs (reset/erase when needed).
- Use dedicated simulators for parallel test execution.
Limitations¶
- macOS only (requires Xcode Command Line Tools).
- Simulator-only; physical devices are out of scope for simctl.
See also¶
- CtrlProxy iOS - Touch injection and element queries.
- iOS overview
- Android biometrics - The
biometricAuthAndroid counterpart. - Android notifications - The
postNotificationAndroid counterpart, and the cross-platform notification policy concept (Android DND policy access vs iOS per-app authorization).