MobDev.Discovery.IOS (mob_dev v0.7.21)

Copy Markdown View Source

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

Types

What EPMD at one IP says about the project's iOS node.

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.

Picks the IP a USB-attached iPhone's node is registered at, from EPMD probes ({ip, epmd_probe}) of the phone's USB link-local IP and of the phone's own other addresses (see resolve_usb_node/1).

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 the current project's iOS node (see select_ios_node/2) and returns a Device, or nil if that node is not 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.

The first link-local (169.254.x.x) address in ips, or nil.

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.

Every {name, port} in an EPMD NAMES_REQ reply — a 4-byte EPMD port, then one name <node> at port <port> line per registered node — in EPMD order. An iPhone's EPMD can list more than one Mob app, so all entries matter.

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.

The devicectl device process launch environment for a physical iPhone (DEVICECTL_CHILD_* reaches the app): the dist cookie (:dist_cookie) and the host the node must be named after (:node_host, an IPv4 the Mac reaches the phone at). mob_beam.m (mob ≥ 0.9.16) takes MOB_NODE_HOST when it is one of the phone's own addresses; without it the phone names its node after its WiFi address, which the Mac can't dial when that WiFi is a network the Mac isn't on (MOB-428). Older mob ignores it.

Resolves where a USB-discovered iPhone's node is registered: probes EPMD on link_local_ip (the phone's USB address, or nil if unknown) and on the phone's other IPv4 addresses, then decides with choose_usb_node/2.

Restarts the app on a physical iOS device via xcrun devicectl.

The EPMD entry that is the project's iOS node, or nil.

The USB link-local IPv4 (169.254.x.x) of the physical iPhone udid, looked up from the phone's own mDNS name, or nil.

The .local names the phone udid answers mDNS under, from devicectl's device list: each <label>.coredevice.local hostname of that device as <label>.local, skipping the labels that are the UDID or a CoreDevice identifier (those only resolve to the IPv6 tunnel). Other devices' names are never returned.

Types

epmd_probe()

@type epmd_probe() :: {:ok, String.t(), pos_integer()} | {:error, atom()}

What EPMD at one IP says about the project's iOS node.

Functions

build_simctl_env(opts, runtime_dir)

@spec build_simctl_env(
  keyword(),
  String.t()
) :: [{String.t(), String.t()}]

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_port opt, default 9100
  • SIMCTL_CHILD_MOB_SIM_RUNTIME_DIR — runtime_dir arg

Conditionally emits:

  • SIMCTL_CHILD_MOB_NODE_SUFFIX — only when :node_suffix is a non-empty string. nil / "" → mob_beam.m auto-derives from SIMULATOR_UDID.

choose_usb_node(arg1, same_phone_probes)

@spec choose_usb_node({String.t(), epmd_probe()} | nil, [{String.t(), epmd_probe()}]) ::
  {:registered, String.t(), String.t(), pos_integer()}
  | {:predicted, String.t()}
  | :none

Picks the IP a USB-attached iPhone's node is registered at, from EPMD probes ({ip, epmd_probe}) of the phone's USB link-local IP and of the phone's own other addresses (see resolve_usb_node/1).

mob_beam.m names the node after the phone's WiFi/LAN IP whenever it has one and uses the link-local IP only without WiFi; mob ≥ 0.9.16 takes the host mix mob.connect passes as MOB_NODE_HOST instead. The phone's EPMD binds 0.0.0.0, so the link-local probe lists the node even when its name carries the WiFi IP. The link-local probe is the one that reached the phone on the cable, so it is the reference: another address is taken only if its EPMD lists the same node at the same dist port — the same BEAM, seen twice — and it is the only such address. Anything else (another address's entry disagrees, several agree, or the link-local EPMD lists nothing) keeps the link-local IP, because nothing proves the other entry is this phone's.

When the link-local EPMD does not list the node yet (the app is not running; mix mob.connect launches it after tunnel setup, passing the predicted IP as MOB_NODE_HOST), the prediction is the phone's one other address the Mac reaches — its EPMD answered or refused the connection — else the link-local IP. Naming the node after the WiFi IP whenever the Mac reaches it keeps the name the phone picks by itself, which LAN discovery, --no-restart and hot push dial; link-local is only for a WiFi the Mac isn't on (MOB-428).

Returns {:registered, ip, name, dist_port} from EPMD, {:predicted, ip} when the link-local EPMD does not list the node, or :none without a link-local IP.

devicectl_ipv4_addresses()

@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:

  1. connectionProperties.tunnelIPAddress if it's an IPv4 (CoreDevice USB tunnel; sometimes IPv6, which Erlang dist doesn't speak)
  2. connectionProperties.localHostnames resolved via :inet.gethostbyname/1 (mDNS hostnames like Kevins-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.

enable_accessibility(udid)

@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.

except_app_name_for(ours, bundle_id)

@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.

find_physical_at(ip)

@spec find_physical_at(String.t()) :: MobDev.Device.t() | nil

Queries EPMD at a specific IP for the current project's iOS node (see select_ios_node/2) and returns a Device, or nil if that node is not reachable there. Used for direct connection when the IP is already known (e.g. from xcrun devicectl) and ARP may not be warm.

launch_app(udid, bundle_id, opts \\ [])

@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 port
  • MOB_NODE_SUFFIX — appended to the BEAM node name. When absent, mob_beam.m falls back to deriving a suffix from SIMULATOR_UDID so concurrent sims still get unique names.
  • MOB_DIST_COOKIE — private development distribution cookie.
  • MOB_SIM_RUNTIME_DIR — directory the OTP runtime was written to; mob_beam.m reads from the same place ios/build.sh wrote.

Options:

  • :dist_port — pin the dist listen port (default 9100).
  • :node_suffix — override the BEAM node-name suffix. nil lets mob_beam.m auto-derive from SIMULATOR_UDID.
  • :dist_cookie — private development distribution cookie.

list_devices()

@spec list_devices() :: [MobDev.Device.t()]

Returns all iOS devices (simulators + physical).

list_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) — only for the current project's app (select_ios_node/2); another Mob app's node on the same phone is not this project's device. 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.

