mob_dev

Development tooling for Mob — the BEAM-on-device mobile framework for Elixir.

Hex.pm

Installation

Add to your project's mix.exs (dev only):

def deps do
[
{:mob_dev, "~> 0.2", only: :dev}
]
end

Mix tasks

Task Description
mix mob.new APP_NAME Generate a new Mob project (see mob_new archive)
mix mob.adopt Install Mob into an existing Phoenix project (Igniter-based; composes mob.adopt.{deps,bridge,screen,mob_app,mob_exs,native,finalize}). The install-into-existing counterpart to mix mob.new
mix mob.install First-run setup: download OTP runtime, generate icons, write mob.exs
mix mob.deploy Compile and push BEAMs to one selected emulator/simulator
mix mob.deploy --native Also build and install the native APK/iOS app
mix mob.deploy --slim Same, but with the App Store strip pass applied (slow, lets you verify a slim build before TestFlight — see guides/slim_release.md)
mix mob.release Build a signed .ipa / .aab for App Store / TestFlight / Play Store (slim by default)
mix mob.release --security-gate Same, but runs mix mob.security_scan first and aborts on any critical/high/medium finding (details)
mix mob.audit_otp Reachability audit of the bundled OTP runtime (find strip candidates)
mix mob.security_scan Scan for known CVEs across every surface — Hex, Gradle, Swift, bundled OpenSSL/OTP/SQLite, C/Kotlin/Swift source (details)
mix mob.security_scan.log Scheduled-run wrapper: writes SECURITY_SCAN.md + appends to SECURITY_HISTORY.md for cron / GitHub Actions (details)
mix mob.connect Tunnel + restart + open IEx connected to device nodes (--no-restart to attach to a running app, --name for multiple sessions)
mix mob.watch Auto-push BEAMs on file save
mix mob.watch_stop Stop a running mix mob.watch
mix mob.devices List connected devices and their status
mix mob.attest Prove a device is running the code you just pushed — compares module digests, not artifacts (see below)
mix mob.smoke Replay recorded agent-device UI flows on devices and check the app's own diagnostics held up (see below)
mix mob.mutate Mutation-test the lines this branch changed: break the code on purpose and report what nothing noticed (see below)
mix mob.push Hot-push only changed modules (no restart)
mix mob.enable <feature>... Wire up an optional Mob feature — platform-manifest entries, Elixir stubs, dep injections (see below)
mix mob.add_nif <name> Scaffold a statically-linked NIF — Elixir stub + mob.exs :static_nifs append + optional native skeleton (see below)
mix mob.regen_driver_tab Regenerate priv/generated/driver_tab_{ios,android}.zig from mob.exs's :static_nifs (default; pass --format c for the hand-editable C variant; composed automatically into mob.add_nif)
mix mob.server Start the dev dashboard at localhost:4040
mix mob.icon Regenerate app icons
mix mob.routes Validate navigation destinations across the codebase
mix mob.battery_bench_android Measure BEAM idle power draw on an Android device
mix mob.battery_bench_ios Measure BEAM idle power draw on a physical iOS device

Dev dashboard (mix mob.server)

mix mob.server starts a local Phoenix server (default port 4040) with:

Run with IEx for an interactive terminal alongside the dashboard:

iex -S mix mob.server

Watch mode

Click Watch in the dashboard header or control it programmatically:

MobDev.Server.WatchWorker.start_watching()
MobDev.Server.WatchWorker.stop_watching()
MobDev.Server.WatchWorker.status()
#=> %{active: true, nodes: [:"my_app_ios@127.0.0.1"], last_push: ~U[...]}

Watch events broadcast on "watch" PubSub topic:

{:watch_status, :watching | :idle}
{:watch_push, %{pushed: [...], failed: [...], nodes: [...], files: [...]}}

Hot-push transport (mix mob.deploy)

The task resolves its target set once before compiling. With no target flag it automatically selects exactly one emulator or simulator and never selects a physical device. Use --device <id> for one explicit target, --all-devices for every emulator/simulator, or --all-physical for attached phones. Combining the two broad flags selects every connected device. ANDROID_SERIAL has the same single-target effect as --device for Android; an explicit CLI scope takes precedence.

When Erlang distribution is reachable, mix mob.deploy hot-pushes changed BEAMs in-place via RPC — no adb push, no app restart. The running modules are replaced exactly like nl/1 in IEx.

Pushing 14 BEAM file(s) to 2 device(s)...
Pixel_7_API_34 → pushing... ✓ (dist, no restart)
iPhone 15 Pro → pushing... ✓ (dist, no restart)

If dist is not reachable (first deploy, app not running), it falls back to adb push + restart. Mixed deploys work — one device can hot-push while another restarts.

Requirements: The app must start development distribution. Every launch and attach command shares one private cookie per app, kept in ~/.mob/dist_cookies/ and handed to the app at deploy/connect time. An app built against a mob from before MOB-49 still uses the public mob_secret; the tasks fall back to it with a warning until it is redeployed. Pass --cookie only for an app that sets a custom cookie.

Did that deploy actually land? (mix mob.attest)

mix mob.deploy reports what it did. It does not report what is now true, and the two come apart more often than the exit code suggests.

Two real cases: a bundle-id divergence sent the BEAM push into one app's container while a different app was running — it succeeded and printed a tick, because both containers existed on the device. And a plain dist deploy reported success while twelve modules on the device kept their old code.

