Discovers iOS simulators via xcrun simctl.
Physical iOS device support requires libimobiledevice (ideviceinfo, iproxy). Best-effort: works if tools are installed, degrades gracefully if not.
Summary
Functions
Builds the SIMCTL_CHILD_* env-var list launch_app/3 passes to
simctl. Extracted as a pure function so the override behaviour can be
unit-tested without spawning subprocesses.
Returns the IPv4 addresses every connected physical device is known to
reach Mac at, derived from xcrun devicectl list devices --json-output.
Sources, in order
Enables the iOS accessibility system for the given simulator (or "booted").
The .app name for bundle_id, or nil if we have no record of it.
Queries EPMD at a specific IP for any *_ios node and returns a Device, or
nil if no iOS BEAM node is reachable there. Used for direct connection when
the IP is already known (e.g. from xcrun devicectl) and ARP may not be warm.
Launches the app on a booted simulator.
Returns all iOS devices (simulators + physical).
Returns connected physical iOS devices.
Returns booted iOS simulators.
Process ids on the device that belong to Mob apps we may kill.
Parses a CoreSimulator runtime key into a human-readable version string. Exposed for testing.
Parses the JSON output of xcrun simctl list devices booted --json.
Exposed for testing.
Parses the plain-text output of xcrun simctl list devices booted.
Exposed for testing.
Restarts the app on a physical iOS device via xcrun devicectl.
Functions
Builds the SIMCTL_CHILD_* env-var list launch_app/3 passes to
simctl. Extracted as a pure function so the override behaviour can be
unit-tested without spawning subprocesses.
Always emits:
SIMCTL_CHILD_MOB_DIST_PORT—:dist_portopt, default 9100SIMCTL_CHILD_MOB_SIM_RUNTIME_DIR— runtime_dir arg
Conditionally emits:
SIMCTL_CHILD_MOB_NODE_SUFFIX— only when:node_suffixis a non-empty string. nil / "" → mob_beam.m auto-derives from SIMULATOR_UDID.
@spec devicectl_ipv4_addresses() :: [String.t()]
Returns the IPv4 addresses every connected physical device is known to
reach Mac at, derived from xcrun devicectl list devices --json-output.
Sources, in order:
connectionProperties.tunnelIPAddressif it's an IPv4 (CoreDevice USB tunnel; sometimes IPv6, which Erlang dist doesn't speak)connectionProperties.localHostnamesresolved via:inet.gethostbyname/1(mDNS hostnames likeKevins-iPhone.coredevice.local, which usually resolve to the device's WiFi IPv4)
Returns [] if xcrun isn't installed, the JSON parse fails, or no
device has any IPv4. Pure of side effects beyond the temp file used to
capture devicectl's JSON output.
@spec enable_accessibility(String.t()) :: :ok
Enables the iOS accessibility system for the given simulator (or "booted").
SwiftUI lazily populates its accessibility tree only when an accessibility
service is active. pegleg_nif:ui_tree/0 requires this to be called once
per simulator session before it can return elements. Writes the VoiceOver
preference into the simulator's preference store and posts the Darwin
notification that UIKit listens to.
Safe to call repeatedly — idempotent.
@spec except_app_name_for([MobDev.IOSInstalls.app()], String.t() | nil) :: String.t() | nil
The .app name for bundle_id, or nil if we have no record of it.
Extracted so the translation is testable. Getting it wrong is not
cosmetic: returning nil for the app about to be launched puts that app
back in the kill set, so mob_dev --kills it moments before devicectl launch targets it — the exact race the caller avoids by excluding it.
@spec find_physical_at(String.t()) :: MobDev.Device.t() | nil
Queries EPMD at a specific IP for any *_ios node and returns a Device, or
nil if no iOS BEAM node is reachable there. Used for direct connection when
the IP is already known (e.g. from xcrun devicectl) and ARP may not be warm.
@spec launch_app(String.t(), String.t(), keyword()) :: {String.t(), non_neg_integer()}
Launches the app on a booted simulator.
Passes env vars through to the simulator app via simctl's
SIMCTL_CHILD_* mechanism (the prefix is stripped before delivery
to the child process):
MOB_DIST_PORT— Erlang dist listen portMOB_NODE_SUFFIX— appended to the BEAM node name. When absent,mob_beam.mfalls back to deriving a suffix fromSIMULATOR_UDIDso concurrent sims still get unique names.MOB_SIM_RUNTIME_DIR— directory the OTP runtime was written to;mob_beam.mreads from the same placeios/build.shwrote.
Options:
:dist_port— pin the dist listen port (default9100).:node_suffix— override the BEAM node-name suffix.nilletsmob_beam.mauto-derive fromSIMULATOR_UDID.
@spec list_devices() :: [MobDev.Device.t()]
Returns all iOS devices (simulators + physical).
@spec list_physical() :: [MobDev.Device.t()]
Returns connected physical iOS devices.
Always runs both USB discovery (ideviceinfo) and a LAN EPMD scan in
parallel. The LAN scan finds the device's actual node IP (which is
WiFi-first since mob_beam.m prefers a stable LAN address). The USB scan
provides the UDID and device name. Results are merged: one device with the
correct WiFi IP and full USB metadata.
If only one path finds the device, that result is used directly — so this
works on USB-only setups and WiFi-only setups equally. When USB finds a
device but LAN scan doesn't (cold ARP, rapid app launch, etc.), the
result is enriched via xcrun devicectl — we ask for the device's known
hostnames + tunnel IPs, resolve to IPv4, and probe each with EPMD. Single
TCP probe per candidate, so it costs ~50 ms in the success case and
doesn't slow down the no-iOS path.
@spec list_simulators() :: [MobDev.Device.t()]
Returns booted iOS simulators.
@spec mob_pids_to_kill(String.t(), [String.t()], String.t() | nil) :: [pos_integer()]
Process ids on the device that belong to Mob apps we may kill.
Pure, so the decision that used to be untestable is now the testable part.
process_output is devicectl device info processes output; ours is what
MobDev.IOSInstalls says we installed on this device; except_app_name is
the app about to be launched, which the caller launches with
--terminate-existing anyway.
Matching is on the .app bundle name in the executable path, because that
is the only identifier the process listing carries — it has no bundle ids.
Anything not in ours is left alone. The bug this replaced matched every
process under Bundle/Application/, which is where all third-party apps
live, so running mix mob.connect with a personal iPhone attached force-quit
every app the owner had open (MOB-70). An empty ours returns []: not
knowing what is ours means killing nothing.
Parses a CoreSimulator runtime key into a human-readable version string. Exposed for testing.
@spec parse_simctl_json(String.t()) :: [MobDev.Device.t()]
Parses the JSON output of xcrun simctl list devices booted --json.
Exposed for testing.
@spec parse_simctl_text(String.t()) :: [MobDev.Device.t()]
Parses the plain-text output of xcrun simctl list devices booted.
Exposed for testing.
@spec restart_app_physical(String.t(), String.t()) :: {String.t(), non_neg_integer()}
Restarts the app on a physical iOS device via xcrun devicectl.
First clears other Mob apps that mob_dev installed on this device — they
each hold EPMD 4369 and only one can run at a time — then launches the
target app fresh. Apps mob_dev did not install are never touched, whoever
they belong to. See MobDev.IOSInstalls and MOB-70.
@spec terminate_app(String.t(), String.t()) :: {String.t(), non_neg_integer()}