list_simulators()

@spec list_simulators() :: [MobDev.Device.t()]

Returns booted iOS simulators.

mob_pids_to_kill(process_output, ours, except_app_name \\ nil)

@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.

parse_epmd_names(arg1)

@spec parse_epmd_names(binary()) :: [{String.t(), pos_integer()}]

Every {name, port} in an EPMD NAMES_REQ reply — a 4-byte EPMD port, then one name <node> at port <port> line per registered node — in EPMD order. An iPhone's EPMD can list more than one Mob app, so all entries matter.

parse_runtime_version(runtime)

@spec parse_runtime_version(String.t()) :: String.t()

Parses a CoreSimulator runtime key into a human-readable version string. Exposed for testing.

parse_simctl_json(json_string)

@spec parse_simctl_json(String.t()) :: [MobDev.Device.t()]

Parses the JSON output of xcrun simctl list devices booted --json. Exposed for testing.

parse_simctl_text(output)

@spec parse_simctl_text(String.t()) :: [MobDev.Device.t()]

Parses the plain-text output of xcrun simctl list devices booted. Exposed for testing.

physical_launch_env(opts)

@spec physical_launch_env(keyword()) :: [{String.t(), String.t()}]

The devicectl device process launch environment for a physical iPhone (DEVICECTL_CHILD_* reaches the app): the dist cookie (:dist_cookie) and the host the node must be named after (:node_host, an IPv4 the Mac reaches the phone at). mob_beam.m (mob ≥ 0.9.16) takes MOB_NODE_HOST when it is one of the phone's own addresses; without it the phone names its node after its WiFi address, which the Mac can't dial when that WiFi is a network the Mac isn't on (MOB-428). Older mob ignores it.

resolve_usb_node(link_local_ip)

@spec resolve_usb_node(String.t() | nil) ::
  {:registered, String.t(), String.t(), pos_integer()}
  | {:predicted, String.t()}
  | :none

Resolves where a USB-discovered iPhone's node is registered: probes EPMD on link_local_ip (the phone's USB address, or nil if unknown) and on the phone's other IPv4 addresses, then decides with choose_usb_node/2.

The other addresses are looked up from the phone, not collected from the LAN: the link-local IP reverse-resolves to the phone's mDNS name (kevins-iphone.local), which forward-resolves to the addresses registered under that name, its WiFi IP included. Scanning ARP neighbours instead finds any phone running this app, and every phone's BEAM listens on the same dist port (mob_beam.m's 9101 default), so EPMD cannot tell them apart.

This narrows the candidates but does not prove they are the same phone. The lookups are not scoped to the USB interface, and mDNS allows the same .local name on different links (RFC 6762 §14). A known limitation: if two phones share a .local name, one USB-only and one on the LAN running the same app, the LAN phone's address can be taken for the USB phone's. Scoping the lookups to the USB interface (dns-sd -i <enN>) would close that.

The lookups are native and synchronous, and each can take seconds, so they share a deadline (same_phone_ipv4s/3); running out means no other addresses, and the link-local IP is used.

restart_app_physical(udid, bundle_id, opts \\ [])

@spec restart_app_physical(String.t(), String.t(), keyword()) ::
  {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.

select_ios_node(entries, base)

@spec select_ios_node([{String.t(), pos_integer()}], String.t() | nil) ::
  {String.t(), pos_integer()} | nil

The EPMD entry that is the project's iOS node, or nil.

base is Device.ios_node_base/0 (<app>_ios). The entry must be base itself or base_<suffix> (mob_beam.m appends MOB_NODE_SUFFIX when set); the unsuffixed name wins when both are listed. Another app's node on the same EPMD never matches — taking the first *_ios entry attached mix mob.connect to a different app on the same phone (MOB-283).

base is nil only outside a Mix project, where no app is known; then the first *_ios entry is the only choice there is.

terminate_app(udid, bundle_id)

@spec terminate_app(String.t(), String.t()) :: {String.t(), non_neg_integer()}

usb_mdns_names(devices, udid)

@spec usb_mdns_names([map()], String.t()) :: [String.t()]

The .local names the phone udid answers mDNS under, from devicectl's device list: each <label>.coredevice.local hostname of that device as <label>.local, skipping the labels that are the UDID or a CoreDevice identifier (those only resolve to the IPv6 tunnel). Other devices' names are never returned.