Packages
mob_dev
0.3.33
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/native_build.ex
defmodule MobDev.NativeBuild do
alias MobDev.Release
@moduledoc """
Builds native binaries (APK for Android, .app bundle for iOS simulator)
for the current Mob project.
Reads paths from `mob.exs` in the project root. If `mob.exs` is missing
or paths haven't been configured, prints instructions and exits.
OTP runtimes for Android and iOS are downloaded automatically from GitHub
and cached at `~/.mob/cache/` by `MobDev.OtpDownloader`.
## mob.exs keys
* `:mob_dir` — mob library repo (native C/ObjC/Swift source)
* `:elixir_lib` — Elixir stdlib lib dir
"""
@doc """
Builds native binaries for all platforms present in the project.
Runs Android Gradle build if `android/` dir exists.
Runs iOS build script if `ios/build.sh` exists (simulator), or
`xcodebuild` if targeting a physical iOS device via `device:` opt.
"""
@spec build_all(keyword()) :: [:ok | {:error, term()}]
def build_all(opts \\ []) do
cfg = load_config()
platforms = Keyword.get(opts, :platforms, [:android, :ios])
device_id = Keyword.get(opts, :device, nil)
slim = Keyword.get(opts, :slim, true)
platforms = narrow_platforms_for_device(platforms, device_id)
Process.put(:mob_slim, slim)
results = []
# Skip Android when its toolchain isn't installed instead of failing the
# build half an hour into a partial-setup user's first deploy. Default
# `mix mob.deploy` (no platform flag) targets every platform with a
# `<dir>/` scaffold, but users who only set up iOS hit a Gradle error
# half a build later — annoying, and fixable by checking up front.
results =
cond do
:android not in platforms ->
results
not File.dir?("android") ->
results
not android_toolchain_available?() ->
warn_skipped_android()
results
true ->
[build_android(cfg, device_id) | results]
end
results =
if :ios in platforms do
physical_udid =
cond do
is_binary(device_id) and ios_physical_udid?(device_id) ->
device_id
is_nil(device_id) ->
auto_detect_physical_ios()
true ->
nil
end
cond do
not ios_toolchain_available?() ->
warn_skipped_ios()
results
physical_udid ->
[build_ios_physical(cfg, physical_udid) | results]
File.exists?("ios/build.sh") ->
[build_ios(cfg) | results]
true ->
results
end
else
results
end
if results == [] do
IO.puts(
" #{IO.ANSI.yellow()}No native build targets found (missing android/ or ios/build.sh, or toolchains)#{IO.ANSI.reset()}"
)
end
Enum.each(results, fn
{:ok, platform} ->
IO.puts(" #{IO.ANSI.green()}✓ #{platform} native build complete#{IO.ANSI.reset()}")
{:error, platform, reason} ->
IO.puts(
" #{IO.ANSI.red()}✗ #{platform} native build failed: #{reason}#{IO.ANSI.reset()}"
)
end)
ok_count = Enum.count(results, &match?({:ok, _}, &1))
ok_count == length(results)
end
# ── Android ──────────────────────────────────────────────────────────────────
defp build_android(cfg, device_id) do
IO.puts(" Building Android APK...")
bundle_id = cfg[:bundle_id] || MobDev.Config.bundle_id()
apk = "android/app/build/outputs/apk/debug/app-debug.apk"
with {:ok, otp_arm64} <- MobDev.OtpDownloader.ensure_android("arm64-v8a"),
{:ok, otp_arm32} <- MobDev.OtpDownloader.ensure_android("armeabi-v7a"),
:ok <- ensure_jni_libs(otp_arm64, "arm64-v8a"),
:ok <- ensure_jni_libs(otp_arm32, "armeabi-v7a"),
:ok <- gradle_assemble(),
:ok <- adb_install_all(apk, bundle_id, device_id),
:ok <-
push_otp_release_android(
bundle_id,
cfg[:elixir_lib],
otp_arm64,
otp_arm32,
device_id
) do
{:ok, "Android"}
else
{:error, reason} -> {:error, "Android", reason}
end
end
# Copies ERTS helper executables into jniLibs as lib*.so so Android grants
# them the apk_data_file SELinux label (required for execve).
defp ensure_jni_libs(otp_dir, abi) do
jni_libs = "android/app/src/main/jniLibs/#{abi}"
File.mkdir_p!(jni_libs)
erts_bins = Path.wildcard("#{otp_dir}/erts-*/bin") |> List.first()
if erts_bins do
for {exe, lib} <- [
{"erl_child_setup", "liberl_child_setup.so"},
{"inet_gethost", "libinet_gethost.so"},
{"epmd", "libepmd.so"}
] do
src = Path.join(erts_bins, exe)
dst = Path.join(jni_libs, lib)
if File.exists?(src), do: cp(src, dst)
end
end
:ok
end
defp gradle_assemble do
IO.puts(" Running Gradle assembleDebug...")
IO.puts(" (first build may take a few minutes while CMake compiles native code)")
android_dir = Path.join(File.cwd!(), "android")
gradlew = Path.join(android_dir, "gradlew")
# Stale Gradle daemon registry locks accumulate when builds are killed (Ctrl+C,
# force-stop, etc.) and cause subsequent runs to hang silently while the wrapper
# waits to acquire the lock. Clear them before every build.
clear_stale_gradle_locks()
if File.exists?(gradlew) do
# Run gradlew as `bash scriptpath args` rather than exec-ing it directly
# or using `bash -c "cmd"`.
#
# When System.cmd exec's gradlew directly, Gradle's worker subprocesses
# may inherit the Erlang port's I/O pipes. If they outlive the main JVM,
# the pipe stays open and System.cmd never receives EOF — hanging forever.
#
# `bash scriptpath args` keeps bash as the parent process. bash exits when
# the script finishes (even if exec'd children remain), cleanly closing the
# pipe to Erlang.
#
# NOTE: Kotlin errors appear before "* What went wrong:" in the output.
# If the build fails, scroll up or run `cd android && ./gradlew assembleDebug`.
case System.cmd("bash", [gradlew, "assembleDebug", "--no-daemon"],
cd: android_dir,
stderr_to_stdout: true,
into: IO.stream()
) do
{_, 0} ->
:ok
{_, _} ->
{:error,
"Gradle failed — scroll up for errors\n (or run: cd android && ./gradlew assembleDebug)"}
end
else
{:error, "gradlew not found at #{gradlew}"}
end
end
# Remove stale Gradle lock files left behind when a build is interrupted
# (Ctrl+C, kill, etc.). These cause the next run to hang indefinitely while
# the wrapper waits to acquire the lock.
defp clear_stale_gradle_locks do
gradle_home =
System.get_env("GRADLE_USER_HOME") ||
Path.join(System.user_home!(), ".gradle")
patterns = [
"#{gradle_home}/daemon/*/registry.bin.lock",
"#{gradle_home}/wrapper/dists/**/*.lck",
"#{gradle_home}/native/**/*.lock",
"#{gradle_home}/caches/**/*.lock",
"#{gradle_home}/caches/**/*.lck"
]
Enum.each(patterns, fn pattern ->
Path.wildcard(pattern, match_dot: true) |> Enum.each(&File.rm/1)
end)
end
defp adb_install_all(apk, bundle_id, device_id) do
case System.cmd("adb", ["devices"], stderr_to_stdout: true) do
{output, 0} ->
serials =
output
|> String.split("\n")
|> Enum.drop(1)
|> Enum.filter(&String.contains?(&1, "\tdevice"))
|> Enum.map(&hd(String.split(&1, "\t")))
|> filter_serials(device_id)
Enum.each(serials, fn serial ->
IO.puts(" Installing APK on #{serial}...")
System.cmd("adb", ["-s", serial, "shell", "am", "force-stop", bundle_id],
stderr_to_stdout: true
)
System.cmd("adb", ["-s", serial, "uninstall", bundle_id], stderr_to_stdout: true)
{install_out, install_rc} =
System.cmd("adb", ["-s", serial, "install", apk], stderr_to_stdout: true)
if install_rc != 0 or String.contains?(install_out, "INSTALL_FAILED") do
reason =
install_out
|> String.split("\n")
|> Enum.find(&String.contains?(&1, "INSTALL_FAILED")) || String.trim(install_out)
IO.puts(
" #{IO.ANSI.yellow()}⚠ #{serial}: APK install failed — #{reason}#{IO.ANSI.reset()}"
)
IO.puts(" (OTP push will be skipped for this device)")
else
fix_erts_helper_labels(serial, bundle_id)
end
end)
:ok
{out, _} ->
{:error, "adb devices failed: #{out}"}
end
end
# Android 15 streaming install labels ERTS helper .so files as app_data_file
# instead of apk_data_file, blocking execute_no_trans by untrusted_app.
# Fix by chcon-ing them back to apk_data_file (requires root / emulator).
defp fix_erts_helper_labels(serial, bundle_id) do
adb = fn args -> System.cmd("adb", ["-s", serial | args], stderr_to_stdout: true) end
# Only works on rooted/emulator builds — silently skip on real devices.
rooted? =
case adb.(["root"]) do
{out, 0} -> out =~ "restarting" or out =~ "already running as root"
_ -> false
end
if rooted? do
:timer.sleep(800)
{lib_dir_out, _} =
adb.([
"shell",
"pm dump #{bundle_id} | grep nativeLibraryDir | head -1 | awk '{print $NF}'"
])
lib_dir = String.trim(lib_dir_out)
if lib_dir != "" do
for lib <- ["liberl_child_setup.so", "libinet_gethost.so", "libepmd.so"] do
adb.(["shell", "chcon", "u:object_r:apk_data_file:s0", "#{lib_dir}/#{lib}"])
end
end
end
end
defp push_otp_release_android(bundle_id, elixir_lib, otp_arm64, otp_arm32, device_id) do
app_data = "/data/data/#{bundle_id}/files"
IO.puts(" Pushing OTP release to device(s)...")
case System.cmd("adb", ["devices"], stderr_to_stdout: true) do
{output, 0} ->
serials = parse_adb_serials(output) |> filter_serials(device_id)
if serials == [], do: IO.puts(" (no devices connected, skipping OTP push)")
Enum.reduce_while(serials, :ok, fn serial, _ ->
otp_dir = device_otp_dir(serial, otp_arm64, otp_arm32)
result =
try do
push_otp_to_device(serial, bundle_id, app_data, otp_dir, elixir_lib)
catch
{:skip, _} -> :ok
end
case result do
:ok -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
{out, _} ->
{:error, "adb devices failed: #{out}"}
end
end
defp device_otp_dir(serial, otp_arm64, otp_arm32) do
{abi_out, _} =
System.cmd("adb", ["-s", serial, "shell", "getprop", "ro.product.cpu.abi"],
stderr_to_stdout: true
)
abi = String.trim(abi_out)
otp_dir_for_abi(abi, otp_arm64, otp_arm32)
end
@doc "Returns the OTP directory for the given Android ABI string."
@spec otp_dir_for_abi(String.t(), String.t(), String.t()) :: String.t()
def otp_dir_for_abi("armeabi-v7a", _arm64, arm32), do: arm32
def otp_dir_for_abi(_abi, arm64, _arm32), do: arm64
defp push_otp_to_device(serial, bundle_id, app_data, otp_dir, elixir_lib) do
adb = fn args -> System.cmd("adb", ["-s", serial | args], stderr_to_stdout: true) end
{pm_out, _} = adb.(["shell", "pm", "list", "packages", bundle_id])
unless String.contains?(pm_out, "package:#{bundle_id}") do
IO.puts(
" #{IO.ANSI.yellow()}⚠ #{serial}: #{bundle_id} not installed — skipping OTP push#{IO.ANSI.reset()}"
)
throw({:skip, serial})
end
# Launch briefly so the app creates its files directory, then stop.
adb.(["shell", "am", "start", "-n", "#{bundle_id}/.MainActivity"])
:timer.sleep(2000)
adb.(["shell", "am", "force-stop", bundle_id])
:timer.sleep(500)
case adb.(["root"]) do
{out, 0} ->
if out =~ "restarting" or out =~ "already running as root" do
:timer.sleep(1000)
push_otp_root(adb, app_data, otp_dir, elixir_lib)
else
push_otp_runas(serial, bundle_id, app_data, otp_dir, elixir_lib)
end
_ ->
push_otp_runas(serial, bundle_id, app_data, otp_dir, elixir_lib)
end
end
defp push_otp_root(adb, app_data, otp_dir, elixir_lib) do
try do
adb.(["shell", "mkdir -p #{app_data}/otp"])
case adb.(["push", "#{otp_dir}/.", "#{app_data}/otp/"]) do
{_, 0} -> :ok
{out, _} -> throw({:error, "push OTP release failed: #{String.slice(out, -300, 300)}"})
end
adb.(["shell", "mkdir -p #{app_data}/otp/lib/elixir/ebin"])
adb.(["shell", "mkdir -p #{app_data}/otp/lib/logger/ebin"])
adb.(["shell", "mkdir -p #{app_data}/otp/lib/eex/ebin"])
case adb.(["push", "#{elixir_lib}/elixir/ebin/.", "#{app_data}/otp/lib/elixir/ebin/"]) do
{_, 0} -> :ok
{out, _} -> throw({:error, "push elixir failed: #{String.slice(out, -300, 300)}"})
end
case adb.(["push", "#{elixir_lib}/logger/ebin/.", "#{app_data}/otp/lib/logger/ebin/"]) do
{_, 0} -> :ok
{out, _} -> throw({:error, "push logger failed: #{String.slice(out, -300, 300)}"})
end
case adb.(["push", "#{elixir_lib}/eex/ebin/.", "#{app_data}/otp/lib/eex/ebin/"]) do
{_, 0} -> :ok
{out, _} -> throw({:error, "push eex failed: #{String.slice(out, -300, 300)}"})
end
# Fix ownership so the app can read its own files.
{uid_out, _} = adb.(["shell", "stat -c %u #{app_data}/.."])
uid = String.trim(uid_out)
if uid != "", do: adb.(["shell", "chown -R #{uid}:#{uid} #{app_data}"])
:ok
catch
{:error, reason} -> {:error, reason}
end
end
defp push_otp_runas(serial, bundle_id, app_data, otp_dir, elixir_lib) do
stage_local = Path.join(System.tmp_dir!(), "mob_otp_#{serial}.tar")
stage_device = "/data/local/tmp/mob_otp.tar"
try do
tmp = Path.join(System.tmp_dir!(), "mob_otp_stage_#{serial}")
File.rm_rf!(tmp)
otp_tmp = Path.join(tmp, "otp")
File.mkdir_p!(otp_tmp)
System.cmd("cp", ["-r", "#{otp_dir}/.", otp_tmp], stderr_to_stdout: true)
File.mkdir_p!(Path.join(otp_tmp, "lib/elixir/ebin"))
File.mkdir_p!(Path.join(otp_tmp, "lib/logger/ebin"))
File.mkdir_p!(Path.join(otp_tmp, "lib/eex/ebin"))
System.cmd(
"cp",
["-r", "#{elixir_lib}/elixir/ebin/.", Path.join(otp_tmp, "lib/elixir/ebin")],
stderr_to_stdout: true
)
System.cmd(
"cp",
["-r", "#{elixir_lib}/logger/ebin/.", Path.join(otp_tmp, "lib/logger/ebin")],
stderr_to_stdout: true
)
System.cmd("cp", ["-r", "#{elixir_lib}/eex/ebin/.", Path.join(otp_tmp, "lib/eex/ebin")],
stderr_to_stdout: true
)
# COPYFILE_DISABLE=1 prevents macOS from inserting ._<file> AppleDouble
# sidecars into the archive (Toybox tar on Android can't chown to macOS UID).
case System.cmd("tar", ["cf", stage_local, "-C", tmp, "otp"],
env: [{"COPYFILE_DISABLE", "1"}],
stderr_to_stdout: true
) do
{_, 0} -> :ok
{out, _} -> throw({:error, "tar create failed: #{out}"})
end
case System.cmd("adb", ["-s", serial, "push", stage_local, stage_device],
stderr_to_stdout: true
) do
{_, 0} -> :ok
{out, _} -> throw({:error, "adb push failed: #{out}"})
end
# `2>/dev/null; true` — Toybox tar cannot chown files to macOS UID 501
# and exits 1, but extraction succeeds. Suppress errors and always succeed.
cmd =
"run-as #{bundle_id} mkdir -p #{app_data} && " <>
"run-as #{bundle_id} tar xf #{stage_device} -C #{app_data} 2>/dev/null; true"
case System.cmd("adb", ["-s", serial, "shell", cmd], stderr_to_stdout: true) do
{_, 0} -> :ok
{out, _} -> throw({:error, "run-as tar failed: #{out}"})
end
System.cmd("adb", ["-s", serial, "shell", "rm -f #{stage_device}"], stderr_to_stdout: true)
:ok
catch
{:error, reason} -> {:error, reason}
after
File.rm(stage_local)
File.rm_rf(Path.join(System.tmp_dir!(), "mob_otp_stage_#{serial}"))
end
end
defp parse_adb_serials(output) do
output
|> String.split("\n")
|> Enum.drop(1)
|> Enum.filter(&String.contains?(&1, "\tdevice"))
|> Enum.map(&hd(String.split(&1, "\t")))
end
# Filters a list of adb serials by `--device <id>`. The id is matched against
# the serial directly, against an `IP:port` form (auto-strip `:5555`), and
# against a bare IP for WiFi-adb devices. Returns all serials when device_id
# is nil. Returns empty + warning if device_id matches no connected serial.
@doc false
@spec filter_serials([String.t()], String.t() | nil) :: [String.t()]
def filter_serials(serials, nil), do: serials
def filter_serials(serials, id) when is_binary(id) do
matches =
Enum.filter(serials, fn s ->
s == id or s == "#{id}:5555" or strip_port(s) == id
end)
if matches == [] do
IO.puts(
" #{IO.ANSI.yellow()}⚠ --device #{id} matched no connected adb device — skipping#{IO.ANSI.reset()}"
)
end
matches
end
defp strip_port(s) do
case String.split(s, ":", parts: 2) do
[host, _port] -> host
_ -> s
end
end
# ── iOS ──────────────────────────────────────────────────────────────────────
defp build_ios(cfg) do
with :ok <- check_path(cfg[:mob_dir], "mob_dir"),
:ok <- check_path(cfg[:elixir_lib], "elixir_lib"),
{:ok, otp_root} <- MobDev.OtpDownloader.ensure_ios_sim() do
IO.puts(" Building iOS simulator app...")
env = [
{"MOB_DIR", Path.expand(cfg[:mob_dir])},
{"MOB_ELIXIR_LIB", Path.expand(cfg[:elixir_lib])},
{"MOB_IOS_OTP_ROOT", otp_root}
]
case System.cmd("bash", ["ios/build.sh"],
env: env,
stderr_to_stdout: true,
into: IO.stream()
) do
{_, 0} -> {:ok, "iOS"}
{_, _} -> {:error, "iOS", "build.sh failed — check output above"}
end
else
{:error, reason} -> {:error, "iOS", reason}
end
end
# Physical iOS: compile for device SDK, bundle OTP, sign, install via devicectl.
# Mirrors the mob_qa build_device.sh approach but driven from mob.exs config.
#
# Required mob.exs keys:
# ios_team_id — Apple Developer Team ID (10-char alphanumeric)
# ios_sign_identity — codesign identity string (from `security find-identity -v -p codesigning`)
# ios_profile_uuid — provisioning profile UUID (filename without .mobileprovision)
#
# Optional mob.exs key:
# ios_epmd_build_src — path to an OTP tree that exposes EPMD source under
# erts/epmd/src/ and iOS headers under erts/include/.
# Defaults to the iOS-device OTP cache, which ships
# these files starting with the post-(c) tarball.
defp build_ios_physical(cfg, udid) do
IO.puts(" Building iOS app for physical device #{udid}...")
with {:ok, cfg} <- check_device_signing_config(cfg),
{:ok, otp_root} <- MobDev.OtpDownloader.ensure_ios_device() do
script = generate_build_device_sh(cfg, otp_root)
script_path = "ios/build_device.sh"
File.write!(script_path, script)
File.chmod!(script_path, 0o755)
env = build_device_env(cfg, otp_root)
case System.cmd("bash", [script_path, udid],
env: env,
stderr_to_stdout: true,
into: IO.stream()
) do
{_, 0} -> {:ok, "iOS (device)"}
{_, _} -> {:error, "iOS", "build_device.sh failed — check output above"}
end
else
{:error, reason} -> {:error, "iOS", reason}
end
end
# Returns {:ok, cfg_with_signing} or {:error, reason}.
# Values already in mob.exs are kept; missing ones are auto-detected from the
# keychain and provisioning profile directories. Fails with a clear message only
# when auto-detection itself finds multiple candidates and can't pick one.
defp check_device_signing_config(cfg) do
bundle_id = cfg[:bundle_id]
with {:ok, identity} <- resolve_sign_identity(cfg[:ios_sign_identity], cfg[:ios_team_id]),
{:ok, {profile_uuid, team_id}} <-
resolve_profile_uuid(cfg[:ios_profile_uuid], bundle_id, cfg[:ios_team_id]) do
{:ok,
cfg
|> Keyword.put(:ios_sign_identity, identity)
|> Keyword.put(:ios_team_id, team_id)
|> Keyword.put(:ios_profile_uuid, profile_uuid)}
end
end
# Resolves signing identity. Returns {:ok, identity} or {:error, reason}.
defp resolve_sign_identity(identity, _team_id) when is_binary(identity), do: {:ok, identity}
defp resolve_sign_identity(_identity, _team_id) do
case System.cmd("security", ["find-identity", "-v", "-p", "codesigning"],
stderr_to_stdout: true
) do
{output, 0} ->
identities =
Regex.scan(Regex.compile!("\\d+\\) [0-9A-F]+ \"([^\"]+)\""), output)
|> Enum.map(fn [_, full] -> full end)
|> Enum.filter(&String.contains?(&1, "Apple Development"))
|> Enum.uniq()
case identities do
[] ->
{:error,
"""
No Apple Development signing identity found in the keychain.
One-time setup:
1. Open Xcode → Settings → Accounts → add your Apple ID
2. Select your team → click "Download Manual Profiles"
3. Close Xcode
This installs a development certificate into your Keychain so mob
can sign device builds without Xcode.
"""}
[identity] ->
IO.puts(
" #{IO.ANSI.cyan()}Auto-detected signing identity: #{identity}#{IO.ANSI.reset()}"
)
{:ok, identity}
many ->
choices = Enum.map_join(many, "\n", &" #{&1}")
{:error,
"""
Multiple signing identities found — add ios_sign_identity to mob.exs:
config :mob_dev,
ios_sign_identity: "Apple Development: you@example.com (XXXXXXXXXX)"
Available identities:
#{choices}
"""}
end
{out, _} ->
{:error, "security find-identity failed: #{out}"}
end
end
# Resolves provisioning profile UUID + team ID from profiles on disk.
# Returns {:ok, {uuid, team_id}} or {:error, reason}.
# Team ID is read from the profile itself (more reliable than parsing the cert string).
defp resolve_profile_uuid(uuid, _bundle_id, team_id)
when is_binary(uuid) and is_binary(team_id),
do: {:ok, {uuid, team_id}}
defp resolve_profile_uuid(uuid, bundle_id, _team_id) do
profile_dirs = [
Path.expand("~/Library/Developer/Xcode/UserData/Provisioning Profiles"),
Path.expand("~/Library/MobileDevice/Provisioning Profiles")
]
all_profiles =
Enum.flat_map(profile_dirs, &Path.wildcard(Path.join(&1, "*.mobileprovision")))
|> Enum.flat_map(&Release.parse_mobileprovision/1)
# `mix mob.deploy --native` is for installing dev builds on registered
# test devices (via xcrun devicectl). devicectl rejects App Store / Beta
# profiles with "Attempted to install a Beta profile without the proper
# entitlement" — so filter to Development profiles only:
# - Development: provisioned_devices? = true, provisions_all_devices? = false
# - Ad Hoc: provisioned_devices? = true, provisions_all_devices? = false (signed
# with Distribution cert; rare for our flow)
# - App Store: provisioned_devices? = false, provisions_all_devices? = false
# - Enterprise: provisioned_devices? = false, provisions_all_devices? = true
# We require provisioned_devices? = true. App Store profiles fall through to release.ex.
dev_profiles = Enum.filter(all_profiles, & &1.provisioned_devices?)
# Prefer exact bundle ID match; fall back to wildcard profiles (app_id "TEAMID.*")
exact_profiles =
Enum.filter(dev_profiles, fn %{app_id: app_id} ->
String.ends_with?(app_id, ".#{bundle_id}")
end)
profiles =
if exact_profiles != [] do
exact_profiles
else
Enum.filter(dev_profiles, fn %{app_id: app_id} ->
String.ends_with?(app_id, ".*")
end)
end
candidates =
if is_binary(uuid) do
Enum.filter(profiles, &(&1.uuid == uuid))
else
profiles
end
case candidates do
[] ->
{:error, no_dev_profile_message(bundle_id, all_profiles)}
[%{uuid: found_uuid, app_id: app_id, team_id: team}] ->
unless is_binary(uuid) do
IO.puts(
" #{IO.ANSI.cyan()}Auto-detected Development profile: #{found_uuid} (team #{team})#{IO.ANSI.reset()}"
)
end
if String.ends_with?(app_id, ".*") do
IO.puts(
" #{IO.ANSI.cyan()} (using wildcard profile — run `mix mob.provision` to create a dedicated profile for #{bundle_id})#{IO.ANSI.reset()}"
)
end
{:ok, {found_uuid, team}}
many ->
choices = Enum.map_join(many, "\n", fn %{uuid: u, app_id: a} -> " #{u} (#{a})" end)
{:error,
"""
Multiple Development profiles match '#{bundle_id}' — add ios_profile_uuid to mob.exs:
config :mob_dev, ios_profile_uuid: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
Matching Development profiles:
#{choices}
"""}
end
end
# Distinguish "no profiles at all" from "have App Store profile but no Development one".
# The latter is the conflict case we hit after setting up TestFlight publishing —
# the user has a working App Store profile but `mob.deploy` needs a Development one.
defp no_dev_profile_message(bundle_id, all_profiles) do
has_app_store_profile? =
Enum.any?(all_profiles, fn %{app_id: app_id} = p ->
not p.provisioned_devices? and not p.provisions_all_devices? and
String.ends_with?(app_id, ".#{bundle_id}")
end)
if has_app_store_profile? do
"""
No Development profile found for bundle ID '#{bundle_id}', but an App Store
profile exists. `mix mob.deploy --native` needs a Development profile —
App Store profiles can only be installed via TestFlight / App Store, not via
xcrun devicectl.
To get one (one-time):
1. Open https://developer.apple.com/account/resources/profiles/list
2. Click + → iOS App Development → choose '#{bundle_id}' → select your dev cert
and the device(s) you want to install on → Generate
3. Open Xcode → Settings → Accounts → select your team → "Download Manual Profiles"
4. Re-run `mix mob.deploy --native`
Now your machine has both profiles. mob_dev picks Development for `mob.deploy`
and App Store for `mob.release` automatically.
"""
else
"""
No provisioning profile found for bundle ID '#{bundle_id}'.
One-time setup (only needed once per machine):
1. Open Xcode
2. Xcode → Settings → Accounts → add your Apple ID if not already listed
3. Select your team → click "Download Manual Profiles"
4. Close Xcode — you won't need to open it again
After that, `mix mob.deploy --native` will find the profile automatically.
If the bundle ID is not yet registered in your developer account:
open https://developer.apple.com/account/resources/identifiers/list
"""
end
end
defp build_device_env(cfg, otp_root) do
app_atom = Mix.Project.config()[:app]
app_name = app_atom |> to_string() |> Macro.camelize()
app_module = to_string(app_atom)
elixir_lib = resolve_elixir_lib(cfg[:elixir_lib])
# The iOS device OTP cache (under ~/.mob/cache/otp-ios-device-<hash>/) ships
# the EPMD source files needed for static EPMD compilation, so the cache dir
# itself is the EPMD build root. The `ios_epmd_build_src` config remains as
# an escape hatch for advanced users pointing at a custom OTP tree.
epmd_src = cfg[:ios_epmd_build_src] || otp_root
# Dev path defaults to OFF (slim adds ~5-10s per build that dev iteration
# doesn't want). `mix mob.deploy --slim` opts in. `mix mob.release` flips
# this default in its own env via MobDev.Release.
slim_flag = if Process.get(:mob_slim, false), do: "1", else: "0"
[
{"MOB_DIR", Path.expand(cfg[:mob_dir])},
{"MOB_ELIXIR_LIB", Path.expand(elixir_lib)},
{"MOB_IOS_DEVICE_OTP_ROOT", otp_root},
{"MOB_IOS_EPMD_BUILD_SRC", epmd_src},
{"MOB_IOS_BUNDLE_ID", cfg[:bundle_id]},
{"MOB_IOS_TEAM_ID", cfg[:ios_team_id]},
{"MOB_IOS_SIGN_IDENTITY", cfg[:ios_sign_identity]},
{"MOB_IOS_PROFILE_UUID", cfg[:ios_profile_uuid]},
{"MOB_APP_NAME", app_name},
{"MOB_APP_MODULE", app_module},
{"MOB_SLIM", slim_flag}
]
end
defp generate_build_device_sh(_cfg, _otp_root) do
~S"""
#!/bin/bash
# ios/build_device.sh — Physical iOS device build (generated by mix mob.deploy --native).
# All config comes from environment variables set by NativeBuild. Do not hardcode values here.
set -e
cd "$(dirname "$0")/.."
# ── Config from mob.exs (set by mix mob.deploy --native) ─────────────────────
MOB_DIR="${MOB_DIR:?MOB_DIR not set}"
# Always use the Elixir lib dir that matches the running `elixir` binary so
# the bundled Elixir stdlib matches the version that compiled the BEAMs.
ELIXIR_LIB=$(elixir -e "IO.puts(Path.dirname(to_string(:code.lib_dir(:elixir))))" 2>/dev/null)
if [ -z "$ELIXIR_LIB" ] || [ ! -d "$ELIXIR_LIB/elixir/ebin" ]; then
ELIXIR_LIB="${MOB_ELIXIR_LIB:?MOB_ELIXIR_LIB not set}"
fi
OTP_ROOT="${MOB_IOS_DEVICE_OTP_ROOT:?MOB_IOS_DEVICE_OTP_ROOT not set}"
# MOB_IOS_EPMD_BUILD_SRC is exported by `mix mob.deploy --native` (defaults
# to the iOS-device OTP cache at ~/.mob/cache/otp-ios-device-<hash>/, which
# ships the EPMD source files). The fallback below covers the rare case of
# running this script directly without going through `mix mob.deploy`.
EPMD_BUILD_SRC="${MOB_IOS_EPMD_BUILD_SRC:-$OTP_ROOT}"
BUNDLE_ID="${MOB_IOS_BUNDLE_ID:?bundle_id not set in mob.exs}"
TEAM_ID="${MOB_IOS_TEAM_ID:?ios_team_id not set in mob.exs}"
SIGN_IDENTITY="${MOB_IOS_SIGN_IDENTITY:?ios_sign_identity not set in mob.exs}"
PROFILE_UUID="${MOB_IOS_PROFILE_UUID:?ios_profile_uuid not set in mob.exs}"
APP_NAME="${MOB_APP_NAME:?MOB_APP_NAME not set}" # CamelCase binary name, e.g. MobDemo
APP_MODULE="${MOB_APP_MODULE:?MOB_APP_MODULE not set}" # snake_case, e.g. mob_demo
DEVICE_UDID="${1:?Usage: build_device.sh <device-udid>}"
ERTS_VSN=$(ls "$OTP_ROOT" | grep '^erts-' | sort -V | tail -1)
[ -z "$ERTS_VSN" ] && echo "ERROR: No erts-* in $OTP_ROOT" && exit 1
# Auto-detect OTP release number (e.g. "27", "28", "29") from the tarball
# so mob_beam.m's hard-coded `-boot $ROOTDIR/releases/<N>/start_clean`
# matches what was actually shipped. Crash mode if mismatched:
# "Runtime terminating during boot ({'cannot get bootfile', ...})"
OTP_RELEASE=$(ls "$OTP_ROOT/releases" 2>/dev/null | grep -E '^[0-9]+$' | sort -V | tail -1)
[ -z "$OTP_RELEASE" ] && echo "ERROR: No releases/<N>/ in $OTP_ROOT" && exit 1
echo "=== ERTS: $ERTS_VSN, OTP: $OTP_RELEASE, App: $APP_NAME, Bundle: $BUNDLE_ID ==="
BEAMS_DIR="$OTP_ROOT/$APP_MODULE"
SDKROOT=$(xcrun -sdk iphoneos --show-sdk-path)
HOSTCC=$(xcrun -find cc)
CC="$HOSTCC -arch arm64 -miphoneos-version-min=17.0 -isysroot $SDKROOT"
IFLAGS="-I$OTP_ROOT/$ERTS_VSN/include \
-I$OTP_ROOT/$ERTS_VSN/include/internal \
-I$MOB_DIR/ios"
# Same lib set as the iOS sim build. `libmicro_openssl.a` was historically
# listed here to provide MD5Init/MD5Update/MD5Final, but `--without-ssl`
# OTP doesn't reference those symbols — libbeam.a's `erts_md5_*` covers
# everything that does get called. The link succeeds without it.
LIBS="
$OTP_ROOT/$ERTS_VSN/lib/libbeam.a
$OTP_ROOT/$ERTS_VSN/lib/internal/liberts_internal_r.a
$OTP_ROOT/$ERTS_VSN/lib/internal/libethread.a
$OTP_ROOT/$ERTS_VSN/lib/libzstd.a
$OTP_ROOT/$ERTS_VSN/lib/libepcre.a
$OTP_ROOT/$ERTS_VSN/lib/libryu.a
$OTP_ROOT/$ERTS_VSN/lib/asn1rt_nif.a
"
# ── Compile Elixir/Erlang ─────────────────────────────────────────────────────
echo "=== Compiling Erlang/Elixir ==="
mix compile
echo "=== Copying BEAM files to $BEAMS_DIR ==="
mkdir -p "$BEAMS_DIR"
for lib_dir in _build/dev/lib/*/ebin; do
cp "$lib_dir"/* "$BEAMS_DIR/" 2>/dev/null || true
done
SQLITE_STATIC_LIB=""
if [ -d "_build/dev/lib/exqlite" ]; then
echo "=== Installing exqlite as OTP library (static NIF) ==="
EXQLITE_VSN=$(grep '"exqlite"' mix.lock \
| grep -o '"[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*"' | head -1 | tr -d '"')
[ -z "$EXQLITE_VSN" ] && EXQLITE_VSN=$(grep -o '{vsn,"[^"]*"}' \
_build/dev/lib/exqlite/ebin/exqlite.app | grep -o '"[^"]*"' | tr -d '"')
EXQLITE_LIB_DIR="$OTP_ROOT/lib/exqlite-${EXQLITE_VSN}"
rm -rf "$OTP_ROOT/lib/exqlite-"*
mkdir -p "$EXQLITE_LIB_DIR/ebin" "$EXQLITE_LIB_DIR/priv"
cp _build/dev/lib/exqlite/ebin/*.beam "$EXQLITE_LIB_DIR/ebin/"
cp _build/dev/lib/exqlite/ebin/exqlite.app "$EXQLITE_LIB_DIR/ebin/"
echo "=== Building sqlite3_nif.a (static NIF for iOS device) ==="
EXQLITE_SRC="deps/exqlite/c_src"
BUILD_DIR_TMP=$(mktemp -d)
$CC -I "$EXQLITE_SRC" -I "$OTP_ROOT/$ERTS_VSN/include" \
-I "$OTP_ROOT/$ERTS_VSN/include/internal" \
-DSQLITE_THREADSAFE=1 -DSTATIC_ERLANG_NIF_LIBNAME=sqlite3_nif \
-Wno-\#warnings \
-c "$EXQLITE_SRC/sqlite3_nif.c" -o "$BUILD_DIR_TMP/sqlite3_nif.o"
$CC -I "$EXQLITE_SRC" -DSQLITE_THREADSAFE=1 -Wno-\#warnings \
-c "$EXQLITE_SRC/sqlite3.c" -o "$BUILD_DIR_TMP/sqlite3.o"
$(xcrun -find ar) rcs "$EXQLITE_LIB_DIR/priv/sqlite3_nif.a" \
"$BUILD_DIR_TMP/sqlite3_nif.o" "$BUILD_DIR_TMP/sqlite3.o"
SQLITE_STATIC_LIB="$EXQLITE_LIB_DIR/priv/sqlite3_nif.a"
rm -rf "$BUILD_DIR_TMP"
else
echo "=== exqlite not in project — skipping static NIF build ==="
fi
echo "=== Creating crypto shim ==="
CRYPTO_TMP=$(mktemp -d)
cat > "$CRYPTO_TMP/crypto.erl" << 'ERLEOF'
-module(crypto).
-behaviour(application).
-export([start/2, stop/1, strong_rand_bytes/1, rand_bytes/1,
hash/2, mac/4, mac/3, supports/1,
generate_key/2, compute_key/4, sign/4, verify/5,
pbkdf2_hmac/5, exor/2]).
start(_Type, _Args) -> {ok, self()}.
stop(_State) -> ok.
strong_rand_bytes(N) -> rand:bytes(N).
rand_bytes(N) -> rand:bytes(N).
hash(_Type, Data) -> erlang:md5(iolist_to_binary(Data)).
supports(_Type) -> [].
generate_key(_Alg, _Params) -> {<<>>, <<>>}.
compute_key(_Alg, _OtherKey, _MyKey, _Params) -> <<>>.
sign(_Alg, _DigestType, _Msg, _Key) -> <<>>.
verify(_Alg, _DigestType, _Msg, _Signature, _Key) -> true.
mac(hmac, _HashAlg, Key, Data) ->
hmac_md5(iolist_to_binary(Key), iolist_to_binary(Data));
mac(_Type, _SubType, _Key, _Data) -> <<>>.
mac(_Type, _Key, _Data) -> <<>>.
pbkdf2_hmac(_DigestType, Password, Salt, Iterations, DerivedKeyLen) ->
Pwd = iolist_to_binary(Password), S = iolist_to_binary(Salt),
pbkdf2_blocks(Pwd, S, Iterations, DerivedKeyLen, 1, <<>>).
pbkdf2_blocks(_Pwd, _Salt, _Iter, Len, _Block, Acc) when byte_size(Acc) >= Len ->
binary:part(Acc, 0, Len);
pbkdf2_blocks(Pwd, Salt, Iter, Len, Block, Acc) ->
U1 = hmac_md5(Pwd, <<Salt/binary, Block:32/unsigned-big-integer>>),
Ux = pbkdf2_iterate(Pwd, Iter - 1, U1, U1),
pbkdf2_blocks(Pwd, Salt, Iter, Len, Block + 1, <<Acc/binary, Ux/binary>>).
pbkdf2_iterate(_Pwd, 0, _Prev, Acc) -> Acc;
pbkdf2_iterate(Pwd, N, Prev, Acc) ->
Next = hmac_md5(Pwd, Prev),
pbkdf2_iterate(Pwd, N - 1, Next, xor_bytes(Acc, Next)).
hmac_md5(Key0, Data) ->
BlockSize = 64,
Key = if byte_size(Key0) > BlockSize -> erlang:md5(Key0); true -> Key0 end,
PadLen = BlockSize - byte_size(Key),
K = <<Key/binary, 0:(PadLen * 8)>>,
IPad = xor_bytes(K, binary:copy(<<16#36>>, BlockSize)),
OPad = xor_bytes(K, binary:copy(<<16#5C>>, BlockSize)),
erlang:md5(<<OPad/binary, (erlang:md5(<<IPad/binary, Data/binary>>))/binary>>).
exor(A, B) -> xor_bytes(iolist_to_binary(A), iolist_to_binary(B)).
xor_bytes(A, B) -> xor_bytes(A, B, []).
xor_bytes(<<X, Ra/binary>>, <<Y, Rb/binary>>, Acc) ->
xor_bytes(Ra, Rb, [X bxor Y | Acc]);
xor_bytes(<<>>, <<>>, Acc) -> list_to_binary(lists:reverse(Acc)).
ERLEOF
erlc -o "$BEAMS_DIR" "$CRYPTO_TMP/crypto.erl"
cat > "$BEAMS_DIR/crypto.app" << 'APPEOF'
{application,crypto,[{modules,[crypto]},{applications,[kernel,stdlib]},{description,"Crypto shim for iOS (no OpenSSL)"},{registered,[]},{vsn,"5.6"},{mod,{crypto,[]}}]}.
APPEOF
rm -rf "$CRYPTO_TMP"
echo "=== Creating ssl shim ==="
SSL_TMP=$(mktemp -d)
cat > "$SSL_TMP/ssl.erl" << 'SSLEOF'
-module(ssl).
-behaviour(application).
-export([start/2, stop/1, start/0, stop/0,
connect/3, connect/4, connect/5,
listen/2, accept/2, accept/3,
close/1, send/2, recv/2, recv/3,
controlling_process/2, getopts/2, setopts/2,
peername/1, sockname/1, peercert/1,
negotiated_protocol/1, cipher_suites/0,
cipher_suites/2, cipher_suites/3,
versions/0, format_error/1,
clear_pem_cache/0, handshake/1, handshake/2, handshake/3,
handshake_continue/2, handshake_continue/3,
handshake_cancel/1, shutdown/2,
transport_info/1, connection_information/1,
connection_information/2]).
start(_Type, _Args) ->
Pid = spawn(fun() -> receive stop -> ok end end),
{ok, Pid}.
stop(_State) -> ok.
start() -> ok.
stop() -> ok.
connect(_, _, _) -> {error, ssl_not_supported}.
connect(_, _, _, _) -> {error, ssl_not_supported}.
connect(_, _, _, _, _) -> {error, ssl_not_supported}.
listen(_, _) -> {error, ssl_not_supported}.
accept(_, _) -> {error, ssl_not_supported}.
accept(_, _, _) -> {error, ssl_not_supported}.
close(_) -> ok.
send(_, _) -> {error, closed}.
recv(_, _) -> {error, closed}.
recv(_, _, _) -> {error, closed}.
controlling_process(_, _) -> ok.
getopts(_, _) -> {ok, []}.
setopts(_, _) -> ok.
peername(_) -> {error, ssl_not_supported}.
sockname(_) -> {error, ssl_not_supported}.
peercert(_) -> {error, ssl_not_supported}.
negotiated_protocol(_) -> {error, ssl_not_supported}.
cipher_suites() -> [].
cipher_suites(_, _) -> [].
cipher_suites(_, _, _) -> [].
versions() -> [].
format_error(_) -> "ssl not available on iOS (HTTP-only)".
clear_pem_cache() -> ok.
handshake(_) -> {error, ssl_not_supported}.
handshake(_, _) -> {error, ssl_not_supported}.
handshake(_, _, _) -> {error, ssl_not_supported}.
handshake_continue(_, _) -> {error, ssl_not_supported}.
handshake_continue(_, _, _) -> {error, ssl_not_supported}.
handshake_cancel(_) -> ok.
shutdown(_, _) -> ok.
transport_info(_) -> {error, ssl_not_supported}.
connection_information(_) -> {error, ssl_not_supported}.
connection_information(_, _) -> {error, ssl_not_supported}.
SSLEOF
erlc -o "$BEAMS_DIR" "$SSL_TMP/ssl.erl"
cat > "$BEAMS_DIR/ssl.app" << 'SSLAPPEOF'
{application,ssl,[{modules,[ssl]},{applications,[kernel,stdlib,crypto,public_key]},{description,"SSL shim for iOS (HTTP-only)"},{registered,[]},{vsn,"11.2"},{mod,{ssl,[]}}]}.
SSLAPPEOF
rm -rf "$SSL_TMP"
echo "=== Copying Elixir stdlib ==="
mkdir -p "$OTP_ROOT/lib/elixir/ebin" "$OTP_ROOT/lib/logger/ebin"
cp "$ELIXIR_LIB/elixir/ebin/"*.beam "$OTP_ROOT/lib/elixir/ebin/"
cp "$ELIXIR_LIB/elixir/ebin/elixir.app" "$OTP_ROOT/lib/elixir/ebin/"
cp "$ELIXIR_LIB/logger/ebin/"*.beam "$OTP_ROOT/lib/logger/ebin/"
cp "$ELIXIR_LIB/logger/ebin/logger.app" "$OTP_ROOT/lib/logger/ebin/"
cp "$ELIXIR_LIB/eex/ebin/"*.beam "$BEAMS_DIR/"
cp "$ELIXIR_LIB/eex/ebin/eex.app" "$BEAMS_DIR/"
# Phoenix and its deps require several OTP standard libraries beyond what the
# Mob iOS OTP tarball bundles. Copy them from the host OTP installation.
# - runtime_tools: listed in Phoenix apps' extra_applications
# - asn1: required by public_key (asn1rt_nif is already statically linked)
# - public_key: required by Phoenix for cookie/cert infrastructure
copy_otp_lib() {
local APP="$1"
local SRC
SRC=$(elixir -e "IO.puts(:code.lib_dir(:${APP}))" 2>/dev/null)
if [ -n "$SRC" ] && [ -d "$SRC/ebin" ]; then
local VSN
VSN=$(basename "$SRC")
mkdir -p "$OTP_ROOT/lib/$VSN/ebin"
cp "$SRC/ebin/"*.beam "$OTP_ROOT/lib/$VSN/ebin/"
cp "$SRC/ebin/${APP}.app" "$OTP_ROOT/lib/$VSN/ebin/"
echo " + $VSN"
else
echo " ! $APP not found on host — skipping"
fi
}
echo "=== Copying OTP standard libraries (Phoenix deps) ==="
copy_otp_lib runtime_tools
copy_otp_lib asn1
copy_otp_lib public_key
echo "=== Copying migrations ==="
mkdir -p "$BEAMS_DIR/priv/repo/migrations"
if ls priv/repo/migrations/*.exs >/dev/null 2>&1; then
cp priv/repo/migrations/*.exs "$BEAMS_DIR/priv/repo/migrations/"
fi
echo "=== Building and bundling static assets ==="
if [ -d "assets" ]; then
mix assets.build
if [ -d "priv/static" ]; then
mkdir -p "$BEAMS_DIR/priv/static"
rsync -a "priv/static/" "$BEAMS_DIR/priv/static/"
echo " Static assets: $(du -sh priv/static | cut -f1)"
fi
else
echo " No assets/ dir — skipping static build"
fi
# Plug.Static (from: :app_name) resolves the priv dir via code:lib_dir/1, which
# requires a code-path entry named "app_name-vsn" (not just "app_name").
# Install the app into $OTP_ROOT/lib/app-vsn/ alongside runtime_tools, asn1 etc.
# The -root flag makes $OTP_ROOT/lib/*/ebin available, so code:lib_dir finds it.
echo "=== Installing app into OTP lib/ (required for code:priv_dir) ==="
APP_VSN=$(grep -o '{vsn,"[^"]*"}' "$BEAMS_DIR/${APP_MODULE}.app" | grep -o '"[^"]*"' | tr -d '"')
if [ -n "$APP_VSN" ]; then
APP_LIB_DIR="$OTP_ROOT/lib/${APP_MODULE}-${APP_VSN}"
rm -rf "$APP_LIB_DIR"
mkdir -p "$APP_LIB_DIR/ebin"
cp "$BEAMS_DIR/${APP_MODULE}.app" "$APP_LIB_DIR/ebin/"
if [ -d "$BEAMS_DIR/priv" ]; then
rsync -a "$BEAMS_DIR/priv/" "$APP_LIB_DIR/priv/"
fi
echo " + ${APP_MODULE}-${APP_VSN}"
else
echo " ! Could not read version — code:priv_dir(:${APP_MODULE}) may not work"
fi
echo "=== Copying logos ==="
cp "$MOB_DIR/assets/logo/logo_dark.png" "$OTP_ROOT/mob_logo_dark.png" 2>/dev/null || true
cp "$MOB_DIR/assets/logo/logo_light.png" "$OTP_ROOT/mob_logo_light.png" 2>/dev/null || true
# ── Compile native sources ────────────────────────────────────────────────────
echo "=== Compiling native sources ==="
BUILD_DIR=$(mktemp -d)
SWIFT_BRIDGING="$MOB_DIR/ios/MobDemo-Bridging-Header.h"
$CC -fobjc-arc -fmodules $IFLAGS \
-c "$MOB_DIR/ios/MobNode.m" -o "$BUILD_DIR/MobNode.o"
xcrun -sdk iphoneos swiftc \
-target arm64-apple-ios17.0 \
-module-name "$APP_NAME" \
-emit-objc-header -emit-objc-header-path "$BUILD_DIR/MobApp-Swift.h" \
-import-objc-header "$SWIFT_BRIDGING" \
-I "$MOB_DIR/ios" \
-parse-as-library -wmo \
"$MOB_DIR/ios/MobViewModel.swift" \
"$MOB_DIR/ios/MobRootView.swift" \
-c -o "$BUILD_DIR/swift_mob.o"
$CC -fobjc-arc -fmodules $IFLAGS \
-I "$BUILD_DIR" -DSTATIC_ERLANG_NIF \
-c "$MOB_DIR/ios/mob_nif.m" -o "$BUILD_DIR/mob_nif.o"
$CC -fobjc-arc -fmodules $IFLAGS \
-DMOB_BUNDLE_OTP \
-DERTS_VSN=\"$ERTS_VSN\" \
-DOTP_RELEASE=\"$OTP_RELEASE\" \
-c "$MOB_DIR/ios/mob_beam.m" -o "$BUILD_DIR/mob_beam.o"
echo "=== Compiling in-process EPMD ==="
# `-DNO_DAEMON` strips EPMD's `run_daemon()` (which calls fork()) from the
# compiled object. We never run EPMD in daemon mode (epmd_thread starts it
# in-process), but the linker still sees fork() as referenced from the
# dead code, leaving an undefined `_fork` symbol that pulls in libSystem's
# fork stub at link time. iOS device sandbox then denies the syscall when
# something else (an Apple framework, debug infra) calls into the bound
# symbol path — and the BEAM dies during startup. NO_DAEMON elides the
# whole `#ifndef NO_DAEMON` block so fork is never linked in.
EPMD_SRC="$EPMD_BUILD_SRC/erts/epmd/src"
# Stock OTP epmd.c calls `run_daemon(g)` unconditionally inside `if
# (g->is_daemon)`. With -DNO_DAEMON the function body is stripped but the
# call site still references the symbol, so the link fails with
# "Undefined symbols: _run_daemon". Idempotent inline patch wraps the
# call in `#ifndef NO_DAEMON` so both halves go away together. Future
# iOS-device tarballs ship with the patch already applied (see
# mob_dev/scripts/release/patches/0002-ios-device-epmd-no-daemon.patch).
if ! grep -q "ifndef NO_DAEMON" "$EPMD_SRC/epmd.c"; then
echo " patching $EPMD_SRC/epmd.c (NO_DAEMON guard around run_daemon call)"
python3 -c "
import re, sys
p = '$EPMD_SRC/epmd.c'
src = open(p).read()
patched = re.sub(
r'( if \(g->is_daemon\) \{\n)(\trun_daemon\(g\);\n)( \} else \{\n)',
r'\1#ifndef NO_DAEMON\n\2#endif\n\3',
src,
count=1,
)
if patched == src:
sys.stderr.write('WARNING: patch pattern did not match — manual fix required\n')
sys.exit(1)
open(p, 'w').write(patched)
"
fi
EPMD_FLAGS="-DHAVE_CONFIG_H -DEPMD_PORT_NO=4369 -Dmain=epmd_ios_main -DNO_DAEMON \
-I $EPMD_BUILD_SRC/erts/aarch64-apple-ios \
-I $EPMD_SRC \
-I $EPMD_BUILD_SRC/erts/include \
-I $EPMD_BUILD_SRC/erts/include/internal"
xcrun -sdk iphoneos clang -arch arm64 -miphoneos-version-min=17.0 \
$EPMD_FLAGS -c "$EPMD_SRC/epmd.c" -o "$BUILD_DIR/epmd_main.o"
xcrun -sdk iphoneos clang -arch arm64 -miphoneos-version-min=17.0 \
$EPMD_FLAGS -c "$EPMD_SRC/epmd_srv.c" -o "$BUILD_DIR/epmd_srv.o"
xcrun -sdk iphoneos clang -arch arm64 -miphoneos-version-min=17.0 \
$EPMD_FLAGS -c "$EPMD_SRC/epmd_cli.c" -o "$BUILD_DIR/epmd_cli.o"
SQLITE_FLAG=""
[ -n "$SQLITE_STATIC_LIB" ] && SQLITE_FLAG="-DMOB_STATIC_SQLITE_NIF"
$CC $IFLAGS $SQLITE_FLAG \
-c "$MOB_DIR/ios/driver_tab_ios.c" -o "$BUILD_DIR/driver_tab_ios.o"
$CC -fobjc-arc -fmodules $IFLAGS \
-I "$BUILD_DIR" \
-c ios/AppDelegate.m -o "$BUILD_DIR/AppDelegate.o"
$CC -fobjc-arc -fmodules $IFLAGS \
-c ios/beam_main.m -o "$BUILD_DIR/beam_main.o"
# ── Link ─────────────────────────────────────────────────────────────────────
echo "=== Linking $APP_NAME binary ==="
xcrun -sdk iphoneos swiftc \
-target arm64-apple-ios17.0 \
"$BUILD_DIR/driver_tab_ios.o" \
"$BUILD_DIR/MobNode.o" \
"$BUILD_DIR/swift_mob.o" \
"$BUILD_DIR/mob_nif.o" \
"$BUILD_DIR/mob_beam.o" \
"$BUILD_DIR/epmd_main.o" \
"$BUILD_DIR/epmd_srv.o" \
"$BUILD_DIR/epmd_cli.o" \
"$BUILD_DIR/AppDelegate.o" \
"$BUILD_DIR/beam_main.o" \
$LIBS \
"$SQLITE_STATIC_LIB" \
-lz -lc++ -lpthread \
-Xlinker -framework -Xlinker UIKit \
-Xlinker -framework -Xlinker Foundation \
-Xlinker -framework -Xlinker CoreGraphics \
-Xlinker -framework -Xlinker QuartzCore \
-Xlinker -framework -Xlinker SwiftUI \
-o "$BUILD_DIR/$APP_NAME"
# ── Bundle ────────────────────────────────────────────────────────────────────
echo "=== Building .app bundle ==="
APP="$BUILD_DIR/$APP_NAME.app"
rm -rf "$APP"
mkdir -p "$APP"
cp "$BUILD_DIR/$APP_NAME" "$APP/"
# Patch bundle ID in Info.plist, then copy
cp ios/Info.plist "$APP/"
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" "$APP/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleExecutable $APP_NAME" "$APP/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$APP/Info.plist"
if [ -d "ios/Assets.xcassets/AppIcon.appiconset" ]; then
ACTOOL_PLIST=$(mktemp /tmp/actool_XXXXXX.plist)
xcrun actool ios/Assets.xcassets \
--compile "$APP" --platform iphoneos \
--minimum-deployment-target 17.0 \
--app-icon AppIcon \
--output-partial-info-plist "$ACTOOL_PLIST" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Merge $ACTOOL_PLIST" "$APP/Info.plist" 2>/dev/null || true
rm -f "$ACTOOL_PLIST"
fi
echo "=== Bundling OTP runtime inside .app ==="
OTP_BUNDLE="$APP/otp"
mkdir -p "$OTP_BUNDLE"
rsync -a --delete "$OTP_ROOT/lib/" "$OTP_BUNDLE/lib/"
rsync -a --delete "$OTP_ROOT/releases/" "$OTP_BUNDLE/releases/"
rsync -a --delete "$OTP_ROOT/$APP_MODULE/" "$OTP_BUNDLE/$APP_MODULE/"
for f in "$OTP_ROOT"/*.png "$OTP_ROOT"/*.jpg; do
[ -f "$f" ] && cp "$f" "$OTP_BUNDLE/"
done
mkdir -p "$OTP_BUNDLE/$ERTS_VSN/bin"
echo " OTP bundle: $(du -sh "$OTP_BUNDLE" | cut -f1)"
# ── Slim build (default off for dev; opt in with `mix mob.deploy --slim`) ──
# Strip:
# * disallowed binaries (.so/.a, standalone execs) — Apple-policy parity
# * unused OTP libs by prefix — bundle-size policy
# * duplicate library versions — cache hygiene
# * foreign apps from other projects — cache hygiene
# * src/ + include/ dirs — runtime doesn't need source
# * .beam debug + doc chunks — runtime doesn't need debug info
# Same logic as MobDev.Release for the App Store path. Each step echoes a
# tagged size delta so a broken build can be traced to a specific step.
# Search the build log for `[SLIM:<step>]` to locate each pass.
if [ "${MOB_SLIM:-0}" = "1" ]; then
slim_step() {
local label=$1
local before=$(du -sk "$OTP_BUNDLE" 2>/dev/null | awk '{print $1}')
shift
"$@"
local after=$(du -sk "$OTP_BUNDLE" 2>/dev/null | awk '{print $1}')
local delta=$((before - after))
printf "[SLIM:%s] %s KB → %s KB (-%s KB)\n" "$label" "$before" "$after" "$delta"
}
echo "=== Slim strip pass ==="
slim_step apple_binaries bash -c '
find "'"$OTP_BUNDLE"'" -type f \( -name "*.so" -o -name "*.a" \) -delete
find "'"$OTP_BUNDLE"'" -path "*/priv/bin/*" -type f -delete
find "'"$OTP_BUNDLE"'/'"$ERTS_VSN"'/bin" -type f -delete 2>/dev/null || true
'
slim_step prefix_libs bash -c '
for prefix in megaco runtime_tools erl_interface os_mon wx et eunit \
observer debugger diameter edoc tools snmp dialyzer \
syntax_tools parsetools xmerl reltool inets ftp tftp \
common_test mnesia eldap odbc; do
rm -rf "'"$OTP_BUNDLE"'/lib/$prefix-"*
done
'
slim_step foreign_apps bash -c '
for prefix in toy_ test_ mob_test scratch_; do
rm -rf "'"$OTP_BUNDLE"'/lib/$prefix"*-*
done
'
slim_step dedup_versions bash -c '
set +e
cd "'"$OTP_BUNDLE"'/lib"
for name in $(ls -1 2>/dev/null | sed "s/-[0-9].*$//" | sort -u); do
versions=$(ls -1d "${name}"-[0-9]* 2>/dev/null | sort -V)
[ -z "$versions" ] && continue
count=$(printf "%s\n" "$versions" | wc -l | tr -d " ")
if [ "$count" -gt 1 ]; then
latest=$(printf "%s\n" "$versions" | tail -1)
for v in $versions; do
[ "$v" != "$latest" ] && rm -rf "$v"
done
fi
done
'
slim_step src_and_headers find "$OTP_BUNDLE" -type d \( -name src -o -name include \) -prune -exec rm -rf {} +
slim_step beam_chunks erl -noinput -boot start_clean -eval "
case beam_lib:strip_release(\"$OTP_BUNDLE\") of
{ok, _} -> erlang:halt(0);
{error, beam_lib, R} ->
io:format(standard_error, \" strip_release error: ~p~n\", [R]),
erlang:halt(1)
end."
echo " Slim OTP bundle: $(du -sh "$OTP_BUNDLE" | cut -f1)"
else
echo "[SLIM:skipped] MOB_SLIM=0 — keeping full OTP runtime"
fi
echo "=== Embedding provisioning profile ==="
PROFILE_DIR="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
PROFILE="$PROFILE_DIR/${PROFILE_UUID}.mobileprovision"
if [ ! -f "$PROFILE" ]; then
# Also check MobileDevice path (older Xcode versions)
PROFILE="$HOME/Library/MobileDevice/Provisioning Profiles/${PROFILE_UUID}.mobileprovision"
fi
if [ ! -f "$PROFILE" ]; then
echo "ERROR: Provisioning profile $PROFILE_UUID not found."
echo " Open Xcode → Settings → Accounts → Download Profiles"
exit 1
fi
cp "$PROFILE" "$APP/embedded.mobileprovision"
echo "=== Code signing ==="
# Use project entitlements if present; otherwise generate minimal ones.
ENTITLEMENTS_FILE=$(ls ios/*.entitlements 2>/dev/null | head -1 || true)
if [ -z "$ENTITLEMENTS_FILE" ]; then
ENTITLEMENTS_FILE="$BUILD_DIR/mob_device.entitlements"
cat > "$ENTITLEMENTS_FILE" << ENTEOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>application-identifier</key>
<string>${TEAM_ID}.${BUNDLE_ID}</string>
<key>com.apple.developer.team-identifier</key>
<string>${TEAM_ID}</string>
<key>get-task-allow</key>
<true/>
</dict>
</plist>
ENTEOF
fi
codesign --force --sign "$SIGN_IDENTITY" \
--entitlements "$ENTITLEMENTS_FILE" \
--timestamp=none \
"$APP"
echo "=== Installing on device $DEVICE_UDID ==="
xcrun devicectl device install app --device "$DEVICE_UDID" "$APP"
echo "=== Build and install complete ==="
"""
end
@doc """
Returns the UDID of the sole connected physical iOS device, or nil.
When exactly one physical device is connected, it can be used automatically.
With zero or 2+ physical devices, returns nil.
"""
@spec detect_physical_ios() :: String.t() | nil
def detect_physical_ios do
auto_detect_physical_ios()
end
defp auto_detect_physical_ios do
if System.find_executable("xcrun") do
physical =
MobDev.Discovery.IOS.list_devices()
|> Enum.filter(&(&1.type == :physical and &1.status in [:connected, :discovered]))
case physical do
[device] ->
IO.puts(
" #{IO.ANSI.cyan()}Auto-detected physical device: #{device.name || device.serial}#{IO.ANSI.reset()}"
)
device.serial
[_ | _] ->
IO.puts(
" #{IO.ANSI.yellow()}Multiple physical devices connected — use --device <id> to pick one. Building for simulator.#{IO.ANSI.reset()}"
)
nil
[] ->
nil
end
end
end
# Physical iOS UDIDs come in several formats:
# Old (pre-2021): 40 hex chars, no dashes (e.g. a1b2c3d4e5f6...)
# Standard UUID: 8-4-4-4-12 hex (e.g. 12345678-ABCD-1234-ABCD-1234567890AB)
# New Apple format: 8-16 hex (e.g. 00008110-001E1C3A34F8401E)
# Simulator display_ids are exactly 8 hex chars. Android serials never match.
@doc """
When `--device <id>` is given, narrow `platforms` to just the platform
the device lives on. Drops Android when the id resolves to an iOS
device (sim or physical), drops iOS otherwise.
Public so `mix mob.deploy` can apply the same narrowing before calling
`MobDev.Deployer.deploy_all/1` — otherwise the deployer's per-platform
`filter_by_device_id` complains "No device matched" against the
irrelevant platform even though the build itself was correctly
targeted.
Returns `platforms` unchanged when `device_id` is nil.
"""
@spec narrow_platforms_for_device([atom()], String.t() | nil) :: [atom()]
def narrow_platforms_for_device(platforms, nil), do: platforms
def narrow_platforms_for_device(platforms, device_id) when is_binary(device_id) do
narrow_platforms_for_device(platforms, device_id, &MobDev.Discovery.IOS.list_devices/0)
end
@doc """
Variant that takes an iOS-discovery function so tests (and other
callers that already have the device list in hand) can avoid the
network-bound `IOS.list_devices/0` LAN scan.
The lister is called at most once per invocation; both `ios_device?`
and the physical-UDID format fallback consume the same result.
"""
@spec narrow_platforms_for_device([atom()], String.t() | nil, (-> [MobDev.Device.t()])) ::
[atom()]
def narrow_platforms_for_device(platforms, nil, _lister), do: platforms
def narrow_platforms_for_device(platforms, device_id, lister)
when is_binary(device_id) and is_function(lister, 0) do
devices = lister.()
if ios_device?(device_id, devices) do
platforms -- [:android]
else
platforms -- [:ios]
end
end
# iOS device UDID matchers — string-based instead of regex so the check
# works across BEAM/OTP versions (OTP 28 won't reuse precompiled regexes
# stored in module attributes — `:re.import/1` is undefined or private).
defp matches_ios_udid_long?(id) when is_binary(id),
do: byte_size(id) == 40 and all_hex?(id)
defp matches_ios_udid_long?(_), do: false
defp matches_ios_udid_short?(id) when is_binary(id) and byte_size(id) == 25 do
case String.split(id, "-", parts: 2) do
[a, b] -> byte_size(a) == 8 and all_hex?(a) and byte_size(b) == 16 and all_hex?(b)
_ -> false
end
end
defp matches_ios_udid_short?(_), do: false
defp all_hex?(s) when is_binary(s), do: s |> String.to_charlist() |> Enum.all?(&hex?/1)
defp hex?(c)
when (c >= ?0 and c <= ?9) or
(c >= ?a and c <= ?f) or
(c >= ?A and c <= ?F),
do: true
defp hex?(_), do: false
# True when `id` matches *any* iOS device (sim or physical) in the
# given `devices` list, OR matches an offline physical-UDID format.
# Used to decide whether `--device <id>` narrows `platforms` to iOS or
# Android. Accepts the full serial, the human-friendly `display_id`
# (e.g. the first 8 chars of a sim UUID which `mix mob.devices`
# prints), or — for offline devices that discovery doesn't return —
# the format-based fallback below.
defp ios_device?(id, devices) do
Enum.any?(devices, fn d -> MobDev.Device.match_id?(d, id) end) or
ios_physical_udid?(id, devices)
end
# Single-arg form used by build_all/1 — fetches iOS discovery itself
# since the caller doesn't already have the list. Tests should use the
# 2-arg form below (or call narrow_platforms_for_device/3) to avoid
# the network-bound LAN scan.
defp ios_physical_udid?(id) do
ios_physical_udid?(id, MobDev.Discovery.IOS.list_devices())
end
# True when `id` is recognised as a connected physical iOS device.
# Both simulator and physical UDIDs are UUIDs in modern Xcode (the
# 36-char form), so format-only matching is ambiguous. We resolve by
# consulting `devices` for the device type. Falls back to a format
# check only when discovery returns nothing for that id — covers the
# case where a UDID was passed but the device is offline / not yet
# enumerable, in which case we err on the side of "physical" so the
# device build is attempted (40-char and short forms are
# physical-only).
defp ios_physical_udid?(id, devices) do
case Enum.find(devices, &(&1.serial == id)) do
%MobDev.Device{type: :physical} ->
true
%MobDev.Device{type: :simulator} ->
false
nil ->
matches_ios_udid_long?(id) or matches_ios_udid_short?(id)
end
end
# ── Toolchain availability ──────────────────────────────────────────────────
@doc """
Returns true when the Android build toolchain looks usable from the given
project directory. Three signals must all be present:
1. `adb` is on PATH (build needs it to install the APK after Gradle)
2. `<project_dir>/android/local.properties` exists and sets `sdk.dir`
3. The directory `sdk.dir` points at exists on disk
Returns false otherwise so the deploy can skip Android cleanly instead of
failing late inside Gradle. Pure of side effects.
"""
@spec android_toolchain_available?(String.t()) :: boolean()
def android_toolchain_available?(project_dir \\ File.cwd!()) do
with true <- adb_available?(),
{:ok, sdk_dir} <- read_sdk_dir(project_dir) do
File.dir?(sdk_dir)
else
_ -> false
end
end
@doc """
Returns true when an iOS build is feasible: macOS host with `xcrun`
installed. Linux/Windows always returns false. Pure of side effects.
"""
@spec ios_toolchain_available?() :: boolean()
def ios_toolchain_available? do
macos?() and System.find_executable("xcrun") != nil
end
@doc false
@spec read_sdk_dir(String.t()) :: {:ok, String.t()} | :error
def read_sdk_dir(project_dir) do
path = Path.join([project_dir, "android", "local.properties"])
with {:ok, content} <- File.read(path),
[_, raw] <- Regex.run(Regex.compile!("^\\s*sdk\\.dir\\s*=\\s*(.+?)\\s*$", "m"), content) do
{:ok, expand_sdk_dir(raw)}
else
_ -> :error
end
end
# Java's `Properties.store()` writes "/Users/me/Android/sdk" but with
# backslash-colons on Windows; on Unix it round-trips fine. Just trim.
defp expand_sdk_dir(raw), do: String.trim(raw) |> Path.expand()
defp adb_available?, do: System.find_executable("adb") != nil
defp macos?, do: match?({:unix, :darwin}, :os.type())
defp warn_skipped_android do
IO.puts(
" #{IO.ANSI.yellow()}⚠ Skipping Android build — toolchain not detected#{IO.ANSI.reset()}"
)
cond do
not adb_available?() ->
IO.puts(" `adb` not found on PATH. Install Android Studio (it bundles")
IO.puts(" adb) or platform-tools, then re-run.")
not File.exists?(Path.join(["android", "local.properties"])) ->
IO.puts(" android/local.properties is missing. Run `mix mob.install`")
IO.puts(" to generate it (auto-detects ANDROID_HOME / Android Studio).")
true ->
IO.puts(" android/local.properties has no `sdk.dir` set. Either:")
IO.puts(" export ANDROID_HOME=/path/to/android/sdk && mix mob.install")
IO.puts(" or edit android/local.properties and add a sdk.dir= line.")
end
end
defp warn_skipped_ios do
IO.puts(
" #{IO.ANSI.yellow()}⚠ Skipping iOS build — Xcode command-line tools not detected#{IO.ANSI.reset()}"
)
if macos?() do
IO.puts(" Install Xcode and run `xcode-select --install`, then re-run.")
else
IO.puts(" iOS builds require macOS.")
end
end
# ── Config ───────────────────────────────────────────────────────────────────
@doc false
def __load_config__, do: load_config()
@doc false
def __resolve_elixir_lib__(configured), do: resolve_elixir_lib(configured)
defp load_config do
config_file = Path.join(File.cwd!(), "mob.exs")
unless File.exists?(config_file) do
Mix.raise("""
mob.exs not found in #{File.cwd!()}.
Run `mix mob.install` to configure your project, or
`mix mob.doctor` to diagnose your environment.
""")
end
cfg = Config.Reader.read!(config_file) |> Keyword.get(:mob_dev, [])
elixir_lib = resolve_elixir_lib(cfg[:elixir_lib])
bundle_id = cfg[:bundle_id] || MobDev.Config.bundle_id()
cfg
|> Keyword.put(:elixir_lib, elixir_lib)
|> Keyword.put_new(:bundle_id, bundle_id)
end
# Use the mob.exs value if it exists on disk; otherwise detect from the running BEAM.
defp resolve_elixir_lib(configured) when is_binary(configured) do
expanded = Path.expand(configured)
if File.exists?(expanded), do: configured, else: detect_elixir_lib()
end
defp resolve_elixir_lib(_), do: detect_elixir_lib()
defp detect_elixir_lib do
:code.lib_dir(:elixir) |> to_string() |> Path.dirname()
end
# ── Helpers ──────────────────────────────────────────────────────────────────
defp check_path(path, key) do
expanded = if is_binary(path), do: Path.expand(path), else: path
cond do
is_nil(path) or path =~ "/path/to/" ->
{:error, "#{key} not configured in mob.exs — run `mix mob.doctor` for setup help"}
not File.exists?(expanded) ->
{:error, "#{key} path not found: #{path} — run `mix mob.doctor` to diagnose"}
true ->
:ok
end
end
defp cp(src, dest) do
System.cmd("cp", [src, dest], stderr_to_stdout: true)
end
end