mix mob.connect --no-iex # set up the tunnel
mix mob.attest # compare the device against this build
mix mob.attest --json # machine-readable, for CI or an agent
mob_plugin_demo_ios_8a4250e9@127.0.0.1: 55 match, 12 stale, 0 not loaded, 0 unreadable
stale: Mob.Renderer
12 module(s) on the device do not match this build. The app is running code
you did not just push.

It compares module_info(:md5) on the device against :beam_lib.md5/1 of the local .beam — the same digest for the same bytes. Deliberately not an artifact hash: two builds of the same source differ in timestamps and paths, so that would report a mismatch on every rebuild, and a check that cries wolf gets switched off.

Exits non-zero when a module differs, and also when the check could not run — a check that could not run is not a check that passed. Modules the device has not loaded yet are reported and are not a failure: interactive BEAM loads a module when something first calls it, so most of a bundle is legitimately unloaded at any moment.

Exit status and argument handling

mix mob.deploy exits non-zero when:

A --native run that built the artifact and found no device to push it to still exits 0: "build the APK now, attach the phone after" is a legitimate workflow.

Unrecognised options and stray arguments are refused rather than ignored. mix mob.deploy -d <udid> previously deployed to every connected device — -d was not an alias here — and a typo'd --devcie did the same, silently. So did mix mob.deploy --native ABC123, a natural fumble of --device: it parsed cleanly and deployed everywhere. All three now fail and name what was wrong, distinguishing an unrecognised flag from a recognised one with a bad value. -d is aliased to --device, matching mix mob.connect.

--beam-flags accepts both spellings. OptionParser will not consume a dash-prefixed argument as a value, so mix mob.deploy joins them before parsing:

mix mob.deploy --beam-flags "-S 4:4 -A 4"
mix mob.deploy --beam-flags="-S 4:4 -A 4"

--json prints a machine-readable result on stdout: an outcome mirroring the exit code, a message, and the deployed, failed and skipped devices with their per-device reasons. Progress goes to stderr, so mix mob.deploy --json | jq receives exactly one document.

Smoke flows on devices (mix mob.smoke)

mix mob.smoke replays the .ad flows in smoke/ on each selected device through the agent-device CLI (npm i -g agent-device), and reads Mob.Diag.health/0 over dist before the first flow and after each one. The app must already be installed and running.

mix mob.smoke # the one connected emulator/simulator
mix mob.smoke --device emulator-5554 --retries 1
mix mob.smoke --all-devices --junit _build/smoke.xml # smoke-<device>-<flow>.xml each

A device fails on any failed or not-run flow, on a store whose lost or resets rose during a flow, or on new undeliverable listener events. No new receipts during a flow is a warning: its taps did not reach this app (mob 0.9.7 or later, where native taps get receipts; on older mob it is a note). A device another agent-device session holds is skipped, named with that session and workspace, and fails the run. --retries (default 0) is always passed to agent-device, so it overrides a script's own context retries=.

The counters belong to the app's BEAM, and a flow that opens with --relaunch boots a new one. So each flow is its own agent-device test run (artifacts in _build/mob_smoke/<device>/<flow>/), the task waits up to 15 s for the relaunched app's node, and a reading from a new BEAM is compared from zero. A node that does not come back is a warning naming the flow. --no-health skips the comparison and runs the flows as one suite; on mob older than 0.9.5 the comparison is skipped with a note.

Record a flow by hand. --save-script needs an absolute path: a relative one is resolved by the agent-device daemon, not your shell's directory, and the script lands somewhere else. --relaunch starts the flow from a fresh app, and wait text steps are the assertions:

agent-device open com.example.my_app --relaunch --serial emulator-5554 \
--session rec --save-script "$PWD/smoke/login.ad"
agent-device snapshot -i --session rec # element refs
agent-device click @e3 --session rec
agent-device wait text "Welcome" --session rec
agent-device close --session rec --save-script

Use --udid <udid> instead of --serial for iOS.

Flows recorded on an iOS simulator: delete the identity lines. The recording writes a # agent-device:target-v1 {…} line above each tap: the element's identity (role, id, label, the containers above it, its position among siblings), which replay checks before tapping. On a simulator agent-device records it from its accessibility bridge but checks it on replay against an XCTest snapshot, and inside a SwiftUI ScrollView the two trees differ (XCTest adds a content container labeled with its first child's text). Every such tap then fails with REPLAY_DIVERGENCE … nothing in the current tree carries the recorded identity, though the selector still matches. agent-device has no flag to change either source (checked on 0.21.1 and 0.21.19), so strip the lines after recording:

sed -i '' '/^# agent-device:target-v1 /d' smoke/login.ad

A step without the line is resolved by its selector alone, which agent-device documents as a normal replay. What is lost is the check that the element the selector finds is the one recorded, for every step in the flow. A selector leads with the element's id when that id is unique on screen (mob's tap tags, id="open_dice", usually are); otherwise it falls back to role and label, so check those steps' selectors by eye. mix mob.smoke prints the command, with the flow's path, when a simulator flow fails the identity check. Android flows keep their lines. A physical iPhone is read through XCTest at record and replay alike, so it should not hit this (not yet tried on one).

Android and mobile-mcp. Android allows one UiAutomation client. While mobile-mcp's com.mobilenext.mobilecli.DeviceServer runs on the phone, every agent-device snapshot fails with "Android snapshot helper output could not be parsed". mix mob.smoke names the fix in its report but does not run it, since mobile-mcp may be in use:

adb -s <serial> shell pkill -f mobilecli.DeviceServer

