Packages
mob_dev
0.5.10
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/mob_dev/otp_downloader.ex
defmodule MobDev.OtpDownloader do
@moduledoc """
Downloads and caches pre-built OTP releases from GitHub for Android and iOS simulator.
Artifacts are cached at `~/.mob/cache/` and reused across projects.
"""
@otp_hash "d9045670"
@release_tag "otp-#{@otp_hash}"
@base_url "https://github.com/GenericJam/mob/releases/download/#{@release_tag}"
@android_name "otp-android-#{@otp_hash}"
@android_arm32_name "otp-android-arm32-#{@otp_hash}"
@ios_sim_name "otp-ios-sim-#{@otp_hash}"
@ios_device_name "otp-ios-device-#{@otp_hash}"
@doc "Ensures the Android OTP release is cached. Returns {:ok, path} or {:error, reason}."
@spec ensure_android(String.t()) :: {:ok, String.t()} | {:error, term()}
def ensure_android(abi \\ "arm64-v8a") do
case abi do
"armeabi-v7a" -> ensure(@android_arm32_name, "#{@android_arm32_name}.tar.gz")
_ -> ensure(@android_name, "#{@android_name}.tar.gz")
end
end
@doc "Ensures the iOS simulator OTP release is cached. Returns {:ok, path} or {:error, reason}."
@spec ensure_ios_sim() :: {:ok, String.t()} | {:error, term()}
def ensure_ios_sim do
ensure(@ios_sim_name, "#{@ios_sim_name}.tar.gz")
end
@doc "Ensures the iOS device OTP release is cached. Returns {:ok, path} or {:error, reason}."
@spec ensure_ios_device() :: {:ok, String.t()} | {:error, term()}
def ensure_ios_device do
ensure(@ios_device_name, "#{@ios_device_name}.tar.gz")
end
@doc "Returns the cached Android OTP directory path (may not exist yet)."
@spec android_otp_dir(String.t()) :: String.t()
def android_otp_dir(abi \\ "arm64-v8a") do
case abi do
"armeabi-v7a" -> cache_dir(@android_arm32_name)
_ -> cache_dir(@android_name)
end
end
@doc "Returns the cached iOS simulator OTP directory path (may not exist yet)."
@spec ios_sim_otp_dir() :: String.t()
def ios_sim_otp_dir, do: cache_dir(@ios_sim_name)
@doc "Returns the cached iOS device OTP directory path (may not exist yet)."
@spec ios_device_otp_dir() :: String.t()
def ios_device_otp_dir, do: cache_dir(@ios_device_name)
# ── Private ──────────────────────────────────────────────────────────────────
defp ensure(name, tarball) do
dir = cache_dir(name)
if valid_otp_dir?(dir, name) do
{:ok, dir}
else
# Remove stale/incomplete directory before re-downloading.
# Two cases here:
# 1. previous download attempt failed mid-extraction (Nix curl, flaky net)
# 2. cached tarball predates a schema change — e.g. iOS device tarball
# now ships EPMD source under `erts/epmd/src/`. Re-download picks up
# the new asset at the same URL (same OTP hash, new revision uploaded).
if File.dir?(dir), do: File.rm_rf!(dir)
download_and_extract(name, tarball, dir)
end
end
# A valid extracted OTP dir must contain at least one erts-* subdirectory.
# The iOS device tarball additionally must ship EPMD source files at
# `erts/epmd/src/`, because `build_device.sh` static-links EPMD into the app
# and there's no other place to source those .c files from. Older tarballs
# (without source) extract cleanly but fail at iOS device build time with
# `clang: no such file or directory: epmd.c` — so we treat them as invalid
# and force a re-download to pick up the schema-bumped asset.
@doc false
@spec valid_otp_dir?(String.t(), String.t()) :: boolean()
def valid_otp_dir?(dir, name) do
base_valid? = File.dir?(dir) and Path.wildcard(Path.join(dir, "erts-*")) != []
cond do
not base_valid? -> false
not crypto_present?(dir) -> false
String.starts_with?(name, "otp-ios-device-") -> ios_device_extras_present?(dir)
true -> true
end
end
@doc false
@spec crypto_present?(String.t()) :: boolean()
def crypto_present?(dir) do
# Schema bump (2026-05-06): tarballs now ship erts-VSN/lib/crypto.a +
# erts-VSN/lib/libcrypto.a so apps can statically link real OpenSSL.
# Older tarballs at the same release tag predate this change and are
# treated as stale — re-download picks up the new content.
Path.wildcard(Path.join([dir, "erts-*", "lib", "crypto.a"])) != [] and
Path.wildcard(Path.join([dir, "erts-*", "lib", "libcrypto.a"])) != []
end
@doc false
@spec ios_device_extras_present?(String.t()) :: boolean()
def ios_device_extras_present?(dir) do
# `build_device.sh` static-links EPMD into the iOS app — it needs both the
# .c sources AND the headers they #include (`epmd.h`, `epmd_int.h` — also
# in erts/epmd/src/). A tarball missing the headers extracts cleanly but
# fails at clang time with `'epmd.h' file not found`, so we treat it as
# invalid and force re-download.
Enum.all?(
~w[
erts/epmd/src/epmd.c
erts/epmd/src/epmd_srv.c
erts/epmd/src/epmd_cli.c
erts/epmd/src/epmd.h
erts/epmd/src/epmd_int.h
],
fn rel -> File.exists?(Path.join(dir, rel)) end
)
end
defp cache_dir(name) do
base =
System.get_env("MOB_CACHE_DIR") ||
Path.join([System.get_env("HOME"), ".mob", "cache"])
Path.join(base, name)
end
defp download_and_extract(name, tarball, dest_dir) do
url = "#{@base_url}/#{tarball}"
tmp_file = Path.join(System.tmp_dir!(), tarball)
IO.puts(" Downloading #{name} OTP release...")
IO.puts(" URL: #{url}")
File.mkdir_p!(Path.dirname(dest_dir))
with :ok <- download(url, tmp_file),
:ok <- extract(tmp_file, dest_dir),
:ok <- verify_erts(dest_dir) do
File.rm(tmp_file)
IO.puts(" Cached at #{dest_dir}")
{:ok, dest_dir}
else
{:error, reason} ->
File.rm(tmp_file)
File.rm_rf(dest_dir)
{:error, reason}
end
end
defp download(url, dest), do: MobDev.Download.curl(url, dest)
defp extract(tarball, dest_dir) do
File.mkdir_p!(dest_dir)
# The tarball extracts into a single top-level directory; strip it with --strip-components=1.
case System.cmd("tar", ["xzf", tarball, "-C", dest_dir, "--strip-components=1"],
stderr_to_stdout: true
) do
{_, 0} -> :ok
{out, rc} -> {:error, "tar failed (exit #{rc}): #{String.trim(out)}"}
end
end
defp verify_erts(dir) do
case Path.wildcard(Path.join(dir, "erts-*")) do
[_ | _] ->
:ok
[] ->
{:error,
"OTP extraction produced no erts-* directory in #{dir}.\n" <>
" The tarball may have an unexpected layout.\n" <>
" Run `mix mob.doctor` for diagnosis, or report at https://github.com/GenericJam/mob/issues"}
end
end
end