defmodule DalaDev.NativeBuild do @moduledoc """ Builds native binaries (APK for Android, .app bundle for iOS simulator) for the current Dala project. Reads paths from `dala.exs` in the project root. If `dala.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 `~/.dala/cache/` by `DalaDev.OtpDownloader`. ## dala.exs keys * `:dala_dir` — dala 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) release = Keyword.get(opts, :release, false) platforms = narrow_platforms_for_device(platforms, device_id) 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 dala.deploy` (no platform flag) targets every platform with a # `/` 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 release -> [build_android_release(cfg) | 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 (debug)...") bundle_id = cfg[:bundle_id] || DalaDev.Config.bundle_id() apk = "android/app/build/outputs/apk/debug/app-debug.apk" with {:ok, otp_arm64} <- DalaDev.OtpDownloader.ensure_android("arm64-v8a"), {:ok, otp_arm32} <- DalaDev.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 defp build_android_release(cfg) do IO.puts(" Building Android App Bundle (release)...") bundle_id = cfg[:bundle_id] || DalaDev.Config.bundle_id() aab = "android/app/build/outputs/bundle/release/app-release.aab" with {:ok, otp_arm64} <- DalaDev.OtpDownloader.ensure_android("arm64-v8a"), {:ok, otp_arm32} <- DalaDev.OtpDownloader.ensure_android("armeabi-v7a"), :ok <- ensure_jni_libs(otp_arm64, "arm64-v8a"), :ok <- ensure_jni_libs(otp_arm32, "armeabi-v7a"), :ok <- gradle_bundle_release() do if File.exists?(aab) do size = File.stat!(aab).size IO.puts(" AAB: #{aab}") IO.puts(" Size: #{format_size(size)}") {:ok, "Android (release)"} else {:error, "AAB not found at #{aab}. Build may have failed."} end else {:error, "Android", reason} -> {:error, "Android (release)", reason} end end defp format_size(bytes) when bytes >= 1024 * 1024 do :io_lib.format("~.1fM", [bytes / (1024 * 1024)]) |> List.flatten() |> to_string() end defp format_size(bytes) when bytes >= 1024 do :io_lib.format("~.1fK", [bytes / 1024]) |> List.flatten() |> to_string() end defp format_size(bytes), do: "#{bytes}B" # 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 gradle_build(:debug) end defp gradle_bundle_release do gradle_build(:release) end defp gradle_build(:debug) do IO.puts(" Running Gradle assembleDebug...") IO.puts(" (first build may take a few minutes while CMake compiles native code)") run_gradle("assembleDebug") end defp gradle_build(:release) do IO.puts(" Running Gradle bundleRelease...") IO.puts(" (first build may take a few minutes while CMake compiles native code)") apply_release_signing_config() run_gradle("bundleRelease") end defp run_gradle(task) do 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, task, "--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 #{task})"} end else {:error, "gradlew not found at #{gradlew}"} end end defp apply_release_signing_config do cfg = load_config() signing = cfg[:android_signing] if signing do store_file = signing[:store_file] |> Path.expand() store_password = signing[:store_password] key_alias = signing[:key_alias] key_password = signing[:key_password] unless File.exists?(store_file) do IO.puts( "#{IO.ANSI.yellow()}Warning: Keystore not found at #{store_file}#{IO.ANSI.reset()}" ) end # Write signing config to gradle.properties for the build props_path = Path.join("android", "gradle.properties") props = File.exists?(props_path) |> if(do: File.read!(props_path), else: "") signing_block = """ # Android release signing (added by dala) android.injected.signing.store.file=#{store_file} android.injected.signing.store.password=#{store_password} android.injected.signing.key.alias=#{key_alias} android.injected.signing.key.password=#{key_password} """ # Remove old signing config if present, then append new one props = props |> String.split("\n") |> Enum.reject(&String.contains?(&1, "android.injected.signing")) |> Enum.join("\n") File.write!(props_path, props <> signing_block) else IO.puts( "#{IO.ANSI.yellow()}Warning: No android_signing config in dala.exs. Release build will use debug signing.#{IO.ANSI.reset()}" ) IO.puts(" Add to dala.exs:") IO.puts("") IO.puts(" config :dala_dev,") IO.puts(" android_signing: [") IO.puts(" store_file: \"~/.android/keystore.jks\",") IO.puts(" store_password: \"your_password\",") IO.puts(" key_alias: \"your_alias\",") IO.puts(" key_password: \"your_password\"") IO.puts(" ]") 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!(), "dala_otp_#{serial}.tar") stage_device = "/data/local/tmp/dala_otp.tar" try do tmp = Path.join(System.tmp_dir!(), "dala_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 ._ 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!(), "dala_otp_stage_#{serial}")) end end defp parse_adb_serials(output) do DalaDev.Utils.parse_adb_devices_output(output) end # Filters a list of adb serials by `--device `. 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[:dala_dir], "dala_dir"), :ok <- check_path(cfg[:elixir_lib], "elixir_lib"), {:ok, otp_root} <- DalaDev.OtpDownloader.ensure_ios_sim() do IO.puts(" Building iOS simulator app...") env = [ {"DALA_DIR", Path.expand(cfg[:dala_dir])}, {"DALA_ELIXIR_LIB", Path.expand(cfg[:elixir_lib])}, {"DALA_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 dala_qa build_device.sh approach but driven from dala.exs config. # # Required dala.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 dala.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} <- DalaDev.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 dala.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 dala 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 dala.exs: config :dala_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 dala.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 dala.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 dala.exs: config :dala_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, "") do xml = binary_part(data, s, e - s + len) uuid_match = Regex.run(Regex.compile!("UUID\\s*([^<]+)"), xml) bundle_match = Regex.run( Regex.compile!("application-identifier\\s*([^<]+)"), xml ) team_match = Regex.run( Regex.compile!("TeamIdentifier\\s*\\s*([^<]+)"), 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]) # The iOS device OTP cache (under ~/.dala/cache/otp-ios-device-/) 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 [ {"DALA_DIR", Path.expand(cfg[:dala_dir])}, {"DALA_ELIXIR_LIB", Path.expand(elixir_lib)}, {"DALA_IOS_DEVICE_OTP_ROOT", otp_root}, {"DALA_IOS_EPMD_BUILD_SRC", epmd_src}, {"DALA_IOS_BUNDLE_ID", cfg[:bundle_id]}, {"DALA_IOS_TEAM_ID", cfg[:ios_team_id]}, {"DALA_IOS_SIGN_IDENTITY", cfg[:ios_sign_identity]}, {"DALA_IOS_PROFILE_UUID", cfg[:ios_profile_uuid]}, {"DALA_APP_NAME", app_name}, {"DALA_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 dala.deploy --native). # All config comes from environment variables set by NativeBuild. Do not hardcode values here. set -e cd "$(dirname "$0")/.." # ── Config from dala.exs (set by mix dala.deploy --native) ───────────────────── DALA_DIR="${DALA_DIR:?DALA_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="${DALA_ELIXIR_LIB:?DALA_ELIXIR_LIB not set}" fi OTP_ROOT="${DALA_IOS_DEVICE_OTP_ROOT:?DALA_IOS_DEVICE_OTP_ROOT not set}" # DALA_IOS_EPMD_BUILD_SRC is exported by `mix dala.deploy --native` (defaults # to the iOS-device OTP cache at ~/.dala/cache/otp-ios-device-/, which # ships the EPMD source files). The fallback below covers the rare case of # running this script directly without going through `mix dala.deploy`. EPMD_BUILD_SRC="${DALA_IOS_EPMD_BUILD_SRC:-$OTP_ROOT}" BUNDLE_ID="${DALA_IOS_BUNDLE_ID:?bundle_id not set in dala.exs}" TEAM_ID="${DALA_IOS_TEAM_ID:?ios_team_id not set in dala.exs}" SIGN_IDENTITY="${DALA_IOS_SIGN_IDENTITY:?ios_sign_identity not set in dala.exs}" PROFILE_UUID="${DALA_IOS_PROFILE_UUID:?ios_profile_uuid not set in dala.exs}" APP_NAME="${DALA_APP_NAME:?DALA_APP_NAME not set}" # CamelCase binary name, e.g. DalaDemo APP_MODULE="${DALA_APP_MODULE:?DALA_APP_MODULE not set}" # snake_case, e.g. dala_demo DEVICE_UDID="${1:?Usage: build_device.sh }" 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 dala_beam.m's hard-coded `-boot $ROOTDIR/releases//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// 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$DALA_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, <>), Ux = pbkdf2_iterate(Pwd, Iter - 1, U1, U1), pbkdf2_blocks(Pwd, Salt, Iter, Len, Block + 1, <>). 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 = <>, IPad = xor_bytes(K, binary:copy(<<16#36>>, BlockSize)), OPad = xor_bytes(K, binary:copy(<<16#5C>>, BlockSize)), erlang:md5(<>))/binary>>). exor(A, B) -> xor_bytes(iolist_to_binary(A), iolist_to_binary(B)). xor_bytes(A, B) -> xor_bytes(A, B, []). xor_bytes(<>, <>, 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 # Dala 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 "$DALA_DIR/assets/logo/logo_dark.png" "$OTP_ROOT/dala_logo_dark.png" 2>/dev/null || true cp "$DALA_DIR/assets/logo/logo_light.png" "$OTP_ROOT/dala_logo_light.png" 2>/dev/null || true # ── Compile native sources ──────────────────────────────────────────────────── echo "=== Compiling native sources ===" BUILD_DIR=$(mktemp -d) SWIFT_BRIDGING="$DALA_DIR/ios/DalaDemo-Bridging-Header.h" $CC -fobjc-arc -fmodules $IFLAGS \ -c "$DALA_DIR/ios/DalaNode.m" -o "$BUILD_DIR/DalaNode.o" xcrun -sdk iphoneos swiftc \ -target arm64-apple-ios17.0 \ -module-name "$APP_NAME" \ -emit-objc-header -emit-objc-header-path "$BUILD_DIR/DalaApp-Swift.h" \ -import-objc-header "$SWIFT_BRIDGING" \ -I "$DALA_DIR/ios" \ -parse-as-library -wmo \ "$DALA_DIR/ios/DalaViewModel.swift" \ "$DALA_DIR/ios/DalaRootView.swift" \ -c -o "$BUILD_DIR/swift_dala.o" $CC -fobjc-arc -fmodules $IFLAGS \ -I "$BUILD_DIR" -DSTATIC_ERLANG_NIF \ -c "$DALA_DIR/ios/dala_nif.m" -o "$BUILD_DIR/dala_nif.o" $CC -fobjc-arc -fmodules $IFLAGS \ -DDALA_BUNDLE_OTP \ -DERTS_VSN=\"$ERTS_VSN\" \ -DOTP_RELEASE=\"$OTP_RELEASE\" \ -c "$DALA_DIR/ios/dala_beam.m" -o "$BUILD_DIR/dala_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 # dala_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="-DDALA_STATIC_SQLITE_NIF" $CC $IFLAGS $SQLITE_FLAG \ -c "$DALA_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/DalaNode.o" \ "$BUILD_DIR/swift_dala.o" \ "$BUILD_DIR/dala_nif.o" \ "$BUILD_DIR/dala_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/dala_device.entitlements" cat > "$ENTITLEMENTS_FILE" << ENTEOF application-identifier ${TEAM_ID}.${BUNDLE_ID} com.apple.developer.team-identifier ${TEAM_ID} get-task-allow 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 = DalaDev.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 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 ` 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 dala.deploy` can apply the same narrowing before calling `DalaDev.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, &DalaDev.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, (-> [DalaDev.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 format regexes — pre-compiled so we don't pay # `Regex.compile!/1` per call (and avoid OTP 28's "regex re-compiled # at runtime" warning). @ios_udid_long ~r/^[0-9A-Fa-f]{40}$/ @ios_udid_short ~r/^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{16}$/ # 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 ` 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 dala.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 -> DalaDev.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, DalaDev.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 %DalaDev.Device{type: :physical} -> true %DalaDev.Device{type: :simulator} -> false nil -> Regex.match?(@ios_udid_long, id) or Regex.match?(@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. `/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: DalaDev.Utils.adb_available?() 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 dala.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 dala.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!(), "dala.exs") unless File.exists?(config_file) do Mix.raise(""" dala.exs not found in #{File.cwd!()}. Run `mix dala.install` to configure your project, or `mix dala.doctor` to diagnose your environment. """) end cfg = Config.Reader.read!(config_file) |> Keyword.get(:dala_dev, []) elixir_lib = resolve_elixir_lib(cfg[:elixir_lib]) bundle_id = cfg[:bundle_id] || DalaDev.Config.bundle_id() cfg |> Keyword.put(:elixir_lib, elixir_lib) |> Keyword.put_new(:bundle_id, bundle_id) end # Use the dala.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 dala.exs — run `mix dala.doctor` for setup help"} not File.exists?(expanded) -> {:error, "#{key} path not found: #{path} — run `mix dala.doctor` to diagnose"} true -> :ok end end defp cp(src, dest) do System.cmd("cp", [src, dest], stderr_to_stdout: true) end end