A physical iPhone. agent-device drives it through its own XCTest runner, which must be signed for your team. Its daemon reads AGENT_DEVICE_IOS_TEAM_ID and AGENT_DEVICE_IOS_BUNDLE_ID (plus AGENT_DEVICE_IOS_PROVISIONING_PROFILE if Xcode cannot pick one) when it starts, not per command, so export them before the first agent-device call or run a separate daemon with its own AGENT_DEVICE_STATE_DIR. Signing needs an Apple account in Xcode (automatic) or a manually managed development profile for that bundle id and <id>.uitests: an Xcode-managed wildcard team profile is rejected. Simulators and Android need none of this.

Do the tests guard anything? (mix mob.mutate)

A green suite says the tests ran, not that they guard anything. This changes the production code one line at a time, runs the suite, and reports the changes nothing noticed.

mix mob.mutate # lines this branch changed
mix mob.mutate --base main # diff base (default: origin/master)
mix mob.mutate --file lib/foo.ex # every mutable line in a file; repeatable
mix mob.mutate --max 20 # bound a first run
mix mob.mutate --test-command "mix test test/foo_test.exs"
mix mob.mutate --json

Every mutant runs the suite once, so a run costs roughly mutations × suite. Narrow it with --test-command and bound it with --max before pointing it at a large diff. --base defaults to origin/master; on a repo whose default branch is main, or a shallow CI checkout, pass it explicitly or the run fails rather than silently measuring nothing.

It exits non-zero when any mutation survived, so it can gate a change the way a failing test does.

89 killed, 6 survived, 57 did not build, 0 unmeasured
Nothing noticed these changes:
lib/foo.ex:188 delete: file: file,
lib/foo.ex:196 flip comparison: if count == 0 do
…

Three operators: delete the line, flip a boolean, flip a comparison. Deletion is the blunt one and the most informative — it asks whether anything notices the line exists at all, which is the question a vacuous test fails.

A mutant that fails to compile is counted apart from a real kill — it died without any test noticing, so counting it as a win inflates the score. So is a run that could not be measured at all.

It rewrites real source files and restores them from memory, so an interrupted run leaves the last mutant on disk. It therefore refuses to start unless the files it would touch are clean in git, which makes git checkout -- <file> a complete recovery.

In default mode that means it will essentially always refuse until you commit or stash: the lines it targets are the ones you just changed. Commit first, then mutate.

Expect roughly a third of the mutants on idiomatic Elixir to land in "did not build": removing a def head or a middle segment of a pipeline is a syntax error, not a test failure.

mix mob.enable <feature>

Wires up an optional Mob feature in one command — platform-manifest entries, Elixir stubs, and dep injections, all rolled into a single Igniter diff that's shown before any file is touched. Multiple features in one invocation are fine; the diff covers all of them.

mix mob.enable camera # iOS Info.plist + Android <uses-permission>
mix mob.enable camera photo_library # multiple features in one diff
mix mob.enable file_sharing # iOS plist keys + Android FileProvider XML
mix mob.enable location # iOS plist + Android ACCESS_FINE_LOCATION
mix mob.enable notifications # creates ios/<app>.entitlements with aps-environment
mix mob.enable liveview # generates lib/<app>/mob_screen.ex + assets + mob.exs
mix mob.enable pythonx # adds :pythonx dep + generates <App>.PythonPaths

Per-feature surface:

Feature iOS Android Elixir
camera NSCameraUsageDescription in Info.plist <uses-permission android.permission.CAMERA> —
photo_library NSPhotoLibraryAddUsageDescription in Info.plist (none — API 29+ runtime-only) —
location NSLocationWhenInUseUsageDescription ACCESS_FINE_LOCATION permission —
file_sharing UIFileSharingEnabled + LSSupports… plist keys <provider FileProvider> + res/xml/file_provider_paths.xml —
notifications Creates ios/<app>.entitlements with aps-environment (runtime-only — request POST_NOTIFICATIONS) (none)
liveview (none) networkSecurityConfig allowing loopback Generates <App>.MobScreen; injects MobHook into assets/js/app.js + bridge element into root.html.heex; sets :liveview_port in mob.exs
python (handled by mob.deploy --native) (handled by mob.deploy --native) Adds {:pythonx, "~> 0.4"} to mix.exs via AST; generates <App>.PythonPaths

Idempotent — re-running with already-applied features is a no-op. Diff preview surfaces every change before commit; missing platform dirs (ios/, android/, assets/) become notices instead of silent skips.

mix mob.add_nif <name>

Scaffolds a statically-linked NIF in one command. Picks up the StaticNifs schema, drops native + Elixir templates appropriate to the chosen backend, appends the entry to mob.exs, and re-runs mix mob.regen_driver_tab so priv/generated/driver_tab_{ios,android}.zig reflects the new entry — all visible as a single Igniter diff before commit.

mix mob.add_nif audio_engine # default --type elixir-only (you write the C)
mix mob.add_nif audio_engine --type c # also drops c_src/audio_engine.c
mix mob.add_nif audio_engine --type zigler # use Zig (~Z sigil) — adds :zigler dep
mix mob.add_nif audio_engine --type rustler # use Rust — adds :rustler dep + native/audio_engine/ Cargo crate
mix mob.add_nif audio_engine --module MyApp.Audio # custom Elixir module name

Always created:

Conditional, per --type:

