You're in mob_dev, the build/deploy/devices toolkit. Read
~/code/mob/AGENTS.md first for the system view, the
three-repo topology, the cross-cutting pre-empt-failure rules, and the
"Don't write this slop" list (AI-generated patterns to avoid at write
time, not after credo flags them). The notes below are mob_dev-specific.
Mix tasks (mob.deploy, mob.connect, mob.devices, mob.emulators,
mob.provision, mob.doctor, mob.battery_bench_*) plus their backing
modules (MobDev.Discovery.{Android,IOS}, MobDev.NativeBuild,
MobDev.OtpDownloader, MobDev.Deployer, MobDev.Emulators).
The release tooling lives at scripts/release/ — shell scripts for
cross-compiling OTP for Android arm64/arm32, iOS sim, and iOS device, then
staging the tarballs and uploading to GitHub Releases. Patches we apply to
OTP source for iOS-device compatibility live at
scripts/release/patches/ (forker_start skip, EPMD NO_DAEMON guard).
See build_release.md for the full release walkthrough.
Write tests before or alongside new code. Every new function should have corresponding tests before the task is considered done. The test suite must stay green at all times.
mix test # all tests
mix test --exclude integration # skip the device-dependent ones- Compile-time regex literals are unsafe on Elixir 1.19 / OTP 28.0. Use
Regex.compile!("...", "flags")for runtime compilation. Already swept in 0.3.17 — don't reintroduce. mix mob.deploy --device <id>resolves the id via discovery before deciding which platform to build. The narrowing logic is innarrow_platforms_for_device/2and is the single source of truth for both build and deploy. Bypass it and you'll get either spurious "No device matched" warnings (deploy) or builds for the wrong platform (build).xcodebuilderrors get rewritten to actionable hints bydiagnose_xcodebuild_failure/1inmob.provision. Apple's verbatim text is preserved alongside our hint so the snippet stays google-able. Add new pattern matches there when you encounter a new Apple error string.- APNs push token never arrives on iOS device if the binary's codesigning
entitlements omit
aps-environment.NativeBuild.codesign_ios_device_app/3auto-mirrors the value from the embedded provisioning profile into the fallback entitlements. If the profile was provisioned without push, no mirroring happens — either re-provision with push enabled or createios/<AppName>.entitlementswithaps-environment: development. Test the plist text viaNativeBuild.fallback_entitlements_plist/3. - OTP tarball schema changes need bumping
valid_otp_dir?/2inotp_downloader.exso existing caches auto-redownload. Don't bump the OTP hash — the schema check is the right knob. - The release scripts assume
~/code/otpexists with the right cross-compile output. The patches inscripts/release/patches/are applied automatically byxcompile_ios_device.sh, idempotently — re-running is safe. mob.add_nifis the entry point for new NIFs. Don't add:static_nifsentries by hand tomob.exs— the task already does the AST-aware append, generates the Elixir stub via Igniter, and re-runsmob.regen_driver_tabsopriv/generated/driver_tab_*.zigstays in sync.--typeofc,zigler,rustleralso drops the right native skeleton;elixir-only(default) leaves the C/Zig/Rust to you. The stubs for zigler/rustler carry an explicit static-link warning — those backends produce dlopen'd.soby default, which is wrong for Mob's iOS App Store / Android-RTLD_LOCAL constraints. The host-dev path works; on-device shipping needs the user to wire the archive into ios/build.zig + android/jni/ themselves. Reach for--type cif static linking matters more than the source language.mob.regen_driver_tabreads:static_nifsfrommob.exsviaConfig.Reader, NOT fromApplication.get_env. mob.exs is not auto-imported into Mix application env (this matches every other mob_dev task that consumes mob.exs values). If you add a new task that reads:static_nifs, useMobDev.Config.load_mob_config()to stay consistent — usingApplication.get_env(:mob_dev, :static_nifs, [])silently misses the user's entries.mob.enableis now Igniter-driven (Phase 4). Per-feature handlers live inMobDev.Enable.Igniterand returnigniter -> igniter. When adding a new feature: add a clause to the@valid_featureslist inmob.enable.ex, adispatch/3clause, and anenable_<name>/2function inMobDev.Enable.Igniter. UseIgniter.update_filefor text-level patches (plist, AndroidManifest, JS, HEEX) and AST-aware helpers (Igniter.Project.Module.create_module,Project.Deps.add_dep,Project.Config.modify_config_code) for Elixir source. Always emitIgniter.add_noticewhen a platform dir is missing — silent skips were a recurring user-confusion source in the legacy task.- File discovery in
Enable.Igniteris Igniter-aware. Helpers likefind_ios_plist/1andfind_android_manifest/1check disk first then fall back toRewrite.paths(igniter.rewrite)/Igniter.exists?/2, soIgniter.test_project(files: %{...})in tests works without writing to disk. Don't bypass this with rawFile.exists?/1—mix mob.enabletests will pass on disk but break Igniter test virtualization. mix mob.enablereads app name viaIgniter.Project.Application.app_name/1, NOTFile.cwd!() <> "/mix.exs". Undertest_project, disk reads see mob_dev's own mix.exs (wrong app). Falls back to the legacy on-disk read only when Igniter has no mix.exs source.
A few helpers are public specifically to enable testing (the parsing and narrowing functions). Don't make them private:
Discovery.Android.parse_devices_output/1Discovery.IOS.parse_simctl_json/1,parse_simctl_text/1,parse_runtime_version/1OtpDownloader.valid_otp_dir?/2,ios_device_extras_present?/1PythonAppleSupport.valid_dir?/1NativeBuild.narrow_platforms_for_device/2,ios_toolchain_available?/0,read_sdk_dir/1,fallback_entitlements_plist/3NativeBuild.pythonx_in_project?/1,python_apple_support_env/2Enable.inject_pythonx_dep/1,inject_pythonx_uv_init_gate/2,python_paths_module_template/1Emulators.parse_simctl_json/1,find_emulator_binary/1Provision.diagnose_xcodebuild_failure/1
If you make any of these private, every downstream test breaks loudly — but you'll lose the ability to evolve the parsers safely.
Apply consistently to every Mix task that mutates device state
(mix mob.uninstall today; mix mob.deploy --all-devices,
mix mob.connect, future ones).
Emulator vs physical safety pattern (from mix mob.uninstall):
--all-devicessweeps emulators and simulators only. NEVER physical devices. Phones are someone's personal property and have real-data blast radius; emulators are throwaway dev fixtures.--all-physicalis the opt-in for sweeping physical devices. Composes with--all-devicesfor "literally everything."--device <id>is the explicit-id escape hatch — the user typed the id, that's consent; bypasses the type filter regardless of whether the device is emulator or physical.- Auto-detect (no flags, exactly one device connected) only
fires for a non-physical device. A solo phone connected with no
flags → error with a hint pointing at
--all-physicalor--device.
The predicate to route on is MobDev.Device.physical?/1. The
selection logic lives in MobDev.Uninstaller.select_devices/3
(public for testing); same shape should appear in any new task
needing the same fan-out behavior. Pin the headline guarantee in
each task's tests — "personal iPhone + dev emulators + --all-devices
must leave the iPhone alone."
TODO: apply this pattern to mix mob.deploy (today's --all-devices
deploy can push BEAMs to a personal phone). When that fan-out exists
or grows, factor select_devices/3 plus the flag plumbing into a
shared MobDev.TaskTargets (or similar) module so the rules don't
drift between tasks.
These look like inverses but aren't. Future agents touching either should know:
mix mob.install— first-run project setup. Downloads the OTP runtime, generates icons, writesmob.exs. Per-project, runs once. Doesn't touch any device.mix mob.uninstall— per-device app removal. Sweeps connected devices and removes installed.app/.apkbundles. Doesn't undomix mob.install's project setup.
A user reading the task list will plausibly type mix mob.uninstall
expecting it to undo mix mob.install. If we ever want true
symmetry, the device-cleanup task wants a clearer name (e.g.
mob.app.uninstall or mob.devices.clear) and mob.uninstall
could become the project-cleanup inverse of mob.install. Until we
make that call, the help text in both task @moduledoc blocks
should call out the scope difference explicitly. Don't quietly
rename — users have muscle memory by now.
When you change repo conventions, add a public seam, or hit a new gotcha — update this file in the same commit. Stale guidance is worse than none.