Packages
mob_dev
0.6.21
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/mix/tasks/mob.battery_bench_ios.ex
defmodule Mix.Tasks.Mob.BatteryBenchIos do
use Mix.Task
alias MobDev.Bench.{DeviceObserver, Logger, Preflight, Probe, Reconnector, Summary}
@shortdoc "Run a battery benchmark on a physical iOS device"
@moduledoc """
Builds a benchmark app, deploys it to a physical iPhone/iPad, and measures
battery drain over time.
Run this from your Mob app project directory (the one containing `ios/`,
`mob.exs`, and your Elixir source). It requires `bundle_id` to be set in
`mob.exs`:
config :mob_dev,
mob_dir: "/path/to/mob",
bundle_id: "com.example.myapp"
## Prerequisites
libimobiledevice is required for battery readings:
brew install libimobiledevice
The device must be trusted on this Mac (accept "Trust This Computer" when
connecting via USB). Xcode 15 or later is required for `xcrun devicectl`.
## Battery measurement
iOS exposes battery capacity via libimobiledevice's `ideviceinfo` tool.
If the device reports `BatteryMaxCapacity` (mAh), drain is shown in mAh like
the Android benchmark. Otherwise, it falls back to percentage points
(1% ≈ 40–60 mAh on most iPhones).
WiFi-only measurements are not possible on iOS — USB-connected readings are
skewed because the cable can trickle-charge. To minimise this, use a USB-only
data cable (no charging), or note the baseline with and without cable.
## Recommended workflow (Mob projects)
Mob projects use `ios/build.zig` (driven by Mix's NativeBuild pipeline)
rather than a full Xcode project, which means the bench task's
`xcodebuild` path doesn't apply. Use this two-step pattern instead:
# 1. Push BEAM flags via mob.deploy (no native rebuild — ~5 sec).
mix mob.deploy --beam-flags "" --ios # tuned (Nerves)
mix mob.deploy --beam-flags "-S 6:6 -A 8" --ios # untuned variant
# 2. Run the bench with --no-build, specifying the phone's WiFi IP.
mix mob.battery_bench_ios --no-build --wifi-ip 10.0.0.120
Find the phone's WiFi IP in Settings → Wi-Fi → (i) → IP Address.
See `README.md` for the full rationale and recovery procedure if a flag
combination crashes the BEAM (which can happen if you request more
threads than iOS allows per process).
## Usage (with built-in Xcode build path)
mix mob.battery_bench_ios
mix mob.battery_bench_ios --no-beam
mix mob.battery_bench_ios --preset nerves
mix mob.battery_bench_ios --flags "-sbwt none -S 1:1"
mix mob.battery_bench_ios --duration 3600 --device UDID
mix mob.battery_bench_ios --no-build # re-run without rebuilding
## Options
* `--duration N` — benchmark duration in **seconds** (default: 1800 = 30 min)
* `--device UDID` — device UDID (auto-detected if one device connected)
* `--wifi-ip IP` — phone's WiFi IPv4 (recommended; bypasses auto-discovery)
* `--no-beam` — baseline: build without starting the BEAM at all
* `--no-keep-alive` — skip the silent-audio background keep-alive call
* `--preset NAME` — named BEAM flag preset (Xcode-build path only)
* `--flags "..."` — arbitrary BEAM VM flags (Xcode-build path only)
* `--no-build` — skip Xcode build and install; benchmark current install
* `--scheme NAME` — Xcode scheme name (default: camelized app name)
* `--log-path PATH` — override CSV log location (default: `_build/bench/run_<ts>.csv`)
* `--no-csv` — skip CSV logging
* `--skip-preflight` — bypass the preflight checks (USB/app/BEAM/RPC/NIF/keep-alive)
## What the presets do
* `untuned` — raw BEAM with no tuning flags (highest power use baseline)
* `sbwt` — only busy-wait disabled (`-sbwt none`)
* `nerves` — full Nerves set: single scheduler + busy-wait off + multi_time_warp
* (default) — same as `nerves` (production default)
## Understanding the results
iOS battery percentage resolution is coarse. A 30-minute run should produce
2–5% drain, enough to see the difference between no-BEAM and tuned-BEAM.
Run at a fixed screen brightness or with the screen locked for reproducible
results across runs.
## Under the hood
`mix mob.battery_bench_ios` orchestrates the following commands:
# Build
xcodebuild -workspace ios/*.xcworkspace -scheme SCHEME \\
-configuration Debug -sdk iphoneos \\
-derivedDataPath /tmp/mob_bench_ios_STAMP \\
[OTHER_CFLAGS='$(inherited) -DFLAG']
# Install and launch
xcrun devicectl device install app --device UDID /path/to/App.app
xcrun devicectl device process launch --terminate-existing --device UDID \\
com.example.myapp # → captures PID
# Lock screen
idevicediagnostics -u UDID sleep
# Poll battery every 10s
ideviceinfo -u UDID -q com.apple.mobile.battery -k BatteryCurrentCapacity
ideviceinfo -u UDID -q com.apple.mobile.battery -k BatteryMaxCapacity
# Stop app
xcrun devicectl device process terminate --device UDID --pid PID
BEAM tuning flags are injected as `OTHER_CFLAGS` build settings passed to
xcodebuild, matching the same C preprocessor defines used in the Android build.
"""
@switches [
duration: :integer,
device: :string,
wifi_ip: :string,
no_beam: :boolean,
no_keep_alive: :boolean,
preset: :string,
flags: :string,
no_build: :boolean,
scheme: :string,
dry_run: :boolean,
log_path: :string,
no_csv: :boolean,
skip_preflight: :boolean
]
@battery_domain "com.apple.mobile.battery"
@impl Mix.Task
def run(args) do
{opts, _, _} = OptionParser.parse(args, switches: @switches)
unless match?({:unix, :darwin}, :os.type()) do
Mix.raise("mob.battery_bench_ios requires macOS.")
end
if opts[:dry_run] do
dry_run!(opts)
exit(:normal)
end
check_prerequisites!()
duration = opts[:duration] || 1800
no_build = opts[:no_build] || false
# hw_udid: hardware UDID for libimobiledevice tools (USB only, may be nil over WiFi).
# device_id: identifier for xcrun devicectl (works over WiFi for paired devices).
# --device accepts either; if given, use it for both.
{hw_udid, device_id} =
case opts[:device] do
given when is_binary(given) ->
# Hardware UDID has no hyphens in the first segment (e.g. 00008110-...)
# CoreDevice UUID has the standard 8-4-4-4-12 UUID format.
hw =
if String.match?(
given,
Regex.compile!("^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}")
),
do: nil,
else: given
{hw, given}
nil ->
case {auto_detect_usb(), auto_detect_wifi()} do
{nil, nil} ->
Mix.raise("""
No iOS device found. Options:
Connect an iPhone/iPad via USB and accept "Trust This Computer"
mix mob.battery_bench_ios --device UDID
List connected devices with: idevice_id -l
""")
{usb, nil} ->
{usb, usb}
{nil, wifi} ->
{nil, wifi}
{usb, wifi} ->
{usb, wifi}
end
end
# device_id is what we pass to xcrun devicectl (install, launch, terminate).
udid = device_id
pkg = MobDev.Config.bundle_id()
cfg = MobDev.Config.load_mob_config()
# Workspace discovery is only needed when building. Skip it with --no-build.
{workspace_kind, workspace_path, scheme} =
if no_build do
{:none, nil, opts[:scheme] || cfg[:ios_scheme] || Macro.camelize(app_name())}
else
{wk, wp} = find_workspace!()
{wk, wp, opts[:scheme] || cfg[:ios_scheme] || detect_scheme!(wk, wp)}
end
IO.puts("")
IO.puts("=== Mob Battery Benchmark (iOS) ===")
IO.puts("")
IO.puts(" Device: #{udid}")
IO.puts(" Bundle: #{pkg}")
IO.puts(" Scheme: #{scheme}")
IO.puts(" Duration: #{duration}s (#{div(duration, 60)} min)")
IO.puts(" Mode: #{describe_mode(opts)}")
IO.puts("")
unless device_ok?(udid) do
Mix.raise("Cannot reach device #{udid} — check it is paired and on the same network.")
end
# ── Build ──────────────────────────────────────────────────────────────────
derived_data = Path.join(System.tmp_dir!(), "mob_bench_ios_#{System.os_time(:second)}")
unless no_build do
{other_cflags, header_dir} = resolve_build_flags(opts)
IO.puts("=== Building iOS app ===")
app_path = build_app(workspace_kind, workspace_path, scheme, other_cflags, derived_data)
IO.puts("=== Installing on device ===")
install_app!(udid, app_path)
if header_dir, do: File.rm_rf!(header_dir)
end
# ── Launch app first so the BEAM is reachable for all battery reads ──────────
IO.puts("=== Launching app ===")
pid = launch_app!(udid, pkg)
:timer.sleep(3000)
# ── Pre-run checks ─────────────────────────────────────────────────────────
# Connect to the phone's BEAM — used as fallback when ideviceinfo is
# unavailable (WiFi-only mode) and for battery reads when screen is locked.
# Best-effort: nil means RPC won't be available.
IO.puts(" Connecting to device BEAM...")
node = connect_beam_node(device_id, opts[:wifi_ip])
if node do
IO.puts(" BEAM connected: #{node}")
# If the user *didn't* pass --wifi-ip but we found the device anyway
# (devicectl + ARP/EPMD scan, which can take 5–15 s on a cold network),
# surface the IP so the next run can short-circuit straight to it.
hint_wifi_ip(node, opts[:wifi_ip])
if opts[:no_keep_alive] do
IO.puts(" (skipping background keep-alive — iOS will suspend the app when locked)")
else
IO.puts(" Starting background keep-alive (silent audio session)...")
:rpc.call(node, :mob_nif, :background_keep_alive, [], 5000)
end
else
IO.puts(" (BEAM not reachable — will use ideviceinfo only, screen must stay on)")
hint_wifi_ip_on_failure(opts[:wifi_ip])
end
# ── Preflight ─────────────────────────────────────────────────────────────
unless opts[:skip_preflight] do
IO.puts("")
IO.puts("=== Preflight checks ===")
preflight_results =
Preflight.run(
node: node,
cookie: :mob_secret,
bundle_id: pkg,
device_id: device_id,
hw_udid: hw_udid,
require_keep_alive: opts[:no_keep_alive] != true
)
IO.puts(Preflight.pretty(preflight_results))
unless Preflight.all_ok?(preflight_results) do
IO.puts("")
IO.puts(">>> Preflight checks reported issues. Continue anyway? (y/N)")
case IO.gets("") |> String.trim() do
"y" -> :ok
_ -> Mix.raise("Aborted at preflight.")
end
end
end
max_mah = read_max_capacity_mah(hw_udid)
battery = read_battery_required(hw_udid, max_mah, node)
unit = if max_mah, do: "mAh", else: "%"
IO.puts("")
IO.puts("Battery: #{format_battery(battery, max_mah)}")
if battery.pct < 80 do
IO.puts("WARNING: Battery below 80%. Charge to >90% for comparable results.")
IO.puts("Continue? (y/N)")
case IO.gets("") |> String.trim() do
"y" -> :ok
_ -> Mix.raise("Aborted.")
end
end
IO.puts("")
total_min = div(duration, 60)
start_b = read_battery_required(hw_udid, max_mah, node)
start_val = battery_value(start_b, max_mah)
start_time = System.monotonic_time(:second)
IO.puts("Start: #{format_battery(start_b, max_mah)}")
IO.puts("")
screen_locked =
if node do
IO.puts(">>> Step 1 of 2 — Unplug the USB cable (if connected), then press Enter.")
IO.gets("")
IO.puts("")
IO.puts(">>> Step 2 of 2 — Locking the screen now...")
result = lock_screen_auto(hw_udid)
IO.puts("")
result
else
IO.puts(">>> Unplug the USB cable (if connected), keep the screen ON, then press Enter.")
IO.puts(" (BEAM not connected — battery reads require USB or an active screen.)")
IO.gets("")
false
end
IO.puts("Running for #{div(duration, 60)} min...")
IO.puts("")
IO.puts("")
# ── Open CSV log unless --no-csv ───────────────────────────────────────
log =
if opts[:no_csv] do
nil
else
log_path =
opts[:log_path] ||
Path.join([
File.cwd!(),
"_build",
"bench",
"run_#{System.os_time(:second)}.csv"
])
IO.puts(" Logging samples to #{log_path}")
Logger.open(log_path, start_ts_ms: System.monotonic_time(:millisecond))
end
reconnector = Reconnector.new(node || :unset@unset, :mob_secret)
expected_screen = if screen_locked, do: :off, else: :on
# Subscribe to Mob.Device events on the device. If the app supports it,
# we'll get ground-truth screen + app-state events as they happen (via
# `:rpc.call(node, Mob.Device, :subscribe, ...)`). If not, the observer
# falls back to passing through `expected_screen`.
observer = DeviceObserver.subscribe(node, categories: [:app, :display, :memory])
if observer.subscribed? do
IO.puts(" Subscribed to Mob.Device events on #{inspect(node)}")
else
IO.puts(" (Mob.Device events not available — using expected screen state)")
end
{final_log, final_reconnector, _final_observer} =
Enum.reduce(1..duration, {log, reconnector, observer}, fn i,
{log_acc, recon_acc, obs_acc} ->
:timer.sleep(1000)
if rem(i, 10) == 0 do
poll_tick(
i,
log_acc,
recon_acc,
obs_acc,
node: node,
wifi_ip: opts[:wifi_ip],
hw_udid: hw_udid,
device_id: device_id,
app_pid: pid,
expected_screen: expected_screen,
start_time: start_time,
start_val: start_val,
unit: unit,
total_min: total_min
)
else
# Even on non-poll iterations, drain device events into the observer.
{log_acc, recon_acc, DeviceObserver.consume_messages(obs_acc)}
end
end)
log = final_log
_ = final_reconnector
# ── Results ────────────────────────────────────────────────────────────────
IO.puts("")
IO.puts("=== Collecting results ===")
if node, do: :rpc.call(node, :mob_nif, :background_stop, [], 5000)
if pid, do: terminate_app(udid, pid)
:timer.sleep(1000)
IO.puts(" Unlock the phone to read final battery level...")
end_b = read_battery_required(hw_udid, max_mah, node, 1, 60)
end_val = battery_value(end_b, max_mah)
drain = start_val - end_val
elapsed_actual = System.monotonic_time(:second) - start_time
rate =
if elapsed_actual > 0,
do: Float.round(drain * 3600 / elapsed_actual, 1),
else: 0.0
IO.puts("")
IO.puts("=== Summary: #{describe_mode(opts)} ===")
IO.puts("")
IO.puts(" Duration: #{div(elapsed_actual, 60)} min #{rem(elapsed_actual, 60)} sec")
IO.puts(" Start: #{format_battery(start_b, max_mah)}")
IO.puts(" End: #{format_battery(end_b, max_mah)}")
IO.puts(" Drain: #{Float.round(drain * 1.0, 1)} #{unit}")
IO.puts(" Rate: #{rate} #{unit}/hr")
if is_nil(max_mah) do
IO.puts("")
IO.puts("Note: BatteryMaxCapacity unavailable; showing percentage. 1% ≈ 40–60 mAh.")
end
IO.puts("")
# ── Optional: precise USB read for 1% resolution ─────────────────────
# iOS UIDevice.batteryLevel is clamped to 5% increments at the OS level
# (privacy measure). ideviceinfo over USB exposes the raw 1% reading
# via the battery domain. After a screen-off bench where the phone was
# unplugged, plug it back in here for a more precise final number.
precise_final_read(hw_udid)
# ── CSV-based summary (probes, reconnects, gap analysis) ─────────────
if log do
log_path = log.path
Logger.close(log)
IO.puts("=== Probe-based summary ===")
IO.puts("")
try do
metrics = Summary.from_csv(log_path)
IO.puts(Summary.pretty(metrics))
IO.puts("")
IO.puts("Full log: #{log_path}")
rescue
e ->
IO.puts(" (could not parse #{log_path}: #{Exception.message(e)})")
end
IO.puts("")
end
File.rm_rf!(derived_data)
end
# ── Probe-driven poll tick ────────────────────────────────────────────────
# One iteration of the polling loop (called every 10s). Takes a probe
# snapshot, prints a one-line trace, logs it, and runs the reconnector.
# Returns updated {log, reconnector, observer} for the next tick.
defp poll_tick(_iter, log, reconnector, observer, opts) do
elapsed_sec = System.monotonic_time(:second) - opts[:start_time]
elapsed_min = Float.round(elapsed_sec / 60, 1)
ts = time_string()
# Drain any buffered Mob.Device events first so the probe reflects
# ground-truth screen/app state.
observer = DeviceObserver.consume_messages(observer)
probe =
Probe.snapshot(
node: opts[:node],
host: opts[:wifi_ip] || derive_host_from_node(opts[:node]),
hw_udid: opts[:hw_udid],
device_id: opts[:device_id],
app_pid: opts[:app_pid],
expected_screen: opts[:expected_screen]
)
# Apply observer's authoritative screen/app state on top of the probe.
probe = DeviceObserver.apply_to_probe(observer, probe)
log = if log, do: Logger.append(log, probe), else: log
# Render the live line.
fragment = Probe.format(probe)
line =
case probe.battery_pct do
nil ->
" [#{ts}] #{elapsed_min}/#{opts[:total_min]} min — #{fragment}"
pct ->
drain = opts[:start_val] - pct
rate_str =
if elapsed_sec > 30 do
rate = Float.round(drain * 3600 / elapsed_sec, 1)
" @ #{rate} #{opts[:unit]}/hr"
else
""
end
" [#{ts}] #{elapsed_min}/#{opts[:total_min]} min — #{fragment} (−#{Float.round(drain * 1.0, 1)} #{opts[:unit]}#{rate_str})"
end
IO.puts(line)
# Reconnect logic — attempt Node.connect when in a recoverable state.
now_ms = System.monotonic_time(:millisecond)
reconnector =
case Reconnector.tick(reconnector, probe, now_ms) do
{:no_action, r} ->
r
{:attempt, r} ->
if opts[:node] && Node.connect(opts[:node]) do
IO.puts(
" ↻ reconnected to #{opts[:node]} (attempt #{r.attempts}, total #{r.total_reconnects + 1})"
)
Reconnector.record_success(r)
else
r
end
end
{log, reconnector, observer}
end
# Optional precise final-battery read via ideviceinfo over USB. iOS clamps
# UIDevice.batteryLevel to 5% increments — `99` rounds to `100`, hiding
# small drains that matter for benchmarking. ideviceinfo's battery domain
# exposes 1% precision when the device is connected via USB.
defp precise_final_read(nil), do: :ok
defp precise_final_read(hw_udid) when is_binary(hw_udid) do
IO.puts("iOS reports battery in 5% increments. For 1% precision: plug in USB now.")
IO.puts("Press Enter to read precise battery, or Ctrl-C to skip...")
case IO.gets("") do
:eof ->
:ok
{:error, _} ->
:ok
_ ->
if System.find_executable("ideviceinfo") do
case System.cmd(
"ideviceinfo",
["-u", hw_udid, "-q", "com.apple.mobile.battery"],
stderr_to_stdout: true
) do
{out, 0} ->
IO.puts("")
IO.puts("=== Precise battery (via ideviceinfo) ===")
fields =
out
|> String.split("\n", trim: true)
|> Enum.filter(fn line ->
String.starts_with?(line, [
"BatteryCurrentCapacity:",
"BatteryMaxCapacity:",
"BatteryIsCharging:",
"ExternalConnected:",
"FullyCharged:"
])
end)
Enum.each(fields, &IO.puts(" " <> &1))
{out, _} ->
IO.puts(" (ideviceinfo failed — is USB connected? trust this Mac?)")
IO.puts(" " <> String.trim(out))
end
else
IO.puts(" (ideviceinfo not installed — `brew install libimobiledevice`)")
end
IO.puts("")
end
end
defp derive_host_from_node(nil), do: nil
defp derive_host_from_node(node) when is_atom(node) do
case Atom.to_string(node) |> String.split("@", parts: 2) do
[_, host] -> host
_ -> nil
end
end
# ── Prerequisites ─────────────────────────────────────────────────────────────
defp check_prerequisites! do
unless System.find_executable("xcrun") do
Mix.raise("xcrun not found. Install Xcode command-line tools: xcode-select --install")
end
# ideviceinfo is needed for USB battery reads but not strictly required —
# WiFi mode falls back to Erlang distribution RPC. Warn but don't abort.
unless System.find_executable("ideviceinfo") do
IO.puts("""
Note: ideviceinfo not found (brew install libimobiledevice).
Battery readings will use Erlang distribution over WiFi instead.
""")
end
end
# ── Dry run ───────────────────────────────────────────────────────────────────
defp dry_run!(opts) do
cfg = MobDev.Config.load_mob_config()
pkg = MobDev.Config.bundle_id()
scheme =
opts[:scheme] || cfg[:ios_scheme] ||
case find_workspace() do
{:ok, {kind, path}} -> detect_scheme!(kind, path)
:error -> Macro.camelize(app_name())
end
duration = opts[:duration] || 1800
# Validate preset / flags (raises on bad preset name)
{cflags, header_dir} = resolve_build_flags(opts)
if header_dir, do: File.rm_rf!(header_dir)
IO.puts("")
IO.puts("=== Mob Battery Benchmark (iOS) — Dry Run ===")
IO.puts("")
IO.puts(" Device: #{opts[:device] || "(auto-detect at run time)"}")
IO.puts(" Bundle: #{pkg}")
IO.puts(" Scheme: #{scheme}")
IO.puts(" Duration: #{duration}s (#{div(duration, 60)} min)")
IO.puts(" Mode: #{describe_mode(opts)}")
IO.puts(" Flags: #{if cflags == "", do: "(default Nerves tuning)", else: cflags}")
IO.puts(" Build: #{if opts[:no_build], do: "skip (--no-build)", else: "yes"}")
IO.puts("")
IO.puts("Dry run complete — no prerequisites checked, no device contacted.")
IO.puts("")
end
# ── Build flags ──────────────────────────────────────────────────────────────
@doc false
@spec resolve_build_flags(keyword()) :: {String.t(), String.t() | nil}
def resolve_build_flags(opts) do
cond do
opts[:no_beam] ->
{"-DNO_BEAM", nil}
opts[:flags] ->
header_dir = Path.join(System.tmp_dir!(), "mob_bench_flags_#{System.os_time(:second)}")
File.mkdir_p!(header_dir)
flags_list = String.split(opts[:flags], Regex.compile!("\\s+"), trim: true)
c_literals = Enum.map_join(flags_list, ", ", &~s("#{&1}"))
header =
"/* generated by mix mob.battery_bench_ios -- do not edit */\n" <>
"#define BEAM_EXTRA_FLAGS #{c_literals},\n"
File.write!(Path.join(header_dir, "mob_beam_flags.h"), header)
{"-DBEAM_USE_CUSTOM_FLAGS -I#{header_dir}", header_dir}
opts[:preset] ->
flag =
case opts[:preset] do
"untuned" -> "-DBEAM_UNTUNED"
"sbwt" -> "-DBEAM_SBWT_ONLY"
"nerves" -> "-DBEAM_FULL_NERVES"
other -> Mix.raise("Unknown preset #{inspect(other)}. Choose: untuned, sbwt, nerves")
end
{flag, nil}
true ->
{"", nil}
end
end
@doc false
@spec describe_mode(keyword()) :: String.t()
def describe_mode(opts) do
cond do
opts[:no_beam] -> "no-beam (baseline)"
opts[:flags] -> "custom flags: #{opts[:flags]}"
opts[:preset] -> "preset: #{opts[:preset]}"
true -> "default (Nerves tuning)"
end
end
# ── Xcode build ──────────────────────────────────────────────────────────────
defp find_workspace! do
ios_dir = Path.join(File.cwd!(), "ios")
unless File.dir?(ios_dir), do: Mix.raise("ios/ directory not found in #{File.cwd!()}")
# Prefer .xcworkspace (CocoaPods/SPM), fall back to .xcodeproj.
# Exclude Provision.xcodeproj — that is a mob.provision stub with no BEAM.
workspaces = Path.wildcard(Path.join(ios_dir, "*.xcworkspace"))
projects =
ios_dir
|> Path.join("*.xcodeproj")
|> Path.wildcard()
|> Enum.reject(&(Path.basename(&1) == "Provision.xcodeproj"))
case {workspaces, projects} do
{[ws | _], _} ->
{:workspace, ws}
{[], [proj | _]} ->
{:project, proj}
_ ->
# Mob projects use ios/build.zig (mob's Mix-driven build) rather than an
# Xcode project. Physical device battery benchmarks require xcodebuild,
# which needs a .xcodeproj or .xcworkspace.
build_zig = Path.join(ios_dir, "build.zig")
if File.exists?(build_zig) do
Mix.raise("""
This project uses ios/build.zig (mob's build system), driven by Mix. \
Physical device battery benchmarks require an Xcode project file.
To run the battery benchmark:
1. Open the project in Xcode, select a physical device, and run once.
2. Then use --no-build to skip the build step and measure the installed app:
mix mob.battery_bench_ios --no-build
Alternatively, add an Xcode project to ios/ and re-run without --no-build.
""")
else
Mix.raise("No .xcworkspace or .xcodeproj found in ios/")
end
end
end
defp build_app(kind, path, scheme, other_cflags, derived_data) do
type_flag =
case kind do
:workspace -> ["-workspace", path]
:project -> ["-project", path]
end
# Build settings are passed as positional KEY=VALUE args to xcodebuild.
# $(inherited) is processed by xcodebuild itself, not the shell.
cflags_arg =
if other_cflags != "",
do: ["OTHER_CFLAGS=$(inherited) #{other_cflags}"],
else: []
args =
type_flag ++
[
"-scheme",
scheme,
"-configuration",
"Debug",
"-sdk",
"iphoneos",
"-derivedDataPath",
derived_data
] ++ cflags_arg ++ ["build"]
IO.puts(" Running xcodebuild (this may take a while)...")
case System.cmd("xcodebuild", args, stderr_to_stdout: true, into: IO.stream()) do
{_, 0} -> :ok
{_, _} -> Mix.raise("xcodebuild failed — check output above")
end
products_dir = Path.join(derived_data, "Build/Products/Debug-iphoneos")
case Path.wildcard(Path.join(products_dir, "*.app")) do
[app | _] ->
IO.puts(" Built: #{Path.basename(app)}")
app
[] ->
Mix.raise("Built app not found in #{products_dir}. xcodebuild may have failed silently.")
end
end
defp install_app!(udid, app_path) do
IO.puts(" Installing #{Path.basename(app_path)}...")
case System.cmd(
"xcrun",
["devicectl", "device", "install", "app", "--device", udid, app_path],
stderr_to_stdout: true
) do
{_, 0} ->
:ok
{out, _} ->
Mix.raise("App install failed: #{String.trim(out)}")
end
end
# ── App lifecycle ─────────────────────────────────────────────────────────────
# Returns the process PID (integer) or nil if it couldn't be parsed.
defp launch_app!(udid, bundle_id) do
case System.cmd(
"xcrun",
[
"devicectl",
"device",
"process",
"launch",
"--terminate-existing",
"--device",
udid,
bundle_id
],
stderr_to_stdout: true
) do
{out, 0} ->
case Regex.run(Regex.compile!("\\bprocess identifier\\s+(\\d+)", "i"), out) ||
Regex.run(Regex.compile!("\\bpid[:\\s]+(\\d+)", "i"), out) ||
Regex.run(Regex.compile!("\\b(\\d{4,6})\\b"), out) do
[_, pid_str] ->
String.to_integer(pid_str)
nil ->
IO.puts(" App launched (could not parse PID from: #{String.trim(out)})")
nil
end
{out, _} ->
Mix.raise("Failed to launch #{bundle_id}: #{String.trim(out)}")
end
end
defp terminate_app(udid, pid) when is_integer(pid) do
case System.cmd(
"xcrun",
[
"devicectl",
"device",
"process",
"terminate",
"--device",
udid,
"--pid",
to_string(pid)
],
stderr_to_stdout: true
) do
{_, 0} ->
:ok
{out, _} ->
# Non-fatal — app may have already exited
IO.puts(" terminate warning: #{String.trim(out)}")
end
end
# ── Screen lock ──────────────────────────────────────────────────────────────
# Returns true when the screen is locked (either automatically or by user).
defp lock_screen_auto(nil) do
IO.puts(" No hardware UDID — please lock the phone now.")
IO.puts(" Press Enter once the screen is locked.")
IO.gets("")
true
end
defp lock_screen_auto(udid) do
case System.cmd("idevicediagnostics", ["-u", udid, "sleep"], stderr_to_stdout: true) do
{_, 0} ->
IO.puts(" Screen locked.")
:timer.sleep(1000)
true
_ ->
IO.puts(" Auto-lock failed — please lock the phone manually.")
IO.puts(" Press Enter once the screen is locked.")
IO.gets("")
true
end
end
# ── Battery readings ─────────────────────────────────────────────────────────
# Establish Erlang distribution to the running app on the device.
# Returns the node atom if connected, nil otherwise.
#
# Retries up to 5 times, sleeping ~2 s between attempts so the device-side
# BEAM has time to start, register in EPMD, and accept connections after
# the app launch. Each attempt logs its outcome (no device / connect false /
# connect ignored) so failures are diagnosable without re-running the bench.
#
# Once a device is discovered, subsequent attempts skip the discovery
# cascade and just retry `Node.connect` against the same node — the
# discovery itself can take 3–5 s on iOS (devicectl + ARP + EPMD scan)
# which would chew through the retry budget if repeated each iteration.
#
# device_id: CoreDevice UUID — used to resolve the phone's IP via
# `xcrun devicectl` so we can query EPMD directly without relying on
# the ARP cache (which may not have the WiFi IP when all prior
# communication was over USB).
@max_connect_attempts 5
@connect_retry_sleep_ms 2_000
# If we connected to a non-loopback iOS device (i.e. a physical iPhone over
# WiFi) without the user passing --wifi-ip, suggest passing it next run.
# Discovery via devicectl + ARP/EPMD scan takes 5–15 s on a cold network;
# `--wifi-ip` skips it. Loopback nodes (simulators, sub-second discovery)
# don't get the hint — there's nothing to skip.
defp hint_wifi_ip(_node, wifi_ip) when is_binary(wifi_ip), do: :ok
defp hint_wifi_ip(node, _wifi_ip) when is_atom(node) do
case node |> Atom.to_string() |> String.split("@", parts: 2) do
[_, host] when host != "127.0.0.1" and host != "localhost" ->
IO.puts(
" #{IO.ANSI.faint()}tip: pass `--wifi-ip #{host}` next time to skip discovery#{IO.ANSI.reset()}"
)
_ ->
:ok
end
end
# When discovery fails completely and the user didn't pass --wifi-ip, tell
# them the option exists. `mix mob.devices` shows the IP after a successful
# deploy, so the user has a way to learn it; we just need to point at the
# flag.
defp hint_wifi_ip_on_failure(wifi_ip) when is_binary(wifi_ip), do: :ok
defp hint_wifi_ip_on_failure(_) do
IO.puts(
" #{IO.ANSI.faint()}tip: if you know the iPhone's WiFi IP, pass `--wifi-ip <ip>` to#{IO.ANSI.reset()}"
)
IO.puts(
" #{IO.ANSI.faint()} skip discovery entirely. `mix mob.devices` prints it after a deploy.#{IO.ANSI.reset()}"
)
end
defp connect_beam_node(device_id, explicit_wifi_ip) do
case Node.start(:"mob_bench@127.0.0.1", :longnames) do
{:ok, _} -> Node.set_cookie(:mob_secret)
{:error, {:already_started, _}} -> :ok
_ -> :ok
end
# If the user passed --wifi-ip, warm the ARP cache with a quick ping so
# the subsequent EPMD probe doesn't fail on a cold ARP lookup.
if is_binary(explicit_wifi_ip) do
System.cmd("ping", ["-c", "1", "-W", "1000", explicit_wifi_ip], stderr_to_stdout: true)
end
expected_node_prefix = "#{app_name()}_ios"
do_connect_attempts(device_id, explicit_wifi_ip, expected_node_prefix, _cache = nil, 1)
end
defp do_connect_attempts(_device_id, _wifi_ip, _expected_prefix, _cache, attempt)
when attempt > @max_connect_attempts,
do: nil
defp do_connect_attempts(device_id, wifi_ip, expected_prefix, cache, attempt) do
device = cache || discover_ios_device(device_id, wifi_ip, expected_prefix)
cond do
is_nil(device) ->
IO.puts(
" attempt #{attempt}/#{@max_connect_attempts}: no device discovered " <>
"(devicectl + ARP + EPMD scan all empty)"
)
sleep_unless_last(attempt)
do_connect_attempts(device_id, wifi_ip, expected_prefix, nil, attempt + 1)
true ->
Node.set_cookie(device.node, :mob_secret)
case Node.connect(device.node) do
true ->
device.node
false ->
IO.puts(
" attempt #{attempt}/#{@max_connect_attempts}: found #{device.serial} at " <>
"#{device.host_ip || "?"} (#{device.node}) but Node.connect returned false " <>
"(BEAM not yet ready or cookie mismatch)"
)
sleep_unless_last(attempt)
# Keep the device cached — likely the BEAM just isn't up yet.
do_connect_attempts(device_id, wifi_ip, expected_prefix, device, attempt + 1)
:ignored ->
# Local node isn't alive (couldn't start it) — retrying won't help.
IO.puts(
" attempt #{attempt}/#{@max_connect_attempts}: Node.connect returned :ignored — " <>
"local node never started, aborting retries"
)
nil
end
end
end
defp sleep_unless_last(attempt) do
if attempt < @max_connect_attempts, do: :timer.sleep(@connect_retry_sleep_ms)
end
# Three-stage discovery cascade. Returns the first %Device{} matching the
# current project's expected node-name prefix (`<app>_ios`) or nil. Stages
# run only as far as needed; ARP/EPMD scan is the slowest so it's last.
#
# The prefix filter matters because `MobDev.Discovery.IOS.list_physical/0`
# scans EPMD and ARP and can return false positives — e.g. an Android phone
# at 10.0.0.17 happens to have a stale `mob_qa_ios_*` EPMD entry tunneled
# via adb-reverse, which the iOS-name regex matches. Without filtering, the
# bench would happily try to connect to that and fail every retry. With the
# filter, we'll only accept nodes whose name actually corresponds to the
# app being benched.
defp discover_ios_device(device_id, explicit_wifi_ip, expected_prefix) do
explicit_match =
if is_binary(explicit_wifi_ip),
do: MobDev.Discovery.IOS.find_physical_at(explicit_wifi_ip)
devicectl_match =
explicit_match ||
with ip when is_binary(ip) <- device_ip_from_devicectl(device_id) do
MobDev.Discovery.IOS.find_physical_at(ip)
end
devicectl_match ||
MobDev.Discovery.IOS.list_physical()
|> Enum.find(fn d ->
d.host_ip && node_matches_prefix?(d.node, expected_prefix)
end)
end
# Match `<expected_prefix>@<host>` (with optional `_<udid>` segment for
# simulators that disambiguate by booted UDID, e.g. `mob_qa_ios_78354490`).
# `test_nif_ios` therefore accepts `test_nif_ios@10.0.0.120` *and*
# `test_nif_ios_<udid>@127.0.0.1` (sim) but rejects `mob_qa_ios_*@anything`.
@doc false
@spec node_matches_prefix?(node() | nil, String.t()) :: boolean()
def node_matches_prefix?(nil, _prefix), do: false
def node_matches_prefix?(node, prefix) when is_atom(node) and is_binary(prefix) do
name = Atom.to_string(node) |> String.split("@", parts: 2) |> hd()
name == prefix or String.starts_with?(name, prefix <> "_")
end
# Returns the device's IP by extracting its mDNS hostname from xcrun devicectl
# output and resolving it. Falls back to nil if devicectl is unavailable or
# the device isn't found.
defp device_ip_from_devicectl(nil), do: nil
defp device_ip_from_devicectl(device_id) do
tmp = Path.join(System.tmp_dir!(), "mob_bench_devlist_#{System.os_time(:millisecond)}.json")
try do
case System.cmd("xcrun", ["devicectl", "list", "devices", "--json-output", tmp],
stderr_to_stdout: true
) do
{_, 0} ->
conn =
tmp
|> File.read!()
|> Jason.decode!()
|> get_in(["result", "devices"])
|> List.wrap()
|> Enum.find_value(fn dev ->
if dev["identifier"] == device_id, do: dev["connectionProperties"]
end)
tunnel_ip = conn && conn["tunnelIPAddress"]
# CoreDevice sometimes reports an IPv6 tunnel address (fd7f::/16 range).
# Erlang EPMD doesn't listen on IPv6, so skip those and fall through to
# hostname resolution which gives us the IPv4 WiFi address.
ipv4_tunnel =
if is_binary(tunnel_ip) && not String.contains?(tunnel_ip, ":"),
do: tunnel_ip,
else: nil
cond do
is_nil(conn) ->
nil
is_binary(ipv4_tunnel) ->
ipv4_tunnel
true ->
hostname =
conn["localHostnames"]
|> List.wrap()
|> List.first()
case hostname && :inet.gethostbyname(String.to_charlist(hostname)) do
{:ok, {:hostent, _, _, :inet, 4, [addr | _]}} ->
addr |> Tuple.to_list() |> Enum.join(".")
_ ->
nil
end
end
_ ->
nil
end
rescue
_ -> nil
after
File.rm(tmp)
end
end
# Returns the max design capacity in mAh, or nil if unavailable.
# Only readable via USB (ideviceinfo); WiFi RPC returns percentage only.
defp read_max_capacity_mah(udid) do
case ideviceinfo(udid, "BatteryMaxCapacity") do
{:ok, val} ->
case Integer.parse(val) do
{mah, _} when mah > 0 -> mah
_ -> nil
end
_ ->
nil
end
end
# Returns integer % or nil.
# Tries USB (ideviceinfo) first; falls back to Erlang RPC (mob_nif:battery_level/0)
# when USB is unavailable — e.g. screen locked with iOS USB restriction, or
# cable unplugged entirely.
defp read_battery_pct(udid, node) do
usb_result =
if System.find_executable("ideviceinfo") do
case ideviceinfo(udid, "BatteryCurrentCapacity") do
{:ok, val} ->
case Integer.parse(val) do
{n, _} when n >= 0 -> n
_ -> nil
end
{:error, _} ->
nil
end
end
usb_result || rpc_battery_level(node)
end
defp rpc_battery_level(nil), do: nil
defp rpc_battery_level(node) do
# Reconnect if the dist connection dropped (WiFi flap while screen locked).
unless node in Node.list(), do: Node.connect(node)
case :rpc.call(node, :mob_nif, :battery_level, [], 5000) do
n when is_integer(n) and n >= 0 -> n
_ -> nil
end
end
# Like read_battery but retries up to max_attempts times (2s apart) before raising.
defp read_battery_required(udid, max_mah, node),
do: read_battery_required(udid, max_mah, node, 1, 5)
defp read_battery_required(udid, max_mah, node, attempt, max_attempts) do
case read_battery_pct(udid, node) do
nil when attempt < max_attempts ->
:timer.sleep(2000)
read_battery_required(udid, max_mah, node, attempt + 1, max_attempts)
nil ->
device_str = if udid, do: "device #{udid}", else: "device"
Mix.raise(
"Could not read battery from #{device_str} after #{attempt} attempts.\n" <>
" USB: no hardware UDID available (idevice_id -l returned nothing).\n" <>
" WiFi: BEAM not reachable — ensure the app is running and on the same network.\n" <>
" Run `mix mob.connect --no-iex` to verify the node is visible."
)
pct ->
mah = if max_mah, do: round(max_mah * pct / 100), else: nil
%{pct: pct, mah: mah}
end
end
defp ideviceinfo(nil, _key), do: {:error, :no_hardware_udid}
defp ideviceinfo(udid, key) do
case System.find_executable("ideviceinfo") &&
System.cmd("ideviceinfo", ["-u", udid, "-q", @battery_domain, "-k", key],
stderr_to_stdout: true
) do
{out, 0} ->
val = String.trim(out)
if val == "", do: {:error, "empty"}, else: {:ok, val}
{out, _} when is_binary(out) ->
{:error, String.trim(out)}
_ ->
{:error, "ideviceinfo not available"}
end
end
# Returns the value we actually track: mAh if available, pct otherwise.
defp battery_value(%{mah: mah}, _max_mah) when is_integer(mah), do: mah
defp battery_value(%{pct: pct}, _max_mah), do: pct
defp format_battery(%{mah: mah, pct: pct}, _max_mah) when is_integer(mah),
do: "#{mah} mAh (#{pct}%)"
defp format_battery(%{pct: pct}, _max_mah),
do: "#{pct}%"
# ── Device detection ─────────────────────────────────────────────────────────
defp auto_detect_usb do
case System.find_executable("idevice_id") &&
System.cmd("idevice_id", ["-l"], stderr_to_stdout: true) do
{out, 0} ->
out
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> List.first()
_ ->
nil
end
end
# Finds the CoreDevice UUID of a connected (including WiFi-paired) iOS device
# via `xcrun devicectl list devices --json-output`.
defp auto_detect_wifi do
tmp = Path.join(System.tmp_dir!(), "mob_devicectl_list_#{System.os_time(:millisecond)}.json")
try do
case System.cmd("xcrun", ["devicectl", "list", "devices", "--json-output", tmp],
stderr_to_stdout: true
) do
{_, 0} ->
tmp
|> File.read!()
|> Jason.decode!()
|> get_in(["result", "devices"])
|> List.wrap()
|> Enum.find_value(fn dev ->
state =
get_in(dev, ["connectionProperties", "tunnelState"]) ||
get_in(dev, ["connectionProperties", "transportType"])
if state not in [nil, "unavailable"] do
dev["identifier"]
end
end)
_ ->
nil
end
rescue
_ -> nil
after
File.rm(tmp)
end
end
defp device_ok?(udid) do
# Try USB (ideviceinfo), then devicectl (works over WiFi for paired devices)
usb_ok =
System.find_executable("ideviceinfo") &&
match?(
{_, 0},
System.cmd("ideviceinfo", ["-u", udid, "-k", "DeviceName"], stderr_to_stdout: true)
)
usb_ok || devicectl_ok?(udid)
end
defp devicectl_ok?(identifier) do
tmp = Path.join(System.tmp_dir!(), "mob_devicectl_check_#{System.os_time(:millisecond)}.json")
try do
case System.cmd("xcrun", ["devicectl", "list", "devices", "--json-output", tmp],
stderr_to_stdout: true
) do
{_, 0} ->
tmp
|> File.read!()
|> Jason.decode!()
|> get_in(["result", "devices"])
|> List.wrap()
|> Enum.any?(fn dev ->
dev["identifier"] == identifier &&
get_in(dev, ["connectionProperties", "tunnelState"]) not in [nil, "unavailable"]
end)
_ ->
false
end
rescue
_ -> false
after
File.rm(tmp)
end
end
# ── Misc ─────────────────────────────────────────────────────────────────────
# Query xcodebuild -list to find the scheme name instead of guessing from the
# Elixir app name — the Xcode project/scheme name often differs from the Mix
# app atom (e.g. project "Provision" scheme "MobProvision" vs app :smoke_test).
defp detect_scheme!(workspace_kind, workspace_path) do
type_flag =
case workspace_kind do
:workspace -> ["-workspace", workspace_path]
:project -> ["-project", workspace_path]
end
case System.cmd("xcodebuild", type_flag ++ ["-list"], stderr_to_stdout: true) do
{output, 0} ->
schemes =
output
|> String.split("\n")
|> Enum.drop_while(&(not String.contains?(&1, "Schemes:")))
|> Enum.drop(1)
|> Enum.take_while(&String.match?(&1, Regex.compile!("^\\s+\\S")))
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
camelized = Macro.camelize(app_name())
case schemes do
[] ->
Mix.raise(
"No schemes found in #{Path.basename(workspace_path)}. " <>
"Use --scheme NAME or set ios_scheme in mob.exs."
)
[single] ->
single
multiple ->
if camelized in multiple do
camelized
else
Mix.raise("""
Multiple schemes found in #{Path.basename(workspace_path)}:
#{Enum.join(multiple, "\n ")}
Use --scheme NAME to select one, or add to mob.exs:
config :mob_dev, ios_scheme: "YourSchemeName"
""")
end
end
{output, _} ->
fallback = Macro.camelize(app_name())
IO.puts(" (warning: xcodebuild -list failed, assuming scheme \"#{fallback}\")")
IO.puts(" #{String.trim(output)}")
fallback
end
end
# Non-raising variant used by dry_run! where ios/ may not exist.
defp find_workspace do
ios_dir = Path.join(File.cwd!(), "ios")
if File.dir?(ios_dir) do
workspaces = Path.wildcard(Path.join(ios_dir, "*.xcworkspace"))
projects = Path.wildcard(Path.join(ios_dir, "*.xcodeproj"))
case {workspaces, projects} do
{[ws | _], _} -> {:ok, {:workspace, ws}}
{[], [proj | _]} -> {:ok, {:project, proj}}
_ -> :error
end
else
:error
end
end
defp app_name, do: Mix.Project.config()[:app] |> to_string()
defp time_string do
{{_y, _mo, _d}, {h, m, s}} = :calendar.local_time()
:io_lib.format("~2..0B:~2..0B:~2..0B", [h, m, s]) |> IO.iodata_to_binary()
end
end