--type Extra files Hex deps added
elixir-only (default) none — you write the C and wire it yourself none
c c_src/<name>.c (skeleton with ERL_NIF_INIT) none
zigler none (Zig source lives inline in the stub via ~Z) :zigler ~> 0.15
rustler native/<name>/{Cargo.toml,src/lib.rs,.gitignore} :rustler ~> 0.32

For the contract per backend — how Rustler, Zigler, Pythonx normally work, what Mob changes for static linking, where the bundled Python runtime comes from on each platform, and which patches are transient — see guides/nifs.md. Read it before filing a "my NIF builds host-dev but not on device" issue; it probably answers the question.

Writing a plugin (mix mob.new_plugin)

mix mob.new_plugin my_plugin --tier 1 # 0 pure Elixir … 4 embedded sub-app

Tiers 1–4 ship a manifest (priv/mob_plugin.exs), and a host's native build verifies it: priv/mob_plugin.sig must be a v2 signature over every file the build reads from the plugin, made with the key in priv/mob_plugin.pub, and the host must trust that key. The scaffold sets up the release side of that:

File Role
.gitignore ignores priv/mob_plugin.sig; priv/mob_plugin.pub is committed
mix.exs package files: ship all of priv/; dev-only :mob_dev for the signing tasks; set @source_url
.github/workflows/release.yml on a version: bump: tag, GitHub Release, check that MOB_PLUGIN_SIGN_KEY derives the committed public key, mix mob.validate_plugin, mix mob.plugin.sign, mix hex.publish. Refuses to publish without the key

One-time setup in the plugin directory:

mix deps.get
mix mob.plugin.keygen # priv/mob_plugin.pub + ~/.mob/keys/my_plugin.priv
gh secret set MOB_PLUGIN_SIGN_KEY < ~/.mob/keys/my_plugin.priv
gh secret set HEX_API_KEY

mix mob.plugin.sign && mix mob.validate_plugin signs and checks locally (re-sign after every edit). A host app records trust once with mix mob.plugin.trust my_plugin. Tier 0 has no manifest and nothing to sign.

Navigation validation (mix mob.routes)

Validates all push_screen, reset_to, and pop_to destinations across lib/**/*.ex via AST analysis. Module destinations are verified with Code.ensure_loaded/1.

mix mob.routes # print warnings
mix mob.routes --strict # exit non-zero (for CI)
✓ 12 navigation reference(s) valid (2 dynamic/named skipped)
# On failure:
✗ 1 unresolvable navigation destination(s):
lib/my_app/home_screen.ex:42 push_screen(socket, MyApp.SettingsScren)
Module MyApp.SettingsScren could not be loaded.

Dynamic destinations (push_screen(socket, var)) and registered name atoms (:main) are skipped with a note.

Security scan (mix mob.security_scan)

Audits a Mob app for known CVEs across every surface a Mob app actually ships — including the bundled OpenSSL, OTP runtime, and SQLite that ordinary scanners can't see (because they're statically linked into the app binary, not declared in any lockfile).

What it scans

Layer Tool(s) Covers
hex_deps mix_audit + osv-scanner Hex dependencies in mix.lock
gradle_deps osv-scanner Android Gradle dependencies (when gradle.lockfile is enabled)
swift_deps osv-scanner iOS Package.resolved / Podfile.lock
bundled_runtime BundledVersions manifest + binary fingerprint OpenSSL, ERTS, Elixir, exqlite, SQLite baked into the OTP tarball — drift detection between the manifest and the actual binaries
c_source semgrep + flawfinder Mob's NIF C/Objective-C plus the exqlite NIF wrapper
kotlin_source detekt Kotlin/Java under android/app/src/main/
swift_source swiftlint Swift under ios/

The bundled-runtime layer is what makes this task interesting — it opens libcrypto.a from the cached OTP tarball and reads the OpenSSL version banner directly out of the static archive. Generic dep scanners can't do this because the OpenSSL version isn't in any lockfile. See priv/security/bundled_versions.exs for the manifest of what versions ship in each tarball.

Usage

mix mob.security_scan # full scan, pretty terminal output
mix mob.security_scan --json # machine-readable JSON to stdout
mix mob.security_scan --skip kotlin,c_source # skip named layers
mix mob.security_scan --strict # exit 1 if any high+ finding
mix mob.security_scan --write-report SECURITY_SCAN.md # also write a markdown report

One-time tool installs

Each layer soft-degrades when its scanner isn't installed. Install on macOS with:

brew install osv-scanner semgrep flawfinder detekt swiftlint

mix_audit is a Hex dependency of mob_dev; no separate install needed. The OpenSSL/SQLite/OTP fingerprinting is pure Elixir — no external strings(1) or similar required.

Scheduled changelog (mix mob.security_scan.log)

For "did we get better or worse this week?" you want a changelog, not a snapshot. mix mob.security_scan.log is the scheduled-run companion: each invocation writes three files at the project root:

File Purpose
SECURITY_SCAN.md Current-state snapshot (overwritten each run). The "what's the situation right now" file.
SECURITY_HISTORY.md Append-only changelog. Each run prepends one entry: timestamp, severity counts, and the New / Resolved / Still present delta against the previous run. Findings still present from earlier runs carry their first seen N days ago patch-lag suffix.
.security_scan/state.json Internal sidecar that records the last-known finding set + per-finding first_seen_at timestamps. Diff computation depends on it.

Commit all three. The state file is what makes the changelog meaningful across machines and CI runs — without it, every run reports every finding as "new" and the timeline loses signal.

