Current section

Files

Jump to
mob_dev lib mix tasks mob.doctor.ex
Raw

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