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.doctor.ex
defmodule Mix.Tasks.Mob.Doctor do
use Mix.Task
alias MobDev.NdkVersion
@shortdoc "Check your environment for common Mob setup issues"
@moduledoc """
Checks your environment, project configuration, OTP caches, and connected
devices, reporting any issues with specific fix instructions.
Run this first when something isn't working:
mix mob.doctor
## What it checks
1. **Tools** — adb, xcrun (macOS), Java, Android SDK, iOS build tools
2. **Project** — mob.exs present, required keys set, paths valid
3. **Build** — Elixir deps fetched, project compiled, native build tools present
4. **OTP cache** — pre-built runtimes downloaded and structurally valid
5. **Devices** — authorized Android devices and booted iOS simulators
Exits non-zero if any required checks fail, so it can be used in scripts.
"""
@impl Mix.Task
def run(_args) do
IO.puts("")
IO.puts("#{ansi(:cyan)}=== Mob Doctor ===#{ansi(:reset)}")
issues = []
issues = issues ++ section("Tools", check_tools())
issues = issues ++ section("Project", check_project())
issues = issues ++ section("Build", check_build())
issues = issues ++ section("OTP Cache", check_otp_cache())
issues = issues ++ section("Devices", check_devices())
IO.puts("")
failures = Enum.count(issues, &(&1 == :fail))
warnings = Enum.count(issues, &(&1 == :warn))
cond do
failures > 0 ->
warn_str =
if warnings > 0,
do: ", #{ansi(:yellow)}#{warnings} warning(s)#{ansi(:reset)}",
else: ""
IO.puts(
"#{ansi(:red)}#{failures} failure(s)#{ansi(:reset)}#{warn_str}" <>
" — fix the issues above and re-run #{ansi(:cyan)}mix mob.doctor#{ansi(:reset)}."
)
Mix.raise("mob.doctor: #{failures} check(s) failed")
warnings > 0 ->
IO.puts(
"#{ansi(:yellow)}#{warnings} warning(s)#{ansi(:reset)}" <>
" — optional items above may limit some features."
)
true ->
IO.puts("#{ansi(:green)}All checks passed.#{ansi(:reset)}")
end
end
# ── Sections ─────────────────────────────────────────────────────────────────
# Prints a titled section and returns list of :ok | :warn | :fail atoms.
defp section(title, checks) do
IO.puts("\n#{ansi(:bright)}#{title}#{ansi(:reset)}")
Enum.map(checks, fn {level, label, detail, fix} ->
print_check(level, label, detail, fix)
level
end)
end
# ── Tool checks ──────────────────────────────────────────────────────────────
defp check_tools do
List.flatten([
check_version_manager(),
check_elixir_versions(),
check_epmd(),
check_adb(),
check_xcrun(),
check_zig(),
if(has_android_project?(), do: check_android_build_tools(), else: []),
if(has_ios_project?() and macos?(), do: check_ios_build_tools(), else: []),
check_optional(
"ideviceinfo",
:warn,
"optional — needed for iOS physical device battery benchmarks",
"brew install libimobiledevice"
)
])
end
defp check_version_manager do
cond do
path = System.find_executable("mise") ->
{:ok, "version manager", "mise (#{path})", nil}
path = System.find_executable("asdf") ->
{:ok, "version manager", "asdf (#{path})", nil}
true ->
{:warn, "version manager", "neither mise nor asdf detected",
"Mob requires specific Elixir and OTP versions that must match the\n" <>
" device runtime. A version manager installs the exact toolchain\n" <>
" from your project's .tool-versions file — no manual juggling.\n" <>
"\n" <>
" mise is the modern standard in the Elixir community (fast, cross-platform):\n" <>
" brew install mise https://mise.jdx.dev\n" <>
"\n" <>
" asdf is the established option if you already use it:\n" <>
" brew install asdf https://asdf-vm.com\n" <>
"\n" <>
" After installing, run: mise install or asdf install"}
end
end
defp check_epmd do
case System.find_executable("epmd") do
nil ->
{:fail, "epmd", "not found in PATH — required for Erlang distribution (mix mob.connect)",
"Ensure OTP's bin directory is in PATH. On Nix: add epmd to your shell environment."}
path ->
# Check if it's reachable by attempting a names query
case System.cmd("epmd", ["-names"], stderr_to_stdout: true) do
{_, 0} ->
{:ok, "epmd", path, nil}
{_, _} ->
{:warn, "epmd",
"#{path} found but not running — mix mob.connect will attempt to start it",
"Run `epmd -daemon` if mob.connect fails to start distribution"}
end
end
end
@min_elixir "1.18.0"
@min_otp 26
@warn_otp 27
defp check_elixir_versions do
[check_elixir(), check_otp(), check_hex()]
end
defp check_elixir do
vsn = System.version()
if Version.compare(vsn, @min_elixir) == :lt do
{:fail, "Elixir", "#{vsn} — mob requires Elixir #{@min_elixir} or later",
elixir_upgrade_hint()}
else
{:ok, "Elixir", vsn, nil}
end
end
defp check_otp do
otp = :erlang.system_info(:otp_release) |> to_string()
n = String.to_integer(otp)
cond do
n < @min_otp ->
{:fail, "OTP", "#{otp} — OTP #{@min_otp} or later required", elixir_upgrade_hint()}
n < @warn_otp ->
{:warn, "OTP", "#{otp} — OTP #{@warn_otp}+ recommended (device runtime is OTP 28)",
elixir_upgrade_hint()}
true ->
erts = :erlang.system_info(:version) |> to_string()
{:ok, "OTP", "#{otp} (ERTS #{erts})", nil}
end
end
defp check_hex do
vsn =
case Application.load(:hex) do
:ok -> Application.spec(:hex, :vsn) |> to_string()
{:error, {:already_loaded, _}} -> Application.spec(:hex, :vsn) |> to_string()
{:error, _} -> nil
end
cond do
is_nil(vsn) ->
{:fail, "Hex", "not installed — required to fetch and manage dependencies",
"mix local.hex"}
Version.compare(vsn, "2.0.0") == :lt ->
{:warn, "Hex", "#{vsn} — version 2.0 or later recommended", "mix local.hex --force"}
true ->
{:ok, "Hex", vsn, nil}
end
end
defp elixir_upgrade_hint do
"Upgrade Elixir to #{@min_elixir} or later. Common methods:\n" <>
" mix local.elixir --force # patch updates within the same minor version\n" <>
" mise install elixir@latest # mise\n" <>
" asdf install elixir latest # asdf\n" <>
" brew upgrade elixir # Homebrew\n" <>
" https://elixir-lang.org/install.html"
end
defp check_adb do
case System.find_executable("adb") do
nil ->
{:fail, "adb", "required to deploy to Android devices and emulators",
"Install Android SDK Platform Tools:\n" <>
" https://developer.android.com/tools/releases/platform-tools\n" <>
if(macos?(),
do: " or: brew install --cask android-platform-tools",
else: " or: sudo apt install adb"
)}
path ->
{:ok, "adb", path, nil}
end
end
defp check_zig do
case System.find_executable("zig") do
nil ->
{:warn, "zig",
"not on PATH — required as the C cross-compile driver from Phase 1 of the build-system migration",
"Install zig 0.15.x:\n macOS: brew install zig\n Linux/asdf: asdf plugin add zig && asdf install zig 0.15.2\n manual: https://ziglang.org/download/"}
_ ->
case System.cmd("zig", ["version"], stderr_to_stdout: true) do
{out, 0} ->
version = String.trim(out)
{:ok, "zig", version, nil}
_ ->
{:warn, "zig", "found but `zig version` failed", nil}
end
end
end
defp check_xcrun do
if macos?() do
case System.find_executable("xcrun") do
nil ->
{:fail, "xcrun", "required to build and run iOS simulator apps",
"Install Xcode command-line tools:\n xcode-select --install"}
_ ->
case System.cmd("xcodebuild", ["-version"], stderr_to_stdout: true) do
{out, 0} ->
version_line = out |> String.split("\n") |> List.first() |> String.trim()
major =
Regex.run(Regex.compile!("Xcode (\\d+)"), version_line)
|> case do
[_, v] -> String.to_integer(v)
nil -> 99
end
if major >= 15 do
{:ok, "xcrun", version_line, nil}
else
{:fail, "xcrun", "Xcode #{major} found — Xcode 15 or later required",
"Update Xcode from the App Store or developer.apple.com"}
end
_ ->
{:warn, "xcrun", "found but xcodebuild -version failed", nil}
end
end
else
{:ok, "xcrun", "skipped (not macOS)", nil}
end
end
@min_jdk 17
defp check_android_build_tools do
[
case System.find_executable("java") do
nil ->
{:fail, "java", "required by Gradle to build the Android APK",
"Install a JDK:\n macOS: brew install --cask temurin\n Ubuntu/Debian: sudo apt install openjdk-21-jdk\n Arch: sudo pacman -S jdk21-openjdk\n Fedora: sudo dnf install java-21-openjdk\n or install Android Studio which bundles a JDK"}
path ->
case System.cmd(path, ["-version"], stderr_to_stdout: true) do
{out, _} ->
version_line = out |> String.split("\n") |> List.first() |> String.trim()
major =
case Regex.run(Regex.compile!("version \"(\\d+)"), version_line,
capture: :all_but_first
) do
[v] -> String.to_integer(v)
_ -> 0
end
cond do
major > 0 and major < @min_jdk ->
{:fail, "java",
"JDK #{major} found — JDK #{@min_jdk}+ required by Android Gradle Plugin 8.x",
"Install a supported JDK:\n #{java_install_hint()}"}
major > 21 ->
{:warn, "java", "#{version_line} — JDK #{major} detected",
"AGP 8.2.0 is tested through JDK 21. JDK #{major} may cause Kotlin compilation errors.\n Switch to JDK 17 or 21:\n macOS: brew install --cask temurin@21 && export JAVA_HOME=$(/usr/libexec/java_home -v 21)\n Ubuntu/Debian: sudo apt install openjdk-21-jdk && sudo update-alternatives --config java\n Arch: sudo pacman -S jdk21-openjdk && sudo archlinux-java set java-21-openjdk\n Fedora: sudo dnf install java-21-openjdk && sudo alternatives --config java"}
true ->
{:ok, "java", version_line, nil}
end
end
end,
check_android_sdk(),
check_android_ndk()
] ++ maybe_check_rust_android_targets()
end
# If the project has any Rust NIFs (native/*/Cargo.toml), make sure both
# Android rustup targets are installed. Without these, `cargo build
# --target=aarch64-linux-android` (or `armv7-linux-androideabi`) fails
# with "error: toolchain '<x>' is not installed".
defp maybe_check_rust_android_targets do
if has_rust_nif?() and System.find_executable("rustup") do
[check_rust_android_targets()]
else
[]
end
end
defp has_rust_nif?, do: Path.wildcard("native/*/Cargo.toml") != []
defp check_rust_android_targets do
case System.cmd("rustup", ["target", "list", "--installed"], stderr_to_stdout: true) do
{out, 0} ->
installed = String.split(out, "\n", trim: true)
wanted = ["aarch64-linux-android", "armv7-linux-androideabi"]
missing = wanted -- installed
case missing do
[] ->
{:ok, "rust android targets", "aarch64 + armv7 ✓", nil}
_ ->
{:fail, "rust android targets", "missing: #{Enum.join(missing, ", ")}",
"Install:\n rustup target add #{Enum.join(missing, " ")}"}
end
{_, _} ->
{:warn, "rust android targets", "rustup target list failed",
"Verify rustup is functional: rustup --version"}
end
end
# Validate the Android NDK install matches what mob's bundled OTP runtime
# was cross-compiled against. The libbeam.a in the OTP tarballs embeds
# libc++ ABI symbols using a specific inline namespace (NDK 27 = ne180000;
# NDK 25 = ne140000). An app's libpigeon.so must link against the same
# namespace or the C++ exception ABI symbols (__cxa_*) come up undefined.
#
# Three states surface here, matching the matrix in the side-quest doc:
#
# ✓ recommended NDK installed and used (or override matches it)
# ⚠ override active for a non-recommended version
# ✗ recommended NDK not installed AND no override
defp check_android_ndk do
recommended = NdkVersion.recommended()
effective = NdkVersion.effective()
override = NdkVersion.override()
cond do
override == :none and effective == recommended and NdkVersion.installed?(recommended) ->
{:ok, "Android NDK", "#{recommended} (recommended) ✓", nil}
override == :none and not NdkVersion.installed?(recommended) ->
installed_hint =
case NdkVersion.installed_versions() do
[] -> "No NDKs installed."
versions -> "Installed: #{Enum.join(versions, ", ")}."
end
{:fail, "Android NDK",
"#{recommended} not installed (Mob's OTP runtime is built against this NDK).\n #{installed_hint}",
"Install with:\n #{NdkVersion.install_command()}\n Or via Android Studio → SDK Manager → SDK Tools → NDK (Side by side)\n → check 27.2.12479018 (or whatever the recommended is, see\n ~/code/mob_dev/lib/mob_dev/ndk_version.ex `@recommended`)."}
override != :none ->
{source, version} = override
source_label =
if source == :env, do: "MOB_ANDROID_NDK_VERSION", else: "mob.exs :android_ndk_version"
installed_label =
if NdkVersion.installed?(version) do
"installed"
else
"NOT installed"
end
# The override case is always a warning (never a fail). The user
# opted out of the happy path; we just remind them what they took
# on. They get to debug the link errors themselves.
msg =
"override active: building with #{version} via #{source_label} (#{installed_label}). " <>
"Recommended is #{recommended}. You've opted out of the bundled-OTP libc++ ABI " <>
"guarantee — mismatched libc++ inline namespaces between your NDK and Mob's libbeam.a " <>
"surface as `undefined symbol: __cxa_allocate_exception` (or similar) at link time."
hint =
"To return to the happy path:\n" <>
" - drop `:android_ndk_version` from mob.exs's :mob_dev config\n" <>
" - or unset MOB_ANDROID_NDK_VERSION\n" <>
"Then run mob.doctor again. See ~/code/mob/common_fixes.md for ABI details."
{:warn, "Android NDK", msg, hint}
end
end
defp java_install_hint do
if macos?() do
"brew install --cask temurin\n or install Android Studio which bundles a JDK"
else
"sudo apt install openjdk-21-jdk\n" <>
" then: export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64\n" <>
" or install Android Studio which bundles a JDK"
end
end
defp check_android_sdk do
sdk_dir =
System.get_env("ANDROID_HOME") ||
System.get_env("ANDROID_SDK_ROOT") ||
read_local_properties_sdk()
cond do
is_binary(sdk_dir) and File.dir?(sdk_dir) ->
{:ok, "Android SDK", sdk_dir, nil}
is_binary(sdk_dir) ->
{:fail, "Android SDK", "ANDROID_HOME/ANDROID_SDK_ROOT points to missing path: #{sdk_dir}",
"Install Android Studio or set ANDROID_HOME to a valid SDK path"}
true ->
default_path =
if macos?(),
do: "$HOME/Library/Android/sdk",
else: "$HOME/Android/Sdk"
{:warn, "Android SDK",
"ANDROID_HOME not set and sdk.dir not found in android/local.properties",
"Set ANDROID_HOME in your shell profile:\n" <>
" export ANDROID_HOME=#{default_path}\n" <>
" or open the android/ folder in Android Studio (it writes local.properties)"}
end
end
defp read_local_properties_sdk do
path = Path.join(File.cwd!(), "android/local.properties")
with {:ok, content} <- File.read(path),
[_, sdk] <- Regex.run(Regex.compile!("^sdk\\.dir=(.+)$", "m"), content) do
String.trim(sdk)
else
_ -> nil
end
end
defp check_ios_build_tools do
[
check_required(
"python3",
:fail,
"required by NativeBuild's iOS pipeline (EPMD source patching for in-process startup)",
"python3 is included with macOS Xcode command-line tools:\n xcode-select --install"
),
check_required(
"rsync",
:fail,
"required by NativeBuild's iOS pipeline to sync the OTP runtime to ~/.mob/runtime/ios-sim",
"rsync is included with macOS — if missing:\n brew install rsync"
)
]
end
defp check_required(cmd, level_if_missing, detail, fix) do
case System.find_executable(cmd) do
nil -> {level_if_missing, cmd, detail, fix}
path -> {:ok, cmd, path, nil}
end
end
defp check_optional(cmd, level_if_missing, detail, fix) do
check_required(cmd, level_if_missing, detail, fix)
end
# ── Project checks ────────────────────────────────────────────────────────────
defp check_project do
if File.exists?("mix.exs") do
do_check_project()
else
[{:warn, "project", "not in a Mix project directory — skipping project checks", nil}]
end
end
defp do_check_project do
mob_exs_check =
if File.exists?("mob.exs") do
{:ok, "mob.exs", "found", nil}
else
{:fail, "mob.exs", "not found in #{File.cwd!()}", "Run: mix mob.install"}
end
cfg =
if File.exists?("mob.exs"),
do: Config.Reader.read!("mob.exs") |> Keyword.get(:mob_dev, []),
else: []
[
mob_exs_check,
check_cfg_path(
cfg,
:mob_dir,
"path to the mob library repo",
"Run: mix mob.install\n" <>
" or add to mob.exs: config :mob_dev, mob_dir: \"/path/to/mob\""
),
check_bundle_id(cfg)
]
end
defp check_cfg_path(cfg, key, description, fix) do
val = cfg[key]
cond do
is_nil(val) ->
{:fail, to_string(key), "#{description} — not set in mob.exs", fix}
is_binary(val) and String.contains?(val, "/path/to/") ->
{:fail, to_string(key), "still has placeholder value: #{val}", fix}
is_binary(val) and not File.exists?(Path.expand(val)) ->
{:fail, to_string(key), "path not found: #{val}",
"Update mob.exs — the path must exist on this machine"}
true ->
{:ok, to_string(key), Path.expand(val), nil}
end
end
defp check_bundle_id(cfg) do
case cfg[:bundle_id] do
nil ->
{:warn, "bundle_id", "not set in mob.exs (only needed for mob.battery_bench)",
"Add to mob.exs: config :mob_dev, bundle_id: \"com.example.myapp\""}
id ->
{:ok, "bundle_id", id, nil}
end
end
# ── Build checks ─────────────────────────────────────────────────────────────
defp check_build do
if File.exists?("mix.exs") do
List.flatten([
check_deps_fetched(),
check_compiled(),
check_driver_tab(),
check_plugin_build_options()
])
else
[]
end
end
# ── Plugin build options ──────────────────────────────────────────────────────
#
# When activated plugins contribute native code, the native build passes
# -Dplugin_* flags to the project's build.zig files. An app scaffolded before
# the plugin system declares no b.option for them, and Zig hard-rejects an
# unknown -D flag — half a build in, with nothing pointing at the real cause.
# Surface the mismatch up front.
@plugin_build_options %{
"android/app/src/main/jni/build.zig" => ~w(plugin_c_nifs plugin_zig_nifs plugin_jni_sources),
"ios/build.zig" => ~w(plugin_c_nifs plugin_swift_files plugin_frameworks),
"ios/build_device.zig" => ~w(plugin_c_nifs plugin_swift_files plugin_frameworks)
}
defp check_plugin_build_options do
case MobDev.Plugin.activated() do
[] ->
[]
_activated ->
for {path, required} <- @plugin_build_options,
{:ok, content} <- [File.read(path)],
missing = __missing_plugin_options__(content, required),
missing != [] do
{:warn, "plugin options (#{path})",
"doesn't declare #{Enum.join(missing, ", ")} — the native build emits " <>
"these -D flags for activated plugins' native code, and Zig rejects " <>
"unknown options",
"Port the plugin option block into #{path} from a freshly generated " <>
"app (mix mob.new) or mob_new's templates"}
end
end
end
@doc false
# Pure kernel: which plugin -D option names the build file doesn't declare.
# Detection is the quoted option name (how every template's b.option call
# spells it). Public for tests.
@spec __missing_plugin_options__(String.t(), [String.t()]) :: [String.t()]
def __missing_plugin_options__(content, required) do
Enum.reject(required, &String.contains?(content, "\"#{&1}\""))
end
# ── Driver_tab manifest drift ────────────────────────────────────────────────
#
# If the project uses the per-app generated driver_tab (Phase 0 of the build
# system migration), check that on-disk priv/generated/driver_tab_*.c match
# what the current :static_nifs declaration would produce. Drift means
# someone changed the manifest but didn't run `mix mob.regen_driver_tab`.
defp check_driver_tab do
paths = Mix.Tasks.Mob.RegenDriverTab.target_paths()
case Enum.filter([paths.ios, paths.android], &File.exists?/1) do
[] ->
[]
_present ->
nifs = Mix.Tasks.Mob.RegenDriverTab.resolved_nifs()
ios_expected = MobDev.StaticNifs.generate(:ios, nifs) |> IO.iodata_to_binary()
android_expected = MobDev.StaticNifs.generate(:android, nifs) |> IO.iodata_to_binary()
drifted =
[{paths.ios, ios_expected}, {paths.android, android_expected}]
|> Enum.filter(fn {path, expected} ->
File.exists?(path) and File.read!(path) != expected
end)
|> Enum.map(fn {path, _} -> path end)
case drifted do
[] ->
{:ok, "driver_tab", "in sync with :static_nifs", nil}
paths ->
{:warn, "driver_tab",
"drift detected — these files don't match :static_nifs:\n - " <>
Enum.join(paths, "\n - "), "Run: mix mob.regen_driver_tab"}
end
end
end
defp check_deps_fetched do
lock_exists = File.exists?("mix.lock")
deps_dir = File.exists?("deps") and File.ls!("deps") != []
cond do
not lock_exists ->
{:warn, "mix deps", "mix.lock not found — dependencies have never been fetched",
"Run: mix deps.get"}
not deps_dir ->
{:fail, "mix deps", "deps/ directory is empty — dependencies not fetched",
"Run: mix deps.get"}
true ->
# Count packages in lock vs fetched dirs as a quick sanity check
locked = count_locked_deps()
fetched = File.ls!("deps") |> length()
if fetched < locked do
{:warn, "mix deps",
"#{fetched} of #{locked} locked deps present in deps/ — may be incomplete",
"Run: mix deps.get"}
else
{:ok, "mix deps", "#{fetched} deps fetched", nil}
end
end
end
defp count_locked_deps do
case File.read("mix.lock") do
{:ok, content} ->
# Each dep appears as a quoted key on its own line: "dep_name": {
content
|> String.split("\n")
|> Enum.count(&Regex.match?(Regex.compile!("^\\s+\"[^\"]+\":"), &1))
_ ->
0
end
end
defp check_compiled do
beam_dirs =
case File.ls("_build/dev/lib") do
{:ok, libs} ->
libs
|> Enum.map(&"_build/dev/lib/#{&1}/ebin")
|> Enum.filter(&File.dir?/1)
|> Enum.filter(fn dir ->
case File.ls(dir) do
{:ok, files} -> Enum.any?(files, &String.ends_with?(&1, ".beam"))
_ -> false
end
end)
{:error, _} ->
[]
end
cond do
not File.dir?("_build/dev") ->
{:fail, "compiled", "_build/dev not found — project has never been compiled",
"Run: mix deps.get && mix compile"}
beam_dirs == [] ->
{:fail, "compiled", "_build/dev/lib has no compiled BEAMs — nothing to push to devices",
"Run: mix compile"}
true ->
total_beams =
Enum.reduce(beam_dirs, 0, fn dir, acc ->
case File.ls(dir) do
{:ok, files} -> acc + Enum.count(files, &String.ends_with?(&1, ".beam"))
_ -> acc
end
end)
{:ok, "compiled", "#{total_beams} BEAMs in #{length(beam_dirs)} lib(s)", nil}
end
end
# ── OTP cache checks ──────────────────────────────────────────────────────────
defp check_otp_cache do
checks = [check_otp_dir("Android", MobDev.OtpDownloader.android_otp_dir())]
if macos?() do
checks ++ [check_otp_dir("iOS simulator", MobDev.OtpDownloader.ios_sim_otp_dir())]
else
checks
end
end
defp check_otp_dir(label, dir) do
name = Path.basename(dir)
cond do
not File.dir?(dir) ->
{:fail, "OTP #{label}", "not downloaded — expected at #{dir}",
"Run: mix mob.install\n" <>
" (mix mob.deploy --native also downloads automatically)"}
Path.wildcard(Path.join(dir, "erts-*")) == [] ->
{:fail, "OTP #{label}",
"directory exists but no erts-* found — extraction was incomplete",
"Remove the stale directory and re-download:\n" <>
" rm -rf #{dir}\n" <>
" mix mob.install"}
true ->
erts =
dir |> Path.join("erts-*") |> Path.wildcard() |> List.first() |> Path.basename()
{:ok, "OTP #{label}", "#{name} (#{erts})", nil}
end
end
# ── Device checks ─────────────────────────────────────────────────────────────
defp check_devices do
List.flatten([
check_android_devices(),
if(macos?(), do: check_ios_simulators(), else: [])
])
end
defp check_android_devices do
case System.find_executable("adb") do
nil ->
# adb missing — already reported in Tools, skip here
[]
_ ->
case System.cmd("adb", ["devices", "-l"], stderr_to_stdout: true) do
{out, 0} ->
lines = out |> String.split("\n") |> Enum.drop(1) |> Enum.reject(&(&1 == ""))
authorized = Enum.filter(lines, &(&1 =~ Regex.compile!("[\\t,\\s]device\\s")))
unauthorized = Enum.filter(lines, &(&1 =~ "\tunauthorized"))
offline = Enum.filter(lines, &(&1 =~ "\toffline"))
results = []
results =
results ++
if authorized == [] do
[
{:warn, "Android devices", "none authorized",
"Connect a device via USB (enable USB Debugging) or start an emulator.\n" <>
" See: https://developer.android.com/studio/debug/dev-options"}
]
else
names =
Enum.map_join(authorized, ", ", fn line ->
serial = line |> String.split() |> hd()
model =
case Regex.run(Regex.compile!("model:(\\S+)"), line) do
[_, m] -> String.replace(m, "_", " ")
nil -> serial
end
"#{model} (#{serial})"
end)
[{:ok, "Android devices", names, nil}]
end
results =
results ++
Enum.map(unauthorized, fn line ->
serial = line |> String.split() |> hd()
{:warn, "Android device #{serial}",
"unauthorized — USB debugging prompt not accepted",
"On the device: check for an 'Allow USB debugging?' dialog and tap Allow.\n" <>
" If it doesn't appear, disconnect and reconnect the USB cable."}
end)
results =
results ++
Enum.map(offline, fn line ->
serial = line |> String.split() |> hd()
{:warn, "Android device #{serial}",
"offline — adb can see the device but cannot communicate",
"Try: adb disconnect && adb kill-server && adb devices"}
end)
results
{out, rc} ->
[
{:fail, "Android devices", "adb devices failed (exit #{rc}): #{String.trim(out)}",
"Check adb is working: adb devices"}
]
end
end
end
defp check_ios_simulators do
case System.find_executable("xcrun") do
nil ->
# xcrun missing — already reported in Tools
[]
_ ->
case System.cmd("xcrun", ["simctl", "list", "devices", "booted", "--json"],
stderr_to_stdout: true
) do
{out, 0} ->
booted =
case Jason.decode(out) do
{:ok, %{"devices" => devs}} ->
devs
|> Map.values()
|> List.flatten()
|> Enum.filter(&(&1["state"] == "Booted"))
_ ->
[]
end
if booted == [] do
[
{:warn, "iOS simulator", "none booted",
"Open Simulator.app, or boot one from the command line:\n" <>
" xcrun simctl list devices available # find a UDID\n" <>
" xcrun simctl boot <UDID>"}
]
else
names = Enum.map_join(booted, ", ", &"#{&1["name"]} (#{&1["udid"]})")
[{:ok, "iOS simulator", names, nil}]
end
_ ->
[{:warn, "iOS simulator", "xcrun simctl failed — cannot list simulators", nil}]
end
end
rescue
_ -> [{:warn, "iOS simulator", "could not query simulators", nil}]
end
# ── Output helpers ────────────────────────────────────────────────────────────
defp print_check(:ok, label, detail, _fix) do
IO.puts(
" #{ansi(:green)}✓#{ansi(:reset)} #{label}" <>
if(detail, do: " — #{ansi(:faint)}#{detail}#{ansi(:reset)}", else: "")
)
end
defp print_check(:warn, label, detail, fix) do
IO.puts(
" #{ansi(:yellow)}⚠#{ansi(:reset)} #{label}" <>
if(detail, do: " — #{detail}", else: "")
)
if fix, do: IO.puts(" #{ansi(:yellow)}#{fix}#{ansi(:reset)}")
end
defp print_check(:fail, label, detail, fix) do
IO.puts(
" #{ansi(:red)}✗#{ansi(:reset)} #{label}" <>
if(detail, do: " — #{detail}", else: "")
)
if fix, do: IO.puts(" #{ansi(:red)}#{fix}#{ansi(:reset)}")
end
# ── Helpers ──────────────────────────────────────────────────────────────────
defp has_android_project?, do: File.dir?("android")
defp has_ios_project?, do: File.exists?("ios/build.zig")
defp macos?, do: match?({:unix, :darwin}, :os.type())
defp ansi(:cyan), do: IO.ANSI.cyan()
defp ansi(:green), do: IO.ANSI.green()
defp ansi(:yellow), do: IO.ANSI.yellow()
defp ansi(:red), do: IO.ANSI.red()
defp ansi(:bright), do: IO.ANSI.bright()
defp ansi(:faint), do: IO.ANSI.faint()
defp ansi(:reset), do: IO.ANSI.reset()
end