A typical entry looks like:

## 2026-05-07T13:59:24Z
**Project:** `/path/to/app`
**Total findings:** 2 (0 critical, 2 high, ...)
### New since last scan (1)
- **HIGH** `mob/otp-tarball@ios_sim` `[MOB-DRIFT-ios_sim-elixir]` — manifest=1.19.5 binary=1.20.0-rc.4
### Resolved since last scan (1) ✓
- **HIGH** `phoenix@1.8.5` `[EEF-CVE-2026-32689]` — Long-poll NDJSON body splitting
### Still present from last scan (1)
- **CRITICAL** `openssl@3.4.0` ... _(first seen 22 days ago)_

Cron / GitHub Actions wiring

The task is designed for unattended invocation. A simple cron entry:

# daily at 06:00 local
0 6 * * * cd /path/to/project && mix mob.security_scan.log >> /tmp/security_scan.log 2>&1

A GitHub Actions workflow that opens a PR with the updated files:

name: security-scan
on:
schedule: [{cron: "0 6 * * *"}]
workflow_dispatch:
jobs:
scan:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
with: {elixir-version: "1.19", otp-version: "28"}
- run: brew install osv-scanner semgrep flawfinder detekt swiftlint
- run: mix deps.get
- run: mix mob.security_scan.log
- uses: peter-evans/create-pull-request@v6
with:
title: "security: weekly scan update"
branch: security-scan-update
add-paths: |
SECURITY_SCAN.md
SECURITY_HISTORY.md
.security_scan/state.json

Updating after rebuilding the OTP tarballs

When you rebuild the bundled OTP runtime (build_release.md), update the priv/security/bundled_versions.exs manifest to match the new versions baked into the tarball. The bundled-runtime scan fingerprints the cached binaries and emits a :high "drift" finding if the manifest disagrees with what's on disk — that's the exact failure mode the manifest exists to catch.

Battery benchmarks

Measure BEAM idle power draw with specific tuning flags. Both tasks share the same presets and flag interface.

Android (mix mob.battery_bench_android)

Deploys an APK and measures drain via the hardware charge counter (dumpsys battery). Reports mAh every 10 seconds. Uses the same probe / observer / CSV-log / preflight infrastructure as the iOS bench.

WiFi ADB required — a USB cable charges the device and skews measurements.

# One-time WiFi ADB setup (while plugged in):
adb -s SERIAL tcpip 5555
adb connect PHONE_IP:5555
# then unplug

Same pattern as iOS — push BEAM flags via mix mob.deploy, then bench with --no-build. Saves the Gradle rebuild (~30+ seconds) when only changing flags.

mix mob.deploy --beam-flags "" --android # tuned (Nerves)
mix mob.deploy --beam-flags "-S 4:4 -A 8" --android # untuned variant
mix mob.battery_bench_android --no-build --device 192.168.1.42:5555

The bench will:

Single-step Gradle path

Still supported when you want a clean rebuild:

mix mob.battery_bench_android # default: Nerves-tuned BEAM, 30 min
mix mob.battery_bench_android --no-beam # baseline: no BEAM at all
mix mob.battery_bench_android --preset untuned # raw BEAM, no tuning
mix mob.battery_bench_android --flags "-sbwt none -S 1:1"
mix mob.battery_bench_android --duration 3600 --device 192.168.1.42:5555
mix mob.battery_bench_android --no-build # re-run without rebuilding

Recovering from bad flags

mix mob.deploy --beam-flags "..." saves to mob.exs so the flags persist across runs. If a flag combination crashes the BEAM, every subsequent deploy re-applies them. Push an empty string to clear:

mix mob.deploy --beam-flags "" --android

iOS (mix mob.battery_bench_ios)

Deploys to a physical iPhone/iPad and reads battery via ideviceinfo (USB) or via Erlang RPC over WiFi. Reports mAh (if BatteryMaxCapacity is available) or percentage points.

Prerequisites: brew install libimobiledevice, Xcode 15+, device trusted on this Mac, phone on the same WiFi as the Mac.

For Mob projects (which use ios/build_device.sh rather than a full Xcode project), you can't rebuild + bench in one command — the bench task's built-in xcodebuild path doesn't support the Mob build system. Instead, do the two steps separately:

# Step 1 — deploy with whatever BEAM flags you want.
# This pushes the .beam files PLUS a runtime mob_beam_flags file that
# the launcher reads at startup. No native rebuild required (~5 seconds).
mix mob.deploy --beam-flags "" --ios # tuned (Nerves defaults)
mix mob.deploy --beam-flags "-S 6:6 -A 16" --ios # untuned variant
mix mob.deploy --ios # uses flags saved in mob.exs
# Step 2 — run the bench with --no-build, since we already deployed.
mix mob.battery_bench_ios --no-build --wifi-ip 10.0.0.120
mix mob.battery_bench_ios --no-build --wifi-ip 10.0.0.120 --duration 600
mix mob.battery_bench_ios --no-build --wifi-ip 10.0.0.120 --skip-preflight

Find your phone's WiFi IP in Settings → Wi-Fi → (i) → IP Address.

--wifi-ip is strongly recommended — without it the bench tries to auto-discover the device, which is flaky for WiFi-only setups (we've seen it pick up the Mac's own EPMD or simulator nodes).

What the bench shows you

A live trace per 10-second poll, with state per tick:

[02:33:00] 0.5/30 min — screen:off app:running rpc:ok battery:100% (−0.0 %)

