# `MobDev.Discovery.IOS`
[🔗](https://github.com/genericjam/mob_dev/blob/master/lib/mob_dev/discovery/ios.ex#L1)

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.

# `epmd_probe`

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

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

# `build_simctl_env`

```elixir
@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`

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

Returns `{:registered, ip, name, dist_port}` from EPMD,
`{:predicted, link_local_ip}` when the link-local EPMD does not list the
node yet (the app is not running; `mix mob.connect` launches it after
tunnel setup), or `:none` without a link-local IP.

# `devicectl_ipv4_addresses`

```elixir
@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`

```elixir
@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`

```elixir
@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 `--kill`s it moments before `devicectl
launch` targets it — the exact race the caller avoids by excluding it.

# `find_physical_at`

```elixir
@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`

```elixir
@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`

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

Returns all iOS devices (simulators + physical).

# `list_physical`

```elixir
@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`

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

Returns booted iOS simulators.

# `mob_pids_to_kill`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

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

# `resolve_usb_node`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
