Packages
mob_dev
0.3.13
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
@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)
# When `--device <android-serial>` is given, the user wants that one phone
# only — skip the iOS build entirely so we don't install on the simulator
# too. iOS UDIDs and Android serials don't overlap in format, so we can
# detect this from the device_id alone.
platforms =
if is_binary(device_id) and not ios_physical_udid?(device_id),
do: platforms -- [:ios],
else: platforms
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 OTP source tree used for EPMD compilation
# (default: /tmp/otp_ios_device_build/otp)
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(~r/\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(&parse_mobileprovision/1)
# Prefer exact bundle ID match; fall back to wildcard profiles (app_id "TEAMID.*")
exact_profiles =
Enum.filter(all_profiles, fn {_u, app_id, _team} ->
String.ends_with?(app_id, ".#{bundle_id}")
end)
profiles =
if exact_profiles != [] do
exact_profiles
else
Enum.filter(all_profiles, fn {_u, app_id, _team} ->
String.ends_with?(app_id, ".*")
end)
end
candidates =
if is_binary(uuid) do
Enum.filter(profiles, fn {u, _, _} -> u == uuid end)
else
profiles
end
case candidates do
[] ->
{:error,
"""
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
"""}
[{found_uuid, app_id, team}] ->
unless is_binary(uuid) do
IO.puts(
" #{IO.ANSI.cyan()}Auto-detected provisioning 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 {u, app_id, _} -> " #{u} (#{app_id})" end)
{:error,
"""
Multiple provisioning profiles match '#{bundle_id}' — add ios_profile_uuid to mob.exs:
config :mob_dev, ios_profile_uuid: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
Matching profiles:
#{choices}
"""}
end
end
# Parses a .mobileprovision file (DER-wrapped plist) and returns [{uuid, app_id, team_id}].
# The plist XML is embedded as plain text inside the DER envelope.
defp parse_mobileprovision(path) do
with {:ok, data} <- File.read(path),
{s, _} <- :binary.match(data, "<?xml"),
{e, len} <- :binary.match(data, "</plist>") do
xml = binary_part(data, s, e - s + len)
uuid_match = Regex.run(~r/<key>UUID<\/key>\s*<string>([^<]+)<\/string>/, xml)
bundle_match =
Regex.run(~r/<key>application-identifier<\/key>\s*<string>([^<]+)<\/string>/, xml)
team_match =
Regex.run(~r/<key>TeamIdentifier<\/key>\s*<array>\s*<string>([^<]+)<\/string>/, xml)
case {uuid_match, bundle_match, team_match} do
{[_, u], [_, b], [_, t]} -> [{String.trim(u), String.trim(b), String.trim(t)}]
_ -> []
end
else
_ -> []
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])
epmd_src = cfg[:ios_epmd_build_src] || "/tmp/otp_ios_device_build/otp"
[
{"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}
]
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}"
EPMD_BUILD_SRC="${MOB_IOS_EPMD_BUILD_SRC:-/tmp/otp_ios_device_build/otp}"
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
echo "=== ERTS: $ERTS_VSN, 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"
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
$OTP_ROOT/$ERTS_VSN/lib/libmicro_openssl.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=\"28\" \
-c "$MOB_DIR/ios/mob_beam.m" -o "$BUILD_DIR/mob_beam.o"
echo "=== Compiling in-process EPMD ==="
EPMD_SRC="$EPMD_BUILD_SRC/erts/epmd/src"
EPMD_FLAGS="-DHAVE_CONFIG_H -DEPMD_PORT_NO=4369 -Dmain=epmd_ios_main \
-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)"
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.
defp ios_physical_udid?(id) do
Regex.match?(~r/^[0-9A-Fa-f]{40}$/, id) or
Regex.match?(
~r/^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/,
id
) or
Regex.match?(~r/^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{16}$/, id)
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(~r/^\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()}"
)
IO.puts(
" Install Android Studio (or set sdk.dir in android/local.properties)"
)
IO.puts(" and ensure `adb` is on PATH, then re-run.")
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 ───────────────────────────────────────────────────────────────────
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