A CSV log in _build/bench/run_<ts>.csv (every sample, every state).

A probe-based summary at the end with success rate, reconnect count, longest gap, time-by-state, screen-on/off durations, and taint warnings that catch invalid runs (screen turned on, app died, majority unreachable, flapping connection).

Recovering from bad flags

mix mob.deploy --beam-flags "..." saves the flags to mob.exs so they persist across runs. If a flag combination crashes the BEAM (e.g. requesting more threads than iOS allows per process), every subsequent mix mob.deploy re-applies the same bad flags and the app keeps crashing.

To recover, push an empty flags string — clears mob.exs and the runtime override file on every device:

mix mob.deploy --beam-flags "" --ios

Flag prefix convention (iOS)

The Mob iOS BEAM build is conservative about flag syntax. Match the compile-time defaults' format — - prefix, space-separated values:

-S 1:1 -SDcpu 1:1 -SDio 1 -A 1 -sbwt none ← compile-time defaults (Nerves)

When in doubt, copy that pattern. We've observed +S 6:6 +A 64 +SDio 8 crashing the BEAM at startup with no useful log line — likely because the combined thread count exceeds iOS's per-process limit. Build untuned configs incrementally:

# Smallest delta from defaults — multi-scheduler but everything else minimal:
mix mob.deploy --beam-flags "-S 2:2 -SDcpu 2:2 -SDio 2 -A 2" --ios
# Bench. If the app launches and runs, ramp up:
mix mob.deploy --beam-flags "-S 6:6 -SDcpu 6:6 -SDio 6 -A 8" --ios

Other options

mix mob.battery_bench_ios --no-build --wifi-ip 10.0.0.120 --no-keep-alive
# Skips the silent-audio keep-alive call. Use when the keep-alive NIF is
# misbehaving or you want to verify how much drain comes from background
# audio session vs the BEAM itself.
mix mob.battery_bench_ios --no-build --wifi-ip 10.0.0.120 --skip-preflight
# Bypass the pre-flight checks (useful when the checks are spuriously
# failing on devicectl noise or similar).
mix mob.battery_bench_ios --no-build --wifi-ip 10.0.0.120 --no-csv
# Don't write the CSV log (run is purely live-trace + final summary).
mix mob.battery_bench_ios --no-build --wifi-ip 10.0.0.120 --log-path /tmp/run.csv
# Override CSV location.

Presets and results

Preset Flags mAh/hr (Moto G, screen on, low brightness)
No BEAM — ~200
Nerves (default) -S 1:1 -SDcpu 1:1 -SDio 1 -A 1 -sbwt none ~202
Untuned (none) ~250

The Nerves-tuned BEAM is essentially indistinguishable from a stock Android app at idle. The untuned BEAM costs ~25% more because schedulers spin-wait instead of sleeping.

iOS results are tracked separately in mob/guides/why_beam.md (different device, different methodology — physical iPhone with screen on/off distinction). The --preset shortcuts (untuned/sbwt/nerves) aren't useful on iOS because they require a full Xcode rebuild (which Mob projects don't have), so on iOS you set flags via mix mob.deploy --beam-flags ... and bench with --no-build.

Battery-read precision (iOS)

iOS clamps UIDevice.batteryLevel to 5% increments as a privacy measure. So a 1% drain over 30 minutes shows as 100% → 100% in the bench's RPC reads. To get a precise final number:

  1. After the bench finishes (and prints both summaries), the iOS bench now prompts you to plug in USB and press Enter. This calls ideviceinfo's battery domain which returns 1% precision over USB.

  2. You'll see fields like:

    === Precise battery (via ideviceinfo) ===
    BatteryCurrentCapacity: 99
    BatteryIsCharging: true
    ExternalConnected: true
    FullyCharged: false
  3. Compare to the start-of-run reading the bench printed at the top.

You can also read precise battery any time by hand:

ideviceinfo -u <UDID> -q com.apple.mobile.battery

This caveat doesn't apply to Android — dumpsys battery returns 1% precision natively.

Duration unit

--duration N is in seconds on both bench tasks. Default 1800 = 30 minutes. The bench's live trace and summaries always show elapsed_min / total_min for readability, but the CLI flag is seconds.

Working with an agent (Claude Code / LLM)

Because OTP runs on the device, an agent can connect directly to the running app via Erlang distribution and inspect or drive it programmatically — no screenshots required.

How it works

Agent (Claude Code)
│
├── mix mob.connect → tunnels EPMD, connects IEx to device node
│
├── Mob.Test.* → inspect screen state, trigger taps via RPC
│ (exact state: module, assigns, render tree)
│
└── MCP tools → native UI when needed
├── adb-mcp → Android: screenshot, shell, UI inspect
└── ios-simulator-mcp → iOS: screenshot, tap, describe UI

Mob.Test — preferred for agents

Mob.Test gives exact app state via Erlang distribution. Prefer it over screenshots whenever possible — it doesn't depend on rendering, is instantaneous, and works offline.

