Packages
mob_dev
0.2.14
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.17
0.5.16
0.5.15
0.5.14
0.5.13
0.5.12
0.5.11
0.5.10
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.37
0.3.35
0.3.34
0.3.33
0.3.28
0.3.26
0.3.23
0.3.21
0.3.19
0.3.18
0.3.17
0.3.16
0.3.15
0.3.14
0.3.13
0.3.12
0.3.11
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.18
0.2.17
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Development tooling for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
lib/mob_dev/discovery/ios.ex
defmodule MobDev.Discovery.IOS do
@moduledoc """
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.
"""
alias MobDev.Device
@doc "Returns booted iOS simulators."
@spec list_simulators() :: [Device.t()]
def list_simulators do
case System.find_executable("xcrun") do
nil -> []
_ -> do_list_simulators()
end
end
@doc "Returns connected physical iOS devices (requires libimobiledevice)."
@spec list_physical() :: [Device.t()]
def list_physical do
case System.find_executable("ideviceinfo") do
nil -> []
_ -> do_list_physical()
end
end
@doc "Returns all iOS devices (simulators + physical)."
@spec list_devices() :: [Device.t()]
def list_devices do
list_simulators() ++ list_physical()
end
defp do_list_simulators do
case System.cmd("xcrun", ["simctl", "list", "devices", "booted", "--json"],
stderr_to_stdout: true) do
{output, 0} -> parse_simctl_json(output)
_ -> []
end
rescue
# Jason not available — fall back to simpler text parsing
_ -> list_simulators_text()
end
@doc """
Parses the JSON output of `xcrun simctl list devices booted --json`.
Exposed for testing.
"""
@spec parse_simctl_json(String.t()) :: [Device.t()]
def parse_simctl_json(json_string) do
json_string
|> Jason.decode!()
|> Map.get("devices", %{})
|> Enum.flat_map(fn {runtime, devices} ->
version = parse_runtime_version(runtime)
Enum.map(devices, &sim_to_device(&1, version))
end)
|> Enum.reject(&is_nil/1)
end
defp list_simulators_text do
case System.cmd("xcrun", ["simctl", "list", "devices", "booted"],
stderr_to_stdout: true) do
{output, 0} -> parse_simctl_text(output)
_ -> []
end
end
@doc """
Parses the plain-text output of `xcrun simctl list devices booted`.
Exposed for testing.
"""
@spec parse_simctl_text(String.t()) :: [Device.t()]
def parse_simctl_text(output) do
output
|> String.split("\n")
|> Enum.flat_map(&parse_simctl_text_line/1)
end
# Parse lines like:
# iPhone 17 (78354490-EF38-44D7-A437-DD941C20524D) (Booted)
defp parse_simctl_text_line(line) do
case Regex.run(~r/^\s+(.+?) \(([0-9A-F-]{36})\) \(Booted\)/i, line) do
[_, name, udid] ->
d = %Device{
platform: :ios,
serial: udid,
name: name,
type: :simulator,
status: :booted,
}
[%{d | node: Device.node_name(d)}]
_ -> []
end
end
defp sim_to_device(%{"udid" => udid, "name" => name, "state" => "Booted"}, version) do
d = %Device{
platform: :ios,
serial: udid,
name: name,
version: version,
type: :simulator,
status: :booted,
}
%{d | node: Device.node_name(d)}
end
defp sim_to_device(_, _), do: nil
@doc "Parses a CoreSimulator runtime key into a human-readable version string. Exposed for testing."
@spec parse_runtime_version(String.t()) :: String.t()
def parse_runtime_version(runtime) do
case Regex.run(~r/iOS-(\d+)-(\d+)/, runtime) do
[_, major, minor] -> "iOS #{major}.#{minor}"
_ ->
# "com.apple.CoreSimulator.SimRuntime.iOS-18-0" style
runtime |> String.split(".") |> List.last() |> String.replace("-", ".")
end
end
defp do_list_physical do
case System.cmd("ideviceinfo", ["-k", "UniqueDeviceID"], stderr_to_stdout: true) do
{udid, 0} ->
udid = String.trim(udid)
name = ideviceinfo(udid, "DeviceName")
version = ideviceinfo(udid, "ProductVersion")
d = %Device{
platform: :ios,
serial: udid,
name: name,
version: "iOS #{version}",
type: :physical,
status: :discovered,
}
[%{d | node: Device.node_name(d)}]
_ -> []
end
end
defp ideviceinfo(_udid, key) do
case System.cmd("ideviceinfo", ["-k", key], stderr_to_stdout: true) do
{val, 0} -> String.trim(val)
_ -> nil
end
end
@doc """
Launches the app on a booted simulator.
Passes MOB_DIST_PORT as an environment variable (xcrun simctl launch supports this).
"""
@spec launch_app(String.t(), String.t(), keyword()) :: {String.t(), non_neg_integer()}
def launch_app(udid, bundle_id, opts \\ []) do
dist_port = Keyword.get(opts, :dist_port, 9100)
# xcrun simctl passes SIMCTL_CHILD_* env vars to the launched app (prefix stripped).
System.cmd("xcrun", ["simctl", "launch", udid, bundle_id],
stderr_to_stdout: true,
env: [{"SIMCTL_CHILD_MOB_DIST_PORT", to_string(dist_port)}])
end
@spec terminate_app(String.t(), String.t()) :: {String.t(), non_neg_integer()}
def terminate_app(udid, bundle_id) do
System.cmd("xcrun", ["simctl", "terminate", udid, bundle_id], stderr_to_stdout: true)
end
@doc """
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 enable_accessibility(String.t()) :: :ok
def enable_accessibility(udid) do
System.cmd("xcrun", ["simctl", "spawn", udid, "defaults", "write",
"com.apple.Accessibility", "VoiceOverTouchEnabled", "-bool", "YES"],
stderr_to_stdout: true)
System.cmd("xcrun", ["simctl", "spawn", udid, "notifyutil",
"-p", "com.apple.accessibility.voiceover.status.changed"],
stderr_to_stdout: true)
:ok
end
end