mob_dev
Development tooling for Mob — the BEAM-on-device mobile framework for Elixir.
Installation
Add to your project's mix.exs (dev only):
def deps do
[
{:mob_dev, "~> 0.2", only: :dev}
]
endMix tasks
| Task | Description |
|---|---|
mix mob.new APP_NAME |
Generate a new Mob project (see mob_new archive) |
mix mob.install |
First-run setup: download OTP runtime, generate icons, write mob.exs |
mix mob.deploy | Compile and push BEAMs to all connected devices |
mix mob.deploy --native | Also build and install the native APK/iOS app |
mix mob.connect |
Tunnel + restart + open IEx connected to device nodes (--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.push | Hot-push only changed modules (no restart) |
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:
- Device cards — live status for connected Android emulators and iOS simulators, with Deploy and Update buttons per device
- Device log panel — streaming logcat / iOS simulator console with text filter
- Elixir log panel — Elixir
Loggeroutput forwarded from the running BEAM, with text filter - Watch mode toggle — auto-push changed BEAMs on file save without running a separate terminal
- QR code — LAN URL for opening the dashboard on a physical device
Run with IEx for an interactive terminal alongside the dashboard:
iex -S mix mob.serverWatch 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)
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 call Mob.Dist.ensure_started/1 at startup, and the cookie must match the one in mob.exs (default :mob_secret).
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.
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.
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
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
iOS (mix mob.battery_bench_ios)
Deploys to a physical iPhone/iPad and reads battery via ideviceinfo. Reports mAh (if BatteryMaxCapacity is available) or percentage points.
Prerequisites:brew install libimobiledevice, Xcode 15+, device trusted on this Mac.
mix mob.battery_bench_ios # default: Nerves-tuned BEAM, 30 min
mix mob.battery_bench_ios --no-beam # baseline: no BEAM at all
mix mob.battery_bench_ios --preset untuned # raw BEAM, no tuning
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 rebuildingPresets and results
| Preset | Flags | mAh/hr (Moto G) |
|---|---|---|
| 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.
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 UIMob.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"
# 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.
Recommended CLAUDE.md for Mob projects
Add a CLAUDE.md to your Mob project root to give an agent the context it needs:
# MyApp — Agent Instructions
## Connecting to a running device
```bash
mix mob.connect # discover, tunnel, connect IEx
mix mob.connect --no-iex # print node names without IEx
mix mob.devices # list connected devices
```
Node names:
- iOS simulator: `my_app_ios@127.0.0.1`
- Android emulator: `my_app_android@127.0.0.1`
## 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
```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 expectedFor device API interactions, simulate the result rather than triggering real hardware:
# 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.