node = :"my_app_ios@127.0.0.1"
# What can this build actually be probed with? Ask before choosing an approach
# — on Android a probe is unavailable when the app's generated MobBridge.kt
# lacks the method, and on iOS the whole harness is compiled out of release
# builds. A freshly generated Android app has no synthetic input at all.
Mob.Test.capabilities(node)
#=> %{dist_rpc: true, tap_xy: false, screenshot: true, element_frames: true, ...}
# Inspection
Mob.Test.screen(node) #=> MyApp.HomeScreen
Mob.Test.assigns(node) #=> %{count: 3, user: %{name: "Alice"}, ...}
Mob.Test.find(node, "Save") #=> [{[0, 2], %{"type" => "button", ...}}]
Mob.Test.inspect(node) # full snapshot: screen + assigns + nav history + tree
# Tap a button by tag atom (from on_tap: {self(), :save} in render/1)
Mob.Test.tap(node, :save)
# Navigation — synchronous, safe to read state immediately after
Mob.Test.back(node) # system back gesture (fire-and-forget)
Mob.Test.pop(node) # pop to previous screen (synchronous)
Mob.Test.navigate(node, MyApp.DetailScreen, %{id: 42})
Mob.Test.pop_to(node, MyApp.HomeScreen)
Mob.Test.pop_to_root(node)
Mob.Test.reset_to(node, MyApp.HomeScreen)
# List interaction
Mob.Test.select(node, :my_list, 0) # select first row
# Simulate device API results (permission dialogs, camera, location, etc.)
Mob.Test.send_message(node, {:permission, :camera, :granted})
Mob.Test.send_message(node, {:camera, :photo, %{path: "/tmp/p.jpg", width: 1920, height: 1080}})
Mob.Test.send_message(node, {:location, %{lat: 43.65, lon: -79.38, accuracy: 10.0, altitude: 80.0}})
Mob.Test.send_message(node, {:notification, %{id: "n1", title: "Hi", body: "Hey", data: %{}, source: :push}})
Mob.Test.send_message(node, {:biometric, :success})

Accessing IEx alongside an agent

Option 1 — shared session (iex -S mix mob.server):

iex -S mix mob.server

Starts the dev dashboard and gives you an IEx prompt in the same process. The agent uses Tidewave to execute Mob.Test.* calls in this session; you type directly in the same IEx prompt. Both share the same connected node and see the same live state. This is the recommended setup for working alongside an agent.

Option 2 — separate sessions (--name):

Because Erlang distribution allows multiple nodes to connect to the same device, you can run independent sessions simultaneously:

# Your terminal
mix mob.connect --name mob_dev_1@127.0.0.1
# Agent's terminal (or a second developer)
mix mob.connect --name mob_dev_2@127.0.0.1

Both connect to the same device nodes, can call Mob.Test.* and nl/1, and don't interfere with each other.

MCP tool setup

For native UI interaction (screenshots, native gestures, accessibility inspection), install MCP servers for Claude Code:

Android — adb-mcp:

npm install -g adb-mcp

Add to ~/.claude.json:

{
"mcpServers": {
"adb": {
"command": "npx",
"args": ["adb-mcp"]
}
}
}

iOS simulator — ios-simulator-mcp:

npm install -g ios-simulator-mcp

Add to ~/.claude.json:

{
"mcpServers": {
"ios-simulator": {
"command": "ios-simulator-mcp"
}
}
}

With these installed, Claude Code can take screenshots, inspect the accessibility tree, and simulate gestures on the native device — useful when you need to verify layout or test native gesture paths.

Add an AGENTS.md to your Mob project root to give an agent the context it needs (Claude Code, Codex and others read it; a one-line CLAUDE.md pointing at it is enough):

# MyApp — Agent Instructions
## Connecting to a running device
```bash
mix mob.connect # discover, tunnel, restart the app, connect IEx
mix mob.connect --no-restart # attach to the running app, keeping its state
mix mob.connect --no-iex # print node names without IEx
mix mob.devices # list connected devices
```
Node names:
- iOS simulator: `my_app_ios_<udid prefix>@127.0.0.1`
- Android: `my_app_android_<suffix>@127.0.0.1` (`emulator_5554`, or the phone's
serial), on a port derived from the serial. `mix mob.deploy` records both on
the device, so a start from the launcher comes back under the same name
(mob 0.9.6+; older mob registers the bare `my_app_android` on 9100, which
`--no-restart` also finds).
When another process on the Mac already holds `mob_dev@127.0.0.1`, mob_dev
uses `mob_dev_<os pid>@127.0.0.1` for itself.
## Inspecting and driving the running app
Prefer `Mob.Test` over screenshots — it gives exact state, not a visual approximation.
```elixir
node = :"my_app_ios@127.0.0.1"
# Inspection
Mob.Test.screen(node) # current screen module
Mob.Test.assigns(node) # current assigns map
Mob.Test.find(node, "text") # find UI nodes by visible text
Mob.Test.inspect(node) # full snapshot: screen + assigns + nav history + tree
# Interaction
Mob.Test.tap(node, :tag) # tap by tag atom (from on_tap: {self(), :tag} in render/1)
Mob.Test.back(node) # system back gesture
Mob.Test.pop(node) # pop to previous screen (synchronous)
Mob.Test.navigate(node, Screen, %{}) # push a screen (synchronous)
Mob.Test.select(node, :list_id, 0) # select a list row
# Simulate device API results
Mob.Test.send_message(node, {:permission, :camera, :granted})
Mob.Test.send_message(node, {:camera, :photo, %{path: "/tmp/p.jpg", width: 1920, height: 1080}})
Mob.Test.send_message(node, {:biometric, :success})
```
Navigation functions (`pop`, `navigate`, `pop_to`, `pop_to_root`, `reset_to`) are
synchronous — safe to read state immediately after.
`back/1` and `send_message/2` are fire-and-forget. If you need to wait:
```elixir
Mob.Test.back(node)
:rpc.call(node, :sys, :get_state, [:mob_screen]) # flush
Mob.Test.screen(node)
```
## Hot-pushing code changes
```bash
mix mob.push # compile + push all changed modules to all connected devices
mix mob.push --all # force-push every module
```
## Deploying
```bash
mix mob.deploy # push changed BEAMs, restart
mix mob.deploy --native # full native rebuild + install
```
Plugin NIFs only reach the device through a native build, and a plugin's
`on_load` tolerates a missing NIF — so a mistake here shows up only as
`{:nif_not_loaded, ...}` at the first call. Both deploy paths warn (never
fail) about the two ways that happens:
- `mix mob.deploy --native` (and `mix mob.doctor`) name every device-runtime
dep (not `only: :dev` / `runtime: false`) that ships a `priv/mob_plugin.exs`
declaring `nifs:` but isn't in `config :mob, :plugins`, with the exact
`config` line to set.
- Each successful native build records, per platform, which activated plugins
it compiled NIFs for (`mob_native_plugins.txt` under `Mix.Project.build_path/0`).
A BEAM-only `mix mob.deploy` or `mix mob.push` names any NIF plugin activated
since — "installed app was built without it — run `mix mob.deploy --native`" —
or, when there is no record for that platform yet, says it can't tell.
An Android deploy that restarts the app first sets up `adb reverse tcp:4369`
and the dist-port forward, so the app joins distribution even right after an
emulator reboot, and `mix mob.connect --no-restart` can attach to it later.
### Why dependencies recompile between `mix run` and `mix mob.deploy`
They shouldn't, and with fixed dependencies they don't: alternating
`mix run`, `mix compile` and `mix mob.deploy` on the same project compiles
nothing (all three use the project's `MIX_ENV`, target and `config/config.exs`,
and mob_dev sets no compile-time environment for an Android or hot deploy).
What does trigger it is a `path:` dependency (`mob`, a plugin, `mob_deliver`)
whose checkout changed in between, for example while you or another agent is
editing it. Mix recompiles the changed dependency and every module in other
dependencies that uses it at compile time, which for `mob` includes each
plugin's `~MOB` screens and `use Mob.Screen` modules (mob_camera,
mob_location, ...). That is Mix doing its job, not something mob_dev can skip.
### Application config on the device
Mob apps don't boot as an OTP release, so nothing loads `config/*.exs` on the
device. mob_dev evaluates `config/config.exs` (for the current `MIX_ENV` and
target) plus `config/runtime.exs` on the host, drops `:mob_dev`, and ships the
result as the generated module `mob_app_config` next to the app's BEAMs on every
deploy and native build. mob 0.9.6+ applies it at app start, so
`Application.get_env/3` works as on the host. Two differences: `runtime.exs` runs
on your Mac at build time (its `System.get_env/1` reads your environment), and a
key whose value is an anonymous function, pid, port or reference (a compiled
regex is one) is left out with a warning. A config change reaches a running app
at its next start.
## iOS push notifications (APNs)
For APNs push tokens to be delivered, the app binary must have `aps-environment`
in its codesigning entitlements — the provisioning profile having it is not
sufficient.
### Automatic (recommended)
`mix mob.deploy --native` extracts `aps-environment` from the embedded
provisioning profile and mirrors it into the fallback entitlements when no
explicit entitlements file exists. If the provisioning profile was created
with push enabled, nothing extra is needed.
### Explicit entitlements file
Create `ios/<AppName>.entitlements` in your project root:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>application-identifier</key>
<string>TEAM_ID.com.example.myapp</string>
<key>com.apple.developer.team-identifier</key>
<string>TEAM_ID</string>
<key>get-task-allow</key>
<true/>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>
```
When this file is present, `mix mob.deploy --native` uses it verbatim (no
auto-mirroring). Use `development` for Xcode/mob development builds and
`production` for App Store / TestFlight production builds.
### Verifying entitlements on a built app
```bash
codesign -d --entitlements :- path/to/MyApp.app | plutil -p -
# Should include: "aps-environment" => "development"
```
### Agent workflow example
A typical agent session for debugging or feature work:
```
1. mix mob.connect — connect to the running device node
2. Mob.Test.screen(node) — confirm which screen is showing
3. Mob.Test.assigns(node) — inspect current state
4. Mob.Test.tap(node, :some_button) — interact with the UI
5. Mob.Test.screen(node) — confirm navigation happened
6. edit lib/my_app/screen.ex — make a code change
7. mix mob.push — hot-push changed modules without restart
8. Mob.Test.assigns(node) — verify state updated as expected
```
For device API interactions, simulate the result rather than triggering real hardware:
```elixir
# Instead of actually opening the camera:
Mob.Test.tap(node, :take_photo) # triggers handle_event → Mob.Camera.capture_photo
# Simulate the result:
Mob.Test.send_message(node, {:camera, :photo, %{path: "/tmp/test.jpg", width: 1920, height: 1080}})
Mob.Test.assigns(node) # verify photo_path was stored
```
If you need to see the rendered UI, take a screenshot with the native MCP tool, then use `Mob.Test.find/2` to correlate what you see with the component tree.
## Development
Clone, then run once:
```bash
mix setup
```
That fetches deps and activates the repo's git hooks (`.githooks/pre-push`):
`mix format --check`, `mix credo --strict` (incl. ExSlop), and `mix compile --warnings-as-errors` run on every push, plus the full test
suite when `mix.exs` changes — the same gate CI enforces before publishing.